focus on intervals, and fix provider
This commit is contained in:
+223
-3
@@ -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 个 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<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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user