1462 lines
76 KiB
C#
1462 lines
76 KiB
C#
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;
|
||
|
||
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("OverviewParser", OverviewParserTests.Run);
|
||
Run("BackqueryParser", BackqueryParserTests.Run);
|
||
Run("BackquerySliceExtractor", BackquerySliceExtractorTests.Run);
|
||
Run("StructuredEvidence", StructuredEvidenceTests.Run);
|
||
Run("MachineReadableClaims", MachineReadableClaimsTests.Run);
|
||
Run("ClaimFindingsFormatter", ClaimFindingsFormatterTests.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("OpenCodeGoFakeToolCallE2e", OpenCodeGoFakeToolCallTests.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>(T expected, T actual, string message)
|
||
{
|
||
if (EqualityComparer<T>.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<EventSpan>();
|
||
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<EventSpan>.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<EventSpan>();
|
||
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 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<EventSpan>();
|
||
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<EventSpan>.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<string>.Empty)),
|
||
ImmutableArray<AIEventClaim>.Empty,
|
||
ImmutableArray<AITimelineClaim>.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 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<int, Player>.Empty.Add(4, player);
|
||
var factIndex = TestData.BuildIndex(
|
||
ImmutableDictionary<uint, TimeSpan>.Empty.Add(1, TimeSpan.FromSeconds(80)),
|
||
ImmutableDictionary<uint, ImmutableHashSet<string>>.Empty.Add(
|
||
1, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")),
|
||
ImmutableHashSet<uint>.Empty.Add(1),
|
||
ImmutableHashSet<uint>.Empty,
|
||
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty.Add(
|
||
4, ImmutableDictionary<string, TimeSpan>.Empty.Add("AlliedMCV", TimeSpan.FromSeconds(100))),
|
||
ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty.Add(
|
||
4, ImmutableHashSet.Create<uint>(1)),
|
||
ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
|
||
ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
|
||
ImmutableDictionary<int, ImmutableHashSet<string>>.Empty);
|
||
|
||
const string fullText = "[0:00] 玩家 A,开始建造建筑\n [UnitId]1(建造者)\n AlliedBarracks\n\n[4:00] 玩家 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("AlliedMCV@1:40"), "首次出兵时间");
|
||
Program.Assert(digest.Contains("SpecialPower_PackReplaceSelf"), "打包/展开段");
|
||
Program.Assert(digest.Contains("建造者"), "建造者段");
|
||
Program.Assert(digest.Contains("第1段"), "分段元数据");
|
||
Program.Assert(digest.Contains("开始建造建筑"), "关键事件采样");
|
||
Program.Assert(digest.Contains("# 协议选择"), "摘要包含协议选择");
|
||
Program.Assert(digest.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<string, int> Mapping =
|
||
new Dictionary<string, int> { { "PlayerA", 4 }, { "PlayerB", 5 } };
|
||
|
||
public static void Run()
|
||
{
|
||
var index = TestData.BuildIndex(
|
||
firstObserved: ImmutableDictionary<uint, TimeSpan>.Empty
|
||
.Add(1, TimeSpan.FromSeconds(60))
|
||
.Add(2, TimeSpan.FromSeconds(90))
|
||
.Add(3, TimeSpan.FromSeconds(120))
|
||
.Add(7, TimeSpan.FromSeconds(60)),
|
||
strong: ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty
|
||
.Add(4, ImmutableHashSet.Create<uint>(1)),
|
||
powers: ImmutableDictionary<uint, ImmutableHashSet<string>>.Empty
|
||
.Add(7, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")),
|
||
weak: ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty
|
||
.Add(5, ImmutableHashSet.Create<uint>(1, 3)),
|
||
productions: ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty
|
||
.Add(4, ImmutableDictionary<string, TimeSpan>.Empty
|
||
.Add("AlliedBomberAircraft", TimeSpan.FromSeconds(300))),
|
||
tech: ImmutableDictionary<int, ImmutableHashSet<string>>.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<uint, TimeSpan>? firstObserved = null,
|
||
ImmutableDictionary<uint, ImmutableHashSet<string>>? powers = null,
|
||
ImmutableHashSet<uint>? builders = null,
|
||
ImmutableHashSet<uint>? producers = null,
|
||
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>? productions = null,
|
||
ImmutableDictionary<int, ImmutableHashSet<uint>>? selected = null,
|
||
ImmutableDictionary<int, ImmutableHashSet<uint>>? strong = null,
|
||
ImmutableDictionary<int, ImmutableHashSet<uint>>? weak = null,
|
||
ImmutableDictionary<int, ImmutableHashSet<string>>? tech = null) =>
|
||
new ReplayFactIndex(
|
||
firstObserved ?? ImmutableDictionary<uint, TimeSpan>.Empty,
|
||
powers ?? ImmutableDictionary<uint, ImmutableHashSet<string>>.Empty,
|
||
builders ?? ImmutableHashSet<uint>.Empty,
|
||
producers ?? ImmutableHashSet<uint>.Empty,
|
||
productions ?? ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty,
|
||
selected ?? ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
|
||
strong ?? ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
|
||
weak ?? ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
|
||
tech ?? ImmutableDictionary<int, ImmutableHashSet<string>>.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<uint, string>
|
||
{
|
||
[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<uint, string>());
|
||
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 → 弱所有权");
|
||
}
|
||
}
|
||
|
||
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("不要修改"), "总览轮不修改边界");
|
||
|
||
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 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<uint, TimeSpan>.Empty.Add(1, TimeSpan.FromSeconds(60)),
|
||
strong: ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty
|
||
.Add(4, ImmutableHashSet.Create<uint>(1)),
|
||
builders: ImmutableHashSet<uint>.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<string>.Empty,
|
||
ImmutableArray<string>.Empty)),
|
||
ImmutableArray<AIEventClaim>.Empty,
|
||
ImmutableArray<AITimelineClaim>.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<AIAnalyze.ChatMessage>(json);
|
||
Program.Assert(deserialized!.Role == "user" && deserialized.Content == "内容", "往返一致");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// AI 分析主工程实验代码已清理;本类只存在于测试工程中。
|
||
/// 用于直接构造 OpenAI 兼容请求,避免把实验字段带回主项目。
|
||
/// </summary>
|
||
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<ChatMessage.ToolCall>? 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<Result> CompleteAsync(
|
||
ImmutableList<ChatMessage> 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<ReplaySlice> slices) =>
|
||
AnotherReplayReader.Utils.AIAnalyze.BuildOverviewUserPrompt(slices);
|
||
|
||
public static string BuildSegmentUserPromptV2(
|
||
int segmentIndex,
|
||
int totalSegments,
|
||
ReplaySlice slice,
|
||
int eventCount,
|
||
string? title,
|
||
string? description = null,
|
||
IEnumerable<string>? backqueryHints = null) =>
|
||
AnotherReplayReader.Utils.AIAnalyze.BuildSegmentUserPromptV2(
|
||
segmentIndex, totalSegments, slice, eventCount,
|
||
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<TestAiResponse> CompleteAsync(
|
||
AiProvider provider,
|
||
AiModel model,
|
||
IReadOnlyList<TestAIAnalyze.ChatMessage> messages,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var request = new Dictionary<string, object>
|
||
{
|
||
["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 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<AiSettings>(
|
||
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<AIAnalyze.ChatMessage>.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<AIAnalyze.ChatMessage>.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<string, object>(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<AIAnalyze.Result?> CompleteAndRecordAsync(
|
||
AIAnalyze analyzer,
|
||
AiRequestContext context,
|
||
ImmutableList<AIAnalyze.ChatMessage> 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<AIAnalyze.ChatMessage> 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("```");
|
||
}
|
||
|
||
private 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 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<uint, string>());
|
||
Program.Assert(true, "事实索引构建无异常");
|
||
}
|
||
}
|
||
}
|