ai plan v2

This commit is contained in:
2026-08-21 15:34:24 +02:00
parent 9555423f51
commit d06a93f759
20 changed files with 3093 additions and 523 deletions
+382 -78
View File
@@ -8,6 +8,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.Tracing;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -64,6 +65,20 @@ namespace AnotherReplayReader
private TimeIndexedPrefixSums? _eventCounts;
private ReplayFactIndex? _factIndex;
// v2 管线状态
private Replay? _replay;
private ImmutableSortedDictionary<int, Player> _players = ImmutableSortedDictionary<int, Player>.Empty;
private string? _systemPrompt;
private string? _replayData;
private ImmutableArray<EventSpan> _eventSpans = ImmutableArray<EventSpan>.Empty;
private ImmutableArray<ReplaySlice> _slices = ImmutableArray<ReplaySlice>.Empty;
private string? _digest;
private OverviewResult _overview = new(string.Empty, ImmutableArray<SegmentOverview>.Empty);
private string? _overviewNarrative;
private readonly List<string> _findings = new();
private readonly List<string> _segmentResponses = new();
private int _currentSegmentIndex;
// 分析状态
private bool _isRunning;
private bool _isFailed;
@@ -79,6 +94,8 @@ namespace AnotherReplayReader
private bool _thinkBlockWasExpanded = false;
// 输出段落
private (Paragraph? Think, Paragraph? Content)? _currentContent;
private Paragraph? _lastContentParagraph;
private bool _suppressDisplay;
// 进度计时
private TimeSpan _previousTotalTime;
@@ -167,11 +184,25 @@ namespace AnotherReplayReader
_analyzer = null;
_eventCounts = null;
_factIndex = null;
_replay = null;
_players = ImmutableSortedDictionary<int, Player>.Empty;
_systemPrompt = null;
_replayData = null;
_eventSpans = ImmutableArray<EventSpan>.Empty;
_slices = ImmutableArray<ReplaySlice>.Empty;
_digest = null;
_overview = new(string.Empty, ImmutableArray<SegmentOverview>.Empty);
_overviewNarrative = null;
_findings.Clear();
_segmentResponses.Clear();
_currentSegmentIndex = 0;
_document.Blocks.Clear();
_updateCurrentThinkingSection = null;
_thinkBlockWasExpanded = false;
_currentContent = null;
_lastContentParagraph = null;
_suppressDisplay = false;
_previousTotalTime = TimeSpan.Zero;
_previousTotalChars = 0;
@@ -201,6 +232,7 @@ namespace AnotherReplayReader
string replayData,
TimeIndexedPrefixSums eventCounts,
ReplayFactIndex factIndex,
ImmutableArray<EventSpan> eventSpans,
CancellationToken externalToken)
{
if (_isRunning)
@@ -249,54 +281,79 @@ namespace AnotherReplayReader
_analyzer = new AIAnalyze();
_eventCounts = eventCounts;
_factIndex = factIndex;
_replay = replay;
_players = players;
_replayData = replayData;
_eventSpans = eventSpans;
FinishCurrentContent();
var requestContext = GetRequestContext();
_systemPrompt = AIAnalyze.GetSystemPrompt(replay, players, GetPromptSettings?.Invoke());
var systemPrompt = AIAnalyze.GetSystemPrompt(replay, players, GetPromptSettings?.Invoke());
var userPrompt = AIAnalyze.BuildUserPrompt(replay.Mod, players, replayData, out var userPromptPrefix);
AppendLog($"让 AI 了解录像...",
userPromptPrefix
+ "[输入:操作信息流水账]\r\n"
// ---- 机械分段(M2 ----
var model = requestContext.Model;
var budget = AiContextBudget.GetContextBudget(model);
var hardCap = model.ContextLength > 0
? (int)(model.ContextLength * AiContextBudget.HardUsageRatio)
: 48_000;
var effectiveBudget = budget > 0 ? budget : hardCap;
var headroom = AiContextBudget.GetOutputHeadroom(requestContext.Provider, model);
var fixedOverhead = AiContextBudget.EstimateTokens(_systemPrompt)
+ 4_000 // digest
+ 2_000 // overview
+ 1_500 // instruction
+ 10_000 // findings reserve
+ headroom;
var sliceBudget = Math.Max(effectiveBudget - fixedOverhead, MechanicalSegmenter.MinSliceTokens);
var (slices, sliceWarnings) = MechanicalSegmenter.Slice(
replayData, eventSpans, sliceBudget, sliceBudget / 12);
_slices = slices;
foreach (var warning in sliceWarnings)
{
AppendLog("分段警告", warning, false);
}
if (_slices.IsEmpty)
{
throw new InvalidOperationException("操作记录为空,无法分析。");
}
AppendLog(
"v2 管线准备",
$"上下文预算: {FormatNumber(budget, false)} token0 = 不支持长录像)\r\n"
+ $"分段预算: {FormatNumber(sliceBudget, false)} token,实际切分 {_slices.Length} 段\r\n"
+ $"日志总量: {FormatNumber(AiContextBudget.EstimateTokens(replayData), false)} token\r\n"
+ $"[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
true);
false);
#region info
var (systemPromptSize, systemPromptTokenCount) = AIAnalyze.EstimateTokenCount(systemPrompt);
var (userPromptSize, userPromptTokenCount) = AIAnalyze.EstimateTokenCount(userPrompt);
var totalPromptSize = systemPromptSize + userPromptSize;
var totalPromptTokenCount = systemPromptTokenCount + userPromptTokenCount;
var estimateText = $"系统提示词: {FormatNumber(systemPromptSize, true, "B")},约 {FormatNumber(systemPromptTokenCount, false)} Token\r\n" +
$"初始用户提示词: {FormatNumber(userPromptSize, true, "B")},约 {FormatNumber(userPromptTokenCount, false)} Token\r\n" +
$"总大小: {FormatNumber(totalPromptSize, true, "B")},约 {FormatNumber(totalPromptTokenCount, false)} Token";
AppendLog("初始阶段 Token 用量预测", estimateText, false);
#endregion
// ---- 对局摘要 ----
_digest = MatchDigestBuilder.Build(factIndex, players, replay.Mod, _slices, replayData);
AppendLog("对局摘要", _digest, true);
var result = await _analyzer.AnalyzeAsync(
systemPrompt,
userPrompt,
// ---- 总览轮 ----
_phaseText.Text = "正在生成总览...";
var overviewMessages = BuildSystemMessages(_systemPrompt, _digest, null)
.Add(new AIAnalyze.ChatMessage("user", AIAnalyze.BuildOverviewUserPrompt(_slices)));
CheckAndLogContextUsage(overviewMessages, requestContext);
var overviewResult = await _analyzer.CompleteAsync(
overviewMessages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(result);
var eventCountsBuilder = new StringBuilder();
foreach (var (Start, End, Description) in result.State.Segments)
UpdateTokenDisplay(overviewResult);
_overview = OverviewParser.Parse(overviewResult.Response);
_overviewNarrative = _overview.Narrative;
AppendLog("整局总览", overviewResult.Response, false);
if (_overview.Segments.IsEmpty)
{
var count = eventCounts.Query(Start, End);
eventCountsBuilder.AppendLine($"[{Start:mm\\:ss\\.ff} - {End:mm\\:ss\\.ff}],事件数: {count}");
AppendLog("总览解析警告", "未解析到 [分段概述] 块,将直接使用机械分段。", false);
}
AppendLog("各分段事件数量", eventCountsBuilder.ToString(), false);
// 分析完成,分段列表就绪
_lastSuccessfulSegment = 0;
// ---- 分段分析循环 ----
_currentSegmentIndex = 0;
await ProcessSegmentsAsync(requestContext);
// 分段分析循环
await ProcessSegmentsAsync(result.State, eventCounts);
// 总结
await ProcessSummaryAsync(eventCounts);
// ---- 总结 ----
await ProcessSummaryAsync(requestContext);
// 成功结束
_phaseText.Text = "分析完成";
@@ -328,42 +385,113 @@ namespace AnotherReplayReader
}
}
// ---- 分析阶段 ----
private async Task ProcessSegmentsAsync(AIAnalyze.State lastState,
TimeIndexedPrefixSums eventCounts)
// ---- 分析阶段v2 ----
private async Task ProcessSegmentsAsync(AiRequestContext requestContext)
{
_analyzer!.SetState(lastState);
while (lastState.CurrentSegment < lastState.Segments.Count)
if (_linkedCts is null || _replayData is null || _systemPrompt is null || _digest is null || _replay is null)
{
return;
}
var playerNameToIndex = AIAnalyze.PlayerNamesForAI(_replay.Mod, _players)
.ToDictionary(kv => kv.Value, kv => kv.Key);
var structuredKnowledge = StructuredKnowledge.GetForMod(
AIAnalyze.GetKnowledgeModName(_replay));
while (_currentSegmentIndex < _slices.Length)
{
if (_linkedCts is null)
{
break;
}
_linkedCts.Token.ThrowIfCancellationRequested();
var seg = lastState.Segments[lastState.CurrentSegment];
var segmentIndex = lastState.CurrentSegment + 1;
var totalSegments = lastState.Segments.Count;
var slice = _slices[_currentSegmentIndex];
var segmentIndex = _currentSegmentIndex + 1;
var totalSegments = _slices.Length;
_phaseText.Text = $"正在分析第 {segmentIndex}/{totalSegments} 段";
FinishCurrentContent();
// 记录回滚点
_blockCountBeforeSegment = _document.Blocks.Count;
_lastContentParagraph = null;
// 计算事件数
var eventCount = eventCounts.Query(seg.Start, seg.End);
var segmentPrompt = AIAnalyze.BuildSegmentUserPrompt(lastState.Segments, lastState.CurrentSegment, eventCount);
var requestContext = GetRequestContext!();
AppendLog($"让 AI 分析第{segmentIndex}段...",
segmentPrompt
+ $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
var eventCount = _eventCounts?.Query(slice.Start, slice.End) ?? slice.EventCount;
var title = _overview.Segments.FirstOrDefault(s => s.Index == segmentIndex)?.Title;
var sliceText = slice.GetText(_replayData);
var findingsText = string.Join("\n\n", _findings);
var instruction = AIAnalyze.BuildSegmentUserPromptV2(
_currentSegmentIndex, totalSegments, slice, eventCount, title);
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));
}
messages = messages.Add(new AIAnalyze.ChatMessage("user", instruction));
AppendLog(
$"让 AI 分析第{segmentIndex}段...",
instruction + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
true);
var segmentResult = await _analyzer.ContinueAnalyzeAsync(
segmentPrompt,
requestContext,
OnChunk,
_linkedCts.Token);
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(segmentResult.Response, _factIndex);
CheckAndLogContextUsage(messages, requestContext);
// 回查循环(M3):同一段会话内,模型可多次请求远处原始区间
const int maxBackqueriesPerSegment = 3;
var segmentMessages = messages;
var backqueryCount = 0;
string segmentResponse;
while (true)
{
var segmentResult = await _analyzer!.CompleteAsync(
segmentMessages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(segmentResult);
segmentResponse = segmentResult.Response;
if (backqueryCount >= maxBackqueriesPerSegment)
{
break;
}
var backqueries = BackqueryParser.Parse(segmentResponse);
var pendingTexts = new List<string>();
foreach (var (start, end) in backqueries)
{
if (backqueryCount + pendingTexts.Count >= maxBackqueriesPerSegment)
{
AppendLog("回查限制", "本段回查次数已达上限,剩余区间已忽略。", false);
break;
}
var (text, reason) = BackquerySliceExtractor.Extract(
_replayData, _eventSpans, start, end);
if (text is null)
{
AppendLog("回查失败", reason ?? "未知原因", false);
continue;
}
pendingTexts.Add(
$"[回查 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)}]\n" + text);
}
if (pendingTexts.Count == 0)
{
break;
}
backqueryCount += pendingTexts.Count;
segmentMessages = segmentMessages
.Add(new AIAnalyze.ChatMessage("assistant", segmentResponse))
.Add(new AIAnalyze.ChatMessage(
"user", AIAnalyze.BuildBackqueryUserPrompt(string.Join("\n\n", pendingTexts))));
AppendLog(
$"第{segmentIndex}段回查(第 {backqueryCount} 次)",
$"已提供 {pendingTexts.Count} 个区间,继续分析。",
true);
}
FinishCurrentContent();
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(
segmentResponse, _factIndex, playerNameToIndex, structuredKnowledge);
if (validationResult.HasIssues)
{
AppendLog(
@@ -371,16 +499,91 @@ namespace AnotherReplayReader
AIAnalysisValidation.FormatIssues(validationResult.Issues),
false);
}
_lastSuccessfulSegment = lastState.CurrentSegment;
lastState = segmentResult.State;
UpdateTokenDisplay(segmentResult);
// 隐藏修订 passM6):Contradiction 触发,最多 1 次;修订期间不流式显示
var finalResponse = segmentResponse;
var finalValidation = validationResult;
if (validationResult.RequiresRevision)
{
var issueCount = validationResult.Issues.Count(
i => i.Severity == AIValidationSeverity.Contradiction);
AppendLog(
$"第{segmentIndex}段修订",
$"验证器发现 {issueCount} 个矛盾问题,正在请求 AI 修正...",
false);
var relevantFacts = RelevantFactsFormatter.Format(validationResult.Claims, _factIndex!);
var revisionPrompt = AIAnalyze.BuildRevisionUserPrompt(
segmentResponse,
AIAnalysisValidation.FormatIssues(validationResult.Issues),
string.IsNullOrWhiteSpace(relevantFacts) ? "(无额外事实)" : relevantFacts);
var revisionMessages = segmentMessages
.Add(new AIAnalyze.ChatMessage("assistant", segmentResponse))
.Add(new AIAnalyze.ChatMessage("user", revisionPrompt));
CheckAndLogContextUsage(revisionMessages, requestContext);
_suppressDisplay = true;
AIAnalyze.Result revisionResult;
try
{
revisionResult = await _analyzer!.CompleteAsync(
revisionMessages,
requestContext,
OnChunk,
_linkedCts.Token);
}
finally
{
_suppressDisplay = false;
}
UpdateTokenDisplay(revisionResult);
if (string.IsNullOrWhiteSpace(revisionResult.Response))
{
AppendLog("修订失败", "修订输出为空,保留原分析。", false);
}
else
{
finalResponse = revisionResult.Response;
finalValidation = AIAnalysisValidation.ValidateMachineReadableClaims(
revisionResult.Response, _factIndex, playerNameToIndex, structuredKnowledge);
ReplaceSegmentContent(finalResponse);
AppendLog(
"修订完成",
"已采用修正后的分析。" + (finalValidation.HasIssues
? "\n仍存在的问题:\n" + AIAnalysisValidation.FormatIssues(finalValidation.Issues)
: string.Empty),
false);
}
}
FinishCurrentContent();
// 追加已发现事实
var entry = new StringBuilder();
var summary = ClaimFindingsFormatter.ExtractSummary(finalResponse);
if (!string.IsNullOrWhiteSpace(summary))
{
entry.AppendLine($"[小结] {summary}");
}
var claimsText = ClaimFindingsFormatter.Format(finalValidation.Claims);
if (!string.IsNullOrWhiteSpace(claimsText))
{
entry.AppendLine(claimsText);
}
if (entry.Length > 0)
{
_findings.Add(entry.ToString().TrimEnd());
}
_segmentResponses.Add(finalResponse);
_lastSuccessfulSegment = _currentSegmentIndex;
_currentSegmentIndex++;
AppendLog($"第{segmentIndex}段完成。", null, true);
}
}
private async Task ProcessSummaryAsync(TimeIndexedPrefixSums eventCounts)
private async Task ProcessSummaryAsync(AiRequestContext requestContext)
{
if (_linkedCts is null)
if (_linkedCts is null || _systemPrompt is null || _digest is null)
{
return;
}
@@ -390,16 +593,37 @@ namespace AnotherReplayReader
FinishCurrentContent();
_blockCountBeforeSegment = _document.Blocks.Count;
var totalEvents = eventCounts.GetTotal();
var finalPrompt = AIAnalyze.BuildFinalUserPrompt(totalEvents);
var requestContext = GetRequestContext!();
AppendLog($"让 AI 生成总结...",
finalPrompt
+ $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
var totalEvents = _eventCounts?.GetTotal() ?? 0;
var analyses = string.Join("\n\n", _segmentResponses);
var budget = AiContextBudget.GetContextBudget(requestContext.Model);
var maxAnalysesTokens = budget > 0 ? Math.Max(20_000, budget / 3) : 40_000;
var truncated = TruncateByTokens(analyses, maxAnalysesTokens);
if (!ReferenceEquals(truncated, analyses))
{
AppendLog(
"总结输入警告",
$"各段分析过长,已截断到约 {FormatNumber(maxAnalysesTokens, false)} token。",
false);
}
var finalPrompt = AIAnalyze.BuildSummaryUserPromptV2(totalEvents);
var findingsText = string.Join("\n\n", _findings);
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
.Add(new AIAnalyze.ChatMessage("user", "各分段的推理分析:\n" + truncated));
if (!string.IsNullOrWhiteSpace(findingsText))
{
messages = messages.Add(new AIAnalyze.ChatMessage(
"user", "各分段的已发现事实:\n" + findingsText));
}
messages = messages.Add(new AIAnalyze.ChatMessage("user", finalPrompt));
AppendLog(
"让 AI 生成总结...",
finalPrompt + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
true);
var result = await _analyzer!.FinishAnalyzeAsync(
finalPrompt,
CheckAndLogContextUsage(messages, requestContext);
var result = await _analyzer!.CompleteAsync(
messages,
requestContext,
OnChunk,
_linkedCts.Token);
@@ -407,6 +631,53 @@ namespace AnotherReplayReader
UpdateTokenDisplay(result);
}
// ---- v2 管线辅助 ----
private static ImmutableList<AIAnalyze.ChatMessage> BuildSystemMessages(
string systemPrompt,
string digest,
string? overviewNarrative)
{
var content = systemPrompt + "\n\n# 对局摘要\n" + digest;
if (!string.IsNullOrWhiteSpace(overviewNarrative))
{
content += "\n\n# 整局总览\n" + overviewNarrative;
}
return ImmutableList<AIAnalyze.ChatMessage>.Empty
.Add(new AIAnalyze.ChatMessage("system", content));
}
private void CheckAndLogContextUsage(
ImmutableList<AIAnalyze.ChatMessage> messages,
AiRequestContext requestContext)
{
var total = 0;
foreach (var message in messages)
{
total += AiContextBudget.EstimateTokens(message.Content);
}
var check = AiContextBudget.CheckPromptUsage(
total, requestContext.Provider, requestContext.Model);
if (check.Block)
{
throw new InvalidOperationException(check.Message);
}
if (!check.IsOk)
{
AppendLog("上下文预算警告", check.Message, false);
}
}
private static string TruncateByTokens(string text, int maxTokens)
{
if (AiContextBudget.EstimateTokens(text) <= maxTokens)
{
return text;
}
// 中文约 1 token/字,取保守系数 1.1 字符/token
var chars = Math.Min(text.Length, (int)(maxTokens * 1.1));
return text.Substring(0, chars) + "\n…(已截断)";
}
// ---- 块追加与折叠 ----
private void OnChunk(AIAnalyze.AIChunk chunk)
{
@@ -444,9 +715,28 @@ namespace AnotherReplayReader
private void StartContentParagraph()
{
_currentContent = (_currentContent?.Think, Content: new Paragraph());
_lastContentParagraph = _currentContent.Value.Content;
_document.Blocks.Add(_currentContent.Value.Content);
}
private void ReplaceSegmentContent(string text)
{
FinishCurrentContent();
// 移除本段已显示的全部块(草稿/回查中间内容),只保留最终修正版
while (_document.Blocks.Count > _blockCountBeforeSegment)
{
_document.Blocks.Remove(_document.Blocks.LastBlock);
}
_currentContent = null;
_lastContentParagraph = null;
StartContentParagraph();
_lastContentParagraph!.Inlines.Add(new Run(text)
{
Foreground = ContentStyle.Foreground,
FontSize = ContentStyle.FontSize
});
}
private void AppendToParagraph(Paragraph? paragraph, string text, ParagraphStyle style)
{
if (paragraph is null)
@@ -610,6 +900,16 @@ namespace AnotherReplayReader
{
AutoScroll();
// 修订 pass 期间:不显示中间内容,只统计字符数
if (_suppressDisplay)
{
while (_chunkQueue.TryDequeue(out var data))
{
_currentOutputChars += data.Chunk.Text.Length;
}
return;
}
var thinkSb = new StringBuilder();
var contentSb = new StringBuilder();
var errorSb = new StringBuilder();
@@ -738,7 +1038,7 @@ namespace AnotherReplayReader
MessageBox.Show("无法重试:外部取消令牌不可用。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (_analyzer.LastSuccessfulState.CurrentSegment < 0)
if (_overviewNarrative is null)
{
_canRetry = false;
UpdateButtons();
@@ -778,12 +1078,16 @@ namespace AnotherReplayReader
_internalCts = new CancellationTokenSource();
_linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCancelToken, _internalCts.Token);
// 注意:此时 _analyzer.CurrentSegment 停留在失败的那一段
// 需要继续 ProcessSegmentsAsync,但需要将现有的 result.State 作为起点
await ProcessSegmentsAsync(_analyzer.LastSuccessfulState, _eventCounts);
// 注意:此时 _currentSegmentIndex 停留在失败的那一段
// _slices/_findings/_overview 保留,从失败段继续
if (GetRequestContext() is not { } retryContext)
{
throw new InvalidOperationException("未配置 AI 请求上下文,无法重试。");
}
await ProcessSegmentsAsync(retryContext);
// 总结
await ProcessSummaryAsync(_eventCounts);
await ProcessSummaryAsync(retryContext);
}
catch (OperationCanceledException)
{
+10 -5
View File
@@ -139,6 +139,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@@ -154,25 +155,29 @@
<Label Grid.Row="1" Grid.Column="0" Content="上下文长度"/>
<TextBox x:Name="_contextLengthBox" Grid.Row="1" Grid.Column="1"/>
<Label Grid.Row="2" Grid.Column="0" Content="上下文预算"/>
<TextBox x:Name="_contextBudgetBox" Grid.Row="2" Grid.Column="1"
ToolTip="一次请求总 token 软上限(含输出/推理余量)。0 = 按上下文长度自动:≥1M → 160K200K~256K → 100K;更小 → 不支持长录像"/>
<CheckBox x:Name="_supportsSseCheck"
Grid.Row="2" Grid.Column="1"
Grid.Row="3" Grid.Column="1"
Content="支持 SSE 流式输出"
Margin="0,5"/>
<Label Grid.Row="3" Grid.Column="0" Content="高级参数"/>
<Label Grid.Row="4" Grid.Column="0" Content="高级参数"/>
<TextBox x:Name="_extraParamsBox"
Grid.Row="3" Grid.Column="1"
Grid.Row="4" Grid.Column="1"
MinHeight="80"
AcceptsReturn="True"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
<StackPanel Grid.Row="4" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="应用模型修改" Click="OnApplyModelClick" Margin="2"/>
<Button Content="从已知模板填充" Click="OnFillFromKnownModelsClick" Margin="2"/>
</StackPanel>
<TextBlock x:Name="_modelStatusText"
Grid.Row="5" Grid.ColumnSpan="2"
Grid.Row="6" Grid.ColumnSpan="2"
Foreground="Gray" Margin="0,5"/>
</Grid>
</DockPanel>
+12
View File
@@ -232,6 +232,7 @@ namespace AnotherReplayReader
_modelIdBox.Text = _currentModel.ModelId;
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
_supportsSseCheck.IsChecked = _currentModel.IsStream;
// 显示 ExtraParameters 为缩进 JSON
@@ -285,6 +286,7 @@ namespace AnotherReplayReader
ModelId = id,
DisplayName = id,
ContextLength = known.ContextLength,
ContextBudget = known.ContextBudget,
IsStream = known.IsStream,
ExtraParameters = new Dictionary<string, object>(known.ExtraParameters)
});
@@ -365,6 +367,14 @@ namespace AnotherReplayReader
{
_currentModel.ContextLength = ctxLen;
}
if (int.TryParse(_contextBudgetBox.Text, out int budget) && budget > 0)
{
_currentModel.ContextBudget = budget;
}
else
{
_currentModel.ContextBudget = null;
}
_currentModel.IsStream = _supportsSseCheck.IsChecked == true;
try
@@ -404,6 +414,7 @@ namespace AnotherReplayReader
_currentModel.ExtraParameters = new Dictionary<string, object>(known.ExtraParameters);
_currentModel.IsStream = known.IsStream;
_currentModel.ContextLength = known.ContextLength;
_currentModel.ContextBudget = known.ContextBudget;
RefreshModelList();
_modelComboBox.SelectedItem = _modelComboBox.Items
.OfType<ModelDisplayItem>()
@@ -420,6 +431,7 @@ namespace AnotherReplayReader
{
_modelIdBox.Text = "";
_contextLengthBox.Text = "";
_contextBudgetBox.Text = "";
_supportsSseCheck.IsChecked = false;
_extraParamsBox.Text = "";
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<AssemblyName>AiV2.Tests</AssemblyName>
<RootNamespace>AiV2.Tests</RootNamespace>
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AnotherReplayReader.csproj" AdditionalProperties="AiV2TestsBuilding=true" />
</ItemGroup>
<ItemGroup>
<Content Include="..\knowledge_units_default.json" Link="knowledge_units_default.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\knowledge_default.md" Link="knowledge_default.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+697
View File
@@ -0,0 +1,697 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using AnotherReplayReader;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
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("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, "小预算仍应有切片");
}
}
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");
// 高置信无证据 → 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), "高置信无证据");
// 低置信无备选/待确认 → 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), "低置信无备选");
}
}
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, "预算内 → 通过");
}
}
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("开始建造建筑"), "关键事件采样");
}
}
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");
}
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), "首次出现记录");
}
}
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 标签");
// 不存在的 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("出生点"), "地图知识保留");
}
}
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, "开局");
Program.Assert(segment.Contains("第1/1段"), "段指令编号");
Program.Assert(segment.Contains("开局"), "段标题");
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 == "内容", "往返一致");
}
}
internal static class UserReplayFactIndexReproTests
{
public static void Run()
{
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, "事实索引构建无异常");
}
}
}
+7 -3
View File
@@ -22,6 +22,10 @@
<EmbeddedResource Remove="publish\**" />
<None Remove="publish\**" />
<Page Remove="publish\**" />
<Compile Remove="AiV2.Tests\**" />
<EmbeddedResource Remove="AiV2.Tests\**" />
<None Remove="AiV2.Tests\**" />
<Page Remove="AiV2.Tests\**" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Web" />
@@ -60,11 +64,11 @@
<Content Include="knowledge_corona.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="knowledge_units.json">
<Content Include="knowledge_units_default.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Target Name="CustomAfterBuild" AfterTargets="Build">
<Target Name="CustomAfterBuild" AfterTargets="Build" Condition="'$(AiV2TestsBuilding)' != 'true'">
<ItemGroup>
<_FilesToMove Include="$(OutputPath)*.dll" />
</ItemGroup>
@@ -72,4 +76,4 @@
<Message Text="DestFiles:&#xD;&#xA; @(_FilesToMove->'$(OutputPath)$(ProjectName)Data\%(Filename)%(Extension)')" Importance="high" />
<Move SourceFiles="@(_FilesToMove)" DestinationFiles="@(_FilesToMove->'$(OutputPath)$(ProjectName)Data\%(Filename)%(Extension)')" />
</Target>
</Project>
</Project>
+34 -11
View File
@@ -78,6 +78,7 @@ namespace AnotherReplayReader
private Model _model = new();
private string? _cached;
private TimeIndexedPrefixSums? _cachedPrefixSums;
private ImmutableArray<EventSpan> _cachedSpans = ImmutableArray<EventSpan>.Empty;
private ReplayFactIndex? _cachedFactIndex;
public EventDump()
@@ -168,23 +169,21 @@ namespace AnotherReplayReader
_textBox.Text = "正在加载,请稍候";
_tokenUsageLabel.Content = "";
Show();
var (text, prefixSums) = await Task.Run(() =>
{
var text = GeneratePlainText(_model, out var prefixSums);
return (text, prefixSums);
});
var (text, prefixSums, spans) = await Task.Run(() => GeneratePlainText(_model));
var (bytesCount, estimatedTokenCount) = AIAnalyze.EstimateTokenCount(text);
_textBox.Text = text;
_cached = text;
_cachedPrefixSums = prefixSums;
_cachedSpans = spans;
_cachedFactIndex = ReplayFactIndex.Build(_model.Commands, _stringHashes);
// display KB and K tokens in _tokenUsageLabel
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
}
private static string GeneratePlainText(Model model, out TimeIndexedPrefixSums prefixSums)
private static (string Text, TimeIndexedPrefixSums PrefixSums, ImmutableArray<EventSpan> EventSpans) GeneratePlainText(Model model)
{
prefixSums = new([], []);
var prefixSums = new TimeIndexedPrefixSums([], []);
var spans = ImmutableArray.CreateBuilder<EventSpan>();
var sb = new StringBuilder();
for (int chunkIndex = 0; chunkIndex < model.Commands.Length; ++chunkIndex)
{
@@ -196,6 +195,7 @@ namespace AnotherReplayReader
{
continue;
}
var blockStart = sb.Length;
prefixSums.Add(time, filtered.Count);
sb.AppendLine($"[{TimeStampToString(time, model.Level)}]");
foreach (var command in filtered)
@@ -233,14 +233,18 @@ namespace AnotherReplayReader
0x207 when i == 1 && j == 1 => $"序列:{ProductionQueueTypeToString((int)value)}",
0x207 or 0x208 or 0x209 when i == 0 => $"{text}(建造者)",
0x205 or 0x206 when i == 0 => $"{text}(出兵建筑)",
0x252 => $"{model.PlayerNameByGameSlotIndex((int)command.Data[0].Value)}已主动退出游戏",
0x252 => $"{model.PlayerNameByGameSlotIndex(FirstInt32(command.Data[0]) ?? 0)}已主动退出游戏",
0x22E when i == 0 => j == 0 ? StanceToString((int)value) : null,
_ => text,
});
sb.AppendLine();
}
var blockLength = sb.Length - blockStart;
var tokens = (int)Math.Ceiling(
Encoding.UTF8.GetByteCount(sb.ToString(blockStart, blockLength)) / 2.2);
spans.Add(new EventSpan(time, blockStart, blockLength, tokens));
}
return sb.ToString();
return (sb.ToString(), prefixSums, spans.ToImmutable());
}
private static bool ShouldDisplay(CompactLevel level, ImmutableArray<CommandChunk> commands, int i)
@@ -265,7 +269,12 @@ namespace AnotherReplayReader
}
if (commandId is 0x1F5) // 选择单位
{
var isNewSelection = ((bool[])chunk.Data[0].Value)[0];
var isNewSelection = chunk.Data[0].Value switch
{
bool[] bools => bools.Length > 0 && bools[0],
bool b => b,
_ => false,
};
if (chunk.Data.Length <= 1 || chunk.Data[1].Count == 0)
{
// 空选择:
@@ -324,6 +333,13 @@ namespace AnotherReplayReader
_ => $"无效({value})"
};
private static int? FirstInt32(CommandArgumentEntry entry) =>
entry.Count == 1 && entry.Value is int singleInt
? singleInt
: entry.Value is int[] ints && ints.Length > 0
? ints[0]
: (int?)null;
private static string StanceToString(int value) => value switch
{
0 => "Guard",
@@ -453,7 +469,14 @@ namespace AnotherReplayReader
_aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!;
_aiPanel.GetPromptSettings = _aiSettings.GetPromptSettings;
await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, factIndex, _cancellation.Token);
await _aiPanel.StartAnalysisAsync(
replay,
_model.Players,
cached,
cachedPrefixSums,
factIndex,
_cachedSpans,
_cancellation.Token);
}
}
}
+299
View File
@@ -0,0 +1,299 @@
# AI 分析 v2 计划:上下文策略与管线重构
## 状态
- 日期:2026-08-20
- 版本:v2(重写)。v1 初稿未覆盖第一轮审视的部分问题;本文新增第 2 节"问题追踪表"并重组章节结构,确保第一轮提出的每一条问题都有对应的落地章节。
- 关联文档:[WIP.md](WIP.md)、[CONTEXT.md](CONTEXT.md)、`docs/adr/0001-hidden-revision-pass.md``docs/adr/0002-structured-game-knowledge.md`
## 实施状态(2026-08-20
里程碑全部完成,代码已落地并通过 124 项单元测试(`AiV2.Tests`,见 §12 M7)。
| 里程碑 | 状态 | 备注 |
| --- | --- | --- |
| M1 预算与护栏 | ✅ | `AiModel.ContextBudget`0/空 = 档位默认 160K/100K)、`AiContextBudget` 安全系数与硬护栏、设置 UI 编辑项 |
| M2 管线重构 | ✅ | 机械分段 + 对局摘要 + 总览轮 + 分段独立会话 + 已发现事实 + 总结轮;移除 `[分段列表]` 解析与旧流式状态机;提示词与知识文件已重新生成 |
| M3 回查机制 | ✅ | `[回查]` 标记、容错时间解析、切片提取、每段 3 次上限、失败降级 Info |
| M4 验证修正 | ✅ | 所有权强/弱分层与 4 条规则、施法者归属(仅 `0x1FE/0x200`)、协议记录与校验、多 JSON 块合并、move 降级、首次出兵时间线、player 映射接线 |
| M5 知识修正 | ✅ | `knowledge_units_default.json` 按 mod 加载、旧提示词副作用修复、标签体系补全与加载校验、渲染按参战阵营过滤、用户知识 JSON 覆盖 |
| M6 修订 pass | ✅ | 段内 1 次修订、修订期间抑制流式显示、完成后替换段内容、UI 日志提示 |
| M7 测试与评估 | ✅/部分 | 单元测试完成;A/B 对比与估算校准需真实 API 运行(见 §13) |
**实施中的取舍与遗留**
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
- 修订 pass 的展示采用"实时流式 + 修订后整段替换"ADR 0001 的"完全缓冲至验证完成"仍是开放项。
- `Fatal` 严重度暂不产生 issue,语义保留为修订失败兜底(修订输出为空时保留原分析)。
- `MissingMachineReadableClaims` 仍为 Warning(仅记录),是否触发"修复请求"待定。
- 测试工程 `AiV2.Tests` 通过 `ProjectReference` 引用主工程;构建时通过 `AiV2TestsBuilding=true` 跳过主工程的 DLL 移动目标。
## 1. 背景与目标
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地(ADR 0001)。
本计划的目标:
1. 用"部分操作记录 + 结构化上下文"替代"每轮全量日志",在不明显牺牲远距离关联能力的前提下提升长录像的分析质量与成本效率。
2. 只维护一条分析管线:"短录像 = 只有一个 slice",不保留两个独立模式。
3. 上下文预算成为每模型可配置的软上限,并把长期未使用的 `ContextLength` 接进护栏。
4. 修正验证层与知识层在本会话中发现的所有问题(见第 2 节追踪表)。
5. 落地 ADR 0001 的隐藏修订 pass。
## 2. 问题追踪表(第一轮审视 + 后续讨论确认)
下表汇总 2026-08-20 会话对 WIP/文档/代码的审视结论。后续讨论(所有权分层、缓存、预算、统一管线)调整了部分原始结论,表中"处理章节"指向本文的落地位置。
| # | 问题 | 处理章节 |
| --- | --- | --- |
| P1 | 结构化单位知识无 mod 维度:`knowledge_units.json` 是全局单例,Corona 盟军数据被基础版替换(如 `AlliedBomberAircraft` vs `AlliedAntiStructureBomberAircraft`),违背 ADR 0002"每 mod 自包含" | §8.1 |
| P2 | `GetSystemPrompt` 无条件执行旧 `BuildDefaultSystemPrompt`,即使走知识文件也会弹"缺乏苏联/未知地图"MessageBox(副作用) | §8.2 |
| P3 | 事实索引把特殊能力的施法者与目标混淆(`0x201/0x232` 的 ObjectId 不一定是施法者),Contradiction 校验可能误报/漏报 | §7.2 |
| P4 | 协议(`0x24E 选择协议`)不在验证体系,evidence schema 无法表达无单位能力 | §7.3 |
| P5 | 全量日志每轮重发、对话历史只增不减,长录像易超上下文;`ContextLength` 配置了但从未使用 | §4、§5、§6 |
| P6 | 解析健壮性:`[分段列表]` 缺失导致整体失败、`ParseAITimeSpan` 抛异常、机器可读声明只解析最后一个 JSON 块(中间块静默丢失) | §7.4 |
| P7 | `move` 证据只带坐标不带 UnitId,永远无法验证,却允许撑高置信结论 | §7.5 |
| P8 | 所有权归属:原建议"只能选中自己单位"过强;修正为强/弱分层(编队≈确定,选择≈弱信号) | §7.1 |
| P9 | 标签体系漂移:JSON 出现 `land/sea/miner/scout/support/siege/bomber` 等未定义 tag`KnowledgeTag` 常量未被执行 | §8.3 |
| P10 | `PlayerFirstProductionTime` 已建未消费,"首次出兵时间线"规则(如轰炸机)未做 | §7.6 |
| P11 | 修订 pass 未接线:`RequiresRevision` 存在但未用,`Fatal` 严重度从不产生,严重度语义未统一 | §10、§7.7 |
| P12 | 验证器/解析器/事实索引是纯逻辑但无测试 | §12 M7 |
| P13 | token 估算 `bytes/2.2` 对中文偏乐观,且无真实用量校准 | §4.3 |
| P14 | flat 文本作为单一 global 条目渲染,未参战阵营的知识也全部进入提示词(token 浪费) | §8.4 |
| P15 | 提示词/知识文件写死三阶段流程(`[分段列表]` 输出要求),需与 v2 管线同步修改 | §9 |
## 3. 决策摘要
| 决策点 | 结论 |
| --- | --- |
| 上下文预算 | 每模型 `ContextBudget` 软上限,默认档位:≥1M → 160K200K~256K → 100K<200K → 只支持短录像(单 slice) |
| 模式 | 单一管线;"全量模式"取消,短录像 = 1 个 slice |
| 分段 | 机械式(按 token 预算 + 事件数,带重叠);不再由 LLM 决定边界 |
| 总览轮 | 保留;输入为摘要 + 分段元数据(不读全量日志);输出允许跨段描述、跨段线索、回查建议 |
| 回查机制 | 进 v1;允许模型按需请求远处原始区间 |
| 缓存 | 稳定内容前置;跨段前缀 = system+摘要+总览;段内复用 = 前缀+slice(修订/回查共用) |
| 128K 及以下 | 允许短录像(切片后为 1 个 slice 时自然工作),不承诺长录像质量 |
| 修订 pass | 按 ADR 0001 在段内落地,每段最多 1 次 |
## 4. 上下文预算策略
### 4.1 语义
- `ContextBudget` = 一次请求的总 token 软上限(prompt + 输出/推理余量)。
- 用户给出的 150K / 90K 已经是保守值;为显式容纳估算误差与输出余量,默认档位取 **160K(≥1M/ 100K200K~256K**
- 每个模型可单独覆盖(新增 `AiModel.ContextBudget`0 表示用档位默认)。
### 4.2 输出余量与切片上限
- 输出余量 = `max(2 × max_tokens, 32K)`,推理模型的 thinking token 计入输出余量。
- 切片上限由预算反推:`slice_max = ContextBudget 固定开销 − 已发现事实 − 输出余量`
- 参考值:1M 档 slice ≈ 90K256K 档 slice ≈ 40K(固定开销约 system 20K + 摘要 3K + 总览 3K + 指令 1K)。
### 4.3 估算与护栏
- 现用 `bytes / 2.2` 对中文偏乐观;切片计算统一加安全系数 ×1.2。
- 用 API 返回的真实 `usage`(已收集 `PromptTokens`)校准估算系数,可记录在设置中或仅用于诊断。
- 硬护栏:估算总用量超过 `ContextLength` 的 90% 时拒绝发起请求并提示;超过 `ContextBudget` 时警告并自动收缩 slice。
## 5. 统一管线(单一模式)
流程:预处理 → 机械分段 → 对局摘要 → 总览轮 → 分段分析轮(每段独立会话)→ 总结轮。
### 5.1 预处理
- 保留现有 `CompactLevel` 压缩逻辑。
- 长录像的切片若仍超过 slice_max(密集事件区),对切片再做一次噪声过滤(如丢弃纯选择、空选择),仍超限则按时间二次细分。
### 5.2 机械分段
- 按事件累积估计 token,达到 slice_max 即切段;相邻段重叠前一段尾部 5%~10%(或 min(10%, 2K 事件))。
- 每段最小约 2K token;边界对齐 `TimeIndexedPrefixSums` 的事件分块。
- 分段数上限(如 20);超限时提高切片压缩力度而不是无限增加段数。
- N=1 时即旧"全量模式"的特例:整份日志作为一个 slice。
### 5.3 对局摘要(确定性)
- 来源:`ReplayFactIndex` + 规则采样器,不依赖 LLM 输出,保证同一次运行内稳定。
- 内容:玩家/阵营、首次出兵时间表、打包/展开链、建造者/生产者/所有权证据、协议选择、每段时间范围与事件数、每段采样关键事件(建造/摆放/出售/技能/协议)。
- 目标 2~5K token;是跨段缓存前缀的一部分。
### 5.4 总览轮
- 输入:system + 对局摘要 + 机械分段元数据(每段时间范围、事件数、采样事件)。
- 输出(自由格式,允许跨段):
- 每段标题 + 一两句概述(以机械段为锚点,不要求逐段对齐);
- 整局走势的跨段描述;
- 值得注意的跨段线索(如"第 1 段打包基地,第 3 段才重新展开");
- 回查建议(如"第 4 段分析时可回查 1:20~1:45")。
- 不读全量原始日志;输出在同一次运行内作为稳定前缀的一部分。
- 若总览轮输出明确建议合并/调整边界,v1 忽略,仅记录为后续可选优化。
- 总览轮失败(空输出/解析异常)→ 重试 1 次,仍失败则降级为"无总览输出"直接进入分段分析轮。
### 5.5 分段分析轮
- 每段一个独立会话,消息顺序(缓存关键,稳定在前):
`system → 对局摘要 → 总览输出 → slice_i → 已发现事实(1..i-1) → 段指令_i`
- 段指令:段标题/概述 + "请重点分析第 N 段(起止时间),可回查远处区间";N=1 时改为"分析整局"。
- 输出:自然语言分析 + `[机器可读声明]`(沿用现有 schema,见 §7.4 的解析修正)。
- 同一段的后续请求(修订、回查)复用同一消息列表。
### 5.6 已发现事实
- 每段分析完成后,由验证过的机器可读声明 + 3~5 句小结组成追加条目,每段 ≤ ~1K token。
- 追加在消息尾部,不影响前缀缓存;是跨段关联的主要载体。
### 5.7 回查协议(v1
- 格式:段回复末尾输出 `[回查] mm:ss~mm:ss`(可多个)。
- 程序解析后从缓存日志切出该区间,作为同一会话的追加 user 消息发回。
- 限制:每段最多 3 次;单次区间 ≤ 10K token;总回查量受预算约束。
- 解析失败/越界/超限 → 忽略并记录 Info 级 issue,不中断流程。
- 时间解析复用容错解析器(见 §7.4),不允许抛异常导致整段失败。
- 回查区间内容不进入"已发现事实"(它是临时上下文,不跨会话累积)。
### 5.8 总结轮
- 输入:system + 摘要 + 总览 + 各段分析 + 已发现事实(不含原始日志)。
- 沿用现有指令:允许跨段修正之前的分析;修正理由记录回验证层。
- 后段分析不直接改写前段结论,跨段修正统一由总结轮承担。
## 6. 缓存设计
- 前缀顺序是核心实现细节:稳定内容在前,变量内容在后,不要在稳定段中间插入变化内容。
- 两层复用:
- 跨段:`system + 摘要 + 总览输出` 对所有分段请求一致(主要命中点);
- 段内:`以上 + slice_i` 被分析、修订、回查多次复用(P5 缓存诉求的落点)。
- 系统提示本身约 20K token,通常已超过各家缓存最小前缀要求;若未来换更短的 system,需复核。
- 成本说明:缓存折扣可达 90~95%,但本方案的主要动机是质量与延迟,成本是次要收益。
## 7. 验证与事实索引修正
### 7.1 所有权证据(强/弱分层)与校验规则(P8)
- 强证据(近乎确定是己方单位):`0x1FA 创建编队` 的成员;以建造者/出兵建筑身份出现(`0x207/0x209/0x205`);维修(`0x228`)、出售(`0x20A`)、矿车指令(`0x212/0x248`);特殊能力施法者(仅 `0x1FE/0x200`,见 §7.2)。
- 弱证据(可能是点了敌方单位):`0x1F5 选择单位``0x1FB/0x1FC` 通过编队状态解析出的成员可升级为强证据。
- 注意:`0x205` 不带新单位 UnitId,不能作为所有权证据(只能做时间线检查,见 §7.6)。
- 校验规则:
1. 声称 X 属于 PlayerA 且为 `confirmed`/`highly likely`,但 X 对 A 无任何强/弱证据 → `WeakEvidence`
2. X 存在 PlayerB 的强证据而声称属于 A → `Contradiction`
3. X 仅有 PlayerB 的弱证据且对 A 无证据 → `Warning`,建议降置信度。
4. 双玩家强证据冲突(异常/作弊操作)→ `Contradiction`,提示无法判定归属。
- `unitClaims.player` 接入上述规则。
### 7.2 特殊能力归属修正(P3)
- 只对布局确凿的 `0x1FE/0x200` 记录施法者;`0x201/0x232`(及待核实的 `0x1FF`)不用于施法者校验。
- 用真实回放抽样验证各命令类型的 ObjectId 含义后再扩展。
### 7.3 协议与选择类指令(P4)
- 事实索引记录 `0x24E 选择协议`evidence schema 新增 `protocol|时间|科技名`
- 事实索引/编队状态补全:`0x1F6``0x1FA``0x1FB``0x1FC``0x22A`;维护编队号 → 成员 UnitId 的状态表。
### 7.4 机器可读声明解析健壮性(P6)
- 机器可读声明不再"只取最后一个 JSON 块":改为解析标记之后的所有 JSON 块并合并声明(重复 unitId 取后块),或至少对被忽略的块记录 Info issue。
- v2 移除 `[分段列表]` 解析(分段改机械式),消除"分段标记缺失导致整体失败"的路径。
- 时间解析统一改为容错实现(返回 null + issue,而不是抛异常),回查与段边界共用。
### 7.5 move 证据处理(P7
- 提示词注明:`move` 证据只带坐标不带 UnitId,不能单独支撑 `confirmed`/`highly likely` 结论。
- 验证器对 move 证据不做交叉校验;若高置信声明仅有 move 类证据,补发 `WeakEvidence` 提示。
### 7.6 首次出兵时间线规则(P10)
- 消费 `PlayerFirstProductionTime`:如"某 UnitId 被操作的时间早于该玩家首次生产对应单位"→ `Contradiction`;"轰炸机在首次生产轰炸机之前就被操作"→ 要求降级或解释。
- 需要结构化知识的类型 tag 映射(如 `bomber`/`aircraft`/`producedBy`)支持"哪个单位名属于哪类",随 §8.1 的 mod 拆分落地。
### 7.7 严重度语义统一(P11
- 明确 `Fatal` 的产生条件:段输出为空、机器可读声明完全不可解析,且修订后仍失败。
- `MissingMachineReadableClaims` 当前为 Warning(仅记录);是否升级为一次"修复请求"由 M6 与修订 pass 一并决定。
## 8. 知识架构修正
### 8.1 结构化数据按 mod 拆分(P1)
- `knowledge_units.json` 按 mod 拆分(如 `knowledge_corona_units.json`)或加 mod 键,与 flat 文本同作用域。
- `StripUnitSections` 只剥离"该 mod 确有结构化数据"的阵营;硬编码章节标题改为可配置/可校验。
### 8.2 旧提示词构建副作用修复(P2)
- `GetSystemPrompt` 先查知识文件,命中即走 `RenderAsPrompt`;旧 `BuildDefaultSystemPrompt` 的副作用(苏联/未知地图 MessageBox)只在 fallback 路径执行。
### 8.3 标签体系执行(P9
- 加载 `knowledge_units.json` 时校验未知 tag 并告警;补全/收敛 taxonomy`land/sea/miner/scout/support/siege/bomber` 等要么进定义、要么移除)。
- 加载告警在 Debug/设置页可见,避免静默漂移。
### 8.4 渲染过滤(P14
- flat 文本按参战阵营过滤非参战阵营章节;结构化渲染只渲染参战阵营(`RenderAsPrompt` 已有 factionNames 参数,flat 文本需要配套切分)。
### 8.5 用户知识 JSON 加载(WIP 遗留)
- 落地 `AnotherReplayReader.user_knowledge.json`:按 id 覆盖内置条目,加载顺序:内置 → 用户覆盖。
## 9. 提示词与知识文件更新
### 9.1 v2 流程(P15
- 三阶段流程改为:总览轮(无 `[分段列表]` 输出)→ 分段分析轮 → 总结轮。
- 同步修改 `knowledge_default.md``knowledge_corona.md``BuildDefaultSystemPrompt` 回退路径和 `tools/expand_knowledge.py` 生成的模板。
### 9.2 选择/所有权表述修正(P8)
- 修正"选择单位……这些操作的对象是玩家自己的单位":选择可能包含敌方单位(不能下达命令);加入编队几乎可确定是己方单位。
### 9.3 evidence 格式更新(P4、P7
- 补充 `protocol|时间|科技名` 类型。
- 注明 `move` 证据的验证限制(见 §7.5)。
## 10. 修订 passADR 0001 落地,P11
- 段内执行:草稿 + 验证 issue + 相关事实 → 干净修正版;每段最多 1 次。
- 修订后仍 `Fatal` → 回退显示原文 + 警告(`Fatal` 条件见 §7.7)。
- 修订请求复用段内会话(同一前缀,缓存友好)。
- UI:增加"验证器发现并修正 N 个问题"提示;沿用 ADR 0001 的缓冲决策(段内容缓冲至验证/修订完成)。
## 11. 设置与 UI
- `AiModel.ContextBudget`0 = 档位默认);`AIProviderSettingsControl` 增加编辑项。
- 128K 及以下模型:允许短录像(单 slice),设置页提示"长录像不保证质量"。
- 请求 Token 构成展示:system / 摘要 / 总览 / slice / 已发现事实 / 输出余量。
- 可选:回查统计(次数、命中率)与估算系数校准结果显示。
## 12. 实施步骤(里程碑)
1. **M1 预算与护栏**`ContextBudget` + 档位默认 + 估算安全系数 + 超限警告/拒绝。
2. **M2 管线重构**:机械分段 + 对局摘要 + 总览轮 + 分段独立会话;移除 `[分段列表]` 解析(P5、P6)。
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)。
7. **M7 测试与评估**:单元测试(解析器/验证器/事实索引/分段)+ A/B 对比(§13)。
依赖关系:M2 先于 M3;M4/M5 可与 M2 并行;M6 依赖 M2 + M4M7 覆盖全部。
完成标准(每步):`dotnet build` 通过;对应功能用手工回放验证一次;解析/验证改动附单元测试(M7 前至少保证新增逻辑可测)。
## 13. 评估方法
- 指标:验证 issue 数量与严重度分布;关键事件覆盖率(人工清单抽查);总 token/费用;耗时;回查次数与命中率;总览轮失败率。
- A/B:同一录像对比现状(全量模式)与 v2 管线,优先选短/中/长各一局。
- 校准:用真实 `usage` 修正估算系数,检查预算是否被实际超用。
## 14. 风险与开放问题
- 密集事件段可能仍超 slice 预算 → 切片压缩与二次细分是兜底。
- 总览轮质量影响后续所有段 → 摘要/采样质量需要迭代;失败降级路径见 §5.4。
- 回查滥用或格式不稳定 → 限流 + 失败降级(§5.7)。
- 修订后机器可读声明可能与正文不一致 → 修订轮要求同时重出声明并重新验证。
- 用户在运行中修改设置导致前缀变化 → 缓存失效,仅影响本次运行。
- 开放:段内容缓冲 vs 实时流式的最终 UI 决策;是否允许总览轮建议边界调整(v2 候选);`MissingMachineReadableClaims` 是否触发修复请求(§7.7)。
## 15. 本计划不涉及
- 非 AI 分析功能、其他 UI 改动、第三方库升级。
+3
View File
@@ -4,6 +4,9 @@ using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Windows;
// 允许 AiV2.Tests 测试工程访问 internal 类型(验证器/事实索引/分段等纯逻辑)
[assembly: InternalsVisibleTo("AiV2.Tests")]
// 将 ComVisible 设置为 false 会使此程序集中的类型
//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型
//请将此类型的 ComVisible 特性设置为 true。
+1 -1
View File
@@ -128,7 +128,7 @@ namespace AnotherReplayReader.ReplayFile
[0x22A] = "选择所有单位(Q", // 554
// 555
[0x22C] = "队形操作(左右键)", // 556
// 557
// 557 0x22D 似乎是付款
[0x22E] = "切换姿态(警戒/侵略/固守/停火模式)", // 558
[0x22F] = "路径点模式/计划模式(Alt", // 559
// 560
+335 -51
View File
@@ -27,7 +27,10 @@ namespace AnotherReplayReader.Utils
InvalidEvidenceLevel,
UnitCapabilityContradiction,
UnitTimelineContradiction,
UnsupportedGameKnowledge
UnsupportedGameKnowledge,
OwnershipMissingEvidence,
OwnershipConflict,
OwnershipWeakConflict
}
internal enum AIEvidenceLevel
@@ -97,11 +100,13 @@ namespace AnotherReplayReader.Utils
public static AIValidationResult ValidateMachineReadableClaims(
string response,
ReplayFactIndex? factIndex = null)
ReplayFactIndex? factIndex = null,
IReadOnlyDictionary<string, int>? aiNameToPlayerIndex = null,
StructuredKnowledge? structuredKnowledge = null)
{
var issues = ImmutableArray.CreateBuilder<AIValidationIssue>();
var json = ExtractJsonObject(response);
if (json is null || string.IsNullOrWhiteSpace(json))
var jsonBlocks = ExtractAllJsonObjects(response);
if (jsonBlocks.IsEmpty)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
@@ -110,7 +115,7 @@ namespace AnotherReplayReader.Utils
return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable());
}
var claims = ParseClaims(json, issues);
var claims = ParseClaims(jsonBlocks, issues);
if (claims is null)
{
return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable());
@@ -120,7 +125,7 @@ namespace AnotherReplayReader.Utils
ValidateUnpackAmbiguity(claims, issues);
if (factIndex is not null)
{
ValidateTimelineConsistency(claims, factIndex, issues);
ValidateTimelineConsistency(claims, factIndex, aiNameToPlayerIndex, structuredKnowledge, issues);
}
return new AIValidationResult(claims, issues.ToImmutable());
}
@@ -143,69 +148,112 @@ namespace AnotherReplayReader.Utils
return sb.ToString().TrimEnd();
}
private static string? ExtractJsonObject(string response)
private static ImmutableArray<string> ExtractAllJsonObjects(string response)
{
var result = ImmutableArray.CreateBuilder<string>();
var markerIndex = response.LastIndexOf("[机器可读声明]", StringComparison.OrdinalIgnoreCase);
var searchText = markerIndex >= 0 ? response.Substring(markerIndex) : response;
var matches = _jsonFenceRegex.Matches(searchText);
if (matches.Count > 0)
{
return matches[matches.Count - 1].Groups[1].Value;
foreach (Match match in matches)
{
result.Add(match.Groups[1].Value);
}
return result.ToImmutable();
}
// 回退:整段响应中所有 fenced JSON
var allMatches = _jsonFenceRegex.Matches(response);
if (allMatches.Count > 0)
{
foreach (Match match in allMatches)
{
result.Add(match.Groups[1].Value);
}
return result.ToImmutable();
}
var start = searchText.LastIndexOf('{');
var end = searchText.LastIndexOf('}');
if (start >= 0 && end > start)
{
return searchText.Substring(start, end - start + 1);
result.Add(searchText.Substring(start, end - start + 1));
return result.ToImmutable();
}
return null;
return ImmutableArray<string>.Empty;
}
private static AIMachineReadableClaims? ParseClaims(
string json,
ImmutableArray<string> jsonBlocks,
ImmutableArray<AIValidationIssue>.Builder issues)
{
try
{
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var mergedUnits = new Dictionary<string, AIUnitClaim>(StringComparer.OrdinalIgnoreCase);
var mergedEvents = new List<AIEventClaim>();
var mergedTimelines = new List<AITimelineClaim>();
var parsedAny = false;
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
foreach (var json in jsonBlocks)
{
try
{
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
"机器可读声明不是 JSON object。"));
continue;
}
parsedAny = true;
foreach (var unitClaim in ReadUnitClaims(root, issues))
{
mergedUnits[unitClaim.UnitId] = unitClaim;
}
foreach (var claim in ReadSimpleClaims(root, "eventClaims", issues)
.Select(c => new AIEventClaim(c.Claim, c.EvidenceLevel, c.Evidence)))
{
mergedEvents.Add(claim);
}
foreach (var claim in ReadSimpleClaims(root, "timelineClaims", issues)
.Select(c => new AITimelineClaim(c.Claim, c.EvidenceLevel, c.Evidence)))
{
mergedTimelines.Add(claim);
}
}
catch (JsonException ex)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
"机器可读声明不是 JSON object。"));
return null;
$"机器可读声明 JSON 解析失败:{ex.Message}"));
}
return new AIMachineReadableClaims(
ReadUnitClaims(root, issues),
ReadSimpleClaims(root, "eventClaims", issues)
.Select(c => new AIEventClaim(c.Claim, c.EvidenceLevel, c.Evidence))
.ToImmutableArray(),
ReadSimpleClaims(root, "timelineClaims", issues)
.Select(c => new AITimelineClaim(c.Claim, c.EvidenceLevel, c.Evidence))
.ToImmutableArray());
}
catch (JsonException ex)
if (!parsedAny)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"机器可读声明 JSON 解析失败:{ex.Message}"));
return null;
}
return new AIMachineReadableClaims(
mergedUnits.Values.Take(MaxUnitClaims).ToImmutableArray(),
mergedEvents.Take(MaxEventClaims).ToImmutableArray(),
mergedTimelines.Take(MaxTimelineClaims).ToImmutableArray());
}
private const int MaxUnitClaims = 10;
private const int MaxEventClaims = 5;
private const int MaxTimelineClaims = 3;
private static ImmutableArray<AIUnitClaim> ReadUnitClaims(JsonElement root, ImmutableArray<AIValidationIssue>.Builder issues)
{
@@ -263,12 +311,12 @@ namespace AnotherReplayReader.Utils
}
var result = ImmutableArray.CreateBuilder<SimpleClaim>();
var maxClaims = propertyName switch
{
"eventClaims" => 5,
"timelineClaims" => 3,
_ => 50,
};
var maxClaims = propertyName switch
{
"eventClaims" => MaxEventClaims,
"timelineClaims" => MaxTimelineClaims,
_ => 50,
};
var totalCount = 0;
foreach (var item in claims.EnumerateArray())
{
@@ -360,6 +408,7 @@ namespace AnotherReplayReader.Utils
Select,
Move,
Power,
Protocol,
Unknown
}
@@ -372,6 +421,9 @@ namespace AnotherReplayReader.Utils
public string? GetSpecialPowerName() =>
Type == AIEvidenceType.Power && Parameters.Length >= 1 ? Parameters[0] : null;
public string? GetTechName() =>
Type == AIEvidenceType.Protocol && Parameters.Length >= 1 ? Parameters[0] : null;
public string? GetUnitId() => Type switch
{
AIEvidenceType.Build or AIEvidenceType.Place when Parameters.Length >= 2 => Parameters[1],
@@ -391,7 +443,7 @@ namespace AnotherReplayReader.Utils
}
private static readonly Regex _structuredEvidenceRegex = new(
@"^(build|place|produce|sell|select|move|power)\|([^|]+(?:\|(?!\|).*)?)$",
@"^(build|place|produce|sell|select|move|power|protocol)\|([^|]+(?:\|(?!\|).*)?)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
internal static StructuredEvidence ParseStructuredEvidence(string text)
@@ -411,6 +463,7 @@ namespace AnotherReplayReader.Utils
"select" => AIEvidenceType.Select,
"move" => AIEvidenceType.Move,
"power" => AIEvidenceType.Power,
"protocol" => AIEvidenceType.Protocol,
_ => AIEvidenceType.Unknown,
};
@@ -472,6 +525,8 @@ namespace AnotherReplayReader.Utils
private static void ValidateTimelineConsistency(
AIMachineReadableClaims claims,
ReplayFactIndex factIndex,
IReadOnlyDictionary<string, int>? aiNameToPlayerIndex,
StructuredKnowledge? structuredKnowledge,
ImmutableArray<AIValidationIssue>.Builder issues)
{
foreach (var claim in claims.UnitClaims)
@@ -496,10 +551,37 @@ namespace AnotherReplayReader.Utils
continue;
}
var playerIndex = TryParsePlayerIndex(claim.Player, aiNameToPlayerIndex, out var parsed)
? parsed
: -1;
if (playerIndex < 0)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Info,
AIValidationIssueKind.InvalidMachineReadableClaims,
"Unit claim 缺少可识别的 player,无法校验所有权与生产时间线。",
claim.UnitId));
}
// Check 2: Verify special power evidence against fact index
var evidence = ParseAllEvidence(claim.Evidence);
foreach (var ev in evidence)
{
if (ev.Type == AIEvidenceType.Protocol && playerIndex >= 0)
{
var techName = ev.GetTechName();
if (techName is not null
&& factIndex.PlayerTechChoices.TryGetValue(playerIndex, out var chosen)
&& !chosen.Contains(techName))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"玩家 {claim.Player} 在回放中选择过的协议为:{string.Join(", ", chosen.OrderBy(x => x))},但 AI 声称其选择了“{techName}”——该协议未观察到。",
claim.UnitId));
}
continue;
}
if (ev.Type != AIEvidenceType.Power)
{
continue;
@@ -527,20 +609,30 @@ namespace AnotherReplayReader.Utils
}
// Did this UnitId actually use this special power?
if (factIndex.UnitIdSpecialPowers.TryGetValue(evUnitId, out var actualPowers)
&& !actualPowers.Contains(powerName))
if (factIndex.UnitIdSpecialPowers.TryGetValue(evUnitId, out var actualPowers))
{
if (!actualPowers.Contains(powerName))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"UnitId {evUnitIdStr} 在回放中使用过以下特殊能力:{string.Join(", ", actualPowers.OrderBy(x => x))},但 AI 声称其使用了“{powerName}”——此能力未在该 UnitId 上观察到。",
claim.UnitId));
}
}
else
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"UnitId {evUnitIdStr} 在回放中使用过以下特殊能力:{string.Join(", ", actualPowers.OrderBy(x => x))},但 AI 声称其使用了“{powerName}”——此能力未在该 UnitId 上观察到。",
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.WeakEvidence,
$"UnitId {evUnitIdStr} 在回放中未观察到任何特殊能力,但 AI 声称其使用了“{powerName}”,证据不足。",
claim.UnitId));
}
}
// Check 3: UnitId used as builder vs claim
var isBuilderInReplay = factIndex.BuilderUnitIds.Contains(unitId);
var claimLooksLikeBuilder = ClaimLooksLikeBuilder(claim.Claim);
var claimLooksLikeBuilder = ClaimLooksLikeBuilder(claim.Claim, structuredKnowledge);
if (claimLooksLikeBuilder && !isBuilderInReplay)
{
issues.Add(new AIValidationIssue(
@@ -549,9 +641,199 @@ namespace AnotherReplayReader.Utils
$"AI 推测 UnitId {claim.UnitId} 是“{claim.Claim}”(推测是建造单位),但该 UnitId 在回放中从未作为建造者(建造建筑)出现。",
claim.UnitId));
}
// Check 4: 所有权证据(强/弱分层)
if (playerIndex >= 0)
{
ValidateOwnership(claim, unitId, playerIndex, factIndex, issues);
}
// Check 5: move 证据不能单独支撑高置信
if (claim.EvidenceLevel is AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely
&& !claim.Evidence.IsEmpty)
{
var parsedEvidence = ParseAllEvidence(claim.Evidence);
if (parsedEvidence.All(e => e.Type == AIEvidenceType.Move))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.WeakEvidence,
$"UnitId {claim.UnitId} 的高置信推测只使用 move 证据(不携带 UnitId,无法验证),建议降级或补充其他证据。",
claim.UnitId));
}
}
// Check 6: 首次出兵时间线(轰炸机类)
if (playerIndex >= 0)
{
ValidateFirstProductionTimeline(claim, unitId, playerIndex, factIndex, structuredKnowledge, issues);
}
}
}
private static void ValidateOwnership(
AIUnitClaim claim,
uint unitId,
int playerIndex,
ReplayFactIndex factIndex,
ImmutableArray<AIValidationIssue>.Builder issues)
{
var myStrong = factIndex.PlayerStrongOwnershipUnitIds.TryGetValue(playerIndex, out var strong)
&& strong.Contains(unitId);
var myWeak = factIndex.PlayerWeakOwnershipUnitIds.TryGetValue(playerIndex, out var weak)
&& weak.Contains(unitId);
// 规则 1:高置信但无任何所有权证据
if (claim.EvidenceLevel is AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely
&& !myStrong && !myWeak)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.OwnershipMissingEvidence,
$"UnitId {claim.UnitId} 声称属于玩家 {claim.Player},但回放中该玩家对它的强/弱所有权证据(编队、建造者、维修、出售、施法者、选择)均未观察到,建议降级。",
claim.UnitId));
}
// 规则 2/4:其他玩家的强证据 → 冲突
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds)
{
if (kv.Key == playerIndex || !kv.Value.Contains(unitId))
{
continue;
}
var message = myStrong
? $"UnitId {claim.UnitId} 同时存在玩家 {claim.Player} 与玩家 {kv.Key} 的强所有权证据(异常/作弊操作),无法判定归属。"
: $"UnitId {claim.UnitId} 声称属于玩家 {claim.Player},但玩家 {kv.Key} 对它有强所有权证据(编队/建造者/维修/出售/施法者)。";
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.OwnershipConflict,
message,
claim.UnitId));
break;
}
// 规则 3:仅其他玩家的弱证据,且己方无证据 → 警告
if (!myStrong && !myWeak)
{
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds)
{
if (kv.Key == playerIndex || !kv.Value.Contains(unitId))
{
continue;
}
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.OwnershipWeakConflict,
$"UnitId {claim.UnitId} 声称属于玩家 {claim.Player},但玩家 {kv.Key} 选中过它(可能只是点击敌方单位查看血量,不能完全排除),建议降置信度。",
claim.UnitId));
break;
}
}
}
private static void ValidateFirstProductionTimeline(
AIUnitClaim claim,
uint unitId,
int playerIndex,
ReplayFactIndex factIndex,
StructuredKnowledge? structuredKnowledge,
ImmutableArray<AIValidationIssue>.Builder issues)
{
if (claim.EvidenceLevel is not (AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely))
{
return;
}
if (!factIndex.UnitIdFirstObservedTime.TryGetValue(unitId, out var firstObserved))
{
return;
}
if (!factIndex.PlayerFirstProductionTime.TryGetValue(playerIndex, out var productions)
|| productions.Count == 0)
{
return;
}
var claimedBombers = GetBomberAssetNames(claim.Claim, structuredKnowledge);
if (claimedBombers.Count == 0)
{
return;
}
var wildcard = claimedBombers.Contains("*");
var relevant = productions
.Where(kv => claimedBombers.Contains(kv.Key)
|| (wildcard && IsBomberLikeName(kv.Key)))
.Select(kv => kv.Value)
.ToList();
if (relevant.Count == 0 || !relevant.All(t => t > firstObserved))
{
return;
}
var firstTime = $"{(int)firstObserved.TotalMinutes}:{firstObserved:ss\\.ff}";
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitTimelineContradiction,
$"UnitId {claim.UnitId} 首次出现于 {firstTime},早于玩家 {claim.Player} 首次生产轰炸机类单位的时间,声称其为“{claim.Claim}”与回放时间线矛盾。",
claim.UnitId));
}
private static HashSet<string> GetBomberAssetNames(
string claimText,
StructuredKnowledge? structuredKnowledge)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var structured = structuredKnowledge ?? StructuredKnowledge.Instance;
if (structured is not null)
{
foreach (var entity in structured.EntitiesWithTag("bomber"))
{
if (claimText.IndexOf(entity.AssetName, StringComparison.OrdinalIgnoreCase) >= 0)
{
result.Add(entity.AssetName);
}
}
}
if (result.Count == 0
&& (claimText.IndexOf("轰炸机", StringComparison.OrdinalIgnoreCase) >= 0
|| claimText.IndexOf("bomber", StringComparison.OrdinalIgnoreCase) >= 0))
{
result.Add("*");
}
return result;
}
private static bool IsBomberLikeName(string assetName) =>
assetName.IndexOf("Bomber", StringComparison.OrdinalIgnoreCase) >= 0
|| assetName.IndexOf("轰炸", StringComparison.OrdinalIgnoreCase) >= 0;
private static bool TryParsePlayerIndex(
string playerText,
IReadOnlyDictionary<string, int>? aiNameToPlayerIndex,
out int playerIndex)
{
playerIndex = -1;
if (string.IsNullOrWhiteSpace(playerText))
{
return false;
}
var trimmed = playerText.Trim();
if (aiNameToPlayerIndex is not null && aiNameToPlayerIndex.TryGetValue(trimmed, out playerIndex))
{
return true;
}
if (trimmed.StartsWith("Player", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(trimmed.Substring("Player".Length), out playerIndex))
{
return true;
}
if (trimmed.StartsWith("AI_", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(trimmed.Substring("AI_".Length), out playerIndex))
{
return true;
}
return false;
}
#endregion
private static bool TryGetArray(JsonElement root, string propertyName, out JsonElement array)
@@ -652,10 +934,12 @@ namespace AnotherReplayReader.Utils
/// Uses structured knowledge (knowledge_units.json) when available, falls
/// back to heuristic string matching for backward compatibility.
/// </summary>
private static bool ClaimLooksLikeBuilder(string claimText)
private static bool ClaimLooksLikeBuilder(
string claimText,
StructuredKnowledge? structuredKnowledge)
{
// Primary: structured knowledge lookup
var structured = StructuredKnowledge.Instance;
var structured = structuredKnowledge ?? StructuredKnowledge.Instance;
if (structured is not null)
{
foreach (var entity in structured.AllEntities)
+110 -231
View File
@@ -17,21 +17,18 @@ namespace AnotherReplayReader.Utils
{
internal sealed class AIAnalyze
{
/// <summary>聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。</summary>
public sealed record ChatMessage(
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
[property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
public static string GetSystemPrompt(
Replay replay,
ImmutableSortedDictionary<int, Player> players,
AiPromptSettings? promptSettings = null)
{
var defaultPrompt = BuildDefaultSystemPrompt(replay, players);
// Try loading from knowledge_{mod}.md file (generated by tools/expand_knowledge.py).
// If the file exists, use it instead of the built-in string.
var modName = replay.Mod.ModName?.ToLowerInvariant();
var knowledgeModName = modName switch
{
"corona" => "corona",
_ => "default",
};
string defaultPrompt;
var knowledgeModName = GetKnowledgeModName(replay);
var knowledgePath = Path.Combine(AppContext.BaseDirectory, $"knowledge_{knowledgeModName}.md");
if (File.Exists(knowledgePath))
{
@@ -43,10 +40,25 @@ namespace AnotherReplayReader.Utils
var knowledge = KnowledgeSet.ForMod(knowledgeModName, AppContext.BaseDirectory);
defaultPrompt = knowledge.RenderAsPrompt(factionNames, mapId);
}
else
{
// 旧路径:知识文件缺失时的回退(副作用 MessageBox 只在这里执行)
defaultPrompt = BuildDefaultSystemPrompt(replay, players);
}
return ComposeSystemPrompt(defaultPrompt, promptSettings);
}
/// <summary>根据回放 mod 名得到知识文件名(default/corona)。</summary>
public static string GetKnowledgeModName(Replay replay)
{
return replay.Mod.ModName?.ToLowerInvariant() switch
{
"corona" => "corona",
_ => "default",
};
}
public static string ComposeSystemPrompt(string defaultPrompt, AiPromptSettings? promptSettings)
{
var customSystemPrompt = promptSettings?.CustomSystemPrompt ?? string.Empty;
@@ -106,33 +118,27 @@ namespace AnotherReplayReader.Utils
- 玩家 ID 以及操作,例如:`PlayerC: 重新选择单位`
- 操作参数或操作对象,例如:`[UnitId]239`
典型的玩家操作流程
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。这些操作的对象是玩家自己的单位
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。选择的对象通常是玩家自己的单位,但也可能点击选中敌方单位(此时只能查看血量,无法下达命令);被加入编队的单位几乎可以确定是玩家自己的单位
2. 执行操作:让当前被选中的单位执行某个任务,例如攻击、释放技能。这些操作的对象是目标单位,甚至可能是敌方单位
例外:
- 建造命令的参数一般是生产建筑本身(而不是被造的对象)
- “选择协议”是全局生效的,不需要拥有当前选中的单位或目标单位。
# 输出要求
## 1. 初始阶段
触发条件:用户输入包含:""判断是否需要分段处理数据""
- 进行简单的推理,列举你的发现,目标:判断玩家操作记录是否应该分段分析
- 输出:对玩家操作记录的分段,各个分段的开始时间和结束时间,以及简短介绍。分段应按照时间排序,分段之间可以有一定的重叠
- 输出示例:
```
[分段列表]
#1 [0:00.0]~[0:55.4] 开局
#2 [0:50.1]~[3:02.1] 开局(第二部分)
#3 [3:00]~[5:16] 前中期
#4 [5:10]~[10:13.12] 中期
```
- 输出示例(假如判断不需要分段):
```
[分段列表]
#1 [0:00.0]~[17:23] 从开局到玩家操作记录结束
```
## 1. 总览阶段
触发条件:用户输入包含:""请先对整局进行总览""
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:""请分析第N段([BEGIN]至[END]的数据""
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END]""
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
- 推理思考时:不要直接列出所有操作信息,可以先只列出一部分,然后按需向前以及向后“延申”
@@ -143,6 +149,7 @@ namespace AnotherReplayReader.Utils
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:""请对以上内容进行总结""
@@ -193,8 +200,9 @@ namespace AnotherReplayReader.Utils
- `produce|时间|单位名|出兵建筑UnitId` — 开始出兵,例如 `produce|0:14.66|AlliedScoutInfantry|291`
- `sell|时间|建筑UnitId` — 出售建筑,例如 `sell|2:21.93|255`
- `select|时间|单位UnitId` — 选择单位,例如 `select|1:24.13|587`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`。注意:move 证据不携带 UnitId,无法被程序验证,不能单独作为高置信结论的证据
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
@@ -1010,71 +1018,56 @@ PlayerA: 开始出兵
return sb.ToString().Replace("\r", "");
}
public static string BuildUserPrompt(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
string commandData,
out string prefix
)
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
{
var playerNamesForAI = PlayerNamesForAI(mod, players);
var playerData = players.Select(kv =>
{
var id = kv.Key;
var player = kv.Value;
var playerNameForAI = playerNamesForAI[id];
var prefix = player.IsComputer ? "电脑" : "玩家";
var name = player.IsComputer ? $"[{player.PlayerName}AI]" : player.PlayerName;
var factionName = ModData.GetFaction(mod, player.FactionId).Name;
if (player.Team >= 0)
{
return $"{prefix}#{id} {name} ({playerNameForAI}){factionName},队伍{player.Team}";
}
return $"{prefix}#{id} {name} ({playerNameForAI}){factionName}";
});
var sb = new StringBuilder();
sb.AppendLine("请你阅读并分析以下数据,首先进行初步分析,然后判断是否需要分段处理数据");
sb.AppendLine("玩家列表:");
sb.AppendLine(string.Join("\n", playerData));
sb.AppendLine("数据:");
prefix = sb.ToString();
sb.AppendLine(commandData);
sb.AppendLine("请先对整局进行总览。");
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
sb.AppendLine("请描述整局走势(允许跨分段),并为每个分段给出标题与一句话概述。");
sb.AppendLine();
sb.AppendLine("[分段元数据]");
foreach (var slice in slices)
{
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`。");
return sb.ToString().Replace("\r", "");
}
public static string BuildSegmentUserPrompt(IReadOnlyList<Segment> segments, int currentSegmentIndex, int eventCount)
public static string BuildSegmentUserPromptV2(
int segmentIndex,
int totalSegments,
ReplaySlice slice,
int eventCount,
string? title)
{
if (currentSegmentIndex < 0 || currentSegmentIndex >= segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var segment = segments[currentSegmentIndex];
var beginText = currentSegmentIndex <= 0
? "游戏开始"
: segment.Start.ToString();
var endText = currentSegmentIndex >= segments.Count - 1
? "游戏结束"
: segment.End.ToString();
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
var instruction = @$"
请分析第{currentSegmentIndex + 1}段({beginText}至{endText})的数据。
阶段一共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接把{eventCount}条操作信息都列出来。
也不要把每一条操作信息都视作一个单独事件
你需要列出{beginText}至{endText}的主要事件、以及其他有分析价值的事件
请按照按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组。
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
";
return instruction.Trim().Replace("\r", "");
}
public static string BuildFinalUserPrompt(int totalEventCount)
public static string BuildSummaryUserPromptV2(int totalEventCount)
{
var instruction = $@"
请对以上内容进行总结。
基于:
- 你对于各阶段的推理分析
- 由我在对话刚开始时提供的原始数据(约{totalEventCount}个操作信息)。
- 整局总览与对局摘要
- 各分段的推理分析
- 各分段的已发现事实
(原始操作记录约{totalEventCount}条,未全部提供;如需要核实某个具体时间段,可以在总结中说明,由程序另行提供。)
请对各阶段的推理分析进行汇总。
判断是否需要对之前的分析进行补充,例如检查之前是否遗漏了某些重要的操作信息?
判断是否需要对之前的分析进行修正。
@@ -1084,6 +1077,36 @@ PlayerA: 开始出兵
return instruction.Trim().Replace("\r", "");
}
public static string BuildBackqueryUserPrompt(string sliceText)
{
return "以下是按你的 [回查] 请求提供的原始操作记录区间:\n\n" + sliceText;
}
public static string BuildRevisionUserPrompt(
string draft,
string validationIssues,
string relevantFacts)
{
var instruction = $@"
请重新检查你刚才的分析,并输出一份修正后的完整版本(正文 + 机器可读声明)。
机器校验发现以下问题:
{validationIssues}
相关事实:
{relevantFacts}
要求:
- 只输出修正后的分析本身,不要提及""修订""""抱歉""""之前回答有误""等字眼,不要复述本指令。
- 被机器校验否定的结论必须修正或降级;无法确定的结论标记为""不确定""或""可能""。
- 保留 [机器可读声明] JSON 代码块(与修正后的正文一致),并重新输出 [小结]。
- 原始草稿(供参考,不要原样照抄):
---
{draft}
---
";
return instruction.Trim().Replace("\r", "");
}
public static (int BytesCount, int EstimatedTokenCount) EstimateTokenCount(string text)
{
var bytesCount = Encoding.UTF8.GetByteCount(text);
@@ -1143,31 +1166,9 @@ PlayerA: 开始出兵
public string Text;
}
public record Segment(TimeSpan Start, TimeSpan End, string Description);
public record State(ImmutableList<object> Messages, ImmutableList<Segment> Segments, int CurrentSegment)
{
public static State Initial => new(ImmutableList<object>.Empty, ImmutableList<Segment>.Empty, -1);
public State AppendNewMessage(string role, string content)
{
var newMessages = Messages.Add(new
{
role,
content
});
return this with { Messages = newMessages };
}
public State AppendNewSegment(Segment segment)
{
var newSegments = Segments.Add(segment);
return this with { Segments = newSegments };
}
}
public struct Result
{
public string Response;
public State State;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
@@ -1175,9 +1176,6 @@ PlayerA: 开始出兵
}
private readonly HttpClient _http;
private State _state = State.Initial;
public State LastSuccessfulState => _state;
public AIAnalyze()
{
@@ -1187,98 +1185,27 @@ PlayerA: 开始出兵
};
}
public void SetState(State state)
{
_state = state;
}
public async Task<Result> AnalyzeAsync(
string instruction,
string text,
/// <summary>对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。</summary>
public async Task<Result> CompleteAsync(
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
var inputState = State.Initial
.AppendNewMessage("system", instruction)
.AppendNewMessage("user", text);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
var resultState = result.State;
var splitted = result.Response.Split('\n').ToList();
var titleIndex = splitted.FindIndex(l => l.Contains("[分段列表]"));
if (titleIndex == -1)
{
throw new Exception("AI分析失败");
}
// regex match two timespan in "[0:00.0]~[0:55.4]"
var timeSpanRegex = new Regex(@"\[([^]]+)\]~\[([^]]+)\]");
for (var i = titleIndex + 1; i < splitted.Count; ++i)
{
var line = splitted[i];
var match = timeSpanRegex.Match(line);
if (match.Success)
{
var startTimeText = match.Groups[1].Value;
var endTimeText = match.Groups[2].Value;
var start = ParseAITimeSpan(startTimeText);
var end = ParseAITimeSpan(endTimeText);
var description = line.Substring(match.Index + match.Length).Trim();
resultState = resultState.AppendNewSegment(new(start, end, description));
}
}
result.State = resultState with { CurrentSegment = 0 };
_state = result.State;
return result;
}
public async Task<Result> ContinueAnalyzeAsync(
string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
if (_state.CurrentSegment < 0 || _state.CurrentSegment >= _state.Segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var inputState = _state.AppendNewMessage("user", instruction);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
result.State = result.State with { CurrentSegment = _state.CurrentSegment + 1 };
_state = result.State;
return result;
}
public async Task<Result> FinishAnalyzeAsync(
string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
if (_state.CurrentSegment != _state.Segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var inputState = _state.AppendNewMessage("user", instruction);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
_state = result.State;
return result;
return await Task.Run(
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
cancellationToken);
}
private static async Task<Result> DoRequest(
HttpClient http,
State state,
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
var provider = requestContext.Provider;
var requestParams = ProcessRequestParams(state, requestContext.BuildRequestParams());
var requestParams = ProcessRequestParams(messages, requestContext.BuildRequestParams());
var isStream = requestContext.Model.IsStream;
var inputJson = JsonSerializer.Serialize(requestParams);
@@ -1351,7 +1278,6 @@ PlayerA: 开始出兵
}
var resultText = fullBuilder.ToString();
result.State = state.AppendNewMessage("assistant", resultText);
result.Response = resultText;
return result;
}
@@ -1454,12 +1380,12 @@ PlayerA: 开始出兵
}
private static Dictionary<string, object> ProcessRequestParams(
State state,
ImmutableList<ChatMessage> messages,
Dictionary<string, object> inputRequestParams)
{
return new Dictionary<string, object>(inputRequestParams)
{
["messages"] = state.Messages.ToArray(),
["messages"] = messages.ToArray(),
["stream"] = true,
["stream_options"] = new
{
@@ -1468,52 +1394,5 @@ PlayerA: 开始出兵
};
}
private static TimeSpan ParseAITimeSpan(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
throw new FormatException("Empty input");
}
input = input.Trim();
var parts = input.Split(':');
if (parts.Length == 0)
{
throw new FormatException("Invalid format");
}
// -----------------------------
// 1. 解析最后一段:seconds + fraction
// -----------------------------
if (!float.TryParse(parts.Last(), out float floatSeconds))
{
throw new FormatException("Invalid seconds");
}
int seconds = (int)floatSeconds;
int milliseconds = (int)Math.Round((floatSeconds - seconds) * 1000);
// -----------------------------
// 2. 累加前面的部分(从右往左)
// -----------------------------
long totalSeconds = seconds;
long multiplier = 60; // 每层递进:秒->分->时->天...
for (int i = parts.Length - 2; i >= 0; i--)
{
if (!long.TryParse(parts[i], out long value))
throw new FormatException($"Invalid number: {parts[i]}");
totalSeconds += value * multiplier;
multiplier *= 60;
}
var result = TimeSpan.FromSeconds(totalSeconds)
+ TimeSpan.FromMilliseconds(milliseconds);
return result;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using System;
namespace AnotherReplayReader.Utils
{
/// <summary>
/// 上下文预算策略:每模型软上限、输出余量、估算安全系数与请求护栏。
/// </summary>
internal static class AiContextBudget
{
public const int Tier1MBudget = 160_000;
public const int Tier256KBudget = 100_000;
public const double EstimatorSafetyFactor = 1.2;
public const double HardUsageRatio = 0.9;
/// <summary>
/// 获取模型的一次请求总 token 软上限。0 表示该模型不支持长录像(只能短录像单 slice)。
/// </summary>
public static int GetContextBudget(AiModel model)
{
if (model.ContextBudget is { } explicitBudget && explicitBudget > 0)
{
return explicitBudget;
}
if (model.ContextLength >= 1_000_000)
{
return Tier1MBudget;
}
if (model.ContextLength >= 200_000)
{
return Tier256KBudget;
}
return 0;
}
/// <summary>为输出/推理 tokens 预留的余量。</summary>
public static int GetOutputHeadroom(AiProvider provider, AiModel model)
{
var maxTokens = provider.DefaultMaxTokens;
return Math.Max(2 * maxTokens, 32_000);
}
/// <summary>带安全系数的 token 估算(对中文偏乐观的 bytes/2.2 估算 × 1.2)。</summary>
public static int EstimateTokens(string text) =>
(int)Math.Ceiling(AIAnalyze.EstimateTokenCount(text).EstimatedTokenCount * EstimatorSafetyFactor);
/// <summary>请求护栏:超过硬上限返回 Block,超过软预算返回 Warn,否则 null。</summary>
public static ContextCheckResult CheckPromptUsage(
int estimatedPromptTokens,
AiProvider provider,
AiModel model)
{
if (model.ContextLength > 0)
{
var hardLimit = (int)(model.ContextLength * HardUsageRatio);
if (estimatedPromptTokens > hardLimit)
{
return new ContextCheckResult(
true,
$"估算输入 {estimatedPromptTokens:N0} token 超过模型上下文 {model.ContextLength:N0} 的 90%,已拒绝发起请求。请改用更长上下文的模型,或缩短操作记录。");
}
}
var budget = GetContextBudget(model);
if (budget > 0 && estimatedPromptTokens > budget)
{
return new ContextCheckResult(
false,
$"估算输入 {estimatedPromptTokens:N0} token 超过上下文预算 {budget:N0}(可在模型设置中调整),长录像将自动分段,超出部分会被压缩。");
}
return ContextCheckResult.Ok;
}
}
internal sealed record ContextCheckResult(bool Block, string Message)
{
public static ContextCheckResult Ok { get; } = new(false, string.Empty);
public bool IsOk => !Block && string.IsNullOrEmpty(Message);
}
}
+209 -61
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
@@ -34,6 +35,9 @@ namespace AnotherReplayReader.Utils
public const string Production = "production";
public const string Defense = "defense";
public const string Superweapon = "superweapon";
public const string Land = "land";
public const string Sea = "sea";
public const string Air = "air";
// ── Combat role tags (what a unit fights) ─────────────────────
public const string AntiInfantry = "antiInfantry";
@@ -41,6 +45,25 @@ namespace AnotherReplayReader.Utils
public const string AntiStructure = "antiStructure";
public const string AntiAir = "antiAir";
public const string AntiNaval = "antiNaval";
public const string AntiGround = "antiGround";
// ── 其余 JSON 实际使用的角色/定位标签 ──────────────────────────
public const string Miner = "miner";
public const string Scout = "scout";
public const string Support = "support";
public const string Siege = "siege";
public const string Bomber = "bomber";
public const string Engineer = "engineer";
public const string Fighter = "fighter";
/// <summary>完整标签集合:加载 JSON 时校验未知 tag 用。</summary>
public static readonly ImmutableArray<string> All = ImmutableArray.Create(
Builder, Pack, Unpack, Amphibious, Transport, ReturnToProducer, Cloak, ToggleWeapon,
Miner, Scout, Support, Siege, Bomber,
Infantry, Vehicle, Aircraft, Naval, Structure, Hero, Production, Defense, Superweapon,
Land, Sea, Air,
AntiInfantry, AntiVehicle, AntiStructure, AntiAir, AntiNaval, AntiGround,
Engineer, Fighter);
/// <summary>Create a special power reference tag.</summary>
public static string SpecialPower(string powerName) => $"specialPower:{powerName}";
@@ -118,15 +141,18 @@ namespace AnotherReplayReader.Utils
public ImmutableDictionary<string, EntityKnowledge> EntitiesByAssetName { get; }
public ImmutableArray<EntityKnowledge> AllEntities { get; }
public ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> EntitiesByFaction { get; }
public ImmutableArray<string> UnknownTags { get; }
private StructuredKnowledge(
ImmutableDictionary<string, EntityKnowledge> byAsset,
ImmutableArray<EntityKnowledge> allEntities,
ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> byFaction)
ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> byFaction,
ImmutableArray<string> unknownTags)
{
EntitiesByAssetName = byAsset;
AllEntities = allEntities;
EntitiesByFaction = byFaction;
UnknownTags = unknownTags;
}
// ── Query helpers ────────────────────────────────────────────
@@ -149,92 +175,168 @@ namespace AnotherReplayReader.Utils
// ── Factory ──────────────────────────────────────────────────
private static readonly Lazy<StructuredKnowledge?> _lazyInstance = new(() => LoadFromFile());
private static readonly Lazy<StructuredKnowledge?> _lazyDefault = new(() => GetForMod("default"));
public static StructuredKnowledge? Instance => _lazyInstance.Value;
public static StructuredKnowledge? Instance => _lazyDefault.Value;
private static readonly ConcurrentDictionary<string, StructuredKnowledge?> _cache =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>按 mod 加载结构化知识;文件不存在返回 null(该 mod 无结构化数据)。</summary>
public static StructuredKnowledge? GetForMod(string? modName)
{
var key = modName ?? "default";
if (_cache.TryGetValue(key, out var cached))
{
return cached;
}
var loaded = LoadFromFile(key);
if (loaded is not null)
{
_cache.TryAdd(key, loaded);
}
return loaded;
}
/// <summary>Look up the display name for an asset name.</summary>
public string? GetDisplayName(string? assetName) =>
GetEntity(assetName)?.DisplayName;
private static StructuredKnowledge? LoadFromFile()
private static StructuredKnowledge? LoadFromFile(string modName)
{
var path = Path.Combine(AppContext.BaseDirectory, "knowledge_units.json");
var path = Path.Combine(AppContext.BaseDirectory, $"knowledge_units_{modName}.json");
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path, Encoding.UTF8);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("factions", out var factions))
return null;
var allEntities = new List<EntityKnowledge>();
var byFaction = new Dictionary<string, List<EntityKnowledge>>(StringComparer.OrdinalIgnoreCase);
foreach (var faction in factions.EnumerateObject())
var builtin = ParseFactions(path, out var unknownTags);
if (builtin is null)
{
var factionName = faction.Name;
if (!byFaction.ContainsKey(factionName))
byFaction[factionName] = new List<EntityKnowledge>();
return null;
}
// Buildings
if (faction.Value.TryGetProperty("buildings", out var bldgs))
// 用户知识覆盖:AnotherReplayReader.user_knowledge.json,按 (阵营, assetName) 覆盖或新增
var userPath = Path.Combine(AppContext.BaseDirectory, "AnotherReplayReader.user_knowledge.json");
if (File.Exists(userPath))
{
var user = ParseFactions(userPath, out var userUnknownTags);
if (user is not null)
{
foreach (var b in bldgs.EnumerateArray())
foreach (var kv in user)
{
var ek = new EntityKnowledge(
GetString(b, "assetName") ?? "unknown",
GetString(b, "displayName") ?? "",
factionName,
Tier: null,
GetStringArray(b, "tags"),
ParseSpecialPowers(b),
ProducedBy: ImmutableArray<string>.Empty,
GetString(b, "text") ?? "");
allEntities.Add(ek);
byFaction[factionName].Add(ek);
}
}
// Units
if (faction.Value.TryGetProperty("units", out var units))
{
foreach (var u in units.EnumerateArray())
{
var assetName = GetString(u, "assetName") ?? "unknown";
var ek = new EntityKnowledge(
assetName,
GetString(u, "displayName") ?? "",
factionName,
GetString(u, "tier"),
GetStringArray(u, "tags"),
ParseSpecialPowers(u),
GetStringArray(u, "producedBy"),
GetString(u, "text") ?? "");
allEntities.Add(ek);
byFaction[factionName].Add(ek);
if (!builtin.TryGetValue(kv.Key, out var factionEntries))
{
factionEntries = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
builtin[kv.Key] = factionEntries;
}
foreach (var entity in kv.Value)
{
factionEntries[entity.Key] = entity.Value;
}
unknownTags.UnionWith(userUnknownTags);
}
}
}
var allEntities = builtin.Values
.SelectMany(d => d.Values)
.OrderBy(e => e.Faction, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var byFaction = builtin.ToImmutableDictionary(
kv => kv.Key,
kv => kv.Value.Values.OrderBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
StringComparer.OrdinalIgnoreCase);
var unknownTagsArray = unknownTags.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToImmutableArray();
if (!unknownTagsArray.IsEmpty)
{
System.Diagnostics.Debug.WriteLine(
$"[AiKnowledge] 未知标签({modName}):{string.Join(", ", unknownTagsArray)}");
}
return new StructuredKnowledge(
allEntities.ToImmutableDictionary(e => e.AssetName, e => e, StringComparer.OrdinalIgnoreCase),
allEntities.ToImmutableArray(),
byFaction.ToImmutableDictionary(
kv => kv.Key,
kv => kv.Value.ToImmutableArray(),
StringComparer.OrdinalIgnoreCase));
allEntities,
byFaction,
unknownTagsArray);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units.json: {ex.Message}");
System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units_{modName}.json: {ex.Message}");
return null;
}
}
/// <summary>
/// 解析知识 JSON 的 factions 结构,返回 faction → (assetName → EntityKnowledge)
/// 同时收集未知标签。
/// </summary>
private static Dictionary<string, Dictionary<string, EntityKnowledge>>? ParseFactions(
string path,
out HashSet<string> unknownTags)
{
var unknownTagsLocal = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var json = File.ReadAllText(path, Encoding.UTF8);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("factions", out var factions))
{
unknownTags = unknownTagsLocal;
return null;
}
var result = new Dictionary<string, Dictionary<string, EntityKnowledge>>(StringComparer.OrdinalIgnoreCase);
foreach (var faction in factions.EnumerateObject())
{
var factionName = faction.Name;
if (!result.TryGetValue(factionName, out var entries))
{
entries = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
result[factionName] = entries;
}
void AddEntity(JsonElement el, bool isBuilding)
{
var assetName = GetString(el, "assetName") ?? "unknown";
var tags = GetStringArray(el, "tags");
foreach (var tag in tags)
{
if (!KnowledgeTag.All.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
unknownTagsLocal.Add(tag);
}
}
var ek = new EntityKnowledge(
assetName,
GetString(el, "displayName") ?? "",
factionName,
isBuilding ? null : GetString(el, "tier"),
tags,
ParseSpecialPowers(el),
isBuilding ? ImmutableArray<string>.Empty : GetStringArray(el, "producedBy"),
GetString(el, "text") ?? "");
entries[assetName] = ek;
}
if (faction.Value.TryGetProperty("buildings", out var bldgs))
{
foreach (var b in bldgs.EnumerateArray())
{
AddEntity(b, isBuilding: true);
}
}
if (faction.Value.TryGetProperty("units", out var units))
{
foreach (var u in units.EnumerateArray())
{
AddEntity(u, isBuilding: false);
}
}
}
unknownTags = unknownTagsLocal;
return result;
}
private static string? GetString(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
@@ -312,7 +414,10 @@ namespace AnotherReplayReader.Utils
foreach (var entry in ByScope(KnowledgeScopeKind.Global))
{
sb.AppendLine(entry.Text.Trim());
var text = entry.Id.StartsWith("knowledge-text-", StringComparison.Ordinal)
? FilterFlatTextByFactions(entry.Text, factionNames)
: entry.Text;
sb.AppendLine(text.Trim());
sb.AppendLine();
}
@@ -337,6 +442,49 @@ namespace AnotherReplayReader.Utils
return sb.ToString().Replace("\r", "");
}
private static readonly (string Faction, string StartMarker)[] FactionSectionMarkers =
{
("盟军", "盟军常用建筑与升级"),
("神州", "神州常用建筑"),
};
/// <summary>flat 文本按参战阵营过滤:只保留全局部分与参战阵营的章节,减少 token 浪费。</summary>
private static string FilterFlatTextByFactions(string text, IReadOnlyList<string> factionNames)
{
if (factionNames.Count == 0)
{
return text;
}
var participating = new HashSet<string>(factionNames, StringComparer.OrdinalIgnoreCase);
var lines = text.Replace("\r", "").Split('\n');
var sb = new StringBuilder();
string? currentFaction = null;
foreach (var raw in lines)
{
var line = raw.TrimStart();
var matched = FactionSectionMarkers.FirstOrDefault(
m => line.StartsWith(m.StartMarker, StringComparison.OrdinalIgnoreCase));
if (matched.Faction is not null)
{
currentFaction = matched.Faction;
if (participating.Contains(currentFaction))
{
sb.AppendLine(raw);
}
continue;
}
if (line.StartsWith("# 地图参数", StringComparison.OrdinalIgnoreCase))
{
currentFaction = null;
}
if (currentFaction is null || participating.Contains(currentFaction))
{
sb.AppendLine(raw);
}
}
return sb.ToString().TrimEnd();
}
// ── Built-in factory ─────────────────────────────────────────
/// <summary>
@@ -349,7 +497,7 @@ namespace AnotherReplayReader.Utils
{
var searchDir = baseDirectory ?? AppContext.BaseDirectory;
var entries = new List<(KnowledgeScope Scope, KnowledgeEntry Entry)>();
var structured = StructuredKnowledge.Instance;
var structured = StructuredKnowledge.GetForMod(modName);
// 1. Load flat text from knowledge_{modName}.md
var flatPath = Path.Combine(searchDir, $"knowledge_{modName}.md");
+5
View File
@@ -42,6 +42,11 @@ namespace AnotherReplayReader
public string? DisplayName { get; set; }
public bool IsStream { get; set; }
public int ContextLength { get; set; } // 0 表示未知
/// <summary>
/// 一次请求的总 token 软上限(prompt + 输出/推理余量)。
/// null 或 0 表示使用档位默认值:≥1M 上下文 → 160K200K~256K → 100K;更小 → 0(不支持长录像)。
/// </summary>
public int? ContextBudget { get; set; }
public Dictionary<string, object> ExtraParameters { get; set; } = [];
+540
View File
@@ -0,0 +1,540 @@
using AnotherReplayReader.ReplayFile;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace AnotherReplayReader.Utils
{
/// <summary>一段操作记录(一个时间分块)在全文中的位置与规模。</summary>
internal sealed record EventSpan(TimeSpan Time, int StartIndex, int Length, int EstimatedTokens);
/// <summary>机械分段得到的操作记录切片。</summary>
internal sealed record ReplaySlice(
int Index,
TimeSpan Start,
TimeSpan End,
int StartIndex,
int Length,
int EventCount,
int EstimatedTokens)
{
public string GetText(string fullText) => fullText.Substring(StartIndex, Length);
}
/// <summary>
/// 机械分段:按 token 预算切分操作记录,相邻段重叠前一段尾部。
/// </summary>
internal static class MechanicalSegmenter
{
public const int MinSliceTokens = 2_000;
public const int MaxSlices = 20;
public static (ImmutableArray<ReplaySlice> Slices, ImmutableArray<string> Warnings) Slice(
string fullText,
ImmutableArray<EventSpan> spans,
int sliceTokenBudget,
int overlapTokenBudget)
{
var warnings = ImmutableArray.CreateBuilder<string>();
if (string.IsNullOrWhiteSpace(fullText) || spans.IsEmpty)
{
return (ImmutableArray<ReplaySlice>.Empty, warnings.ToImmutable());
}
var budget = Math.Max(sliceTokenBudget, MinSliceTokens);
var overlap = Math.Max(overlapTokenBudget, 200);
var slices = SliceCore(fullText, spans, budget, overlap);
if (slices.Count > MaxSlices)
{
var totalTokens = spans.Sum(s => s.EstimatedTokens);
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(totalTokens / (double)MaxSlices));
slices = SliceCore(fullText, spans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
warnings.Add($"分段数超过上限 {MaxSlices},已放宽单段预算到 {raisedBudget:N0} token。");
}
return (slices.ToImmutableArray(), warnings.ToImmutable());
}
private static List<ReplaySlice> SliceCore(
string fullText,
ImmutableArray<EventSpan> spans,
int budget,
int overlap)
{
var slices = new List<ReplaySlice>();
var segStart = 0;
var i = 0;
var acc = 0;
while (i < spans.Length)
{
var span = spans[i];
if (acc > 0 && acc + span.EstimatedTokens > budget && acc >= MinSliceTokens)
{
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, i));
// 下一段起点:重叠前一段尾部(重叠总 token ≤ overlap
var overlapStart = i;
var overlapTokens = 0;
for (var j = i - 1; j >= segStart; --j)
{
if (overlapTokens + spans[j].EstimatedTokens > overlap)
{
break;
}
overlapTokens += spans[j].EstimatedTokens;
overlapStart = j;
}
segStart = overlapStart;
acc = 0;
for (var j = segStart; j < i; ++j)
{
acc += spans[j].EstimatedTokens;
}
continue;
}
acc += span.EstimatedTokens;
++i;
}
if (segStart < spans.Length)
{
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, spans.Length));
}
return slices;
}
private static ReplaySlice CreateSlice(
int index,
string fullText,
ImmutableArray<EventSpan> spans,
int start,
int endExclusive)
{
var first = spans[start];
var last = spans[endExclusive - 1];
var tokens = 0;
for (var j = start; j < endExclusive; ++j)
{
tokens += spans[j].EstimatedTokens;
}
return new ReplaySlice(
index,
first.Time,
last.Time,
first.StartIndex,
last.StartIndex + last.Length - first.StartIndex,
endExclusive - start,
tokens);
}
}
/// <summary>
/// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。
/// </summary>
internal static class MatchDigestBuilder
{
public static string Build(
ReplayFactIndex factIndex,
ImmutableSortedDictionary<int, Player> players,
Mod mod,
ImmutableArray<ReplaySlice> slices,
string fullText)
{
var sb = new StringBuilder();
var names = AIAnalyze.PlayerNamesForAI(mod, players);
sb.AppendLine("# 玩家");
foreach (var kv in players)
{
var factionName = ModData.GetFaction(mod, kv.Value.FactionId).Name;
var kind = kv.Value.IsComputer ? "电脑" : "玩家";
sb.AppendLine($"- 玩家#{kv.Key} {kv.Value.PlayerName}{names[kv.Key]}),{factionName},队伍{kv.Value.Team}{kind}");
}
sb.AppendLine();
sb.AppendLine("# 首次出兵时间表");
foreach (var kv in factIndex.PlayerFirstProductionTime.OrderBy(k => k.Key))
{
var productions = kv.Value
.OrderBy(x => x.Value)
.Take(15)
.Select(x => $"{x.Key}@{FormatTime(x.Value)}");
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):{string.Join("", productions)}");
}
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)
{
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();
sb.AppendLine("# 建造者/出兵建筑");
sb.AppendLine($"- 建造者:{string.Join("", factIndex.BuilderUnitIds.OrderBy(x => x).Take(20))}");
sb.AppendLine($"- 出兵建筑:{string.Join("", factIndex.ProducerUnitIds.OrderBy(x => x).Take(20))}");
sb.AppendLine();
sb.AppendLine("# 分段");
foreach (var slice in slices)
{
sb.AppendLine($"- 第{slice.Index + 1}段:{FormatTime(slice.Start)}~{FormatTime(slice.End)},事件数 {slice.EventCount},约 {slice.EstimatedTokens:N0} token");
}
sb.AppendLine();
sb.AppendLine("# 各段关键事件采样");
foreach (var slice in slices)
{
sb.AppendLine($"## 第{slice.Index + 1}段");
foreach (var line in SampleKeyEvents(slice.GetText(fullText), 6))
{
sb.AppendLine($"- {line}");
}
}
return sb.ToString().Replace("\r", "");
}
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
{
var result = new List<string>();
var currentTime = "";
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;
}
}
continue;
}
if (IsKeyEventLine(line))
{
result.Add($"[{currentTime}] {line}");
if (result.Count >= maxEvents)
{
break;
}
}
}
return result.ToImmutableArray();
}
private static bool IsKeyEventLine(string line) =>
line.Contains("开始建造") || line.Contains("摆放建筑") || line.Contains("出售建筑") ||
line.Contains("释放特殊能力") || line.Contains("选择协议") || line.Contains("开始出兵") ||
line.Contains("开始升级");
public static string FormatTime(TimeSpan t) => $"{(int)t.TotalMinutes}:{t:ss\\.ff}";
}
internal sealed record SegmentOverview(
int Index,
string Title,
string Description,
ImmutableArray<string> BackqueryHints);
internal sealed record OverviewResult(
string Narrative,
ImmutableArray<SegmentOverview> Segments);
/// <summary>解析总览轮输出:[分段概述] 块之前的自由文本是整局叙述,块内是每段标题/概述。</summary>
internal static class OverviewParser
{
private const string Marker = "[分段概述]";
public static OverviewResult Parse(string response)
{
var markerIndex = response.LastIndexOf(Marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
{
return new OverviewResult(response.Trim(), ImmutableArray<SegmentOverview>.Empty);
}
var narrative = response.Substring(0, markerIndex).Trim();
var builder = ImmutableArray.CreateBuilder<SegmentOverview>();
var hints = new List<string>();
SegmentOverview? current = null;
foreach (var rawLine in response.Substring(markerIndex + Marker.Length).Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var match = Regex.Match(line, @"^#(\d+)\s*(.*)$");
if (match.Success)
{
if (current is not null)
{
builder.Add(current);
}
var index = int.Parse(match.Groups[1].Value);
var rest = match.Groups[2].Value.Trim();
var sep = rest.IndexOfAny(new[] { '', ':' });
var title = sep > 0 ? rest.Substring(0, sep).Trim() : rest;
var description = sep > 0 ? rest.Substring(sep + 1).Trim() : "";
hints = new List<string>();
current = new SegmentOverview(index, title, description, ImmutableArray<string>.Empty);
continue;
}
if (current is not null && line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
{
var sepIndex = line.IndexOfAny(new[] { '', ':' });
if (sepIndex >= 0)
{
hints.Add(line.Substring(sepIndex + 1).Trim());
}
current = current with { BackqueryHints = hints.ToImmutableArray() };
}
}
if (current is not null)
{
builder.Add(current);
}
return new OverviewResult(narrative, builder.ToImmutable());
}
}
/// <summary>解析段回复中的 [回查] 标记(M3 使用)。</summary>
internal static class BackqueryParser
{
public static ImmutableArray<(TimeSpan Start, TimeSpan End)> Parse(string response)
{
var result = ImmutableArray.CreateBuilder<(TimeSpan, TimeSpan)>();
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (line.IndexOf("[回查]", StringComparison.OrdinalIgnoreCase) < 0
&& !line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var content = line.Replace("[回查]", "").Trim();
var sep = content.IndexOfAny(new[] { '', ':' });
if (sep >= 0 && content.Substring(0, sep).Trim().Equals("回查", StringComparison.OrdinalIgnoreCase))
{
content = content.Substring(sep + 1).Trim();
}
foreach (Match m in Regex.Matches(content, @"(\d+:\d+(?:\.\d+)?)\s*~\s*(\d+:\d+(?:\.\d+)?)"))
{
if (AiTimeParser.TryParse(m.Groups[1].Value, out var start)
&& AiTimeParser.TryParse(m.Groups[2].Value, out var end))
{
result.Add((start, end));
}
}
}
return result.ToImmutable();
}
}
/// <summary>按时间区间从全文切出回查所需的原始记录片段。</summary>
internal static class BackquerySliceExtractor
{
public const int MaxBackqueryTokens = 10_000;
public static (string? Text, string? Reason) Extract(
string fullText,
ImmutableArray<EventSpan> spans,
TimeSpan start,
TimeSpan end)
{
if (spans.IsEmpty)
{
return (null, "回放没有事件索引,无法回查。");
}
var first = -1;
var last = -1;
for (var i = 0; i < spans.Length; ++i)
{
if (first < 0 && spans[i].Time >= start)
{
first = i;
}
if (spans[i].Time <= end)
{
last = i;
continue;
}
break;
}
if (first < 0 || last < 0 || last < first)
{
return (null, $"区间 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)} 内没有事件。");
}
var text = fullText.Substring(
spans[first].StartIndex,
spans[last].StartIndex + spans[last].Length - spans[first].StartIndex);
var tokens = AiContextBudget.EstimateTokens(text);
if (tokens > MaxBackqueryTokens)
{
return (null, $"回查区间过大(约 {tokens:N0} token,上限 {MaxBackqueryTokens:N0})。");
}
return (text, null);
}
}
/// <summary>容错的时间解析:失败返回 false,不抛异常。</summary>
internal static class AiTimeParser
{
public static bool TryParse(string input, out TimeSpan result)
{
result = TimeSpan.Zero;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
input = input.Trim();
var parts = input.Split(':');
if (parts.Length == 0 || !float.TryParse(parts[parts.Length - 1], out var seconds) || seconds < 0)
{
return false;
}
var totalSeconds = (long)(int)seconds;
var millis = (int)Math.Round((seconds - (int)seconds) * 1000);
long multiplier = 60;
for (var i = parts.Length - 2; i >= 0; --i)
{
if (!long.TryParse(parts[i], out var value) || value < 0)
{
return false;
}
totalSeconds += value * multiplier;
multiplier *= 60;
}
result = TimeSpan.FromSeconds(totalSeconds) + TimeSpan.FromMilliseconds(millis);
return true;
}
}
/// <summary>把验证过的机器可读声明格式化为"已发现事实",供后续分段引用。</summary>
internal static class ClaimFindingsFormatter
{
public static string Format(AIMachineReadableClaims claims)
{
var sb = new StringBuilder();
foreach (var claim in claims.UnitClaims)
{
sb.AppendLine($"- UnitId {claim.UnitId}{claim.Player}):推测 {claim.Claim},证据等级 {LevelName(claim.EvidenceLevel)}");
if (!claim.Evidence.IsEmpty)
{
sb.AppendLine($" 证据:{string.Join("", claim.Evidence)}");
}
if (!claim.Alternatives.IsEmpty)
{
sb.AppendLine($" 备选:{string.Join("", claim.Alternatives)}");
}
if (!claim.NeedsConfirmation.IsEmpty)
{
sb.AppendLine($" 待确认:{string.Join("", claim.NeedsConfirmation)}");
}
}
foreach (var claim in claims.EventClaims)
{
sb.AppendLine($"- 事件:{claim.Claim}{LevelName(claim.EvidenceLevel)}");
}
foreach (var claim in claims.TimelineClaims)
{
sb.AppendLine($"- 时间线:{claim.Claim}{LevelName(claim.EvidenceLevel)}");
}
return sb.ToString().TrimEnd();
}
public static string? ExtractSummary(string response)
{
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (line.StartsWith("[小结]", StringComparison.OrdinalIgnoreCase))
{
var text = line.Substring("[小结]".Length).Trim();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
return null;
}
private static string LevelName(AIEvidenceLevel level) => level switch
{
AIEvidenceLevel.Confirmed => "确定",
AIEvidenceLevel.HighlyLikely => "高度可能",
AIEvidenceLevel.Possible => "可能",
AIEvidenceLevel.Uncertain => "不确定",
AIEvidenceLevel.RuledOut => "已排除",
_ => "未知",
};
}
/// <summary>为修订 pass 生成与受影响声明相关的事实摘要。</summary>
internal static class RelevantFactsFormatter
{
public static string Format(AIMachineReadableClaims claims, ReplayFactIndex factIndex)
{
var sb = new StringBuilder();
foreach (var claim in claims.UnitClaims)
{
if (!uint.TryParse(claim.UnitId, out var unitId)
|| !factIndex.UnitIdFirstObservedTime.TryGetValue(unitId, out var first))
{
continue;
}
sb.AppendLine($"- UnitId {claim.UnitId}:首次出现 {MatchDigestBuilder.FormatTime(first)}"
+ (factIndex.UnitIdSpecialPowers.TryGetValue(unitId, out var powers) && powers.Count > 0
? $"观察到的能力:{string.Join("", powers.OrderBy(x => x))}"
: "未观察到特殊能力"));
if (factIndex.BuilderUnitIds.Contains(unitId))
{
sb.AppendLine(" 该 UnitId 曾作为建造者出现");
}
if (factIndex.ProducerUnitIds.Contains(unitId))
{
sb.AppendLine(" 该 UnitId 曾作为出兵建筑出现");
}
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds)
{
if (kv.Value.Contains(unitId))
{
sb.AppendLine($" 玩家 {kv.Key} 对它有强所有权证据");
}
}
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds)
{
if (kv.Value.Contains(unitId))
{
sb.AppendLine($" 玩家 {kv.Key} 对它有弱所有权证据(选中过)");
}
}
}
return sb.ToString().TrimEnd();
}
}
}
+316 -42
View File
@@ -31,13 +31,25 @@ namespace AnotherReplayReader.Utils
/// <summary>Per player, which UnitIds they have selected.</summary>
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerSelectedUnitIds { get; }
/// <summary>Per player, UnitIds with strong ownership evidence (control group, builder/producer, repair, sell, power caster).</summary>
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerStrongOwnershipUnitIds { get; }
/// <summary>Per player, UnitIds with weak ownership evidence (plain selection only).</summary>
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerWeakOwnershipUnitIds { get; }
/// <summary>Per player, tech/protocol choices (0x24E).</summary>
public ImmutableDictionary<int, ImmutableHashSet<string>> PlayerTechChoices { get; }
public ReplayFactIndex(
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
ImmutableHashSet<uint> builderUnitIds,
ImmutableHashSet<uint> producerUnitIds,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
ImmutableDictionary<int, ImmutableHashSet<uint>> playerSelectedUnitIds)
ImmutableDictionary<int, ImmutableHashSet<uint>> playerSelectedUnitIds,
ImmutableDictionary<int, ImmutableHashSet<uint>> playerStrongOwnershipUnitIds,
ImmutableDictionary<int, ImmutableHashSet<uint>> playerWeakOwnershipUnitIds,
ImmutableDictionary<int, ImmutableHashSet<string>> playerTechChoices)
{
UnitIdFirstObservedTime = unitIdFirstObservedTime;
UnitIdSpecialPowers = unitIdSpecialPowers;
@@ -45,6 +57,9 @@ namespace AnotherReplayReader.Utils
ProducerUnitIds = producerUnitIds;
PlayerFirstProductionTime = playerFirstProductionTime;
PlayerSelectedUnitIds = playerSelectedUnitIds;
PlayerStrongOwnershipUnitIds = playerStrongOwnershipUnitIds;
PlayerWeakOwnershipUnitIds = playerWeakOwnershipUnitIds;
PlayerTechChoices = playerTechChoices;
}
public static ReplayFactIndex Build(
@@ -57,6 +72,10 @@ namespace AnotherReplayReader.Utils
var producerUnits = new HashSet<uint>();
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
var playerSelected = new Dictionary<int, HashSet<uint>>();
var playerStrongOwnership = new Dictionary<int, HashSet<uint>>();
var playerWeakOwnership = new Dictionary<int, HashSet<uint>>();
var playerTechChoices = new Dictionary<int, HashSet<string>>();
var controlGroups = new Dictionary<int, HashSet<uint>>();
foreach (var (time, commands) in timeline)
{
@@ -65,7 +84,9 @@ namespace AnotherReplayReader.Utils
ProcessCommand(time, command, stringHashTable,
unitFirstObserved, unitSpecialPowers,
builderUnits, producerUnits,
playerFirstProduction, playerSelected);
playerFirstProduction, playerSelected,
playerStrongOwnership, playerWeakOwnership,
playerTechChoices, controlGroups);
}
}
@@ -78,6 +99,12 @@ namespace AnotherReplayReader.Utils
playerFirstProduction.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableDictionary()),
playerSelected.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
playerStrongOwnership.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
playerWeakOwnership.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
playerTechChoices.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()));
}
@@ -90,7 +117,11 @@ namespace AnotherReplayReader.Utils
HashSet<uint> builderUnits,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
Dictionary<int, HashSet<uint>> playerSelected)
Dictionary<int, HashSet<uint>> playerSelected,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> playerWeakOwnership,
Dictionary<int, HashSet<string>> playerTechChoices,
Dictionary<int, HashSet<uint>> controlGroups)
{
var player = command.PlayerIndex;
var cmdId = command.CommandId;
@@ -99,52 +130,80 @@ namespace AnotherReplayReader.Utils
{
// select unit(s): 0x1F5
case 0x1F5:
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected);
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected, playerWeakOwnership);
break;
// special power (no target): 0x1FE
// 从选择中移除单位:与选择类似,仅弱所有权
case 0x1F9:
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerWeakOwnership);
break;
// special power (no target): 0x1FE —— 布局确凿,ObjectId 是施法者
case 0x1FE:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (target position): 0x1FF
case 0x1FF:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (target position and angle): 0x200
// special power (target position and angle): 0x200 —— 布局确凿,ObjectId 是施法者
case 0x200:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
RecordSpecialPower(time, command, player, stringHashTable,
unitFirstObserved, unitSpecialPowers, playerStrongOwnership);
break;
// special power (target unit): 0x201
// special power (target position): 0x1FF —— ObjectId 语义待核实,只记录"出现过"
case 0x1FF:
// special power (target unit): 0x201 —— ObjectId 可能是目标
case 0x201:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (one or more targets): 0x232
// special power (one or more targets): 0x232 —— ObjectId 可能是目标
case 0x232:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
RecordObjectReference(time, command, unitFirstObserved);
break;
// start production: 0x205
case 0x205:
RecordProduction(time, command, player, unitFirstObserved, producerUnits, playerFirstProduction);
RecordProduction(time, command, player, unitFirstObserved,
producerUnits, playerFirstProduction, playerStrongOwnership);
break;
// start construction: 0x207
case 0x207:
RecordConstruction(time, command, unitFirstObserved, builderUnits);
RecordConstruction(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
break;
// place building: 0x209
case 0x209:
RecordPlaceBuilding(time, command, unitFirstObserved, builderUnits);
RecordPlaceBuilding(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
break;
// sell building: 0x20A
case 0x20A:
RecordObjectReference(time, command, unitFirstObserved);
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
break;
// 开始/停止维修建筑:只能维修己方建筑 → 强所有权
case 0x228:
case 0x229:
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
break;
// 命令矿车交矿 / 让矿车去采矿:只能命令己方矿车 → 强所有权
case 0x212:
case 0x248:
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
break;
// 创建编队:编队成员几乎确定是己方单位 → 强所有权
case 0x1FA:
RecordControlGroupCreate(time, command, player, unitFirstObserved,
playerStrongOwnership, controlGroups);
break;
// 选择编队 / 将编队加入选择:通过编队状态解析成员 → 强所有权
case 0x1FB:
case 0x1FC:
RecordControlGroupSelect(time, command, player, unitFirstObserved,
playerStrongOwnership, controlGroups);
break;
// 选择协议:全局生效,无 UnitId
case 0x24E:
RecordTechChoice(time, command, player, playerTechChoices);
break;
// move: 0x214
@@ -162,7 +221,8 @@ namespace AnotherReplayReader.Utils
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerSelected)
Dictionary<int, HashSet<uint>> playerSelected,
Dictionary<int, HashSet<uint>> playerWeakOwnership)
{
// Data layout for 0x1F5:
// Data[0]: Bool (isReplace), if count > 0 the rest are ObjectIds
@@ -176,6 +236,7 @@ namespace AnotherReplayReader.Utils
var unitId = (uint)entry.Value;
TryRecordFirstObserved(unitId, time, unitFirstObserved);
RecordPlayerSelection(player, unitId, playerSelected);
RecordPlayerOwnership(player, unitId, playerWeakOwnership);
}
else
{
@@ -183,6 +244,7 @@ namespace AnotherReplayReader.Utils
{
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerSelection(player, id, playerSelected);
RecordPlayerOwnership(player, id, playerWeakOwnership);
}
}
}
@@ -192,9 +254,11 @@ namespace AnotherReplayReader.Utils
private static void RecordSpecialPower(
TimeSpan time,
CommandChunk command,
int player,
IReadOnlyDictionary<uint, string> stringHashTable,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<uint, HashSet<string>> unitSpecialPowers)
Dictionary<uint, HashSet<string>> unitSpecialPowers,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
string? powerName = null;
var unitIds = new List<uint>();
@@ -205,11 +269,19 @@ namespace AnotherReplayReader.Utils
{
case CommandArgumentType.Int32 when powerName is null:
{
// First Int32 is the special power hash ID
var hash = unchecked((uint)(int)entry.Value);
powerName = stringHashTable.TryGetValue(hash, out var name)
? name
: $"Hash_{hash:X8}";
// First Int32 is the special power hash ID
// 同类型参数可能被打包成数组(首个元素是 hash)
var hash = entry.Count == 1 && entry.Value is int singleInt
? unchecked((uint)singleInt)
: entry.Value is int[] ints && ints.Length > 0
? unchecked((uint)ints[0])
: (uint?)null;
if (hash is { } hashValue)
{
powerName = stringHashTable.TryGetValue(hashValue, out var name)
? name
: $"Hash_{hashValue:X8}";
}
break;
}
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2:
@@ -245,6 +317,7 @@ namespace AnotherReplayReader.Utils
unitSpecialPowers[unitId] = powers;
}
powers.Add(powerName);
RecordPlayerOwnership(player, unitId, playerStrongOwnership);
}
}
@@ -254,7 +327,8 @@ namespace AnotherReplayReader.Utils
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction)
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
uint? producerId = null;
string? unitName = null;
@@ -264,8 +338,23 @@ namespace AnotherReplayReader.Utils
switch (entry.Type)
{
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
when entry.Count == 1 && producerId is null:
producerId = (uint)entry.Value;
when producerId is null:
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0) producerId = id;
}
else if (entry.Value is uint[] ids)
{
foreach (var id in ids)
{
if (id != 0)
{
producerId = id;
break;
}
}
}
break;
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
when unitName is null:
@@ -278,6 +367,7 @@ namespace AnotherReplayReader.Utils
{
TryRecordFirstObserved(producerId.Value, time, unitFirstObserved);
producerUnits.Add(producerId.Value);
RecordPlayerOwnership(player, producerId.Value, playerStrongOwnership);
}
if (!string.IsNullOrWhiteSpace(unitName))
@@ -297,47 +387,218 @@ namespace AnotherReplayReader.Utils
private static void RecordConstruction(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
HashSet<uint> builderUnits,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
// Data[0]: ObjectId (builder)
// Data[1]: AsciiString (building name)
RecordBuilder(time, command, unitFirstObserved, builderUnits);
RecordBuilder(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
}
private static void RecordPlaceBuilding(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
HashSet<uint> builderUnits,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
// Data[0]: ObjectId (builder)
// Data[1]: AsciiString (building name)
// Data[2]: Int32 (count)
// Data[3]: Vector3 (position)
// Data[4]: Float32 (angle)
RecordBuilder(time, command, unitFirstObserved, builderUnits);
RecordBuilder(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
}
private static void RecordBuilder(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
HashSet<uint> builderUnits,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
&& entry.Count == 1)
if (entry.Type is not (CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2))
{
continue;
}
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
builderUnits.Add(id);
RecordPlayerOwnership(player, id, playerStrongOwnership);
}
return; // only first ObjectId is the builder
}
if (entry.Value is uint[] ids)
{
foreach (var id in ids)
{
if (id != 0)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
builderUnits.Add(id);
RecordPlayerOwnership(player, id, playerStrongOwnership);
return;
}
}
}
}
}
private static void RecordControlGroupCreate(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> controlGroups)
{
// Data[0]: Int32 编队号;Data[1..]: 成员 ObjectId
int? groupNumber = null;
var members = new List<uint>();
foreach (var entry in command.Data)
{
if (groupNumber is null && entry.Type == CommandArgumentType.Int32)
{
groupNumber = entry.Count == 1 && entry.Value is int singleInt
? singleInt
: entry.Value is int[] ints && ints.Length > 0
? ints[0]
: (int?)null;
continue;
}
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
{
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0) members.Add(id);
}
else
{
foreach (var id in (uint[])entry.Value)
{
if (id != 0) members.Add(id);
}
}
}
}
if (groupNumber is null || members.Count == 0)
{
return;
}
var group = new HashSet<uint>(members);
controlGroups[groupNumber.Value] = group;
foreach (var id in members)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerStrongOwnership);
}
}
private static void RecordControlGroupSelect(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> controlGroups)
{
foreach (var entry in command.Data)
{
if (entry.Type != CommandArgumentType.Int32)
{
continue;
}
var groupNumber = entry.Count == 1 && entry.Value is int singleInt
? singleInt
: entry.Value is int[] ints && ints.Length > 0
? ints[0]
: (int?)null;
if (groupNumber is null)
{
continue;
}
if (controlGroups.TryGetValue(groupNumber.Value, out var members))
{
foreach (var id in members)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerStrongOwnership);
}
}
}
}
private static void RecordTechChoice(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<int, HashSet<string>> playerTechChoices)
{
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
{
var tech = entry.Count == 1
? entry.Value.ToString()
: entry.Value is string[] strings
? strings.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
: null;
if (string.IsNullOrWhiteSpace(tech))
{
continue;
}
if (!playerTechChoices.TryGetValue(player, out var set))
{
set = new HashSet<string>();
playerTechChoices[player] = set;
}
set.Add(tech);
return;
}
}
}
private static void RecordObjectReferenceWithOwnership(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerOwnership)
{
foreach (var entry in command.Data)
{
if (entry.Type is not (CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2))
{
continue;
}
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id == 0) continue;
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerOwnership);
}
else
{
foreach (var id in (uint[])entry.Value)
{
if (id == 0) continue;
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerOwnership);
}
}
}
}
@@ -375,6 +636,19 @@ namespace AnotherReplayReader.Utils
set.Add(unitId);
}
private static void RecordPlayerOwnership(
int player,
uint unitId,
Dictionary<int, HashSet<uint>> playerOwnership)
{
if (!playerOwnership.TryGetValue(player, out var set))
{
set = new HashSet<uint>();
playerOwnership[player] = set;
}
set.Add(unitId);
}
private static void TryRecordFirstObserved(uint unitId, TimeSpan time, Dictionary<uint, TimeSpan> unitFirstObserved)
{
if (unitId == 0) return;
+16 -20
View File
@@ -33,33 +33,27 @@
- 玩家 ID 以及操作,例如:`PlayerC: 重新选择单位`
- 操作参数或操作对象,例如:`[UnitId]239`
典型的玩家操作流程
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。这些操作的对象是玩家自己的单位
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。选择的对象通常是玩家自己的单位,但也可能点击选中敌方单位(此时只能查看血量,无法下达命令);被加入编队的单位几乎可以确定是玩家自己的单位
2. 执行操作:让当前被选中的单位执行某个任务,例如攻击、释放技能。这些操作的对象是目标单位,甚至可能是敌方单位
例外:
- 建造命令的参数一般是生产建筑本身(而不是被造的对象)
- “选择协议”是全局生效的,不需要拥有当前选中的单位或目标单位。
# 输出要求
## 1. 初始阶段
触发条件:用户输入包含:"判断是否需要分段处理数据"
- 进行简单的推理,列举你的发现,目标:判断玩家操作记录是否应该分段分析
- 输出:对玩家操作记录的分段,各个分段的开始时间和结束时间,以及简短介绍。分段应按照时间排序,分段之间可以有一定的重叠
- 输出示例:
```
[分段列表]
#1 [0:00.0]~[0:55.4] 开局
#2 [0:50.1]~[3:02.1] 开局(第二部分)
#3 [3:00]~[5:16] 前中期
#4 [5:10]~[10:13.12] 中期
```
- 输出示例(假如判断不需要分段):
```
[分段列表]
#1 [0:00.0]~[17:23] 从开局到玩家操作记录结束
```
## 1. 总览阶段
触发条件:用户输入包含:"请先对整局进行总览"
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:"请分析第N段([BEGIN]至[END]的数据"
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END]"
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
- 推理思考时:不要直接列出所有操作信息,可以先只列出一部分,然后按需向前以及向后“延申”
@@ -70,6 +64,7 @@
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:"请对以上内容进行总结"
@@ -120,8 +115,9 @@
- `produce|时间|单位名|出兵建筑UnitId` — 开始出兵,例如 `produce|0:14.66|AlliedScoutInfantry|291`
- `sell|时间|建筑UnitId` — 出售建筑,例如 `sell|2:21.93|255`
- `select|时间|单位UnitId` — 选择单位,例如 `select|1:24.13|587`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`。注意:move 证据不携带 UnitId,无法被程序验证,不能单独作为高置信结论的证据
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
+16 -20
View File
@@ -33,33 +33,27 @@
- 玩家 ID 以及操作,例如:`PlayerC: 重新选择单位`
- 操作参数或操作对象,例如:`[UnitId]239`
典型的玩家操作流程
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。这些操作的对象是玩家自己的单位
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。选择的对象通常是玩家自己的单位,但也可能点击选中敌方单位(此时只能查看血量,无法下达命令);被加入编队的单位几乎可以确定是玩家自己的单位
2. 执行操作:让当前被选中的单位执行某个任务,例如攻击、释放技能。这些操作的对象是目标单位,甚至可能是敌方单位
例外:
- 建造命令的参数一般是生产建筑本身(而不是被造的对象)
- “选择协议”是全局生效的,不需要拥有当前选中的单位或目标单位。
# 输出要求
## 1. 初始阶段
触发条件:用户输入包含:"判断是否需要分段处理数据"
- 进行简单的推理,列举你的发现,目标:判断玩家操作记录是否应该分段分析
- 输出:对玩家操作记录的分段,各个分段的开始时间和结束时间,以及简短介绍。分段应按照时间排序,分段之间可以有一定的重叠
- 输出示例:
```
[分段列表]
#1 [0:00.0]~[0:55.4] 开局
#2 [0:50.1]~[3:02.1] 开局(第二部分)
#3 [3:00]~[5:16] 前中期
#4 [5:10]~[10:13.12] 中期
```
- 输出示例(假如判断不需要分段):
```
[分段列表]
#1 [0:00.0]~[17:23] 从开局到玩家操作记录结束
```
## 1. 总览阶段
触发条件:用户输入包含:"请先对整局进行总览"
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:"请分析第N段([BEGIN]至[END]的数据"
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END]"
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
- 推理思考时:不要直接列出所有操作信息,可以先只列出一部分,然后按需向前以及向后“延申”
@@ -70,6 +64,7 @@
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:"请对以上内容进行总结"
@@ -120,8 +115,9 @@
- `produce|时间|单位名|出兵建筑UnitId` — 开始出兵,例如 `produce|0:14.66|AlliedScoutInfantry|291`
- `sell|时间|建筑UnitId` — 出售建筑,例如 `sell|2:21.93|255`
- `select|时间|单位UnitId` — 选择单位,例如 `select|1:24.13|587`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`。注意:move 证据不携带 UnitId,无法被程序验证,不能单独作为高置信结论的证据
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。