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