focus on intervals, and fix provider

This commit is contained in:
2026-09-08 14:44:34 +02:00
parent 579cd8e4b3
commit acad70aaac
11 changed files with 1163 additions and 286 deletions
+223 -3
View File
@@ -28,12 +28,14 @@ namespace AiV2.Tests
{
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);
@@ -168,6 +170,59 @@ namespace AiV2.Tests
}
}
internal static class FocusPlannerTests
{
public static void Run()
{
var sb = new StringBuilder();
var spans = ImmutableArray.CreateBuilder<EventSpan>();
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 个 span5K/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<EventSpan>();
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()
@@ -344,6 +399,68 @@ namespace AiV2.Tests
}
}
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()
@@ -376,12 +493,18 @@ namespace AiV2.Tests
{
public static void Run()
{
var player = new Player(new[] { "PTest", "0", "", "", "", "4", "", "1" });
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")),
ImmutableArray.Create(
new SpecialPowerEvent(
TimeSpan.FromSeconds(80),
4,
1,
"SpecialPower_PackReplaceSelf")),
ImmutableHashSet<uint>.Empty.Add(1),
ImmutableHashSet<uint>.Empty,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty.Add(
@@ -392,7 +515,7 @@ namespace AiV2.Tests
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";
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));
@@ -400,13 +523,33 @@ namespace AiV2.Tests
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("SpecialPower_PackReplaceSelf"), "打包/展开段");
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<int, Player>.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("- (无)"), "首次出兵/协议空表输出(无)");
}
}
@@ -515,6 +658,7 @@ namespace AiV2.Tests
public static ReplayFactIndex BuildIndex(
ImmutableDictionary<uint, TimeSpan>? firstObserved = null,
ImmutableDictionary<uint, ImmutableHashSet<string>>? powers = null,
ImmutableArray<SpecialPowerEvent>? specialPowerEvents = null,
ImmutableHashSet<uint>? builders = null,
ImmutableHashSet<uint>? producers = null,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>? productions = null,
@@ -525,6 +669,7 @@ namespace AiV2.Tests
new ReplayFactIndex(
firstObserved ?? ImmutableDictionary<uint, TimeSpan>.Empty,
powers ?? ImmutableDictionary<uint, ImmutableHashSet<string>>.Empty,
specialPowerEvents ?? ImmutableArray<SpecialPowerEvent>.Empty,
builders ?? ImmutableHashSet<uint>.Empty,
producers ?? ImmutableHashSet<uint>.Empty,
productions ?? ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty,
@@ -622,6 +767,41 @@ namespace AiV2.Tests
|| !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<uint, string>
{
[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 事件时间");
}
}
@@ -703,6 +883,12 @@ namespace AiV2.Tests
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" });
@@ -714,6 +900,24 @@ namespace AiV2.Tests
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("总结"), "总结指令");
@@ -860,6 +1064,22 @@ namespace AiV2.Tests
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<string>? 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);