diff --git a/AIChatPanel.xaml.cs b/AIChatPanel.xaml.cs index 846cec4..832f91b 100644 --- a/AIChatPanel.xaml.cs +++ b/AIChatPanel.xaml.cs @@ -110,6 +110,7 @@ namespace AnotherReplayReader // Token 累计 private int _totalPromptTokens; private int _totalCompletionTokens; + private int _totalReasoningTokens; // ---------- properties ---------- // 外部注入:每次请求前调用获取最新配置 @@ -220,6 +221,7 @@ namespace AnotherReplayReader _totalPromptTokens = 0; _totalCompletionTokens = 0; + _totalReasoningTokens = 0; StopUiTimer(); UpdateButtons(); @@ -673,6 +675,59 @@ namespace AnotherReplayReader _linkedCts.Token); UpdateTokenDisplay(result); + + // 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。 + if (_replayData is null) + { + return; + } + var backqueries = BackqueryParser.Parse(result.Response); + if (backqueries.IsEmpty) + { + return; + } + + const int maxSummaryBackqueries = 3; + var pendingTexts = new List(); + foreach (var (start, end) in backqueries) + { + if (pendingTexts.Count >= maxSummaryBackqueries) + { + AppendLog("总结回查限制", "总结回查区间数已达上限,剩余区间已忽略。", false); + break; + } + var (text, reason) = BackquerySliceExtractor.Extract(_replayData, _eventSpans, start, end); + if (text is null) + { + AppendLog("总结回查失败", reason ?? "未知原因", false); + continue; + } + pendingTexts.Add( + $"[回查 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)}]\n" + text); + } + if (pendingTexts.Count == 0) + { + return; + } + + _phaseText.Text = "正在根据总结回查补充信息..."; + var backqueryMessages = messages + .Add(new AIAnalyze.ChatMessage("assistant", result.Response)) + .Add(new AIAnalyze.ChatMessage( + "user", + AIAnalyze.BuildBackqueryUserPrompt(string.Join("\n\n", pendingTexts)))); + AppendLog( + "总结回查", + $"已提供 {pendingTexts.Count} 个区间,正在生成最终总结。", + true); + CheckAndLogContextUsage(backqueryMessages, requestContext); + var finalSummary = await _analyzer!.CompleteAsync( + backqueryMessages, + requestContext, + OnChunk, + _linkedCts.Token); + UpdateTokenDisplay(finalSummary); + AppendLog("总结完成", "已根据回查区间补充最终总结。", false); } // ---- v2 管线辅助 ---- @@ -1050,13 +1105,16 @@ namespace AnotherReplayReader { _totalPromptTokens += result.PromptTokens ?? 0; _totalCompletionTokens += result.CompletionTokens ?? 0; + _totalReasoningTokens += result.ReasoningTokens ?? 0; var total = _totalPromptTokens + _totalCompletionTokens; _currentTokensText.Text = $"上次请求 Token:输入 {FormatNumber(result.PromptTokens, false)}" - + $" 输出 {FormatNumber(result.CompletionTokens, false)}"; + + $" 输出 {FormatNumber(result.CompletionTokens, false)}" + + (result.ReasoningTokens is { } reasoning ? $" 推理 {FormatNumber(reasoning, false)}" : ""); _conversationTokensText.Text = $"累计 Token:输入 {FormatNumber(_totalPromptTokens, false)}" + $" 输出 {FormatNumber(_totalCompletionTokens, false)}" + + (_totalReasoningTokens > 0 ? $" 推理 {FormatNumber(_totalReasoningTokens, false)}" : "") + $" 总计 {FormatNumber(total, false)}"; } diff --git a/AiV2.Tests/Program.cs b/AiV2.Tests/Program.cs index 84babb9..b894c71 100644 --- a/AiV2.Tests/Program.cs +++ b/AiV2.Tests/Program.cs @@ -133,6 +133,27 @@ namespace AiV2.Tests // 小预算 → 至少 MinSliceTokens 才切 var (minSlices, _) = MechanicalSegmenter.Slice(fullText, spans.ToImmutable(), 100, 50); Program.Assert(!minSlices.IsEmpty, "小预算仍应有切片"); + + // 超过 20 段时优先压缩纯选择块 + var mixedBuilder = ImmutableArray.CreateBuilder(); + var mixedTextBuilder = new StringBuilder(); + for (var i = 0; i < 30; ++i) + { + var text = $"[{i}:00]\nPlayerA: 选择单位\n [UnitId]{i}\n\n"; + mixedBuilder.Add(new EventSpan(TimeSpan.FromMinutes(i), mixedTextBuilder.Length, text.Length, 1000)); + mixedTextBuilder.Append(text); + } + for (var i = 30; i < 60; ++i) + { + var text = $"[{i}:00]\nPlayerA: 开始建造\n [UnitId]{i}\n\n"; + mixedBuilder.Add(new EventSpan(TimeSpan.FromMinutes(i), mixedTextBuilder.Length, text.Length, 1000)); + mixedTextBuilder.Append(text); + } + var mixedText = mixedTextBuilder.ToString(); + var (mixedSlices, mixedWarnings) = MechanicalSegmenter.Slice( + mixedText, mixedBuilder.ToImmutable(), 2000, 400); + Program.Assert(mixedSlices.Length <= MechanicalSegmenter.MaxSlices, "压缩后分段数不超过上限"); + Program.Assert(mixedWarnings.Any(w => w.Contains("过滤纯选择")), "超过上限时先压缩噪声块"); } } @@ -455,6 +476,23 @@ namespace AiV2.Tests var conflictingPower = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":7,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|7\"]}]}\n```", index); Program.Assert(conflictingPower.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction && i.Message.Contains("SpecialPower_UnpackReplaceSelf")), "记录过其他能力 → Contradiction"); + + // 事件声明:引用不存在的 UnitId → Warning + var badEventUnit = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"某事件\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_Nonexistent|999\"]}]}\n```", index); + Program.Assert(badEventUnit.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidMachineReadableClaims + && i.Severity == AIValidationSeverity.Warning + && i.Message.Contains("999")), "事件声明引用不存在 UnitId → Warning"); + + // 事件声明:技能与事实索引冲突 → Contradiction + var badEventPower = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"基地迁移\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|7\"]}]}\n```", index); + Program.Assert(badEventPower.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction + && i.Severity == AIValidationSeverity.Contradiction), "事件声明技能冲突 → Contradiction"); + + // 事件声明:协议未在任何玩家选择中观察到 → Warning + var badEventTech = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"协议事件\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"protocol|0:02.00|PlayerTech_Unknown\"]}]}\n```", index); + Program.Assert(badEventTech.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidMachineReadableClaims + && i.Severity == AIValidationSeverity.Warning + && i.Message.Contains("PlayerTech_Unknown")), "事件声明未观察协议 → Warning"); } private static AIValidationResult Validate(string response, ReplayFactIndex index) => @@ -608,6 +646,11 @@ namespace AiV2.Tests Program.Assert(prompt.Contains("基地车"), "结构化渲染包含盟军单位"); Program.Assert(!prompt.Contains("神州常用建筑"), "未参战阵营(神州)被过滤"); Program.Assert(prompt.Contains("地图参数") || prompt.Contains("出生点"), "地图知识保留"); + + var corona = KnowledgeSet.ForMod("corona", AppContext.BaseDirectory); + var coronaPrompt = corona.RenderAsPrompt(new[] { "盟军" }, "map_mp_2_rao1"); + Program.Assert(coronaPrompt.Contains("盟军"), "Corona 盟军知识保留"); + Program.Assert(!coronaPrompt.Contains("神州常用建筑"), "Corona 未参战阵营(神州)被过滤"); } } @@ -709,6 +752,11 @@ namespace AiV2.Tests { public static void Run() { + if (Environment.GetEnvironmentVariable("ARR_E2E_REPLAY") != "1") + { + Console.WriteLine(" [跳过] 未设置 ARR_E2E_REPLAY=1,跳过真实回放诊断"); + return; + } var replayPath = @"C:\Users\lanyi\Documents\Red Alert 3\Replays\安洁莉娜.(C)_VS_机枢舞者(A)[1V1][无限岛][2026_06_16 05_57][ra3battle.net].RA3Replay"; if (!File.Exists(replayPath)) { diff --git a/PLAN_ai_analysis_v2.md b/PLAN_ai_analysis_v2.md index efa4b03..e0c05cc 100644 --- a/PLAN_ai_analysis_v2.md +++ b/PLAN_ai_analysis_v2.md @@ -35,7 +35,15 @@ - 预算检查改为“估算 prompt + 输出/推理余量”,并在回查/修订前重新检查;`stream` 标志尊重模型配置。 - 事实索引:编队所有权按玩家隔离;接入 `0x1F6/0x22A`;unpack 歧义规则只作用于 MCV/基地车类实体。 - 知识:`aliases/alsoProducedBy` 已解析;摘要补充协议选择与所有权证据;总览的段落描述和回查提示会传入分段指令。 -- 测试:当前 `AiV2.Tests` 为 144 项断言全部通过。 +- 测试:当前 `AiV2.Tests` 默认 149 项断言全部通过;启用 `ARR_E2E_REPLAY=1` 时为 151 项。 + +### 2026-08-22 第二轮修订 + +- `eventClaims/timelineClaims` 现在也会校验:不存在 UnitId、技能与事实索引冲突、协议未在任何玩家选择中观察到都会产生验证问题。 +- 总结轮支持一次回查:模型可请求远处原始区间,程序在同一总结会话中追加提供。 +- 机械分段超过上限时先过滤纯选择/编队事件块,压缩无效才放宽预算。 +- Corona flat 文本现在也按参战阵营过滤。 +- `AiV2.Tests` 的真实回放诊断改为默认跳过(设置 `ARR_E2E_REPLAY=1` 启用);UI 状态栏显示推理 token。 ## 1. 背景与目标 diff --git a/Utils/AIAnalysisValidation.cs b/Utils/AIAnalysisValidation.cs index 5520745..393cd4c 100644 --- a/Utils/AIAnalysisValidation.cs +++ b/Utils/AIAnalysisValidation.cs @@ -135,6 +135,7 @@ namespace AnotherReplayReader.Utils if (factIndex is not null) { ValidateTimelineConsistency(claims, factIndex, aiNameToPlayerIndex, structuredKnowledge, issues); + ValidateSimpleClaimEvidence(claims, factIndex, issues); } return new AIValidationResult(claims, issues.ToImmutable()); } @@ -711,6 +712,119 @@ namespace AnotherReplayReader.Utils } } + /// 事件/时间线声明只有 claim + evidence,没有 player,因此只校验不依赖归属的事实。 + private static void ValidateSimpleClaimEvidence( + AIMachineReadableClaims claims, + ReplayFactIndex factIndex, + ImmutableArray.Builder issues) + { + foreach (var claim in claims.EventClaims) + { + ValidateSimpleClaim(claim.Claim, claim.Evidence, "事件声明", factIndex, issues); + } + foreach (var claim in claims.TimelineClaims) + { + ValidateSimpleClaim(claim.Claim, claim.Evidence, "时间线声明", factIndex, issues); + } + } + + private static void ValidateSimpleClaim( + string claimText, + ImmutableArray evidenceStrings, + string kind, + ReplayFactIndex factIndex, + ImmutableArray.Builder issues) + { + foreach (var ev in ParseAllEvidence(evidenceStrings)) + { + if (ev.Type == AIEvidenceType.Protocol) + { + var techName = ev.GetTechName(); + if (string.IsNullOrWhiteSpace(techName)) + { + continue; + } + var allTechChoices = new HashSet( + factIndex.PlayerTechChoices.Values.SelectMany(set => set), + StringComparer.OrdinalIgnoreCase); + if (allTechChoices.Count > 0 && !allTechChoices.Contains(techName)) + { + issues.Add(new AIValidationIssue( + AIValidationSeverity.Warning, + AIValidationIssueKind.InvalidMachineReadableClaims, + $"{kind}“{claimText}”引用了未在任何玩家选择中观察到的协议“{techName}”。")); + } + continue; + } + + if (ev.Type == AIEvidenceType.Power) + { + var unitIdText = ev.GetUnitId(); + if (unitIdText is null || !uint.TryParse(unitIdText, out var unitId)) + { + continue; + } + var powerName = ev.GetSpecialPowerName(); + if (string.IsNullOrWhiteSpace(powerName)) + { + continue; + } + + if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId)) + { + issues.Add(new AIValidationIssue( + AIValidationSeverity.Warning, + AIValidationIssueKind.InvalidMachineReadableClaims, + $"{kind}“{claimText}”的证据引用了回放中不存在的 UnitId {unitIdText}。", + unitIdText)); + continue; + } + + if (factIndex.UnitIdSpecialPowers.TryGetValue(unitId, out var actualPowers)) + { + if (!actualPowers.Contains(powerName)) + { + issues.Add(new AIValidationIssue( + AIValidationSeverity.Contradiction, + AIValidationIssueKind.UnitCapabilityContradiction, + $"{kind}“{claimText}”声称 UnitId {unitIdText} 使用了“{powerName}”,但该 UnitId 在回放中使用过:{string.Join("、", actualPowers.OrderBy(x => x))}。", + unitIdText)); + } + } + else + { + issues.Add(new AIValidationIssue( + AIValidationSeverity.WeakEvidence, + AIValidationIssueKind.WeakEvidence, + $"{kind}“{claimText}”声称 UnitId {unitIdText} 使用了“{powerName}”,但该 UnitId 未观察到任何特殊能力。", + unitIdText)); + } + continue; + } + + if (ev.Type is AIEvidenceType.Build + or AIEvidenceType.Place + or AIEvidenceType.Produce + or AIEvidenceType.Select + or AIEvidenceType.Sell) + { + var unitIdText = ev.GetUnitId(); + if (unitIdText is null || !uint.TryParse(unitIdText, out var unitId)) + { + continue; + } + if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId)) + { + issues.Add(new AIValidationIssue( + AIValidationSeverity.Warning, + AIValidationIssueKind.InvalidMachineReadableClaims, + $"{kind}“{claimText}”的证据引用了回放中不存在的 UnitId {unitIdText}。", + unitIdText)); + } + } + } + } + private static void ValidateOwnership( AIUnitClaim claim, uint unitId, diff --git a/Utils/AiKnowledge.cs b/Utils/AiKnowledge.cs index 39025c5..c5d4221 100644 --- a/Utils/AiKnowledge.cs +++ b/Utils/AiKnowledge.cs @@ -432,6 +432,7 @@ namespace AnotherReplayReader.Utils foreach (var entry in ByScope(KnowledgeScopeKind.Global)) { var text = entry.Id.StartsWith("knowledge-text-", StringComparison.Ordinal) + || entry.Id.StartsWith("knowledge-file-", StringComparison.Ordinal) ? FilterFlatTextByFactions(entry.Text, factionNames) : entry.Text; sb.AppendLine(text.Trim()); diff --git a/Utils/AiV2Pipeline.cs b/Utils/AiV2Pipeline.cs index cc32f36..6c3ac73 100644 --- a/Utils/AiV2Pipeline.cs +++ b/Utils/AiV2Pipeline.cs @@ -51,14 +51,71 @@ namespace AnotherReplayReader.Utils if (slices.Count > MaxSlices) { - var totalTokens = spans.Sum(s => s.EstimatedTokens); - var raisedBudget = Math.Max(budget, (int)Math.Ceiling(totalTokens / (double)MaxSlices)); - slices = SliceCore(fullText, spans, raisedBudget, Math.Max(overlap, raisedBudget / 12)); - warnings.Add($"分段数超过上限 {MaxSlices},已放宽单段预算到 {raisedBudget:N0} token。"); + // 先尝试压缩噪声事件块(纯选择/编队类),避免一超限就放宽预算。 + var compressedSpans = CompressNoiseSpans(fullText, spans); + if (compressedSpans.Length < spans.Length && !compressedSpans.IsEmpty) + { + var compressedSlices = SliceCore(fullText, compressedSpans, budget, overlap); + if (compressedSlices.Count <= MaxSlices) + { + warnings.Add($"分段数超过上限 {MaxSlices},已过滤纯选择/编队事件块,压缩到 {compressedSlices.Count} 段。"); + return (compressedSlices.ToImmutableArray(), warnings.ToImmutable()); + } + + var compressedTokens = compressedSpans.Sum(s => s.EstimatedTokens); + var raisedBudget = Math.Max(budget, (int)Math.Ceiling(compressedTokens / (double)MaxSlices)); + slices = SliceCore(fullText, compressedSpans, raisedBudget, Math.Max(overlap, raisedBudget / 12)); + warnings.Add($"分段数仍超过上限 {MaxSlices},已过滤噪声并放宽单段预算到 {raisedBudget:N0} token。"); + } + else + { + var totalTokens = spans.Sum(s => s.EstimatedTokens); + var raisedBudget = Math.Max(budget, (int)Math.Ceiling(totalTokens / (double)MaxSlices)); + slices = SliceCore(fullText, spans, raisedBudget, Math.Max(overlap, raisedBudget / 12)); + warnings.Add($"分段数超过上限 {MaxSlices},已放宽单段预算到 {raisedBudget:N0} token。"); + } } return (slices.ToImmutableArray(), warnings.ToImmutable()); } + private static ImmutableArray CompressNoiseSpans( + string fullText, + ImmutableArray spans) + { + var result = ImmutableArray.CreateBuilder(); + foreach (var span in spans) + { + if (!IsNoiseEventBlock(fullText.Substring(span.StartIndex, span.Length))) + { + result.Add(span); + } + } + return result.ToImmutable(); + } + + private static bool IsNoiseEventBlock(string text) + { + var hasCommand = false; + foreach (var rawLine in text.Replace("\r", "").Split('\n')) + { + var line = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("[", StringComparison.Ordinal)) + { + continue; + } + + // 命令行通常形如 "PlayerA: 选择单位";测试/简写文本把非空行也视作命令。 + hasCommand = true; + if (line.IndexOf("选择", StringComparison.Ordinal) < 0 + && line.IndexOf("编队", StringComparison.Ordinal) < 0 + && line.IndexOf("取消选择", StringComparison.Ordinal) < 0) + { + return false; + } + } + return hasCommand; + } + private static List SliceCore( string fullText, ImmutableArray spans,