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
+86 -70
View File
@@ -1,4 +1,4 @@
using AnotherReplayReader.Apm;
using AnotherReplayReader.Apm;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using System;
@@ -97,7 +97,6 @@ namespace AnotherReplayReader
// 输出段落
private (Paragraph? Think, Paragraph? Content)? _currentContent;
private Paragraph? _lastContentParagraph;
private bool _suppressDisplay;
private bool _nextThinkingIsContinuation;
// 进度计时
@@ -228,7 +227,6 @@ namespace AnotherReplayReader
_thinkBlockWasExpanded = false;
_currentContent = null;
_lastContentParagraph = null;
_suppressDisplay = false;
_nextThinkingIsContinuation = false;
_previousTotalTime = TimeSpan.Zero;
@@ -458,6 +456,8 @@ namespace AnotherReplayReader
var structuredKnowledge = StructuredKnowledge.GetForMod(
AIAnalyze.GetKnowledgeModName(_replay));
const int maxBackqueriesPerSegment = 3;
while (_currentSegmentIndex < _slices.Length)
{
_linkedCts.Token.ThrowIfCancellationRequested();
@@ -481,56 +481,89 @@ namespace AnotherReplayReader
var maxFindingsTokens = modelBudget > 0
? Math.Min(10_000, Math.Max(2_000, modelBudget / 10))
: 4_000;
var findingsText = LimitFindings(_findings, maxFindingsTokens);
var instruction = AIAnalyze.BuildSegmentUserPromptV2(
// 段内焦点窗口:数据保持整段切片(尽量长),分析重点落在窗口上。
var windows = FocusPlanner.Plan(slice, _eventSpans);
if (windows.IsEmpty)
{
windows = ImmutableArray.Create(
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens));
}
// 本段已发现事实:段内窗口之间的累积摘要(窗口内也会看到之前的段/窗口发现)。
var segmentFindings = new List<string>();
for (var windowIndex = 0; windowIndex < windows.Length; ++windowIndex)
{
_linkedCts.Token.ThrowIfCancellationRequested();
var window = windows[windowIndex];
_phaseText.Text =
$"正在分析第 {segmentIndex}/{totalSegments} 段(窗口 {windowIndex + 1}/{windows.Length}";
FinishCurrentContent();
_lastContentParagraph = null;
// 段内已发现事实:窗口之间共享“已发现事实”,但只保留本段窗口已发现的。
var combinedFindings = new List<string>(_findings);
combinedFindings.AddRange(segmentFindings);
var findingsText = LimitFindings(combinedFindings, maxFindingsTokens);
var windowEventCount =
_eventCounts?.Query(window.Start, window.End) ?? window.EventCount;
var instruction = AIAnalyze.BuildFocusWindowUserPromptV2(
_currentSegmentIndex,
totalSegments,
slice,
windowIndex,
windows.Length,
window,
eventCount,
windowEventCount,
title,
overviewEntry?.Description,
overviewEntry?.BackqueryHints);
// 数据层 = 整段切片(唯一的数据消息),只发一次;窗口指令作为“重点”指令。
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
.Add(new AIAnalyze.ChatMessage("user", sliceText));
if (!string.IsNullOrWhiteSpace(findingsText))
{
messages = messages.Add(new AIAnalyze.ChatMessage(
"user", "之前各段的已发现事实:\n" + findingsText));
"user", "之前的已发现事实:\n" + findingsText));
}
messages = messages.Add(new AIAnalyze.ChatMessage("user", instruction));
AppendLog(
$"让 AI 分析第{segmentIndex}段...",
$"让 AI 分析第{segmentIndex}段窗口 {windowIndex + 1}/{windows.Length}...",
instruction + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
true);
CheckAndLogContextUsage(messages, requestContext);
StartVersion("版本 1:正文", collapsedByDefault: false);
StartVersion(
$"窗口 {windowIndex + 1}/{windows.Length}:正文",
collapsedByDefault: false);
// 回查循环(M3):同一会话内,模型可多次请求远处原始区间
const int maxBackqueriesPerSegment = 3;
var segmentMessages = messages;
// 回查循环(M3):同一窗口会话内,模型可多次请求远处原始区间
var windowMessages = messages;
var backqueryCount = 0;
string segmentResponse;
string windowResponse;
while (true)
{
// 回查会增长同一会话的消息列表,每次请求前都重新做预算检查。
CheckAndLogContextUsage(segmentMessages, requestContext);
var segmentResult = await _analyzer!.CompleteAsync(
segmentMessages,
CheckAndLogContextUsage(windowMessages, requestContext);
var windowResult = await _analyzer!.CompleteAsync(
windowMessages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(segmentResult);
ReportReasoningGuardResult(segmentResult);
segmentResponse = segmentResult.Response;
UpdateTokenDisplay(windowResult);
ReportReasoningGuardResult(windowResult);
windowResponse = windowResult.Response;
if (backqueryCount >= maxBackqueriesPerSegment)
{
break;
}
var backqueries = BackqueryParser.Parse(segmentResponse);
var backqueries = BackqueryParser.Parse(windowResponse);
var pendingTexts = new List<string>();
foreach (var (start, end) in backqueries)
{
@@ -557,30 +590,31 @@ namespace AnotherReplayReader
backqueryCount += pendingTexts.Count;
var backqueryPrompt = AIAnalyze.BuildBackqueryUserPrompt(
string.Join("\n\n", pendingTexts));
segmentMessages = segmentMessages
.Add(new AIAnalyze.ChatMessage("assistant", segmentResponse))
windowMessages = windowMessages
.Add(new AIAnalyze.ChatMessage("assistant", windowResponse))
.Add(new AIAnalyze.ChatMessage(
"user", backqueryPrompt));
AppendLog(
$"第{segmentIndex}段回查(第 {backqueryCount} 次)",
$"第{segmentIndex}段窗口 {windowIndex + 1} 回查(第 {backqueryCount} 次)",
$"已提供 {pendingTexts.Count} 个区间,继续分析。\n\n用户消息:\n" + backqueryPrompt,
true);
}
FinishCurrentContent();
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(
segmentResponse, _factIndex, playerNameToIndex, structuredKnowledge);
AppendMachineJsonAndValidation(segmentResponse, validationResult);
windowResponse, _factIndex, playerNameToIndex, structuredKnowledge);
AppendMachineJsonAndValidation(windowResponse, validationResult);
if (validationResult.HasIssues)
{
AppendLog(
$"第{segmentIndex}段机器可读声明检查",
$"第{segmentIndex}段窗口 {windowIndex + 1} 机器可读声明检查",
AIAnalysisValidation.FormatIssues(validationResult.Issues),
false);
}
// 隐藏修订 passM6):Contradiction 触发,最多 1 次;修订期间不流式显示
var finalResponse = segmentResponse;
// 隐藏修订 passM6):Contradiction 触发,最多 1 次;
// 修订草稿按时间顺序流式显示,完成后折叠草稿并默认展开最终正文。
var finalResponse = windowResponse;
var finalValidation = validationResult;
if (validationResult.RequiresRevision)
{
@@ -591,38 +625,35 @@ namespace AnotherReplayReader
or AIValidationSeverity.WeakEvidence);
var relevantFacts = RelevantFactsFormatter.Format(validationResult.Claims, _factIndex!);
var revisionPrompt = AIAnalyze.BuildRevisionUserPrompt(
segmentResponse,
windowResponse,
AIAnalysisValidation.FormatIssues(validationResult.Issues),
string.IsNullOrWhiteSpace(relevantFacts) ? "(无额外事实)" : relevantFacts);
AppendLog(
$"第{segmentIndex}段修订",
$"第{segmentIndex}段窗口 {windowIndex + 1} 修订",
$"验证器发现 {issueCount} 个需要修正/降级的问题,正在请求 AI 修正...\n\n用户消息:\n"
+ revisionPrompt,
true);
var revisionMessages = segmentMessages
.Add(new AIAnalyze.ChatMessage("assistant", segmentResponse))
var revisionMessages = windowMessages
.Add(new AIAnalyze.ChatMessage("assistant", windowResponse))
.Add(new AIAnalyze.ChatMessage("user", revisionPrompt));
CheckAndLogContextUsage(revisionMessages, requestContext);
_suppressDisplay = true;
AIAnalyze.Result revisionResult;
try
{
revisionResult = await _analyzer!.CompleteAsync(
FinishCurrentContent();
var previousVersionTitle = _currentVersionTitle;
var previousVersionUpdate = _currentVersionUpdate;
StartVersion("版本 2:修订草稿", collapsedByDefault: false);
var revisionResult = await _analyzer!.CompleteAsync(
revisionMessages,
requestContext,
OnChunk,
_linkedCts.Token);
}
finally
{
_suppressDisplay = false;
}
UpdateTokenDisplay(revisionResult);
ReportReasoningGuardResult(revisionResult);
if (string.IsNullOrWhiteSpace(revisionResult.Response))
{
CollapseCurrentVersion();
AppendLog("修订失败", "修订输出为空,保留原分析。", false);
}
else
@@ -631,7 +662,10 @@ namespace AnotherReplayReader
finalValidation = AIAnalysisValidation.ValidateMachineReadableClaims(
revisionResult.Response, _factIndex, playerNameToIndex, structuredKnowledge);
CollapseCurrentVersion();
StartVersion("版本 2:修订后正文", collapsedByDefault: false);
previousVersionUpdate?.Invoke(
previousVersionTitle ?? "版本 1:正文",
true);
StartVersion("版本 3:修订后正文", collapsedByDefault: false);
AppendDirectText(finalResponse);
AppendMachineJsonAndValidation(finalResponse, finalValidation);
AppendLog(
@@ -644,7 +678,7 @@ namespace AnotherReplayReader
}
FinishCurrentContent();
// 追加已发现事实
// 追加本窗口已发现事实(同时进入全局 _findings,供后续段使用)
var entry = new StringBuilder();
var summary = ClaimFindingsFormatter.ExtractSummary(finalResponse);
if (!string.IsNullOrWhiteSpace(summary))
@@ -658,10 +692,18 @@ namespace AnotherReplayReader
}
if (entry.Length > 0)
{
_findings.Add(entry.ToString().TrimEnd());
var entryText = entry.ToString().TrimEnd();
segmentFindings.Add(entryText);
_findings.Add(entryText);
}
_segmentResponses.Add(finalResponse);
AppendLog(
$"第{segmentIndex}段窗口 {windowIndex + 1}/{windows.Length} 完成。",
null,
true);
}
_lastSuccessfulSegment = _currentSegmentIndex;
_currentSegmentIndex++;
AppendLog($"第{segmentIndex}段完成。", null, true);
@@ -1547,32 +1589,6 @@ namespace AnotherReplayReader
{
AutoScroll();
// 修订 pass 期间:不显示中间内容,只统计字符数
if (_suppressDisplay)
{
while (_chunkQueue.TryDequeue(out var data))
{
if (data.Chunk.Type == AIAnalyze.AIChunkType.ReasoningGuard)
{
var text = data.Chunk.Text;
_nextThinkingIsContinuation = true;
_pendingUiActions.Enqueue(() =>
AppendLog("推理保护", text, false, _currentStage));
}
else if (data.Chunk.Type == AIAnalyze.AIChunkType.ReasoningGuardRequest)
{
var text = data.Chunk.Text;
_pendingUiActions.Enqueue(() =>
AppendReasoningDiagnostics(text));
}
else
{
_currentOutputChars += data.Chunk.Text.Length;
}
}
return;
}
var thinkSb = new StringBuilder();
var contentSb = new StringBuilder();
var errorSb = new StringBuilder();
+100 -2
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@@ -32,10 +32,22 @@ namespace AnotherReplayReader
RefreshPromptFields();
RefreshProviderList();
if (_settings.Providers.Count > 0)
{
var lastSelection = _settings.ResolveLastSelection();
if (lastSelection is { } last)
{
// 恢复上次选中的 Provider 与模型。
// OnProviderSelectionChanged 会从持久化的 CurrentModelId 恢复模型;
// 显式调用 SelectModel 作为双保险(模型仍存在则精确恢复)。
_providerListBox.SelectedItem = last.Provider;
SelectModel(last.Model);
}
else
{
_providerListBox.SelectedIndex = 0;
}
}
}
// ---------- public API ----------
public AiRequestContext? GetCurrentContext()
@@ -52,6 +64,19 @@ namespace AnotherReplayReader
return _settings.Prompt;
}
/// <summary>
/// 把当前选中的 Provider/模型保存到设置文件(供窗口关闭时调用,
/// 下次打开 AI 设置页时恢复上次选择)。
/// </summary>
public void SaveCurrentSelection()
{
if (_currentProvider is not null && _currentModel is not null)
{
_settings.SetCurrentSelection(_currentProvider, _currentModel);
_settings.Save();
}
}
private void RefreshPromptFields()
{
_useCustomPromptCheck.IsChecked = _settings.Prompt.UseCustomSystemPrompt;
@@ -102,6 +127,9 @@ namespace AnotherReplayReader
private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e)
{
// 记住当前模型选择,切换 Provider 后若新 Provider 存在同名模型则保持选择
var previousModelId = _settings.CurrentModelId;
_currentProvider = _providerListBox.SelectedItem as AiProvider;
if (_currentProvider is null)
{
@@ -115,7 +143,26 @@ namespace AnotherReplayReader
_topPBox.Text = _currentProvider.DefaultTopP.ToString();
_maxTokensBox.Text = _currentProvider.DefaultMaxTokens.ToString();
// 记录上次选中的 Provider(模型在 OnModelSelectionChanged 中记录)
_settings.CurrentProviderName = _currentProvider.Name;
RefreshModelList();
// 尝试恢复上一个选中的模型(启动恢复与 Provider 切换共用同一路径)
if (!string.IsNullOrWhiteSpace(previousModelId))
{
var previousItem = _modelComboBox.Items
.OfType<ModelDisplayItem>()
.FirstOrDefault(m => string.Equals(
m.Model.ModelId, previousModelId, StringComparison.OrdinalIgnoreCase));
if (previousItem is not null)
{
_modelComboBox.SelectedItem = previousItem;
}
}
// 无任何模型/无可恢复目标时回退到第一个模型
if (_currentModel is null && _modelComboBox.Items.Count > 0)
{
_modelComboBox.SelectedIndex = 0;
}
}
private void OnAddProviderClick(object sender, RoutedEventArgs e)
@@ -147,6 +194,8 @@ namespace AnotherReplayReader
}
_settings.Providers.Remove(_currentProvider);
_settings.CurrentProviderName = null;
_settings.CurrentModelId = null;
_settings.Save();
RefreshProviderList();
if (_settings.Providers.Count > 0)
@@ -156,6 +205,7 @@ namespace AnotherReplayReader
else
{
_currentProvider = null;
_currentModel = null;
ClearProviderFields();
}
}
@@ -177,6 +227,7 @@ namespace AnotherReplayReader
int.TryParse(_maxTokensBox.Text, out int maxTokens);
_currentProvider.DefaultMaxTokens = maxTokens;
_settings.CurrentProviderName = _currentProvider.Name;
_settings.Save();
RefreshProviderList();
_providerListBox.SelectedItem = _currentProvider;
@@ -215,10 +266,33 @@ namespace AnotherReplayReader
}
else
{
// 当前 Provider 没有任何模型:清空当前模型,避免残留上一个 Provider 的模型
_currentModel = null;
_settings.CurrentModelId = null;
ClearModelFields();
}
}
/// <summary>
/// 在模型下拉框中选中指定模型;找不到时回退到第一个模型。
/// 仅供启动恢复使用(避免刷新列表后触发首次默认选中)。
/// </summary>
private void SelectModel(AiModel model)
{
var item = _modelComboBox.Items
.OfType<ModelDisplayItem>()
.FirstOrDefault(m => string.Equals(
m.Model.ModelId, model.ModelId, StringComparison.OrdinalIgnoreCase));
if (item is not null)
{
_modelComboBox.SelectedItem = item;
}
else if (_modelComboBox.Items.Count > 0)
{
_modelComboBox.SelectedIndex = 0;
}
}
private void OnModelSelectionChanged(object sender, SelectionChangedEventArgs e)
{
var selected = _modelComboBox.SelectedItem as ModelDisplayItem;
@@ -226,10 +300,13 @@ namespace AnotherReplayReader
if (_currentModel is null)
{
_settings.CurrentModelId = null;
ClearModelFields();
return;
}
_settings.CurrentModelId = _currentModel.ModelId;
_modelIdBox.Text = _currentModel.ModelId;
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
@@ -312,6 +389,18 @@ namespace AnotherReplayReader
_settings.Save();
RefreshModelList();
// 保持上次选中的模型(若仍存在),否则回退到第一个
if (_settings.CurrentModelId is { } lastModelId)
{
var lastItem = _modelComboBox.Items
.OfType<ModelDisplayItem>()
.FirstOrDefault(m => string.Equals(
m.Model.ModelId, lastModelId, StringComparison.OrdinalIgnoreCase));
if (lastItem is not null)
{
_modelComboBox.SelectedItem = lastItem;
}
}
_modelStatusText.Text = $"获取成功,共 {ids.Count} 个模型";
}
catch (Exception ex)
@@ -359,6 +448,8 @@ namespace AnotherReplayReader
_currentProvider.Models.Remove(_currentModel);
_settings.Save();
// 被删除模型不再是上次选择;RefreshModelList 会回退到第一个模型
_settings.CurrentModelId = null;
RefreshModelList();
}
@@ -411,7 +502,13 @@ namespace AnotherReplayReader
}
_settings.Save();
// 模型 ID 已修改:更新持久化选择,避免下一次启动仍按旧 ID 解析
_settings.CurrentModelId = _currentModel.ModelId;
RefreshModelList();
_modelComboBox.SelectedItem = _modelComboBox.Items
.OfType<ModelDisplayItem>()
.FirstOrDefault(m => string.Equals(
m.Model.ModelId, _currentModel.ModelId, StringComparison.OrdinalIgnoreCase));
MessageBox.Show("模型修改已保存", "信息", MessageBoxButton.OK);
}
@@ -437,7 +534,8 @@ namespace AnotherReplayReader
RefreshModelList();
_modelComboBox.SelectedItem = _modelComboBox.Items
.OfType<ModelDisplayItem>()
.FirstOrDefault(m => m.Model.ModelId == _currentModel.ModelId);
.FirstOrDefault(m => string.Equals(
m.Model.ModelId, _currentModel.ModelId, StringComparison.OrdinalIgnoreCase));
MessageBox.Show("已从已知模板填充", "信息", MessageBoxButton.OK);
}
else
+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);
+3 -1
View File
@@ -1,4 +1,4 @@
using AnotherReplayReader.Apm;
using AnotherReplayReader.Apm;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using Microsoft.Win32;
@@ -99,6 +99,7 @@ namespace AnotherReplayReader
{
try
{
_aiSettings.SaveCurrentSelection();
_cancellation.Cancel();
}
catch (Exception ex)
@@ -111,6 +112,7 @@ namespace AnotherReplayReader
{
try
{
_aiSettings.SaveCurrentSelection();
_cancellation.Cancel();
}
catch (Exception ex)
+34 -15
View File
@@ -3,9 +3,23 @@
## 状态
- 日期:2026-08-23
- 版本:v2.1。WIP/CONTEXT/ADR 旧文档已删除;思维链回传实验单独记录在 `AI_REASONING_CONTINUATION_RESEARCH.md`
- 版本:v2.2。WIP/CONTEXT/ADR 旧文档已删除;思维链回传实验单独记录在 `AI_REASONING_CONTINUATION_RESEARCH.md`
- 关联文档:[AI_REASONING_CONTINUATION_RESEARCH.md](AI_REASONING_CONTINUATION_RESEARCH.md)
## 实施状态修订(2026-08-24 段内焦点窗口)
- 新增「段内焦点窗口」设计:**数据层尽量宽**(整段机械切片按上下文上限提供),**注意力层聚焦窄时间窗**(每个切片再切分为若干分析窗口,每轮一个窗口作为重点)。
- 实现:`FocusPlanner`(按 token 把切片切成 1~5 个窗口,上限 5、目标 12K/窗、过小切片不细分)+ `AIAnalyze.BuildFocusWindowUserPromptV2`(强调“数据=整段、重点=窗口、主动关联窗口外/跨时间事件”)。
- 管线影响:原「每段一次分析」改为「每段逐窗口分析」;每个窗口独立会话(system+摘要+总览+整段切片+已发现事实+窗口指令),保留逐窗口的验证/修订/回查;窗口小结与机器可读声明进入已发现事实(段内窗口间共享 + 跨段累积)。
- 提示词与知识文件(`AIAnalyze` 回退路径、`knowledge_default.md``knowledge_corona.md`)已同步说明窗口机制。
- 本项未涉及上下文预算公式变化:窗口不改变可见数据,只改变每轮“重点”的粒度。
### 提示词表述修订(2026-08-25
- 主指令进一步简化为**直接指出重点时间段**:如 `请重点分析 0:30.00 至 2:00.00 时间段的操作数据`
- 窗口编号(`第 k/n 窗口`)从主指令中移除,仅保留一句次要说明("本段已按时间划分为 N 个重点时间段,当前是第 k 个"):编号是程序内部概念,模型无法从原始数据核实,而时间范围可直接映射到数据;保留编号信息有助于用户/日志关联,但不再作为指令重心。
- 知识文件与单元测试已同步(PromptBuilders 断言时间段优先、编号降级)。
## 实施状态(2026-08-20
里程碑全部完成,代码已落地并通过 149 项单元测试(`AiV2.Tests`,见 §12 M7;启用真实回放诊断时为 151 项)。
@@ -17,14 +31,14 @@
| M3 回查机制 | ✅ | `[回查]` 标记、容错时间解析、切片提取、每段 3 次上限、失败降级 Info |
| M4 验证修正 | ✅ | 所有权强/弱分层与 4 条规则、施法者归属(仅 `0x1FE/0x200`)、协议记录与校验、多 JSON 块合并、move 降级、首次出兵时间线、player 映射接线 |
| M5 知识修正 | ✅ | `knowledge_units_default.json` 按 mod 加载、旧提示词副作用修复、标签体系补全与加载校验、渲染按参战阵营过滤、用户知识 JSON 覆盖 |
| M6 修订 pass | ✅ | 段内 1 次修订、修订期间抑制流式显示、完成后替换段内容、UI 日志提示 |
| M6 修订 pass | ✅ | 段内 1 次修订、修订草稿实时流式显示、完成后草稿折叠、最终正文默认展开、UI 日志提示 |
| M7 测试与评估 | ✅/部分 | 单元测试完成;A/B 对比与估算校准需真实 API 运行(见 §13) |
**实施中的取舍与遗留**
- `Data/StringHashes.xml` 是随仓库分发的本地 SDK 临时快照(约 3.5MB / 47,860 条),后续应改为可配置路径或只打包需要的 hash 子集。
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
- 修订 pass 的展示采用"实时流式 + 修订后整段替换";原隐藏修订决策中的"完全缓冲至验证完成"仍是开放项
- 修订 pass 的展示采用"修订草稿实时流式 + 完成后草稿折叠、最终正文默认展开";原隐藏修订决策中的"完全缓冲"已改为"默认折叠中间草稿"
- `Fatal` 在“所有机器可读声明块均无法解析”时产生;修订输出为空时保留原分析。
- `MissingMachineReadableClaims` 为 Warning,并与其他 Warning/WeakEvidence 一样触发一次隐藏修订;是否保留该策略待 A/B 评估。
- 测试工程 `AiV2.Tests` 通过 `ProjectReference` 引用主工程;构建时通过 `AiV2TestsBuilding=true` 跳过主工程的 DLL 移动目标。
@@ -101,11 +115,12 @@
| 上下文预算 | 每模型 `ContextBudget` 软上限,默认档位:≥1M → 160K200K~256K → 100K<200K → 只支持短录像(单 slice) |
| 模式 | 单一管线;"全量模式"取消,短录像 = 1 个 slice |
| 分段 | 机械式(按 token 预算 + 事件数,带重叠);不再由 LLM 决定边界 |
| 段内焦点窗口 | 数据层 = 整段切片(尽量长);注意力层 = 每轮一个窗口(每段最多 5 个,目标 12K/窗);窗口可跨时间关联段内其他事件 |
| 总览轮 | 保留;输入为摘要 + 分段元数据(不读全量日志);输出允许跨段描述、跨段线索、回查建议 |
| 回查机制 | 进 v1;允许模型按需请求远处原始区间 |
| 缓存 | 稳定内容前置;跨段前缀 = system+摘要+总览;段内复用 = 前缀+slice(修订/回查共用) |
| 128K 及以下 | 允许短录像(切片后为 1 个 slice 时自然工作),不承诺长录像质量 |
| 修订 pass | 按隐藏修订方案在内落地,每最多 1 次 |
| 修订 pass | 按隐藏修订方案在窗口内落地,每窗口最多 1 次 |
## 4. 上下文预算策略
@@ -163,16 +178,19 @@
### 5.5 分段分析轮
- 每段一个独立会话,消息顺序(缓存关键,稳定在前)
`system → 对局摘要 → 总览输出 → slice_i → 已发现事实(1..i-1) → 段指令_i`
- 段指令:段标题/概述 + "请重点分析第 N 段(起止时间),可回查远处区间";N=1 时改为"分析整局"
- 每段一个独立阶段,段内再按“焦点窗口”逐轮分析。窗口划分与数据范围分离
- **数据层(不变)**:每轮都提供当前段的完整切片 `slice_i`(尽量长、不超过上下文上限),用于跨时间关联。
- **注意力层(新增)**`FocusPlanner` 把切片按 token 切成 1~5 个窗口(默认目标 12K/窗;≤24K 的切片不细分);每轮只“重点分析”一个窗口,且鼓励关联窗口外/跨时间事件
- 消息顺序(缓存关键,稳定在前):
`system → 对局摘要 → 总览输出 → slice_i → 已发现事实(1..i-1 + 段内前窗口) → 窗口指令(含窗口时间范围/事件数)`
- 窗口指令:段标题/概述 + "请重点分析第 N 段第 k/n 窗口(起止时间),数据为整段切片,可回查远处区间"。
- 输出:自然语言分析 + `[机器可读声明]`(沿用现有 schema,见 §7.4 的解析修正)。
- 同一的后续请求(修订、回查)复用同一消息列表。
- 同一窗口的后续请求(修订、回查)复用同一消息列表;窗口之间独立会话,但共享已发现事实
### 5.6 已发现事实
-分析完成后,由验证过的机器可读声明 + 3~5 句小结组成追加条目,每 ≤ ~1K token。
- 追加在消息尾部,不影响前缀缓存;是跨段关联的主要载体。
-个窗口分析完成后,由验证过的机器可读声明 + 3~5 句小结组成追加条目,每 ≤ ~1K token。
- 窗口小结追加在消息尾部,不影响前缀缓存;是跨段关联的主要载体,也是同一段内窗口间的关联载体
### 5.7 回查协议(v1
@@ -285,10 +303,10 @@
## 10. 修订 passP11
- 内执行:草稿 + 验证 issue + 相关事实 → 干净修正版;每最多 1 次。
- 窗口内执行:草稿 + 验证 issue + 相关事实 → 干净修正版;每窗口最多 1 次。
- 修订后仍 `Fatal` → 回退显示原文 + 警告(`Fatal` 条件见 §7.7)。
- 修订请求复用段内会话(同一前缀,缓存友好)。
- UI:增加"验证器发现并修正 N 个问题"提示;沿用隐藏修订方案的缓冲决策(段内容缓冲至验证/修订完成)
- 修订请求复用窗口会话(同一前缀,缓存友好)。
- UI:增加"验证器发现并修正 N 个问题"提示;修订草稿实时流式显示,完成后自动折叠,最终正文默认展开
## 11. 设置与 UI
@@ -304,7 +322,7 @@
3. **M3 回查机制**:标记解析(容错时间解析)、切片提取、限流、失败降级。
4. **M4 验证修正**:所有权证据与规则(§7.1)、施法者归属(§7.2)、协议与选择指令(§7.3)、JSON 解析健壮性(§7.4)、move 降级(§7.5)、首次出兵时间线(§7.6)、严重度语义(§7.7)。
5. **M5 知识修正**:mod 拆分(§8.1)、副作用修复(§8.2)、标签校验(§8.3)、渲染过滤(§8.4)、用户知识 JSON(§8.5)。
6. **M6 修订 pass**:段内修订 + UI 提示 + 缓冲(§10)。
6. **M6 修订 pass**:段内修订 + 草稿流式显示 + 完成后折叠 + 最终正文默认展开(§10)。
7. **M7 测试与评估**:单元测试(解析器/验证器/事实索引/分段)+ A/B 对比(§13)。
依赖关系:M2 先于 M3;M4/M5 可与 M2 并行;M6 依赖 M2 + M4M7 覆盖全部。
@@ -323,8 +341,9 @@
- 总览轮质量影响后续所有段 → 摘要/采样质量需要迭代;失败降级路径见 §5.4。
- 回查滥用或格式不稳定 → 限流 + 失败降级(§5.7)。
- 修订后机器可读声明可能与正文不一致 → 修订轮要求同时重出声明并重新验证。
- 段内焦点窗口依赖 `EventSpan` 时间索引;若索引缺失(防御性回退)则单窗口分析。
- 用户在运行中修改设置导致前缀变化 → 缓存失效,仅影响本次运行。
- 开放:段内容缓冲 vs 实时流式的最终 UI 决策;是否允许总览轮建议边界调整(v2 候选);`MissingMachineReadableClaims` 是否触发修复请求(§7.7)。
- 开放:是否允许总览轮建议边界调整(v2 候选);`MissingMachineReadableClaims` 是否触发修复请求(§7.7)。
## 15. 本计划不涉及
+77 -10
View File
@@ -1,4 +1,4 @@
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.ReplayFile;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
@@ -175,17 +175,18 @@ namespace AnotherReplayReader.Utils
# 输出要求
## 1. 总览阶段
触发条件:用户输入包含:""请先对整局进行总览""
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END]""
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 程序会把一个分段按时间切分为若干“分析窗口”(每轮一个窗口)。你当前分析的是其中一个窗口,但完整的分段切片仍然是你能看到的数据范围
- 你的重点任务:分析当前窗口时间范围内的主要事件与上下文;但同时应主动查看并关联窗口之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
@@ -197,7 +198,7 @@ namespace AnotherReplayReader.Utils
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该最重要的结论,供后续分段参考
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续分析窗口与后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:""请对以上内容进行总结""
@@ -1069,9 +1070,11 @@ PlayerA: 开始出兵
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
{
var sb = new StringBuilder();
sb.AppendLine("请先对整局进行总览。");
sb.AppendLine("请基于对局摘要对分段进行总览。");
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
sb.AppendLine("请描述整局走势(允许跨分段),并为每个分段给出标题与一句话概述。");
sb.AppendLine("本阶段不会获取原始操作记录。请只根据对局摘要和分段元数据,为每个分段给出标题与一句话概述。");
sb.AppendLine("如果某段在后续分析时可能需要对局摘要之外的原始区间,请在该段后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议。");
sb.AppendLine("不要展开推断对局摘要没有明确支持的内容。");
sb.AppendLine();
sb.AppendLine("[分段元数据]");
foreach (var slice in slices)
@@ -1079,7 +1082,7 @@ PlayerA: 开始出兵
sb.AppendLine($"#{(slice.Index + 1)} {MatchDigestBuilder.FormatTime(slice.Start)}~{MatchDigestBuilder.FormatTime(slice.End)} 事件数 {slice.EventCount} 约 {slice.EstimatedTokens} token");
}
sb.AppendLine();
sb.AppendLine("输出格式:先自由描述整局走势与跨段线索,然后输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`。");
sb.AppendLine("输出格式:直接输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段建议后续回查,在对应行后另起一行写 `回查: mm:ss~mm:ss`。不要输出整局叙述。");
return sb.ToString().Replace("\r", "");
}
@@ -1114,6 +1117,70 @@ PlayerA: 开始出兵
return instruction.Trim().Replace("\r", "");
}
/// <summary>
/// 段内焦点窗口指令:数据范围 = 整个机械分段切片(尽量长,但不超过上下文上限),
/// 分析“重点”只落在窗口时间段内;同时鼓励模型跨时间关联窗口外的背景事件。
/// </summary>
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)
{
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
var windowBeginText = MatchDigestBuilder.FormatTime(window.Start);
var windowEndText = MatchDigestBuilder.FormatTime(window.End);
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
var descriptionLine = string.IsNullOrWhiteSpace(description) ? "" : $"\n段落概述:{description}";
var hintLine = backqueryHints is { } hints && hints.Any()
? "\n总览建议可回查区间:" + string.Join("、", hints)
: "";
string windowInstruction;
string windowHeader;
if (windowCount <= 1)
{
windowInstruction =
$"请重点分析 {beginText} 至 {endText} 时间段的操作数据。";
windowHeader = "\n本段数据量适中,作为一个整体重点时间段分析。";
}
else
{
windowInstruction =
$"请重点分析 {windowBeginText} 至 {windowEndText} 时间段的操作数据。";
windowHeader =
$"\n本段已按时间划分为 {windowCount} 个重点时间段,当前是第 {windowIndex + 1} 个(后续时间段会依次分析)。";
}
var instruction = @$"
{windowInstruction}
{windowHeader}
本重点时间段约有 {windowEventCount} 条操作信息。
下方已提供第{segmentIndex + 1}段({beginText}至{endText})的完整操作记录(约 {sliceEventCount} 条)作为数据与背景。{titleLine}{descriptionLine}
{hintLine}
你可以参考输入中的对局摘要、整局总览与之前各窗口/各段的已发现事实。
你的重点任务是分析本重点时间段内的主要事件与它们的上下文,但不要局限于此时间段:
- 请主动查找并关联本时间段之外、但处于本段完整记录中的相关事件。如果当前事件与更早或更晚的事件存在关联(例如生产、建造、打包/展开、技能释放的后续影响、部队调动),请结合这些跨时间事件进行分析。
- 如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
- 不要因为重点时间段短就把每一条操作都当作单独事件,也不要省略该时间段内的重要事件。
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对本时间段内的事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空 JSON 对象。
最后用一行 `[小结]` 输出 2~3 句本重点时间段最重要的结论。
";
return instruction.Trim().Replace("\r", "");
}
public static string BuildSummaryUserPromptV2(int totalEventCount)
{
var instruction = $@"
+50 -4
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -298,9 +298,55 @@ namespace AnotherReplayReader
public List<AiProvider> Providers { get; set; } = [];
public AiPromptSettings Prompt { get; set; } = new();
// 以下两个不持久化,由 UI 层维护当前选中项
[System.Text.Json.Serialization.JsonIgnore]
public int CurrentProviderIndex { get; set; }
/// <summary>
/// 上次选中的 Provider 名称(持久化;用于下次打开设置页时恢复)。
/// 按名称识别,Provider 被重命名/删除后自动回退到第一个 Provider。
/// </summary>
public string? CurrentProviderName { get; set; }
/// <summary>
/// 上次选中的模型 ID(持久化;与 <see cref="CurrentProviderName"/> 配合使用)。
/// 模型被删除后自动回退到该 Provider 的第一个模型。
/// </summary>
public string? CurrentModelId { get; set; }
/// <summary>
/// 更新并持久化当前选中的 Provider 与模型。
/// </summary>
public void SetCurrentSelection(AiProvider provider, AiModel model)
{
CurrentProviderName = provider.Name;
CurrentModelId = model.ModelId;
}
/// <summary>
/// 解析持久化的上次选择。任一标识缺失或对应 Provider/Model 已不存在时返回 null。
/// </summary>
public (AiProvider Provider, AiModel Model)? ResolveLastSelection()
{
var providerName = CurrentProviderName?.Trim();
var modelId = CurrentModelId?.Trim();
if (string.IsNullOrEmpty(providerName) || string.IsNullOrEmpty(modelId))
{
return null;
}
var provider = Providers.FirstOrDefault(p =>
string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
if (provider is null)
{
return null;
}
var model = provider.Models.FirstOrDefault(m =>
string.Equals(m.ModelId, modelId, StringComparison.OrdinalIgnoreCase));
if (model is null)
{
return null;
}
return (provider, model);
}
private static readonly string ConfigPath = Path.Combine(
AppContext.BaseDirectory,
+344 -33
View File
@@ -189,6 +189,154 @@ namespace AnotherReplayReader.Utils
}
}
/// <summary>
/// 段内的焦点窗口:模型每轮分析的重点时间范围,而不是数据切片的边界。
/// 数据层仍提供整个机械分段切片(上下文允许时尽量长),焦点窗口只决定“重点分析哪段时间”。
/// </summary>
internal sealed record FocusWindow(int Index, TimeSpan Start, TimeSpan End, int EventCount, int EstimatedTokens);
/// <summary>
/// 把机械分段切分为多个“焦点窗口”。与 MechanicalSegmenter 不同:
/// 焦点窗口不改变模型可见的数据范围,只划分每轮分析的重点,用于避免模型一次分析过长的时间段。
/// </summary>
internal static class FocusPlanner
{
/// <summary>焦点窗口的目标 token 大小(近似)。</summary>
public const int DefaultWindowTokens = 12_000;
/// <summary>单个机械分段最多切分的焦点窗口数。</summary>
public const int MaxWindowsPerSlice = 5;
/// <summary>小于该 token 数的机械分段不再细分(直接作为单一焦点窗口)。</summary>
public const int MinSliceForSplitTokens = DefaultWindowTokens * 2;
public static ImmutableArray<FocusWindow> Plan(
ReplaySlice slice,
ImmutableArray<EventSpan> fullSpans)
{
if (slice.EventCount <= 0 || fullSpans.IsEmpty)
{
return ImmutableArray<FocusWindow>.Empty;
}
// 直接用切片自身的字符区间(StartIndex/Length)在 span 索引中定位,
// 避免按时间范围匹配与机械分段(含重叠)的实际内容不一致。
var startIndex = 0;
while (startIndex < fullSpans.Length
&& fullSpans[startIndex].StartIndex < slice.StartIndex)
{
startIndex++;
}
var endIndexExclusive = startIndex;
while (endIndexExclusive < fullSpans.Length
&& fullSpans[endIndexExclusive].StartIndex < slice.StartIndex + slice.Length)
{
endIndexExclusive++;
}
if (startIndex >= fullSpans.Length || endIndexExclusive <= startIndex)
{
// 回退:用切片自身的长度作为单一窗口(不应发生,防御性处理)。
return ImmutableArray.Create(
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens));
}
// 若切片本身不大,或者时间太短,则单一窗口。
var totalTokens = 0;
for (var i = startIndex; i < endIndexExclusive; ++i)
{
totalTokens += fullSpans[i].EstimatedTokens;
}
if (totalTokens <= MinSliceForSplitTokens
|| endIndexExclusive - startIndex <= 1)
{
return ImmutableArray.Create(
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, totalTokens));
}
var windows = new List<FocusWindow>();
var acc = 0;
var winStart = startIndex;
for (var i = startIndex; i < endIndexExclusive; ++i)
{
var span = fullSpans[i];
if (acc > 0 && acc + span.EstimatedTokens > DefaultWindowTokens
&& i - winStart >= 1)
{
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, i));
winStart = i;
acc = 0;
}
acc += span.EstimatedTokens;
}
if (winStart < endIndexExclusive)
{
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, endIndexExclusive));
}
// 超过上限时合并尾部窗口(优先合并 token 较小的相邻窗口,保持时间顺序)。
while (windows.Count > MaxWindowsPerSlice)
{
var best = -1;
var bestTokens = int.MaxValue;
for (var i = 0; i < windows.Count - 1 && windows.Count > MaxWindowsPerSlice; ++i)
{
var merged = windows[i].EstimatedTokens + windows[i + 1].EstimatedTokens;
if (merged < bestTokens)
{
best = i;
bestTokens = merged;
}
}
if (best < 0)
{
break;
}
windows[best] = MergeWindows(windows[best], windows[best + 1]);
windows.RemoveAt(best + 1);
RenumberWindows(windows);
}
return windows.ToImmutableArray();
}
private static FocusWindow CreateWindow(
int index,
ReplaySlice slice,
ImmutableArray<EventSpan> fullSpans,
int start,
int endExclusive)
{
var tokens = 0;
var events = 0;
for (var j = start; j < endExclusive; ++j)
{
tokens += fullSpans[j].EstimatedTokens;
events++;
}
return new FocusWindow(
index,
fullSpans[start].Time,
fullSpans[endExclusive - 1].Time,
events,
tokens);
}
private static FocusWindow MergeWindows(FocusWindow a, FocusWindow b)
{
return new FocusWindow(
a.Index,
a.Start,
b.End,
a.EventCount + b.EventCount,
a.EstimatedTokens + b.EstimatedTokens);
}
private static void RenumberWindows(List<FocusWindow> windows)
{
for (var i = 0; i < windows.Count; ++i)
{
windows[i] = windows[i] with { Index = i };
}
}
}
/// <summary>
/// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。
/// </summary>
@@ -207,13 +355,32 @@ namespace AnotherReplayReader.Utils
sb.AppendLine("# 玩家");
foreach (var kv in players)
{
var factionName = ModData.GetFaction(mod, kv.Value.FactionId).Name;
var faction = ModData.GetFaction(mod, kv.Value.FactionId);
var factionName = faction.Name;
if (faction.Kind == FactionKind.Observer)
{
sb.AppendLine(
$"- 玩家#{kv.Key} {kv.Value.PlayerName}{names[kv.Key]}),"
+ $"{factionName},解说员(观战),不参与对局");
}
else
{
var kind = kv.Value.IsComputer ? "电脑" : "玩家";
sb.AppendLine($"- 玩家#{kv.Key} {kv.Value.PlayerName}{names[kv.Key]}),{factionName}队伍{kv.Value.Team}{kind}");
var teamText = kv.Value.Team < 0 ? "无队伍" : $"队伍{kv.Value.Team}";
sb.AppendLine(
$"- 玩家#{kv.Key} {kv.Value.PlayerName}{names[kv.Key]}),"
+ $"{factionName}{teamText}{kind}");
}
}
sb.AppendLine();
sb.AppendLine("# 首次出兵时间表");
sb.AppendLine("# 首次出兵时间表(命令开始时间)");
if (factIndex.PlayerFirstProductionTime.IsEmpty)
{
sb.AppendLine("- (无)");
}
else
{
foreach (var kv in factIndex.PlayerFirstProductionTime.OrderBy(k => k.Key))
{
var productions = kv.Value
@@ -222,40 +389,75 @@ namespace AnotherReplayReader.Utils
.Select(x => $"{x.Key}@{FormatTime(x.Value)}");
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):{string.Join("", productions)}");
}
}
sb.AppendLine();
sb.AppendLine("# 协议选择");
if (factIndex.PlayerTechChoices.IsEmpty)
{
sb.AppendLine("- (无)");
}
else
{
foreach (var kv in factIndex.PlayerTechChoices.OrderBy(k => k.Key))
{
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):{string.Join("", kv.Value.OrderBy(x => x))}");
}
}
sb.AppendLine();
var unitRoles = BuildUnitRoleMap(factIndex);
sb.AppendLine("# 所有权证据(节选)");
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds.OrderBy(k => k.Key))
{
if (IsObserver(mod, players, kv.Key))
{
continue;
}
var ids = kv.Value.OrderBy(x => x).Take(20);
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):强证据 {kv.Value.Count} 个 UnitId{string.Join("", ids)}{suffix}");
sb.AppendLine(
$"- 玩家#{kv.Key}{names[kv.Key]}):强证据 {kv.Value.Count} 个 UnitId"
+ $"{string.Join("", ids.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix}");
}
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds.OrderBy(k => k.Key))
{
if (IsObserver(mod, players, kv.Key))
{
continue;
}
var ids = kv.Value.OrderBy(x => x).Take(20);
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):弱证据 {kv.Value.Count} 个 UnitId{string.Join("", ids)}{suffix}");
sb.AppendLine(
$"- 玩家#{kv.Key}{names[kv.Key]}):弱证据 {kv.Value.Count} 个 UnitId"
+ $"{string.Join("", ids.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix}");
}
sb.AppendLine();
sb.AppendLine("# 打包/展开");
var packUnits = factIndex.UnitIdSpecialPowers
.Where(kv2 => kv2.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
.OrderBy(kv2 => kv2.Key);
foreach (var kv2 in packUnits)
sb.AppendLine("# 打包/展开(按实际事件时间)");
var packEvents = factIndex.SpecialPowerEvents
.Where(e => ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|| ContainsIgnoreCase(e.PowerName, "UnpackReplaceSelf"))
.OrderBy(e => e.Time)
.ThenBy(e => e.PlayerIndex);
if (!packEvents.Any())
{
var firstTime = factIndex.UnitIdFirstObservedTime.TryGetValue(kv2.Key, out var t)
? FormatTime(t)
: "?";
sb.AppendLine($"- UnitId {kv2.Key}(首次出现 {firstTime}):{string.Join("", kv2.Value.OrderBy(x => x))}");
sb.AppendLine("- (无)");
}
foreach (var e in packEvents)
{
var action = ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
? "Pack"
: "Unpack";
var playerName = names.TryGetValue(e.PlayerIndex, out var name)
? name
: $"玩家#{e.PlayerIndex}";
var conflict = IsPackFactionConflict(mod, players, e)
? " [阵营冲突,需回查]"
: string.Empty;
sb.AppendLine(
$"- 玩家#{e.PlayerIndex}{playerName}UnitId {e.UnitId}"
+ $"{action}@{FormatTime(e.Time)}{conflict}");
}
sb.AppendLine();
@@ -287,41 +489,150 @@ namespace AnotherReplayReader.Utils
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
{
var result = new List<string>();
var currentTime = "";
var counts = new Dictionary<(string Player, string Category), int>();
var seen = new HashSet<string>();
var currentTime = string.Empty;
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (line.StartsWith("[") && line.Contains("]"))
{
var end = line.IndexOf(']');
currentTime = line.Substring(1, end - 1);
var rest = line.Substring(end + 1).Trim();
if (IsKeyEventLine(rest))
{
result.Add($"[{currentTime}] {rest}");
if (result.Count >= maxEvents)
{
break;
}
}
var line = rawLine.TrimEnd();
var trimmed = line.Trim();
if (TimeStampPattern.IsMatch(trimmed))
{
currentTime = TimeStampPattern.Match(trimmed).Groups[1].Value;
continue;
}
if (IsKeyEventLine(line))
// 参数/续行统一跳过,避免把 [UnitId]... 当作事件
if (string.IsNullOrWhiteSpace(trimmed)
|| line.StartsWith(" ", StringComparison.Ordinal)
|| line.StartsWith("\t", StringComparison.Ordinal)
|| trimmed.StartsWith("[", StringComparison.Ordinal))
{
result.Add($"[{currentTime}] {line}");
if (result.Count >= maxEvents)
continue;
}
var commandMatch = CommandPattern.Match(trimmed);
if (!commandMatch.Success)
{
break;
continue;
}
var player = commandMatch.Groups[1].Value.Trim();
var command = commandMatch.Groups[2].Value.Trim();
var category = GetEventCategory(command);
if (category is null)
{
continue;
}
if (!seen.Add(player + "|" + command))
{
continue;
}
var key = (player, category);
counts.TryGetValue(key, out var count);
if (count >= 2)
{
continue;
}
counts[key] = count + 1;
result.Add($"[{currentTime}] {player}: {command}");
}
return result.ToImmutableArray();
}
private static bool IsKeyEventLine(string line) =>
line.Contains("开始建造") || line.Contains("摆放建筑") || line.Contains("出售建筑") ||
line.Contains("释放特殊能力") || line.Contains("选择协议") || line.Contains("开始出兵") ||
line.Contains("开始升级");
private static string? GetEventCategory(string command)
{
if (command.Contains("开始建造")) return "建造";
if (command.Contains("摆放建筑")) return "摆放";
if (command.Contains("出售建筑")) return "出售";
if (command.Contains("开始出兵")) return "生产";
if (command.Contains("开始升级")) return "升级";
if (command.Contains("选择协议")) return "协议";
if (command.Contains("释放特殊能力")) return "技能";
return null;
}
private static readonly Regex TimeStampPattern = new(
@"^\[(\d+:\d+(?:\.\d+)?)\]$",
RegexOptions.Compiled);
private static readonly Regex CommandPattern = new(
@"^([^:,]+)\s*[:,]\s*(.+)$",
RegexOptions.Compiled);
private static Dictionary<uint, HashSet<string>> BuildUnitRoleMap(ReplayFactIndex factIndex)
{
var roles = new Dictionary<uint, HashSet<string>>();
void Add(uint unitId, string role)
{
if (!roles.TryGetValue(unitId, out var set))
{
set = new HashSet<string>();
roles[unitId] = set;
}
set.Add(role);
}
foreach (var id in factIndex.BuilderUnitIds)
{
Add(id, "建造者");
}
foreach (var id in factIndex.ProducerUnitIds)
{
Add(id, "出兵建筑");
}
foreach (var kv in factIndex.UnitIdSpecialPowers)
{
if (kv.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
{
Add(kv.Key, "打包/展开");
}
}
return roles;
}
private static string FormatUnitIdWithRole(
uint unitId,
Dictionary<uint, HashSet<string>> unitRoles)
{
if (!unitRoles.TryGetValue(unitId, out var roles) || roles.Count == 0)
{
return unitId.ToString();
}
return $"{unitId}({string.Join("/", roles.OrderBy(x => x))})";
}
private static bool IsObserver(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
int playerIndex)
{
return players.TryGetValue(playerIndex, out var player)
&& ModData.GetFaction(mod, player.FactionId).Kind == FactionKind.Observer;
}
private static bool IsPackFactionConflict(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
SpecialPowerEvent e)
{
if (!ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|| !players.TryGetValue(e.PlayerIndex, out var player))
{
return false;
}
return ModData.GetFaction(mod, player.FactionId).Name != "盟军";
}
private static bool ContainsIgnoreCase(string text, string value) =>
text.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
public static string FormatTime(TimeSpan t) => $"{(int)t.TotalMinutes}:{t:ss\\.ff}";
}
+110 -14
View File
@@ -6,6 +6,13 @@ using System.Linq;
namespace AnotherReplayReader.Utils
{
/// <summary>一次特殊能力事件(含真实发生时间与玩家),用于生成可读的打包/展开时间线。</summary>
internal sealed record SpecialPowerEvent(
TimeSpan Time,
int PlayerIndex,
uint UnitId,
string PowerName);
/// <summary>
/// Index of replay facts extracted from CommandChunk data.
/// Used by AIAnalysisValidation to cross-reference LLM claims against
@@ -19,6 +26,9 @@ namespace AnotherReplayReader.Utils
/// <summary>Special powers used by each UnitId.</summary>
public ImmutableDictionary<uint, ImmutableHashSet<string>> UnitIdSpecialPowers { get; }
/// <summary>按时间排序的特殊能力事件列表。</summary>
public ImmutableArray<SpecialPowerEvent> SpecialPowerEvents { get; }
/// <summary>UnitIds that appeared as builder ("建造者") in construction commands.</summary>
public ImmutableHashSet<uint> BuilderUnitIds { get; }
@@ -43,6 +53,7 @@ namespace AnotherReplayReader.Utils
public ReplayFactIndex(
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
ImmutableArray<SpecialPowerEvent> specialPowerEvents,
ImmutableHashSet<uint> builderUnitIds,
ImmutableHashSet<uint> producerUnitIds,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
@@ -53,6 +64,7 @@ namespace AnotherReplayReader.Utils
{
UnitIdFirstObservedTime = unitIdFirstObservedTime;
UnitIdSpecialPowers = unitIdSpecialPowers;
SpecialPowerEvents = specialPowerEvents;
BuilderUnitIds = builderUnitIds;
ProducerUnitIds = producerUnitIds;
PlayerFirstProductionTime = playerFirstProductionTime;
@@ -68,6 +80,7 @@ namespace AnotherReplayReader.Utils
{
var unitFirstObserved = new Dictionary<uint, TimeSpan>();
var unitSpecialPowers = new Dictionary<uint, HashSet<string>>();
var specialPowerEvents = new List<SpecialPowerEvent>();
var builderUnits = new HashSet<uint>();
var producerUnits = new HashSet<uint>();
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
@@ -83,7 +96,7 @@ namespace AnotherReplayReader.Utils
foreach (var command in commands)
{
ProcessCommand(time, command, stringHashTable,
unitFirstObserved, unitSpecialPowers,
unitFirstObserved, unitSpecialPowers, specialPowerEvents,
builderUnits, producerUnits,
playerFirstProduction, playerSelected,
playerStrongOwnership, playerWeakOwnership,
@@ -95,6 +108,11 @@ namespace AnotherReplayReader.Utils
unitFirstObserved.ToImmutableDictionary(),
unitSpecialPowers.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
specialPowerEvents
.OrderBy(e => e.Time)
.ThenBy(e => e.PlayerIndex)
.ThenBy(e => e.UnitId)
.ToImmutableArray(),
builderUnits.ToImmutableHashSet(),
producerUnits.ToImmutableHashSet(),
playerFirstProduction.ToImmutableDictionary(
@@ -115,6 +133,7 @@ namespace AnotherReplayReader.Utils
IReadOnlyDictionary<uint, string> stringHashTable,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<uint, HashSet<string>> unitSpecialPowers,
List<SpecialPowerEvent> specialPowerEvents,
HashSet<uint> builderUnits,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
@@ -148,7 +167,8 @@ namespace AnotherReplayReader.Utils
// special power (target position and angle): 0x200 —— 布局确凿,ObjectId 是施法者
case 0x200:
RecordSpecialPower(time, command, player, stringHashTable,
unitFirstObserved, unitSpecialPowers, playerStrongOwnership);
unitFirstObserved, unitSpecialPowers, playerStrongOwnership,
specialPowerEvents);
break;
// special power (target position): 0x1FF —— ObjectId 语义待核实,只记录"出现过"
@@ -163,7 +183,7 @@ namespace AnotherReplayReader.Utils
// start production: 0x205
case 0x205:
RecordProduction(time, command, player, unitFirstObserved,
producerUnits, playerFirstProduction, playerStrongOwnership);
stringHashTable, producerUnits, playerFirstProduction, playerStrongOwnership);
break;
// start construction: 0x207
@@ -208,7 +228,7 @@ namespace AnotherReplayReader.Utils
// 选择协议:全局生效,无 UnitId
case 0x24E:
RecordTechChoice(time, command, player, playerTechChoices);
RecordTechChoice(time, command, player, stringHashTable, playerTechChoices);
break;
// move: 0x214
@@ -263,7 +283,8 @@ namespace AnotherReplayReader.Utils
IReadOnlyDictionary<uint, string> stringHashTable,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<uint, HashSet<string>> unitSpecialPowers,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
Dictionary<int, HashSet<uint>> playerStrongOwnership,
List<SpecialPowerEvent> specialPowerEvents)
{
string? powerName = null;
var unitIds = new List<uint>();
@@ -323,6 +344,8 @@ namespace AnotherReplayReader.Utils
}
powers.Add(powerName);
RecordPlayerOwnership(player, unitId, playerStrongOwnership);
specialPowerEvents.Add(new SpecialPowerEvent(
time, player, unitId, powerName));
}
}
@@ -331,6 +354,7 @@ namespace AnotherReplayReader.Utils
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
IReadOnlyDictionary<uint, string> stringHashTable,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
@@ -362,8 +386,14 @@ namespace AnotherReplayReader.Utils
}
break;
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
or CommandArgumentType.Int32
or CommandArgumentType.UInt32
or CommandArgumentType.UInt32_2
when unitName is null:
unitName = entry.Value.ToString() ?? string.Empty;
if (TryReadCommandName(entry, stringHashTable, out var resolvedName))
{
unitName = resolvedName;
}
break;
}
}
@@ -382,7 +412,7 @@ namespace AnotherReplayReader.Utils
perPlayer = new Dictionary<string, TimeSpan>();
playerFirstProduction[player] = perPlayer;
}
if (!perPlayer.ContainsKey(unitName))
if (unitName is not null && !perPlayer.ContainsKey(unitName))
{
perPlayer[unitName] = time;
}
@@ -552,18 +582,18 @@ namespace AnotherReplayReader.Utils
TimeSpan time,
CommandChunk command,
int player,
IReadOnlyDictionary<uint, string> stringHashTable,
Dictionary<int, HashSet<string>> playerTechChoices)
{
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
if (entry.Type is CommandArgumentType.AsciiString
or CommandArgumentType.UnicodeString
or CommandArgumentType.Int32
or CommandArgumentType.UInt32
or CommandArgumentType.UInt32_2)
{
var tech = entry.Count == 1
? entry.Value.ToString()
: entry.Value is string[] strings
? strings.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
: null;
if (string.IsNullOrWhiteSpace(tech))
if (!TryReadCommandName(entry, stringHashTable, out var tech))
{
continue;
}
@@ -578,6 +608,72 @@ namespace AnotherReplayReader.Utils
}
}
private static bool TryReadCommandName(
CommandArgumentEntry entry,
IReadOnlyDictionary<uint, string> stringHashTable,
out string name)
{
name = string.Empty;
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
{
if (entry.Count == 1 && entry.Value is string single)
{
if (!string.IsNullOrWhiteSpace(single))
{
name = single;
return true;
}
}
else if (entry.Value is string[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
name = value;
return true;
}
}
}
return false;
}
return TryReadCommandNameAsHash(entry, stringHashTable, out name);
}
private static bool TryReadCommandNameAsHash(
CommandArgumentEntry entry,
IReadOnlyDictionary<uint, string> stringHashTable,
out string name)
{
name = string.Empty;
IEnumerable<uint> hashes = entry.Type switch
{
CommandArgumentType.Int32 when entry.Count == 1 && entry.Value is int singleInt =>
new[] { unchecked((uint)singleInt) },
CommandArgumentType.Int32 when entry.Value is int[] ints =>
ints.Select(x => unchecked((uint)x)),
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
when entry.Count == 1 && entry.Value is uint singleUint =>
new[] { singleUint },
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
when entry.Value is uint[] uints =>
uints,
_ => Array.Empty<uint>(),
};
foreach (var hash in hashes)
{
if (stringHashTable.TryGetValue(hash, out var resolved)
&& !string.IsNullOrWhiteSpace(resolved))
{
name = resolved;
return true;
}
}
return false;
}
private static void RecordObjectReferenceWithOwnership(
TimeSpan time,
CommandChunk command,
+6 -5
View File
@@ -42,17 +42,18 @@
# 输出要求
## 1. 总览阶段
触发条件:用户输入包含:"请先对整局进行总览"
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END]"
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 程序会把一个分段按时间划分为若干“重点时间段”(每轮一个时间段)。你当前分析的是其中一个重点时间段,但切割出的完整分段切片仍然是你能看到的数据范围
- 你的重点任务:分析当前重点时间段内的主要事件与上下文;但同时应主动查看并关联该时间段之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
@@ -64,7 +65,7 @@
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该最重要的结论,供后续分段参考
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续重点时间段与后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:"请对以上内容进行总结"
+6 -5
View File
@@ -42,17 +42,18 @@
# 输出要求
## 1. 总览阶段
触发条件:用户输入包含:"请先对整局进行总览"
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END]"
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 程序会把一个分段按时间划分为若干“重点时间段”(每轮一个时间段)。你当前分析的是其中一个重点时间段,但切割出的完整分段切片仍然是你能看到的数据范围
- 你的重点任务:分析当前重点时间段内的主要事件与上下文;但同时应主动查看并关联该时间段之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
@@ -64,7 +65,7 @@
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该最重要的结论,供后续分段参考
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续重点时间段与后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:"请对以上内容进行总结"