using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using AnotherReplayReader; using AnotherReplayReader.ReplayFile; using AnotherReplayReader.Utils; using AIAnalyze = AiV2.Tests.TestAIAnalyze; using MainAIAnalyze = AnotherReplayReader.Utils.AIAnalyze; namespace AiV2.Tests { internal static class Program { private static int _passed; private static int _failed; private static int Main() { Run("AiTimeParser", AiTimeParserTests.Run); Run("MechanicalSegmenter", MechanicalSegmenterTests.Run); Run("FocusPlanner", FocusPlannerTests.Run); Run("OverviewParser", OverviewParserTests.Run); Run("BackqueryParser", BackqueryParserTests.Run); Run("BackquerySliceExtractor", BackquerySliceExtractorTests.Run); Run("StructuredEvidence", StructuredEvidenceTests.Run); Run("MachineReadableClaims", MachineReadableClaimsTests.Run); Run("ClaimFindingsFormatter", ClaimFindingsFormatterTests.Run); Run("AiSettingsPersistence", AiSettingsPersistenceTests.Run); Run("AiContextBudget", AiContextBudgetTests.Run); Run("MatchDigestBuilder", MatchDigestBuilderTests.Run); Run("OwnershipAndTimelineValidation", OwnershipAndTimelineValidationTests.Run); Run("JsonBlockMerging", JsonBlockMergingTests.Run); Run("FactIndexBuild", FactIndexBuildTests.Run); Run("StructuredKnowledge", StructuredKnowledgeTests.Run); Run("KnowledgeSetRendering", KnowledgeSetRenderingTests.Run); Run("UserKnowledgeOverlay", UserKnowledgeOverlayTests.Run); Run("PromptBuilders", PromptBuildersTests.Run); Run("RevisionFactsAndSerialization", RevisionFactsAndSerializationTests.Run); Run("ReasoningGuard", ReasoningGuardTests.Run); Run("OpenCodeGoFakeToolCallE2e", OpenCodeGoFakeToolCallTests.Run); Run("OpenCodeGoGuardE2e", OpenCodeGoGuardE2e.Run); Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run); Console.WriteLine(); Console.WriteLine($"总计: {_passed} 通过, {_failed} 失败"); return _failed == 0 ? 0 : 1; } private static void Run(string name, Action action) { var before = _passed + _failed; try { action(); Console.WriteLine($"[OK] {name}({_passed + _failed - before} 项)"); } catch (Exception ex) { _failed++; Console.WriteLine($"[FAIL] {name}: {ex.Message}"); } } public static void Assert(bool condition, string message) { if (condition) { _passed++; } else { _failed++; Console.WriteLine($" [断言失败] {message}"); throw new Exception($"断言失败: {message}"); } } public static void AssertEqual(T expected, T actual, string message) { if (EqualityComparer.Default.Equals(expected, actual)) { _passed++; } else { _failed++; Console.WriteLine($" [断言失败] {message}: 期望 {expected},实际 {actual}"); throw new Exception($"断言失败: {message}"); } } } internal static class AiTimeParserTests { public static void Run() { Program.AssertEqual(true, AiTimeParser.TryParse("1:24.00", out var t1) && t1 == TimeSpan.FromSeconds(84), "1:24.00"); Program.AssertEqual(true, AiTimeParser.TryParse("0:01.06", out var t2) && t2 > TimeSpan.FromSeconds(1) && t2 < TimeSpan.FromSeconds(1.1), "0:01.06"); Program.AssertEqual(true, AiTimeParser.TryParse("17:23", out var t3) && t3 == TimeSpan.FromSeconds(1043), "17:23"); Program.AssertEqual(false, AiTimeParser.TryParse("abc", out _), "非法输入"); Program.AssertEqual(false, AiTimeParser.TryParse("-1:20", out _), "负数"); Program.AssertEqual(false, AiTimeParser.TryParse("", out _), "空输入"); } } internal static class MechanicalSegmenterTests { public static void Run() { // 10 个 span,每个 1000 token,预算 3000,重叠 500 var spans = ImmutableArray.CreateBuilder(); var sb = new StringBuilder(); for (var i = 0; i < 10; ++i) { var text = $"[{i}:00] 事件 {i}\n\n"; spans.Add(new EventSpan(TimeSpan.FromMinutes(i), sb.Length, text.Length, 1000)); sb.Append(text); } var fullText = sb.ToString(); var (slices, warnings) = MechanicalSegmenter.Slice(fullText, spans.ToImmutable(), 3000, 1500); Program.Assert(!slices.IsEmpty, "不应为空"); Program.AssertEqual(5, slices.Length, "10k/3k(重叠 1.5k)→ 5 段"); Program.Assert(warnings.IsEmpty, "无警告"); foreach (var slice in slices) { Program.Assert(slice.GetText(fullText).Length == slice.Length, "切片文本长度一致"); } // 重叠:第 2 段起点应早于第 1 段终点 Program.Assert(slices[1].StartIndex < slices[0].StartIndex + slices[0].Length, "存在重叠"); // 空输入 var (emptySlices, _) = MechanicalSegmenter.Slice("", ImmutableArray.Empty, 3000, 500); Program.Assert(emptySlices.IsEmpty, "空输入返回空"); // 小预算 → 至少 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("过滤纯选择")), "超过上限时先压缩噪声块"); } } internal static class FocusPlannerTests { public static void Run() { var sb = new StringBuilder(); var spans = ImmutableArray.CreateBuilder(); for (var i = 0; i < 10; ++i) { var text = $"[{i}:00] 事件 {i}\n\n"; spans.Add(new EventSpan(TimeSpan.FromMinutes(i), sb.Length, text.Length, 5000)); sb.Append(text); } var fullText = sb.ToString(); var all = spans.ToImmutable(); // 整段 10 个 span,5K/span = 50K,窗口 12K → 应切分为 5 个窗口(每个 ~12K) var slice = new ReplaySlice(0, TimeSpan.Zero, TimeSpan.FromMinutes(9), 0, fullText.Length, 10, 50000); var windows = FocusPlanner.Plan(slice, all); Program.Assert(!windows.IsEmpty, "不应为空"); Program.AssertEqual(5, windows.Length, "50K/12K → 5 窗口"); Program.AssertEqual(TimeSpan.Zero, windows[0].Start, "第一个窗口起点"); Program.Assert(windows[0].End <= windows[1].Start, "窗口时间顺序(允许跨度间隔)"); Program.Assert(windows[windows.Length - 1].End <= slice.End, "最后一个窗口不越界"); Program.Assert(windows.All(w => w.EventCount > 0), "窗口均有事件"); // 小切片(< MinSliceForSplitTokens)→ 单窗口 var smallSlice = new ReplaySlice( 0, TimeSpan.Zero, TimeSpan.FromMinutes(1), 0, all[1].StartIndex + all[1].Length, 2, 10000); var smallWindows = FocusPlanner.Plan(smallSlice, all); Program.AssertEqual(1, smallWindows.Length, "小切片为单窗口"); // 空/无事件 → 空 Program.Assert(FocusPlanner.Plan(new ReplaySlice(0, TimeSpan.Zero, TimeSpan.Zero, 0, 0, 0, 0), all).IsEmpty, "无事件返回空"); // 窗口合并:超过 MaxWindowsPerSlice 时合并(50K 每段约 5K × 30 = 150K → 12K 窗口 13 个 → 合并到 5) var manySb = new StringBuilder(); var manySpans = ImmutableArray.CreateBuilder(); for (var i = 0; i < 30; ++i) { var text = $"[{i}:00] 事件 {i}\n\n"; manySpans.Add(new EventSpan(TimeSpan.FromMinutes(i), manySb.Length, text.Length, 5000)); manySb.Append(text); } var manyAll = manySpans.ToImmutable(); var manySlice = new ReplaySlice( 0, TimeSpan.Zero, TimeSpan.FromMinutes(29), 0, manySb.Length, 30, 150000); var manyWindows = FocusPlanner.Plan(manySlice, manyAll); Program.Assert(manyWindows.Length <= FocusPlanner.MaxWindowsPerSlice, "窗口数不超过上限"); } } internal static class OverviewParserTests { public static void Run() { var response = "整局走势:开局平稳,中期发生基地迁移。\n\n[分段概述]\n#1 开局:双方正常发育\n#2 中期:基地迁移\n回查: 1:20~1:45\n"; var overview = OverviewParser.Parse(response); Program.Assert(overview.Narrative.Contains("基地迁移"), "叙述文本"); Program.AssertEqual(2, overview.Segments.Length, "两段"); Program.AssertEqual("开局", overview.Segments[0].Title, "第 1 段标题"); Program.AssertEqual("双方正常发育", overview.Segments[0].Description, "第 1 段概述"); Program.AssertEqual("中期", overview.Segments[1].Title, "第 2 段标题"); Program.AssertEqual("基地迁移", overview.Segments[1].Description, "第 2 段概述"); Program.AssertEqual(1, overview.Segments[1].BackqueryHints.Length, "回查提示"); Program.Assert(overview.Segments[1].BackqueryHints[0].Contains("1:20"), "回查内容"); var noMarker = OverviewParser.Parse("只有叙述"); Program.Assert(noMarker.Segments.IsEmpty, "无标记 → 空段列表"); Program.AssertEqual("只有叙述", noMarker.Narrative, "无标记 → 全为叙述"); } } internal static class BackqueryParserTests { public static void Run() { var response = "正文内容\n[回查] 1:20~1:45\n[回查] 5:00~6:10.50\n回查: 0:10~0:20\n无关行\n[回查] 非法区间\n"; var ranges = BackqueryParser.Parse(response); Program.AssertEqual(3, ranges.Length, "三个有效回查"); Program.Assert(ranges[0].Start == TimeSpan.FromSeconds(80) && ranges[0].End == TimeSpan.FromSeconds(105), "1:20~1:45"); Program.Assert(ranges[2].Start == TimeSpan.FromSeconds(10), "0:10~0:20"); } } internal static class StructuredEvidenceTests { public static void Run() { var power = AIAnalysisValidation.ParseStructuredEvidence("power|1:24.00|SpecialPower_PackReplaceSelf|246"); Program.AssertEqual(AIAnalysisValidation.AIEvidenceType.Power, power.Type, "power 类型"); Program.AssertEqual("SpecialPower_PackReplaceSelf", power.GetSpecialPowerName(), "技能名"); Program.AssertEqual("246", power.GetUnitId(), "UnitId"); var move = AIAnalysisValidation.ParseStructuredEvidence("move|1:24.26|2026,2800,280"); Program.AssertEqual(AIAnalysisValidation.AIEvidenceType.Move, move.Type, "move 类型"); Program.AssertEqual(null, move.GetUnitId(), "move 无 UnitId"); var build = AIAnalysisValidation.ParseStructuredEvidence("build|0:01.26|AlliedBarracks|246"); Program.AssertEqual("AlliedBarracks", build.GetAssetName(), "建筑名"); Program.AssertEqual("246", build.GetUnitId(), "建造者"); var unknown = AIAnalysisValidation.ParseStructuredEvidence("自然语言描述"); Program.AssertEqual(AIAnalysisValidation.AIEvidenceType.Unknown, unknown.Type, "未知类型"); } } internal static class BackquerySliceExtractorTests { public static void Run() { var sb = new StringBuilder(); var spans = ImmutableArray.CreateBuilder(); for (var i = 0; i < 5; ++i) { var text = $"[{i}:00] 事件 {i}\n\n"; spans.Add(new EventSpan(TimeSpan.FromMinutes(i), sb.Length, text.Length, 10)); sb.Append(text); } var fullText = sb.ToString(); var all = spans.ToImmutable(); var (middle, reason1) = BackquerySliceExtractor.Extract( fullText, all, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2)); Program.Assert(middle is not null && reason1 is null, "中间区间可提取"); Program.Assert(middle!.Contains("事件 1") && middle.Contains("事件 2"), "区间内容"); Program.Assert(!middle.Contains("事件 0") && !middle.Contains("事件 3"), "不含区间外内容"); var (empty, reason2) = BackquerySliceExtractor.Extract( fullText, all, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(11)); Program.Assert(empty is null && reason2 is not null, "空区间返回原因"); var (noSpans, reason3) = BackquerySliceExtractor.Extract(fullText, ImmutableArray.Empty, TimeSpan.Zero, TimeSpan.FromMinutes(1)); Program.Assert(noSpans is null && reason3 is not null, "无索引返回原因"); } } internal static class MachineReadableClaimsTests { public static void Run() { var json = @"[机器可读声明] ```json { ""unitClaims"": [ { ""unitId"": 587, ""player"": ""PlayerA"", ""claim"": ""AlliedMCV"", ""evidenceLevel"": ""possible"", ""evidence"": [""power|1:41.00|SpecialPower_UnpackReplaceSelf|587""], ""alternatives"": [""AlliedMiner 指挥中心""], ""needsConfirmation"": [""后续是否作为建造者出现""] } ], ""eventClaims"": [ { ""event"": ""PlayerA 基地迁移"", ""evidenceLevel"": ""confirmed"", ""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246""] } ], ""timelineClaims"": [ { ""claim"": ""2 分钟内完成迁移"", ""evidenceLevel"": ""confirmed"", ""evidence"": [] } ] } ```"; var result = AIAnalysisValidation.ValidateMachineReadableClaims(json); Program.AssertEqual(1, result.Claims.UnitClaims.Length, "unitClaims 数量"); Program.AssertEqual("587", result.Claims.UnitClaims[0].UnitId, "unitId 数字转字符串"); Program.AssertEqual(AIEvidenceLevel.Possible, result.Claims.UnitClaims[0].EvidenceLevel, "证据等级"); Program.AssertEqual(1, result.Claims.EventClaims.Length, "eventClaims 数量(event 字段回退)"); Program.AssertEqual("PlayerA 基地迁移", result.Claims.EventClaims[0].Claim, "event 回退"); Program.AssertEqual(1, result.Claims.TimelineClaims.Length, "timelineClaims 数量"); // 未知证据等级 → Info issue + Uncertain var badLevel = AIAnalysisValidation.ValidateMachineReadableClaims( "[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"X\",\"evidenceLevel\":\"很确定\",\"evidence\":[]}]}\n```"); Program.Assert(badLevel.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidEvidenceLevel), "未知证据等级产生 Info"); Program.AssertEqual(AIEvidenceLevel.Uncertain, badLevel.Claims.UnitClaims[0].EvidenceLevel, "降级为不确定"); // 无 JSON → Warning var noJson = AIAnalysisValidation.ValidateMachineReadableClaims("纯文本回复"); Program.Assert(noJson.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingMachineReadableClaims), "无声明产生 Warning"); Program.Assert(noJson.RequiresRevision, "无声明现在会触发一次修订"); // 高置信无证据 → MissingEvidence var highNoEvidence = AIAnalysisValidation.ValidateMachineReadableClaims( "[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}]}\n```"); Program.Assert(highNoEvidence.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingEvidence), "高置信无证据"); Program.Assert(highNoEvidence.RequiresRevision, "高置信无证据会触发修订"); // 低置信无备选/待确认 → MissingAlternative var lowNoAlt = AIAnalysisValidation.ValidateMachineReadableClaims( "[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"possible\",\"evidence\":[\"select|1:00|1\"]}]}\n```"); Program.Assert(lowNoAlt.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingAlternative), "低置信无备选"); Program.Assert(lowNoAlt.RequiresRevision, "低置信无备选会触发修订"); // 仅 Info(例如声明数量超限)不触发修订 var infoOnlyJson = "[机器可读声明]\n```json\n{\"eventClaims\":[" + string.Join(",", Enumerable.Range(1, 6).Select(i => $"{{\"claim\":\"事件{i}\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}}")) + "]}\n```"; var infoOnly = AIAnalysisValidation.ValidateMachineReadableClaims(infoOnlyJson); Program.Assert(!infoOnly.RequiresRevision, "仅 Info 不触发修订"); // 全部 JSON 块都不可解析 → Fatal var allInvalid = AIAnalysisValidation.ValidateMachineReadableClaims( "[机器可读声明]\n```json\n{\"bad\": }\n```"); Program.Assert(allInvalid.Issues.Any(i => i.Severity == AIValidationSeverity.Fatal), "全无效 JSON 产生 Fatal"); Program.Assert(allInvalid.RequiresRevision, "Fatal 触发修订"); } } internal static class ClaimFindingsFormatterTests { public static void Run() { var claims = new AIMachineReadableClaims( ImmutableArray.Create(new AIUnitClaim("587", "PlayerA", "AlliedMCV", AIEvidenceLevel.Possible, ImmutableArray.Create("power|1:41.00|SpecialPower_UnpackReplaceSelf|587"), ImmutableArray.Create("AlliedMiner 指挥中心"), ImmutableArray.Empty)), ImmutableArray.Empty, ImmutableArray.Empty); var text = ClaimFindingsFormatter.Format(claims); Program.Assert(text.Contains("587"), "包含 UnitId"); Program.Assert(text.Contains("AlliedMCV"), "包含推测"); Program.Assert(text.Contains("AlliedMiner"), "包含备选"); var summary = ClaimFindingsFormatter.ExtractSummary("正文\n[小结] 基地迁移完成。"); Program.AssertEqual("基地迁移完成。", summary, "小结提取"); Program.AssertEqual(null, ClaimFindingsFormatter.ExtractSummary("无小结"), "无小结返回 null"); } } internal static class AiSettingsPersistenceTests { public static void Run() { // 基础往返:SetCurrentSelection 后 ResolveLastSelection 命中 var provider = new AiProvider { Name = "测试服务", Models = [new AiModel { ModelId = "model-a" }] }; var model = provider.Models[0]; var settings = new AiSettings { Providers = [provider] }; settings.SetCurrentSelection(provider, model); var resolved = settings.ResolveLastSelection(); Program.Assert(resolved is { } r && ReferenceEquals(r.Provider, provider) && ReferenceEquals(r.Model, model), "选择往返命中"); // 空标识 → null var empty = new AiSettings { Providers = [provider] }; Program.Assert(empty.ResolveLastSelection() is null, "空选择返回 null"); // Provider 名称大小写不敏感 var caseProvider = new AiProvider { Name = "MiXeD", Models = [new AiModel { ModelId = "Model.B" }] }; var caseSettings = new AiSettings { Providers = [caseProvider], CurrentProviderName = "mixed", CurrentModelId = "model.b" }; Program.Assert(caseSettings.ResolveLastSelection() is { } cr && ReferenceEquals(cr.Provider, caseProvider) && ReferenceEquals(cr.Model, caseProvider.Models[0]), "大小写不敏感"); // 失效:Provider 被删除 → null var deletedProvider = new AiSettings { Providers = [new AiProvider { Name = "新服务", Models = [new AiModel { ModelId = "m1" }] }], CurrentProviderName = "旧服务", CurrentModelId = "m1" }; Program.Assert(deletedProvider.ResolveLastSelection() is null, "Provider 被删除 → null"); // 失效:模型被删除 → null var deletedModel = new AiSettings { Providers = [new AiProvider { Name = "服务", Models = [new AiModel { ModelId = "m1" }] }], CurrentProviderName = "服务", CurrentModelId = "m2" }; Program.Assert(deletedModel.ResolveLastSelection() is null, "模型被删除 → null"); // Provider 被重命名:名称不再匹配,但模型仍存在 → 也回退(名称即标识的代价) var renamed = new AiSettings { Providers = [new AiProvider { Name = "服务2", Models = [new AiModel { ModelId = "m1" }] }], CurrentProviderName = "服务1", CurrentModelId = "m1" }; Program.Assert(renamed.ResolveLastSelection() is null, "Provider 重命名 → null(用第一个回退)"); } } internal static class AiContextBudgetTests { public static void Run() { Program.AssertEqual(160_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 1_000_000 }), "1M 档"); Program.AssertEqual(100_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 256_000 }), "256K 档"); Program.AssertEqual(100_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 200_000 }), "200K 档"); Program.AssertEqual(0, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 128_000 }), "128K 不支持长录像"); Program.AssertEqual(50_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 128_000, ContextBudget = 50_000 }), "显式覆盖"); var provider = new AiProvider { DefaultMaxTokens = 16384 }; var block = AiContextBudget.CheckPromptUsage(200_000, provider, new AiModel { ContextLength = 200_000 }); Program.Assert(block.Block, "超过 90% 硬上限 → 拒绝"); var warn = AiContextBudget.CheckPromptUsage(200_000, provider, new AiModel { ContextLength = 1_000_000 }); Program.Assert(!warn.Block && !warn.IsOk, "超过预算 → 警告"); var ok = AiContextBudget.CheckPromptUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 }); Program.Assert(ok.IsOk, "预算内 → 通过"); // 含输出余量的请求检查:输入 170K + 余量 32K 超过 200K 模型的 90% 硬上限 var blockTotal = AiContextBudget.CheckRequestUsage(170_000, provider, new AiModel { ContextLength = 200_000 }); Program.Assert(blockTotal.Block, "输入+输出余量超过硬上限 → 拒绝"); var warnTotal = AiContextBudget.CheckRequestUsage(160_000, provider, new AiModel { ContextLength = 1_000_000 }); Program.Assert(!warnTotal.Block && !warnTotal.IsOk, "输入+输出余量超过预算 → 警告"); var okTotal = AiContextBudget.CheckRequestUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 }); Program.Assert(okTotal.IsOk, "输入+输出余量在预算内 → 通过"); } } internal static class MatchDigestBuilderTests { public static void Run() { var player = new Player(new[] { "PTest", "0", "", "", "", "4", "", "-1" }); var players = ImmutableSortedDictionary.Empty.Add(4, player); var factIndex = TestData.BuildIndex( ImmutableDictionary.Empty.Add(1, TimeSpan.FromSeconds(80)), ImmutableDictionary>.Empty.Add( 1, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")), ImmutableArray.Create( new SpecialPowerEvent( TimeSpan.FromSeconds(80), 4, 1, "SpecialPower_PackReplaceSelf")), ImmutableHashSet.Empty.Add(1), ImmutableHashSet.Empty, ImmutableDictionary>.Empty.Add( 4, ImmutableDictionary.Empty.Add("AlliedMCV", TimeSpan.FromSeconds(100))), ImmutableDictionary>.Empty.Add( 4, ImmutableHashSet.Create(1)), ImmutableDictionary>.Empty, ImmutableDictionary>.Empty, ImmutableDictionary>.Empty); const string fullText = "[0:00]\n玩家 A,开始建造建筑\n [UnitId]1(建造者)\n AlliedBarracks\n\n[4:00]\n玩家 A,释放特殊能力\n SpecialPower_PackReplaceSelf\n [UnitId]1\n\n"; var slices = ImmutableArray.Create( new ReplaySlice(0, TimeSpan.Zero, TimeSpan.FromMinutes(5), 0, fullText.Length, 4, 500)); var digest = MatchDigestBuilder.Build(factIndex, players, new Mod("RA3"), slices, fullText); Program.Assert(digest.Contains("# 玩家"), "玩家段"); Program.Assert(digest.Contains("Test"), "玩家名"); Program.Assert(digest.Contains("盟军"), "阵营名"); Program.Assert(digest.Contains("无队伍"), "无队伍格式"); Program.Assert(digest.Contains("AlliedMCV@1:40"), "首次出兵时间"); Program.Assert(digest.Contains("Pack@1:20.00"), "打包事件真实时间"); Program.Assert(digest.Contains("建造者"), "建造者段"); Program.Assert(digest.Contains("第1段"), "分段元数据"); Program.Assert(digest.Contains("开始建造建筑"), "关键事件采样"); Program.Assert(digest.Contains("# 协议选择"), "摘要包含协议选择"); Program.Assert(digest.Contains("# 所有权证据(节选)"), "摘要包含所有权证据"); // 无队伍/解说员格式 var observer = new Player(new[] { "PObserver", "0", "", "", "", "3", "", "-1" }); var observerPlayers = ImmutableSortedDictionary.Empty .Add(2, observer) .Add(4, player); var observerDigest = MatchDigestBuilder.Build( factIndex, observerPlayers, new Mod("RA3"), slices, fullText); Program.Assert( observerDigest.Contains("解说员(观战),不参与对局"), "解说员不参与对局"); Program.Assert(observerDigest.Contains("无队伍"), "无队伍格式"); Program.Assert(!observerDigest.Contains("自由对战/FFA"), "不引入 FFA 说明"); // 空表明确输出(无) var emptyIndex = TestData.BuildIndex(); var emptyDigest = MatchDigestBuilder.Build( emptyIndex, players, new Mod("RA3"), slices, fullText); Program.Assert(emptyDigest.Contains("- (无)"), "首次出兵/协议空表输出(无)"); } } internal static class JsonBlockMergingTests { public static void Run() { var response = "[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"旧推测\",\"evidenceLevel\":\"possible\",\"evidence\":[],\"alternatives\":[],\"needsConfirmation\":[]}]}\n```\n" + "```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"新推测\",\"evidenceLevel\":\"possible\",\"evidence\":[],\"alternatives\":[],\"needsConfirmation\":[]}],\"eventClaims\":[{\"claim\":\"事件A\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}]}\n```"; var result = AIAnalysisValidation.ValidateMachineReadableClaims(response); Program.AssertEqual(1, result.Claims.UnitClaims.Length, "合并后 unitClaims 数量"); Program.AssertEqual("新推测", result.Claims.UnitClaims[0].Claim, "重复 unitId 取后块"); Program.AssertEqual(1, result.Claims.EventClaims.Length, "合并后 eventClaims"); } } internal static class OwnershipAndTimelineValidationTests { private static readonly IReadOnlyDictionary Mapping = new Dictionary { { "PlayerA", 4 }, { "PlayerB", 5 } }; public static void Run() { var index = TestData.BuildIndex( firstObserved: ImmutableDictionary.Empty .Add(1, TimeSpan.FromSeconds(60)) .Add(2, TimeSpan.FromSeconds(90)) .Add(3, TimeSpan.FromSeconds(120)) .Add(7, TimeSpan.FromSeconds(60)), strong: ImmutableDictionary>.Empty .Add(4, ImmutableHashSet.Create(1)), powers: ImmutableDictionary>.Empty .Add(7, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")), weak: ImmutableDictionary>.Empty .Add(5, ImmutableHashSet.Create(1, 3)), productions: ImmutableDictionary>.Empty .Add(4, ImmutableDictionary.Empty .Add("AlliedBomberAircraft", TimeSpan.FromSeconds(300))), tech: ImmutableDictionary>.Empty .Add(4, ImmutableHashSet.Create("PlayerTech_Allied_AirPower"))); // 规则 2:PlayerA 有强证据(1),声称属于 PlayerB → Contradiction var wrongOwner = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerB\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"select|0:01.00|1\"]}]}\n```", index); Program.Assert(wrongOwner.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipConflict && i.Severity == AIValidationSeverity.Contradiction), "他人强证据 → Contradiction"); // 规则 1 + 3:声称属于 PlayerA,但 A 无证据、B 仅弱证据 → WeakEvidence + Warning var weakOnly = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":3,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"select|0:02.00|3\"]}]}\n```", index); Program.Assert(weakOnly.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipMissingEvidence), "无己方证据 → WeakEvidence"); Program.Assert(weakOnly.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipWeakConflict && i.Severity == AIValidationSeverity.Warning), "他人弱证据 → Warning"); // 正确归属:PlayerA 强证据 → 无所有权问题 var correctOwner = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|1\"]}]}\n```", index); Program.Assert(!correctOwner.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipConflict || i.Kind == AIValidationIssueKind.OwnershipMissingEvidence), "正确归属无所有权问题"); // 协议校验:未选择过的协议 → Contradiction var wrongTech = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"possible\",\"evidence\":[\"protocol|0:02.00|PlayerTech_Allied_Superiority\"]}]}\n```", index); Program.Assert(wrongTech.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction), "未选协议 → Contradiction"); // move-only 高置信 → WeakEvidence var moveOnly = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"move|0:01.00|100,200,300\"]}]}\n```", index); Program.Assert(moveOnly.Issues.Any(i => i.Kind == AIValidationIssueKind.WeakEvidence && i.Message.Contains("move")), "move-only 高置信 → WeakEvidence"); // 首次出兵时间线:轰炸机首次出现早于首次生产 → Contradiction var earlyBomber = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":7,\"player\":\"PlayerA\",\"claim\":\"AlliedBomberAircraft 轰炸机\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"select|0:01.00|7\"]}]}\n```", index); Program.Assert(earlyBomber.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitTimelineContradiction && i.Severity == AIValidationSeverity.Contradiction), "早于首次生产 → Contradiction"); // 特殊能力验证:单位从未观察到任何能力 → WeakEvidence var wrongPower = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_Nonexistent|1\"]}]}\n```", index); Program.Assert(wrongPower.Issues.Any(i => i.Kind == AIValidationIssueKind.WeakEvidence && i.Message.Contains("SpecialPower_Nonexistent")), "未观察到任何能力 → WeakEvidence"); // 特殊能力验证:记录过其他能力但未用过该能力 → Contradiction 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) => AIAnalysisValidation.ValidateMachineReadableClaims(response, index, Mapping); } internal static class TestData { public static ReplayFactIndex BuildIndex( ImmutableDictionary? firstObserved = null, ImmutableDictionary>? powers = null, ImmutableArray? specialPowerEvents = null, ImmutableHashSet? builders = null, ImmutableHashSet? producers = null, ImmutableDictionary>? productions = null, ImmutableDictionary>? selected = null, ImmutableDictionary>? strong = null, ImmutableDictionary>? weak = null, ImmutableDictionary>? tech = null) => new ReplayFactIndex( firstObserved ?? ImmutableDictionary.Empty, powers ?? ImmutableDictionary>.Empty, specialPowerEvents ?? ImmutableArray.Empty, builders ?? ImmutableHashSet.Empty, producers ?? ImmutableHashSet.Empty, productions ?? ImmutableDictionary>.Empty, selected ?? ImmutableDictionary>.Empty, strong ?? ImmutableDictionary>.Empty, weak ?? ImmutableDictionary>.Empty, tech ?? ImmutableDictionary>.Empty); } internal static class FactIndexBuildTests { private static CommandChunk MakeChunk(int commandId, int playerIndex, params CommandArgumentEntry[] data) { var chunk = new CommandChunk(); typeof(CommandChunk).GetProperty(nameof(CommandChunk.CommandId))!.SetValue(chunk, commandId); typeof(CommandChunk).GetProperty(nameof(CommandChunk.PlayerIndex))!.SetValue(chunk, playerIndex); typeof(CommandChunk).GetProperty(nameof(CommandChunk.Data))!.SetValue(chunk, data.ToImmutableArray()); return chunk; } private static CommandArgumentEntry Obj(uint id) => new(CommandArgumentType.ObjectId, id, 1); private static CommandArgumentEntry Int(int value) => new(CommandArgumentType.Int32, value, 1); private static CommandArgumentEntry Str(string value) => new(CommandArgumentType.AsciiString, value, 1); public static void Run() { const uint powerHash = 0x1234u; var timeline = ImmutableArray.Create( (TimeSpan.FromSeconds(1), ImmutableArray.Create( MakeChunk(0x1F5, 4, new CommandArgumentEntry(CommandArgumentType.Bool, new[] { true }, 1), new CommandArgumentEntry(CommandArgumentType.Bool, new[] { false }, 1), Obj(239)))), // 选择 → 弱所有权 (TimeSpan.FromSeconds(2), ImmutableArray.Create( MakeChunk(0x1FA, 4, Int(3), Obj(389)), // 创建编队 → 强所有权 MakeChunk(0x24E, 4, Str("PlayerTech_Allied_AirPower")))), // 协议 (TimeSpan.FromSeconds(3), ImmutableArray.Create( MakeChunk(0x1FB, 4, Int(3)))), // 选择编队 → 解析成员 389 (TimeSpan.FromSeconds(4), ImmutableArray.Create( MakeChunk(0x200, 4, Int(unchecked((int)powerHash)), new CommandArgumentEntry(CommandArgumentType.Vector3, new Vector3(1, 2, 3), 1), new CommandArgumentEntry(CommandArgumentType.Float32, 1.0f, 1), Obj(0), Int(0), Int(1), Obj(587)))), // 指定位置和角度:587 是施法者 (TimeSpan.FromSeconds(5), ImmutableArray.Create( MakeChunk(0x201, 4, Int(unchecked((int)powerHash)), Obj(323), Int(0), Int(1), Obj(322), new CommandArgumentEntry(CommandArgumentType.Vector3, new Vector3(4, 5, 6), 1))))); // 指定目标:不记录为施法者 var stringHashes = new Dictionary { [powerHash] = "SpecialPower_UnpackReplaceSelf" }; var index = ReplayFactIndex.Build(timeline, stringHashes); // 弱所有权:选择 Program.Assert(index.PlayerWeakOwnershipUnitIds.TryGetValue(4, out var weak) && weak.Contains(239), "选择 → 弱所有权"); // 强所有权:编队创建 + 编队选择解析 Program.Assert(index.PlayerStrongOwnershipUnitIds.TryGetValue(4, out var strong) && strong.Contains(389) && strong.Contains(587), "编队/施法者 → 强所有权"); Program.Assert(!strong.Contains(239), "选择不属于强所有权"); Program.Assert(!strong.Contains(322), "目标不属于强所有权"); // 协议 Program.Assert(index.PlayerTechChoices.TryGetValue(4, out var tech) && tech.Contains("PlayerTech_Allied_AirPower"), "协议记录"); // 施法者归属:0x200 记录,0x201 不记录 Program.Assert(index.UnitIdSpecialPowers.TryGetValue(587, out var powers) && powers.Contains("SpecialPower_UnpackReplaceSelf"), "0x200 施法者记录"); Program.Assert(!index.UnitIdSpecialPowers.ContainsKey(322) && !index.UnitIdSpecialPowers.ContainsKey(323), "0x201 目标不记录为施法者"); // 首次出现 Program.Assert(index.UnitIdFirstObservedTime.ContainsKey(239) && index.UnitIdFirstObservedTime.ContainsKey(389) && index.UnitIdFirstObservedTime.ContainsKey(587) && index.UnitIdFirstObservedTime.ContainsKey(322), "首次出现记录"); // 编队按玩家隔离 + 0x1F6/0x22A 选择类命令 var secondTimeline = ImmutableArray.Create( (TimeSpan.FromSeconds(1), ImmutableArray.Create( MakeChunk(0x1FA, 4, Int(3), Obj(401)))), (TimeSpan.FromSeconds(2), ImmutableArray.Create( MakeChunk(0x1FB, 5, Int(3)))), (TimeSpan.FromSeconds(3), ImmutableArray.Create( MakeChunk(0x1F6, 4, Obj(777)), MakeChunk(0x22A, 4, Obj(888))))); var secondIndex = ReplayFactIndex.Build(secondTimeline, new Dictionary()); Program.Assert(secondIndex.PlayerStrongOwnershipUnitIds.TryGetValue(4, out var p4Strong) && p4Strong.Contains(401), "P4 创建编队 → 强证据"); Program.Assert(!secondIndex.PlayerStrongOwnershipUnitIds.TryGetValue(5, out _) || !secondIndex.PlayerStrongOwnershipUnitIds[5].Contains(401), "P5 选择同号编队不应继承 P4 成员"); Program.Assert(secondIndex.PlayerWeakOwnershipUnitIds.TryGetValue(4, out var p4Weak) && p4Weak.Contains(777) && p4Weak.Contains(888), "0x1F6/0x22A → 弱所有权"); // 真实布局兼容:0x205/0x24E 的名称可能以 Int32 hash 编码 var hashTable = new Dictionary { [0x1001u] = "AlliedMiner", [0x2001u] = "PlayerTech_Allied_AirPower" }; var hashTimeline = ImmutableArray.Create( (TimeSpan.FromSeconds(10), ImmutableArray.Create( MakeChunk(0x205, 4, Obj(423), Int(unchecked((int)0x1001)), Int(0), Int(3)))), (TimeSpan.FromSeconds(12), ImmutableArray.Create( MakeChunk(0x24E, 4, Int(unchecked((int)0x2001)))))); var hashIndex = ReplayFactIndex.Build(hashTimeline, hashTable); Program.Assert( hashIndex.PlayerFirstProductionTime.TryGetValue(4, out var productions) && productions.TryGetValue("AlliedMiner", out var productionTime) && productionTime == TimeSpan.FromSeconds(10), "0x205 Int32 hash → 首次出兵时间表"); Program.Assert( hashIndex.PlayerTechChoices.TryGetValue(4, out var techs) && techs.Contains("PlayerTech_Allied_AirPower"), "0x24E Int32 hash → 协议选择"); // 特殊能力事件应保留真实发生时间,供摘要输出 Pack@/Unpack@ Program.Assert( index.SpecialPowerEvents.Any(e => e.Time == TimeSpan.FromSeconds(4) && e.PlayerIndex == 4 && e.UnitId == 587 && e.PowerName == "SpecialPower_UnpackReplaceSelf"), "SpecialPowerEvents 记录 0x200 事件时间"); } } internal static class StructuredKnowledgeTests { public static void Run() { var knowledge = StructuredKnowledge.GetForMod("default"); Program.Assert(knowledge is not null, "默认 mod 结构化知识可加载"); Program.Assert(knowledge!.AllEntities.Length > 20, "盟军实体数量"); Program.Assert(knowledge.UnknownTags.IsEmpty, "无未知标签(实际: " + string.Join(",", knowledge.UnknownTags) + ")"); Program.Assert(knowledge.GetEntity("AlliedMCV") is { } mcv && mcv.HasTag(KnowledgeTag.Builder) && mcv.HasTag(KnowledgeTag.Unpack), "AlliedMCV 标签"); Program.Assert(knowledge.GetEntity("AlliedAntiInfantryVehicle_Ground") is { } aliasEntity && aliasEntity.AssetName == "AlliedAntiInfantryVehicle", "别名可解析到同一实体"); Program.Assert(knowledge.GetEntity("AlliedAntiInfantryVehicle") is { } acv && acv.ProducedBy.Contains("AlliedWarFactory"), "alsoProducedBy 合并进 ProducedBy"); // 不存在的 mod → null(无结构化数据时不剥离 flat 文本) Program.Assert(StructuredKnowledge.GetForMod("corona") is null, "corona 无结构化文件 → null"); } } internal static class KnowledgeSetRenderingTests { public static void Run() { var knowledge = KnowledgeSet.ForMod("default", AppContext.BaseDirectory); var prompt = knowledge.RenderAsPrompt(new[] { "盟军" }, "map_mp_2_rao1"); Program.Assert(prompt.Contains("盟军"), "包含盟军知识"); 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 未参战阵营(神州)被过滤"); } } internal static class UserKnowledgeOverlayTests { public static void Run() { var dir = AppContext.BaseDirectory; var knowledgePath = Path.Combine(dir, "knowledge_units_testmod.json"); var userPath = Path.Combine(dir, "AnotherReplayReader.user_knowledge.json"); const string knowledgeJson = "{\"factions\":{\"盟军\":{\"units\":[{\"assetName\":\"TestUnit\",\"displayName\":\"内置单位\",\"tier\":\"基础\",\"tags\":[\"vehicle\"],\"specialPowers\":[],\"producedBy\":[],\"text\":\"内置描述\"}]}}}"; const string userJson = "{\"factions\":{\"盟军\":{\"units\":[{\"assetName\":\"TestUnit\",\"displayName\":\"用户覆盖\",\"tier\":\"基础\",\"tags\":[\"vehicle\"],\"specialPowers\":[],\"producedBy\":[],\"text\":\"用户描述\"},{\"assetName\":\"NewUnit\",\"displayName\":\"新增单位\",\"tier\":\"T2\",\"tags\":[\"infantry\"],\"specialPowers\":[],\"producedBy\":[],\"text\":\"新增\"}]}}}"; try { File.WriteAllText(knowledgePath, knowledgeJson, Encoding.UTF8); File.WriteAllText(userPath, userJson, Encoding.UTF8); var knowledge = StructuredKnowledge.GetForMod("testmod"); Program.Assert(knowledge is not null, "测试 mod 加载"); Program.Assert(knowledge!.GetEntity("TestUnit") is { } overridden && overridden.DisplayName == "用户覆盖" && overridden.Text == "用户描述", "用户覆盖内置条目"); Program.Assert(knowledge.GetEntity("NewUnit") is { } added && added.DisplayName == "新增单位", "用户新增条目"); } finally { if (File.Exists(knowledgePath)) File.Delete(knowledgePath); if (File.Exists(userPath)) File.Delete(userPath); } } } internal static class PromptBuildersTests { public static void Run() { var slice = new ReplaySlice(0, TimeSpan.Zero, TimeSpan.FromMinutes(5), 0, 100, 10, 500); var overview = AIAnalyze.BuildOverviewUserPrompt(ImmutableArray.Create(slice)); Program.Assert(overview.Contains("[分段概述]"), "总览轮要求 [分段概述] 块"); Program.Assert(overview.Contains("不要修改"), "总览轮不修改边界"); Program.Assert( overview.Contains("本阶段不会获取原始操作记录"), "总览轮明确不获取原始记录"); Program.Assert( overview.Contains("不要输出整局叙述"), "总览轮不输出深度叙述"); var segment = AIAnalyze.BuildSegmentUserPromptV2( 0, 1, slice, 500, "开局", "前期平稳发育", new[] { "1:20~1:45" }); Program.Assert(segment.Contains("第1/1段"), "段指令编号"); Program.Assert(segment.Contains("开局"), "段标题"); Program.Assert(segment.Contains("前期平稳发育"), "段概述"); Program.Assert(segment.Contains("1:20~1:45"), "总览回查提示"); Program.Assert(segment.Contains("[回查]"), "回查说明"); Program.Assert(segment.Contains("[小结]"), "小结要求"); Program.Assert(segment.Contains("[机器可读声明]"), "机器可读声明要求"); // 焦点窗口指令:数据=整段,重点=时间段(时间段应优先于窗口编号) var focusWindow = new FocusWindow(0, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(2), 4, 300); var focus = AIAnalyze.BuildFocusWindowUserPromptV2( 0, 1, slice, 0, 1, focusWindow, 500, 40, "开局", "前期平稳发育", new[] { "1:20~1:45" }); Program.Assert(focus.Contains("请重点分析 游戏开始 至 游戏结束 时间段的操作数据"), "单窗口=整段时间段作为重点"); Program.Assert(!focus.Contains("第1/1段"), "单窗口不再用段编号表达重点"); Program.Assert(focus.Contains("完整操作记录"), "数据提示包含整段记录"); Program.Assert(focus.Contains("40 条操作信息"), "窗口事件数"); Program.Assert(focus.Contains("跨时间"), "鼓励跨时间关联"); Program.Assert(focus.Contains("[回查]"), "回查说明"); Program.Assert(focus.Contains("[机器可读声明]"), "机器可读声明要求"); var multiFocus = AIAnalyze.BuildFocusWindowUserPromptV2( 0, 1, slice, 1, 3, focusWindow, 500, 40, "开局"); Program.Assert(multiFocus.Contains("请重点分析 0:30.00 至 2:00.00 时间段的操作数据"), "多窗口=时间段作为重点"); Program.Assert(multiFocus.Contains("当前是第 2 个"), "窗口编号降级为次要说明"); Program.Assert(multiFocus.Contains("后续时间段会依次分析"), "后续时间段说明"); var summary = AIAnalyze.BuildSummaryUserPromptV2(1000); Program.Assert(summary.Contains("总结"), "总结指令"); var backquery = AIAnalyze.BuildBackqueryUserPrompt("切片内容"); Program.Assert(backquery.Contains("切片内容"), "回查指令携带切片"); var revision = AIAnalyze.BuildRevisionUserPrompt("草稿", "问题1", "事实1"); Program.Assert(revision.Contains("问题1") && revision.Contains("事实1") && revision.Contains("草稿"), "修订指令内容"); Program.Assert(revision.Contains("不要提及"), "修订不暴露修订过程"); } } internal static class RevisionFactsAndSerializationTests { public static void Run() { var index = TestData.BuildIndex( firstObserved: ImmutableDictionary.Empty.Add(1, TimeSpan.FromSeconds(60)), strong: ImmutableDictionary>.Empty .Add(4, ImmutableHashSet.Create(1)), builders: ImmutableHashSet.Empty.Add(1)); var claims = new AIMachineReadableClaims( ImmutableArray.Create(new AIUnitClaim("1", "PlayerA", "AlliedMCV", AIEvidenceLevel.Confirmed, ImmutableArray.Create("power|0:01.00|SpecialPower_PackReplaceSelf|1"), ImmutableArray.Empty, ImmutableArray.Empty)), ImmutableArray.Empty, ImmutableArray.Empty); var facts = RelevantFactsFormatter.Format(claims, index); Program.Assert(facts.Contains("首次出现 1:00"), "事实包含首次出现时间"); Program.Assert(facts.Contains("建造者"), "事实包含建造者"); Program.Assert(facts.Contains("强所有权"), "事实包含强所有权"); // ChatMessage 序列化为小写 role/content,兼容 OpenAI 兼容端点 var message = new AIAnalyze.ChatMessage("user", "内容"); var json = JsonSerializer.Serialize(message); Program.Assert(json.Contains("\"role\":\"user\""), "role 小写"); Program.Assert(json.Contains("\"content\":"), "content 字段小写"); var deserialized = JsonSerializer.Deserialize(json); Program.Assert(deserialized!.Role == "user" && deserialized.Content == "内容", "往返一致"); } } /// /// AI 分析主工程实验代码已清理;本类只存在于测试工程中。 /// 用于直接构造 OpenAI 兼容请求,避免把实验字段带回主项目。 /// internal class TestAIAnalyze { private readonly TestAiClient _client = new(); internal sealed class Result { public Result(string response, string? reasoning) { Response = response; Reasoning = reasoning; } public string Response { get; } public string? Reasoning { get; } public int? PromptTokens { get; } public int? CompletionTokens { get; } public int? TotalTokens { get; } public int? ReasoningTokens { get; } } internal sealed record ChatMessage( [property: JsonPropertyName("role")] string Role, [property: JsonPropertyName("content"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Content = null, [property: JsonPropertyName("reasoning_content"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null, [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ToolCalls = null, [property: JsonPropertyName("tool_call_id"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolCallId = null) { internal sealed record ToolCall( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("function")] ToolCallFunction Function); internal sealed record ToolCallFunction( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("arguments")] string Arguments); internal static ChatMessage Assistant(string? content, string? reasoningContent = null) => new("assistant", string.IsNullOrEmpty(content) ? null : content, string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent); internal static ChatMessage AssistantToolCall( string? reasoningContent, string toolCallId, string toolName, string arguments) => new( "assistant", null, reasoningContent, new[] { new ChatMessage.ToolCall( toolCallId, "function", new ChatMessage.ToolCallFunction(toolName, arguments)) }, null); internal static ChatMessage ToolResult(string toolCallId, string content) => new("tool", content, null, null, toolCallId); } public async Task CompleteAsync( ImmutableList messages, AiRequestContext context, object? unused, CancellationToken cancellationToken) { var response = await _client.CompleteAsync( context.Provider, context.Model, messages, cancellationToken); if (response.Error is not null) { throw new InvalidOperationException(response.Error); } return new Result(response.Content ?? string.Empty, response.Reasoning); } public static string BuildOverviewUserPrompt(IReadOnlyList slices) => AnotherReplayReader.Utils.AIAnalyze.BuildOverviewUserPrompt(slices); public static string BuildSegmentUserPromptV2( int segmentIndex, int totalSegments, ReplaySlice slice, int eventCount, string? title, string? description = null, IEnumerable? backqueryHints = null) => AnotherReplayReader.Utils.AIAnalyze.BuildSegmentUserPromptV2( segmentIndex, totalSegments, slice, eventCount, title, description, backqueryHints); public static string BuildFocusWindowUserPromptV2( int segmentIndex, int totalSegments, ReplaySlice slice, int windowIndex, int windowCount, FocusWindow window, int sliceEventCount, int windowEventCount, string? title, string? description = null, IEnumerable? backqueryHints = null) => AnotherReplayReader.Utils.AIAnalyze.BuildFocusWindowUserPromptV2( segmentIndex, totalSegments, slice, windowIndex, windowCount, window, sliceEventCount, windowEventCount, title, description, backqueryHints); public static string BuildSummaryUserPromptV2(int totalEventCount) => AnotherReplayReader.Utils.AIAnalyze.BuildSummaryUserPromptV2(totalEventCount); public static string BuildBackqueryUserPrompt(string sliceText) => AnotherReplayReader.Utils.AIAnalyze.BuildBackqueryUserPrompt(sliceText); public static string BuildRevisionUserPrompt( string draft, string validationIssues, string relevantFacts) => AnotherReplayReader.Utils.AIAnalyze.BuildRevisionUserPrompt( draft, validationIssues, relevantFacts); } internal sealed record TestAiResponse( string? Content, string? Reasoning, string? Error = null, int? StatusCode = null); internal sealed class TestAiClient { private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) }; public async Task CompleteAsync( AiProvider provider, AiModel model, IReadOnlyList messages, CancellationToken cancellationToken) { var request = new Dictionary { ["model"] = model.ModelId, ["temperature"] = provider.DefaultTemperature, ["top_p"] = provider.DefaultTopP, ["max_tokens"] = provider.DefaultMaxTokens, ["stream"] = false }; foreach (var kv in model.ExtraParameters) { request[kv.Key] = kv.Value; } request["messages"] = messages.ToArray(); var uri = new Uri(new(provider.BaseUrl.TrimEnd('/') + "/"), "chat/completions"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, uri); httpRequest.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", provider.ApiKey); httpRequest.Content = new StringContent( JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); using var response = await _http.SendAsync( httpRequest, cancellationToken); var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { var errorMessage = body; try { using var errorDocument = JsonDocument.Parse(body); if (errorDocument.RootElement.TryGetProperty("error", out var error) && error.TryGetProperty("message", out var message)) { errorMessage = message.GetString() ?? body; } } catch { // 保留原始响应体。 } return new TestAiResponse( null, null, errorMessage, (int)response.StatusCode); } string? content = null; string? reasoning = null; using var document = JsonDocument.Parse(body); AppendFromResponse(document.RootElement, ref content, ref reasoning); return new TestAiResponse( content, reasoning, null, (int)response.StatusCode); } private static void AppendFromResponse( JsonElement root, ref string? content, ref string? reasoning) { if (!root.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0) { return; } var message = choices[0].GetProperty("message"); if (message.TryGetProperty("content", out var contentProperty) && contentProperty.ValueKind == JsonValueKind.String) { content = contentProperty.GetString(); } if (message.TryGetProperty("reasoning_content", out var reasoningProperty) && reasoningProperty.ValueKind == JsonValueKind.String) { reasoning = reasoningProperty.GetString(); } } } internal sealed class GuardHttpHandler : HttpMessageHandler { public int RequestCount; public string? FirstRequestBody; public string? SecondRequestBody; public bool FirstResponseContentOnly; public bool SecondResponseInterrupted; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var body = await request.Content!.ReadAsStringAsync(); RequestCount++; if (RequestCount == 1) { FirstRequestBody = body; if (FirstResponseContentOnly) { return CreateSse( "data: {\"choices\":[{\"delta\":{\"content\":\"final\"}}]}\n", "data: [DONE]\n"); } return CreateSse( "data: {\"choices\":[{\"delta\":{\"content\":\"partial first\"}}]}\n", "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"reasoning\"}}]}\n"); } SecondRequestBody = body; if (SecondResponseInterrupted) { return CreateSse( "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"second reasoning\"}}]}\n"); } return CreateSse( "data: {\"choices\":[{\"delta\":{\"content\":\"final\"}}]}\n", "data: [DONE]\n"); } private static HttpResponseMessage CreateSse(params string[] lines) { return new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent( string.Concat(lines), Encoding.UTF8, "text/event-stream") }; } } internal static class ReasoningGuardTests { public static void Run() { TestPrepareReasoning(); TestToolPrompt(); TestMessageSerialization(); TestDisabledAsync().GetAwaiter().GetResult(); TestGuardedContinuationAsync().GetAwaiter().GetResult(); TestFallbackAsync().GetAwaiter().GetResult(); } private static AiProvider CreateProvider() => new() { Name = "测试", BaseUrl = "http://localhost/v1", ApiKey = "test-key", DefaultMaxTokens = 16384 }; private static AiModel CreateModel(bool enabled = true) => new() { ModelId = "deepseek-v4-flash", IsStream = true, ContextLength = 1_000_000, ContextBudget = 160_000, ReasoningGuardEnabled = enabled, ReasoningGuardTokenLimit = enabled ? 1 : null, ExtraParameters = new Dictionary() }; private static ImmutableList CreateMessages() => ImmutableList.Empty .Add(new MainAIAnalyze.ChatMessage("system", "测试系统")) .Add(new MainAIAnalyze.ChatMessage("user", "请分析。")); private static async Task CompleteAsync( GuardHttpHandler handler, AiModel model) { using var http = new HttpClient(handler); var analyzer = new MainAIAnalyze(http); return await analyzer.CompleteAsync( CreateMessages(), new AiRequestContext(CreateProvider(), model), _ => { }, CancellationToken.None); } private static void TestPrepareReasoning() { var shortReasoning = "第一行。\n第二行。\n第三行。"; var prepared = AiReasoningGuard.PrepareReasoning(shortReasoning, 1000); Program.Assert( prepared.Text.TrimEnd().EndsWith(AiReasoningGuard.WrapUpStatement), "末尾收尾语句"); Program.Assert( prepared.Text.IndexOf(AiReasoningGuard.TruncationMarker) >= 0 && prepared.Text.IndexOf(AiReasoningGuard.TruncationMarker) == prepared.Text.LastIndexOf(AiReasoningGuard.TruncationMarker), "只追加一次截断标记"); Program.Assert( prepared.Text.IndexOf(AiReasoningGuard.TruncationMarker) < prepared.Text.IndexOf(AiReasoningGuard.WrapUpStatement), "截断标记在收尾语句之前"); var longReasoning = "前文。\n" + new string('x', 500) + "后文。"; var truncated = AiReasoningGuard.PrepareReasoning(longReasoning, 10); Program.Assert(!truncated.Text.Contains("后文"), "超限部分被截掉"); var formatState = AiReasoningGuard.DescribeFormatState( "```json\n{\"unitClaims\":["); Program.Assert( formatState.Contains("代码围栏未闭合") && formatState.Contains("JSON"), "格式状态检测"); } private static void TestToolPrompt() { var result = AiReasoningGuard.BuildToolResult(); Program.Assert( result.Contains("立即停止继续展开推理") && result.Contains("直接输出最终结果") && result.Contains("最后一句已经宣告收尾"), "强收尾措辞"); Program.Assert( !result.Contains("上一轮思维链") && !result.Contains("截断点") && !result.Contains("My reasoning has been truncated") && !result.Contains("token limit"), "避免歧义措辞"); } private static void TestMessageSerialization() { var assistant = MainAIAnalyze.ChatMessage.AssistantToolCall( "thinking", "call_1", "analysis_hint", "{\"instruction\":\"wrap up\"}"); var assistantJson = JsonSerializer.Serialize(assistant); Program.Assert( assistantJson.Contains("\"reasoning_content\":\"thinking\"") && assistantJson.Contains("\"tool_calls\"") && assistantJson.Contains("\"analysis_hint\""), "assistant 工具历史序列化"); var tool = MainAIAnalyze.ChatMessage.ToolResult( "call_1", AiReasoningGuard.BuildToolResult()); var toolJson = JsonSerializer.Serialize(tool); Program.Assert( toolJson.Contains("\"tool_call_id\":\"call_1\""), "tool 结果序列化"); } private static async Task TestDisabledAsync() { var handler = new GuardHttpHandler { FirstResponseContentOnly = true }; var result = await CompleteAsync(handler, CreateModel(enabled: false)); Program.AssertEqual(1, handler.RequestCount, "未启用时只发一次请求"); Program.Assert(!result.ContinuationApplied, "未启用不续写"); Program.AssertEqual("final", result.Response, "未启用保持普通结果"); } private static async Task TestGuardedContinuationAsync() { var handler = new GuardHttpHandler(); var result = await CompleteAsync(handler, CreateModel()); Program.AssertEqual(2, handler.RequestCount, "触发后发起一次续写"); Program.Assert(result.ContinuationApplied, "续写成功"); Program.AssertEqual("final", result.Response, "续写返回最终正文"); Program.Assert( handler.FirstRequestBody is { } firstBody && !firstBody.Contains("\"tools\""), "首次请求不携带 tool 定义"); Program.Assert( handler.SecondRequestBody is { } secondBody && secondBody.Contains("\"tools\"") && secondBody.Contains("\"tool_choice\":\"none\"") && secondBody.Contains("\"reasoning_content\"") && secondBody.Contains("\"tool_calls\"") && secondBody.Contains("\"tool_call_id\""), "续写请求携带工具历史"); Program.Assert( result.ReasoningOriginalRequestJson is { } originalJson && originalJson.Contains("\"messages\""), "首次请求 JSON 回传 UI"); Program.Assert( result.ReasoningContinuationRequestJson is { } continuationJson && continuationJson.Contains("\"tools\"") && continuationJson.Contains("\"reasoning_content\"") && continuationJson.Contains("我已经整理出足够的信息"), "修改后请求 JSON 回传 UI"); } private static async Task TestFallbackAsync() { var handler = new GuardHttpHandler { SecondResponseInterrupted = true }; var result = await CompleteAsync(handler, CreateModel()); Program.Assert(!result.ContinuationApplied, "二次超限不再续写"); Program.Assert(result.ReasoningInterrupted, "保留中断状态"); Program.Assert( result.ReasoningContinuationError is { } error && error.Contains("回退"), "回退提示"); Program.Assert( result.ReasoningContinuationRequestJson is { } continuationJson && continuationJson.Contains("\"tools\""), "回退时仍提供修改后请求 JSON"); Program.AssertEqual("partial first", result.Response, "回退首次部分正文"); } } internal static class OpenCodeGoFakeToolCallTests { private const string SystemPrompt = "你是用于验证思维链回传的实验辅助。请先用 reasoning_content 进行内部推理,再给出简短、准确的回答。"; private const string FirstUserPrompt = "请分析:A 比 B 高 20%,B 比 C 高 25%,那么 A 比 C 高多少?请先思考,再回答最终百分比。"; private const string ToolName = "analysis_hint"; private const string MemoryMarker = "TOKEN_MARK=731942"; public static void Run() { if (Environment.GetEnvironmentVariable("ARR_AI_E2E") != "1" || Environment.GetEnvironmentVariable("ARR_AI_E2E_TOOL") != "1") { Console.WriteLine( " [跳过] 未同时设置 ARR_AI_E2E=1 与 ARR_AI_E2E_TOOL=1," + "跳过伪造 tool call 历史实验"); return; } RunAsync().GetAwaiter().GetResult(); } private static async Task RunAsync() { var settingsPath = FindSettingsPath(); Console.WriteLine($" [E2E-TOOL] 配置:{settingsPath}"); var settings = JsonSerializer.Deserialize( File.ReadAllText(settingsPath, Encoding.UTF8)) ?? throw new InvalidOperationException("AI 设置文件无法解析"); var provider = settings.Providers.FirstOrDefault(p => p.Name.Equals("OpenCodeGo", StringComparison.OrdinalIgnoreCase) && p.Models.Any(m => m.ModelId.Equals("deepseek-v4-flash", StringComparison.OrdinalIgnoreCase))) ?? throw new InvalidOperationException( "当前配置中未找到 OpenCodeGo / deepseek-v4-flash 服务与模型"); var model = provider.Models.First(m => m.ModelId.Equals("deepseek-v4-flash", StringComparison.OrdinalIgnoreCase)); if (string.IsNullOrWhiteSpace(provider.ApiKey)) { throw new InvalidOperationException("OpenCodeGo 的 API Key 为空"); } Console.WriteLine( $" [E2E-TOOL] Provider={provider.Name}; Model={model.ModelId}; " + $"BaseUrl={provider.BaseUrl}; Stream={model.IsStream}; ApiKey=***"); var report = new StringBuilder(); AppendHeader(report, provider, model, settingsPath); var reportPath = Environment.GetEnvironmentVariable("ARR_AI_TOOL_REPORT_PATH"); if (string.IsNullOrWhiteSpace(reportPath)) { reportPath = Path.Combine( Environment.CurrentDirectory, "AI_reasoning_continuation_position_sweep_report.md"); } reportPath = Path.GetFullPath(reportPath); try { var analyzer = new AIAnalyze(); var normalContext = new AiRequestContext(provider, model); var firstMessages = ImmutableList.Empty .Add(new AIAnalyze.ChatMessage("system", SystemPrompt)) .Add(new AIAnalyze.ChatMessage("user", FirstUserPrompt)); var first = await CompleteAndRecordAsync( analyzer, normalContext, firstMessages, "初始请求(system + user)", report); var firstReasoning = first?.Reasoning ?? string.Empty; if (first is null || firstReasoning.Length == 0) { report.AppendLine("初始请求未获得 reasoning_content,无法继续伪造 tool call 实验。"); return; } var initialFirst = first; var toolContext = BuildToolContext(provider, model); var wordings = new[] { new { Name = "措辞B:当前推理", Target = "你当前正在进行的内部推理", ArgumentInstruction = "继续你当前正在进行的内部推理,并参考工具结果。" }, new { Name = "措辞D:tool_calls 携带的 reasoning_content", Target = "你本次 tool_calls 消息中携带的 reasoning_content", ArgumentInstruction = "继续你本次 tool_calls 消息中携带的 reasoning_content,并参考工具结果。" } }; var positions = new[] { new { Name = "25%", Ratio = 0.25 }, new { Name = "50%", Ratio = 0.50 }, new { Name = "75%", Ratio = 0.75 }, new { Name = "末尾", Ratio = 1.0 } }; const int repetitions = 5; var combinedCount = wordings.Length * positions.Length; var foundCounts = new int[combinedCount]; var missingCounts = new int[combinedCount]; var failedCounts = new int[combinedCount]; var names = new string[combinedCount]; var markerInSent = new bool[combinedCount]; var comboIndex = 0; foreach (var wording in wordings) { foreach (var position in positions) { var comboName = $"{wording.Name} / 标记位置 {position.Name}"; names[comboIndex] = comboName; var reasoningWithMarker = InsertMemoryMarker( firstReasoning, position.Ratio); markerInSent[comboIndex] = reasoningWithMarker.Contains("731942"); Console.WriteLine( $" [E2E-TOOL] 正在测试:{comboName}(重复 5 次)"); for (var rep = 0; rep < repetitions; ++rep) { var toolCallId = $"call_position_{comboIndex + 1}_{rep + 1}"; var arguments = JsonSerializer.Serialize(new { instruction = wording.ArgumentInstruction }); var messages = ImmutableList.Empty .Add(new AIAnalyze.ChatMessage("system", SystemPrompt)) .Add(new AIAnalyze.ChatMessage("user", FirstUserPrompt)) .Add(AIAnalyze.ChatMessage.AssistantToolCall( reasoningWithMarker, toolCallId, ToolName, arguments)) .Add(AIAnalyze.ChatMessage.ToolResult( toolCallId, BuildToolResult(wording.Target))); var result = await CompleteAndRecordAsync( analyzer, toolContext, messages, $"记忆标记 {comboName}(重复 {rep + 1}/{repetitions})", report); if (result is { } completed) { var combined = (completed.Reasoning ?? string.Empty) + "\n" + completed.Response; var found = ContainsMemoryMarker(combined); var missing = SaysMarkerMissing(combined); if (found) { foundCounts[comboIndex]++; } if (missing) { missingCounts[comboIndex]++; } Console.WriteLine( $" [E2E-TOOL] {comboName} [{rep + 1}/{repetitions}]:" + $"content={completed.Response.Length};" + $"reasoning={(completed.Reasoning ?? string.Empty).Length};" + $"markerFound={found};missing={missing}"); } else { failedCounts[comboIndex]++; Console.WriteLine( $" [E2E-TOOL] {comboName} [{rep + 1}/{repetitions}]:请求失败(详见报告)"); } } comboIndex++; } } AppendSummary( report, names, markerInSent, foundCounts, missingCounts, failedCounts); } finally { var reportDir = Path.GetDirectoryName(reportPath); if (!string.IsNullOrEmpty(reportDir)) { Directory.CreateDirectory(reportDir); } File.WriteAllText(reportPath, report.ToString(), new UTF8Encoding(false)); Console.WriteLine($" [E2E-TOOL] 人工审阅报告已写入:{reportPath}"); } } private static AiRequestContext BuildToolContext( AiProvider provider, AiModel sourceModel) { var model = new AiModel { ModelId = sourceModel.ModelId, DisplayName = sourceModel.DisplayName, IsStream = sourceModel.IsStream, ContextLength = sourceModel.ContextLength, ContextBudget = sourceModel.ContextBudget, ExtraParameters = new Dictionary(sourceModel.ExtraParameters) }; model.ExtraParameters["tools"] = new[] { new { type = "function", function = new { name = ToolName, description = "向模型提供上一轮思维链的继续提示。", parameters = new { type = "object", properties = new { instruction = new { type = "string", description = "模型应遵循的续写提示。" } }, required = new[] { "instruction" } } } } }; model.ExtraParameters["tool_choice"] = "none"; return new AiRequestContext(provider, model); } private static string BuildToolResult(string target) => "工具结果:请继续" + target + "。这是一次确定性记忆测试。" + "该思考内容中有一个内部记忆标记,请不要从本工具结果中猜测或寻找。" + "请原样写出该标记;如果该思考内容中没有该标记,请明确回答“标记不存在”。" + "不要从零重新推导,也不要杜撰标记。"; private static string InsertMemoryMarker(string text, double ratio) { if (ratio >= 1) { return text + "\n\n[内部记忆标记 " + MemoryMarker + "]"; } var index = Math.Max( 0, Math.Min(text.Length, (int)(text.Length * ratio))); return text.Insert( index, "\n\n[内部记忆标记 " + MemoryMarker + "]\n\n"); } private static bool ContainsMemoryMarker(string text) => text.Contains("731942") || text.Contains("TOKEN_MARK"); private static bool SaysMarkerMissing(string text) => text.Contains("标记不存在") || text.Contains("没有该标记") || text.Contains("没有标记"); private static void AppendSummary( StringBuilder report, string[] names, bool[] markerInSent, int[] foundCounts, int[] missingCounts, int[] failedCounts) { report.AppendLine("## 记忆标记统计"); report.AppendLine(); report.AppendLine($"- 记忆标记:`{MemoryMarker}`"); report.AppendLine("- 每个组合重复 5 次。"); report.AppendLine(); for (var i = 0; i < names.Length; ++i) { report.AppendLine( $"- **{names[i]}**:发送链包含标记 = {markerInSent[i]};" + $"正确写出标记 = {foundCounts[i]}/5;" + $"明确说没有 = {missingCounts[i]}/5;" + $"请求失败 = {failedCounts[i]}/5"); } report.AppendLine(); } private static async Task CompleteAndRecordAsync( AIAnalyze analyzer, AiRequestContext context, ImmutableList messages, string label, StringBuilder report) { var sw = Stopwatch.StartNew(); try { var result = await analyzer.CompleteAsync( messages, context, null, CancellationToken.None); sw.Stop(); AppendRecord(report, label, messages, result, sw.Elapsed); return result; } catch (Exception ex) { sw.Stop(); report.AppendLine($"## {label}(请求失败)"); report.AppendLine(); report.AppendLine($"- 耗时:{sw.Elapsed.TotalSeconds:0.00} 秒"); report.AppendLine($"- 错误:{ex.Message}"); report.AppendLine("- 关键点:assistant 消息包含 tool_calls,随后有 tool 回复;请求携带 tools 定义。"); report.AppendLine(); report.AppendLine("---"); report.AppendLine(); Console.WriteLine($" [E2E-TOOL] {label} 失败:{ex.Message}"); return null; } } private static void AppendHeader( StringBuilder report, AiProvider provider, AiModel model, string settingsPath) { report.AppendLine("# 伪造 tool call 历史实验"); report.AppendLine(); report.AppendLine($"- 配置:{provider.Name} / {model.ModelId}"); report.AppendLine($"- BaseUrl:{provider.BaseUrl}"); report.AppendLine($"- 配置文件:{settingsPath}"); report.AppendLine("- API Key:***(不写入报告)"); report.AppendLine(); report.AppendLine("本实验在请求中声明了 `tools` 并将 `tool_choice` 设为 `none`;"); report.AppendLine("构造的历史为:system → 初始 user → assistant(reasoning_content + tool_calls) → tool(tool_call_id + 提示结果)。"); report.AppendLine( $"完整思维链按 25%/50%/75%/末尾四种位置注入确定性记忆标记 `{MemoryMarker}`;" + "工具结果使用“当前推理”和“本次 tool_calls 消息携带的 reasoning_content”两种措辞," + "且不提及“上一轮”“截断点”等概念,要求模型原样写出标记。"); report.AppendLine("没有第 5 条新的 user 消息。"); report.AppendLine(); } private static void AppendRecord( StringBuilder report, string label, ImmutableList messages, AIAnalyze.Result result, TimeSpan elapsed) { report.AppendLine($"## {label}"); report.AppendLine(); report.AppendLine($"- 耗时:{elapsed.TotalSeconds:0.00} 秒"); report.AppendLine($"- prompt_tokens:{result.PromptTokens?.ToString() ?? "?"}"); report.AppendLine($"- completion_tokens:{result.CompletionTokens?.ToString() ?? "?"}"); report.AppendLine($"- reasoning_tokens:{result.ReasoningTokens?.ToString() ?? "?"}"); report.AppendLine(); report.AppendLine("### 发送的 messages"); report.AppendLine(); for (var i = 0; i < messages.Count; ++i) { var message = messages[i]; report.AppendLine($"**{i + 1}. role = `{message.Role}`**"); report.AppendLine(); if (message.Content is null) { report.AppendLine("content:`未发送`"); } else { report.AppendLine("content:"); AppendTextBlock(report, message.Content); } report.AppendLine(); if (message.ToolCallId is not null) { report.AppendLine($"tool_call_id:`{message.ToolCallId}`"); report.AppendLine(); } if (message.ToolCalls is { } toolCalls) { report.AppendLine("tool_calls:"); foreach (var toolCall in toolCalls) { report.AppendLine( $"- id=`{toolCall.Id}`; type=`{toolCall.Type}`; " + $"name=`{toolCall.Function.Name}`"); report.AppendLine(); report.AppendLine("arguments:"); AppendTextBlock(report, toolCall.Function.Arguments); } report.AppendLine(); } if (message.Role == "assistant") { report.AppendLine( message.ReasoningContent is null ? "reasoning_content:`未发送`" : "reasoning_content:"); if (message.ReasoningContent is not null) { AppendTextBlock(report, message.ReasoningContent); } report.AppendLine(); } report.AppendLine(); } report.AppendLine("### 模型响应"); report.AppendLine(); report.AppendLine("新 reasoning_content:"); AppendTextBlock(report, result.Reasoning ?? string.Empty); report.AppendLine(); report.AppendLine("回答正文(content):"); AppendTextBlock(report, result.Response); report.AppendLine(); report.AppendLine("---"); report.AppendLine(); } private static void AppendTextBlock(StringBuilder report, string? text) { report.AppendLine("```text"); report.AppendLine(string.IsNullOrEmpty(text) ? "(空)" : text!); report.AppendLine("```"); } internal static string FindSettingsPath() { var explicitPath = Environment.GetEnvironmentVariable("ARR_AI_SETTINGS_PATH"); if (!string.IsNullOrWhiteSpace(explicitPath) && File.Exists(explicitPath)) { return explicitPath; } var dir = new DirectoryInfo(AppContext.BaseDirectory); for (var i = 0; i < 6 && dir is not null; ++i) { var candidate = Path.Combine( dir.FullName, "bin", "Debug", "net461", "AnotherReplayReader.ai_settings.json"); if (File.Exists(candidate)) { return candidate; } var directCandidate = Path.Combine( dir.FullName, "AnotherReplayReader.ai_settings.json"); if (File.Exists(directCandidate)) { return directCandidate; } dir = dir.Parent; } throw new InvalidOperationException( "未找到 AnotherReplayReader.ai_settings.json;可用 ARR_AI_SETTINGS_PATH 指定"); } } internal static class OpenCodeGoGuardE2e { public static void Run() { if (Environment.GetEnvironmentVariable("ARR_AI_GUARD_E2E") != "1") { Console.WriteLine( " [跳过] 未设置 ARR_AI_GUARD_E2E=1,跳过真实推理保护实验"); return; } RunAsync().GetAwaiter().GetResult(); } private static async Task RunAsync() { var settingsPath = OpenCodeGoFakeToolCallTests.FindSettingsPath(); var settings = JsonSerializer.Deserialize( File.ReadAllText(settingsPath, Encoding.UTF8)) ?? throw new InvalidOperationException("AI 设置文件无法解析"); var provider = settings.Providers.FirstOrDefault(p => p.Name.Equals("OpenCodeGo", StringComparison.OrdinalIgnoreCase) && p.Models.Any(m => m.ModelId.Equals("deepseek-v4-flash", StringComparison.OrdinalIgnoreCase))) ?? throw new InvalidOperationException( "当前配置中未找到 OpenCodeGo / deepseek-v4-flash 服务与模型"); var model = provider.Models.First(m => m.ModelId.Equals("deepseek-v4-flash", StringComparison.OrdinalIgnoreCase)); if (!model.IsStream) { Console.WriteLine( " [跳过] OpenCodeGo/deepseek-v4-flash 当前配置为非流式模型,无法验证中断逻辑"); return; } if (string.IsNullOrWhiteSpace(provider.ApiKey)) { throw new InvalidOperationException("OpenCodeGo 的 API Key 为空"); } var guardModel = new AiModel { ModelId = model.ModelId, DisplayName = model.DisplayName, IsStream = model.IsStream, ContextLength = model.ContextLength, ContextBudget = model.ContextBudget, ReasoningGuardEnabled = true, ReasoningGuardTokenLimit = 1, ExtraParameters = new Dictionary(model.ExtraParameters) }; var messages = ImmutableList.Empty .Add(new MainAIAnalyze.ChatMessage( "system", "请先进行详细推理,再给出简短结论。")) .Add(new MainAIAnalyze.ChatMessage( "user", "请逐条说明分析步骤,最后用一到两句话给出结论。")); var analyzer = new MainAIAnalyze(); var result = await analyzer.CompleteAsync( messages, new AiRequestContext(provider, guardModel), _ => { }, CancellationToken.None); var report = new StringBuilder(); report.AppendLine("# AI 推理保护 E2E 报告"); report.AppendLine(); report.AppendLine($"- Provider:{provider.Name}"); report.AppendLine($"- Model:{guardModel.ModelId}"); report.AppendLine($"- ReasoningInterrupted:{result.ReasoningInterrupted}"); report.AppendLine($"- ContinuationApplied:{result.ContinuationApplied}"); report.AppendLine($"- Reasoning 长度:{result.Reasoning?.Length ?? 0}"); report.AppendLine($"- Response 长度:{result.Response.Length}"); report.AppendLine($"- FormatState:{result.ReasoningFormatState ?? "(无)"}"); if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationError)) { report.AppendLine($"- Error:{result.ReasoningContinuationError}"); } var reportPath = Environment.GetEnvironmentVariable("ARR_AI_GUARD_REPORT_PATH"); if (!string.IsNullOrWhiteSpace(reportPath)) { File.WriteAllText(reportPath, report.ToString(), new UTF8Encoding(false)); Console.WriteLine($" [E2E-GUARD] 报告已写入:{reportPath}"); } Console.WriteLine( $" [E2E-GUARD] interrupted={result.ReasoningInterrupted}; " + $"continued={result.ContinuationApplied}; " + $"reasoning={result.Reasoning?.Length ?? 0}; response={result.Response.Length}"); } } internal static class UserReplayFactIndexReproTests { 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)) { Console.WriteLine(" [跳过] 未找到用户回放文件"); return; } var replay = new Replay(replayPath, parseBody: true); Program.Assert(replay.Body is { } body, "回放解析"); var timeline = (from chunk in body where chunk.Type == 1 select (chunk.Time, CommandChunk.Parse(chunk).ToImmutableArray())) .ToImmutableArray(); // 诊断:0x1FE/0x200 中 Int32 数组条目 foreach (var (time, commands) in timeline) { foreach (var command in commands) { if (command.CommandId is not (0x1FE or 0x200)) { continue; } foreach (var entry in command.Data) { if (entry.Type == CommandArgumentType.Int32 && entry.Count > 1) { Console.WriteLine($" [诊断] 0x{command.CommandId:X3} @{time} 玩家{command.PlayerIndex} Int32 count={entry.Count} valueType={entry.Value?.GetType().Name}"); } } } } ReplayFactIndex.Build(timeline, new Dictionary()); Program.Assert(true, "事实索引构建无异常"); } } }