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)
{