Compare commits
2
Commits
579cd8e4b3
...
6dae58bf42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dae58bf42 | ||
|
|
acad70aaac |
+194
-385
@@ -1,4 +1,4 @@
|
|||||||
using AnotherReplayReader.Apm;
|
using AnotherReplayReader.Apm;
|
||||||
using AnotherReplayReader.ReplayFile;
|
using AnotherReplayReader.ReplayFile;
|
||||||
using AnotherReplayReader.Utils;
|
using AnotherReplayReader.Utils;
|
||||||
using System;
|
using System;
|
||||||
@@ -10,7 +10,6 @@ using System.Diagnostics;
|
|||||||
using System.Diagnostics.Tracing;
|
using System.Diagnostics.Tracing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -97,8 +96,6 @@ namespace AnotherReplayReader
|
|||||||
// 输出段落
|
// 输出段落
|
||||||
private (Paragraph? Think, Paragraph? Content)? _currentContent;
|
private (Paragraph? Think, Paragraph? Content)? _currentContent;
|
||||||
private Paragraph? _lastContentParagraph;
|
private Paragraph? _lastContentParagraph;
|
||||||
private bool _suppressDisplay;
|
|
||||||
private bool _nextThinkingIsContinuation;
|
|
||||||
|
|
||||||
// 进度计时
|
// 进度计时
|
||||||
private TimeSpan _previousTotalTime;
|
private TimeSpan _previousTotalTime;
|
||||||
@@ -228,8 +225,6 @@ namespace AnotherReplayReader
|
|||||||
_thinkBlockWasExpanded = false;
|
_thinkBlockWasExpanded = false;
|
||||||
_currentContent = null;
|
_currentContent = null;
|
||||||
_lastContentParagraph = null;
|
_lastContentParagraph = null;
|
||||||
_suppressDisplay = false;
|
|
||||||
_nextThinkingIsContinuation = false;
|
|
||||||
|
|
||||||
_previousTotalTime = TimeSpan.Zero;
|
_previousTotalTime = TimeSpan.Zero;
|
||||||
_previousTotalChars = 0;
|
_previousTotalChars = 0;
|
||||||
@@ -397,7 +392,6 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(overviewResult);
|
UpdateTokenDisplay(overviewResult);
|
||||||
ReportReasoningGuardResult(overviewResult);
|
|
||||||
_overview = OverviewParser.Parse(overviewResult.Response);
|
_overview = OverviewParser.Parse(overviewResult.Response);
|
||||||
_overviewNarrative = _overview.Narrative;
|
_overviewNarrative = _overview.Narrative;
|
||||||
overviewAttempt++;
|
overviewAttempt++;
|
||||||
@@ -458,6 +452,8 @@ namespace AnotherReplayReader
|
|||||||
var structuredKnowledge = StructuredKnowledge.GetForMod(
|
var structuredKnowledge = StructuredKnowledge.GetForMod(
|
||||||
AIAnalyze.GetKnowledgeModName(_replay));
|
AIAnalyze.GetKnowledgeModName(_replay));
|
||||||
|
|
||||||
|
const int maxBackqueriesPerSegment = 3;
|
||||||
|
|
||||||
while (_currentSegmentIndex < _slices.Length)
|
while (_currentSegmentIndex < _slices.Length)
|
||||||
{
|
{
|
||||||
_linkedCts.Token.ThrowIfCancellationRequested();
|
_linkedCts.Token.ThrowIfCancellationRequested();
|
||||||
@@ -481,187 +477,227 @@ namespace AnotherReplayReader
|
|||||||
var maxFindingsTokens = modelBudget > 0
|
var maxFindingsTokens = modelBudget > 0
|
||||||
? Math.Min(10_000, Math.Max(2_000, modelBudget / 10))
|
? Math.Min(10_000, Math.Max(2_000, modelBudget / 10))
|
||||||
: 4_000;
|
: 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));
|
var windows = FocusPlanner.Plan(slice, _eventSpans);
|
||||||
if (!string.IsNullOrWhiteSpace(findingsText))
|
if (windows.IsEmpty)
|
||||||
{
|
{
|
||||||
messages = messages.Add(new AIAnalyze.ChatMessage(
|
windows = ImmutableArray.Create(
|
||||||
"user", "之前各段的已发现事实:\n" + findingsText));
|
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens));
|
||||||
}
|
}
|
||||||
messages = messages.Add(new AIAnalyze.ChatMessage("user", instruction));
|
|
||||||
|
|
||||||
AppendLog(
|
// 本段已发现事实:段内窗口之间的累积摘要(窗口内也会看到之前的段/窗口发现)。
|
||||||
$"让 AI 分析第{segmentIndex}段...",
|
var segmentFindings = new List<string>();
|
||||||
instruction + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
|
||||||
true);
|
|
||||||
CheckAndLogContextUsage(messages, requestContext);
|
|
||||||
StartVersion("版本 1:正文", collapsedByDefault: false);
|
|
||||||
|
|
||||||
// 回查循环(M3):同一段会话内,模型可多次请求远处原始区间
|
for (var windowIndex = 0; windowIndex < windows.Length; ++windowIndex)
|
||||||
const int maxBackqueriesPerSegment = 3;
|
|
||||||
var segmentMessages = messages;
|
|
||||||
var backqueryCount = 0;
|
|
||||||
string segmentResponse;
|
|
||||||
while (true)
|
|
||||||
{
|
{
|
||||||
// 回查会增长同一会话的消息列表,每次请求前都重新做预算检查。
|
_linkedCts.Token.ThrowIfCancellationRequested();
|
||||||
CheckAndLogContextUsage(segmentMessages, requestContext);
|
var window = windows[windowIndex];
|
||||||
var segmentResult = await _analyzer!.CompleteAsync(
|
_phaseText.Text =
|
||||||
segmentMessages,
|
$"正在分析第 {segmentIndex}/{totalSegments} 段(窗口 {windowIndex + 1}/{windows.Length})";
|
||||||
requestContext,
|
|
||||||
OnChunk,
|
|
||||||
_linkedCts.Token);
|
|
||||||
UpdateTokenDisplay(segmentResult);
|
|
||||||
ReportReasoningGuardResult(segmentResult);
|
|
||||||
segmentResponse = segmentResult.Response;
|
|
||||||
|
|
||||||
if (backqueryCount >= maxBackqueriesPerSegment)
|
FinishCurrentContent();
|
||||||
|
_lastContentParagraph = null;
|
||||||
|
|
||||||
|
// 段内已发现事实:窗口之间共享“已发现事实”,但只保留本段窗口已发现的。
|
||||||
|
var combinedFindings = new List<string>(_findings);
|
||||||
|
combinedFindings.AddRange(segmentFindings);
|
||||||
|
var findingsText = LimitFindings(combinedFindings, maxFindingsTokens);
|
||||||
|
var windowEventCount =
|
||||||
|
_eventCounts?.Query(window.Start, window.End) ?? window.EventCount;
|
||||||
|
var instruction = AIAnalyze.BuildFocusWindowUserPromptV2(
|
||||||
|
_currentSegmentIndex,
|
||||||
|
totalSegments,
|
||||||
|
slice,
|
||||||
|
windowIndex,
|
||||||
|
windows.Length,
|
||||||
|
window,
|
||||||
|
eventCount,
|
||||||
|
windowEventCount,
|
||||||
|
title,
|
||||||
|
overviewEntry?.Description,
|
||||||
|
overviewEntry?.BackqueryHints);
|
||||||
|
|
||||||
|
// 数据层 = 整段切片(唯一的数据消息),只发一次;窗口指令作为“重点”指令。
|
||||||
|
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
|
||||||
|
.Add(new AIAnalyze.ChatMessage("user", sliceText));
|
||||||
|
if (!string.IsNullOrWhiteSpace(findingsText))
|
||||||
{
|
{
|
||||||
break;
|
messages = messages.Add(new AIAnalyze.ChatMessage(
|
||||||
|
"user", "之前的已发现事实:\n" + findingsText));
|
||||||
}
|
}
|
||||||
|
messages = messages.Add(new AIAnalyze.ChatMessage("user", instruction));
|
||||||
|
|
||||||
var backqueries = BackqueryParser.Parse(segmentResponse);
|
AppendLog(
|
||||||
var pendingTexts = new List<string>();
|
$"让 AI 分析第{segmentIndex}段窗口 {windowIndex + 1}/{windows.Length}...",
|
||||||
foreach (var (start, end) in backqueries)
|
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);
|
||||||
|
windowResponse = windowResult.Response;
|
||||||
|
|
||||||
|
if (backqueryCount >= maxBackqueriesPerSegment)
|
||||||
{
|
{
|
||||||
AppendLog("回查限制", "本段回查次数已达上限,剩余区间已忽略。", false);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
var (text, reason) = BackquerySliceExtractor.Extract(
|
|
||||||
_replayData, _eventSpans, start, end);
|
var backqueries = BackqueryParser.Parse(windowResponse);
|
||||||
if (text is null)
|
var pendingTexts = new List<string>();
|
||||||
|
foreach (var (start, end) in backqueries)
|
||||||
{
|
{
|
||||||
AppendLog("回查失败", reason ?? "未知原因", false);
|
if (backqueryCount + pendingTexts.Count >= maxBackqueriesPerSegment)
|
||||||
continue;
|
{
|
||||||
|
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();
|
// 隐藏修订 pass(M6):Contradiction 触发,最多 1 次;
|
||||||
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(
|
// 修订草稿按时间顺序流式显示,完成后折叠草稿并默认展开最终正文。
|
||||||
segmentResponse, _factIndex, playerNameToIndex, structuredKnowledge);
|
var finalResponse = windowResponse;
|
||||||
AppendMachineJsonAndValidation(segmentResponse, validationResult);
|
var finalValidation = validationResult;
|
||||||
if (validationResult.HasIssues)
|
if (validationResult.RequiresRevision)
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
{
|
||||||
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,
|
revisionMessages,
|
||||||
requestContext,
|
requestContext,
|
||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
}
|
UpdateTokenDisplay(revisionResult);
|
||||||
finally
|
|
||||||
{
|
|
||||||
_suppressDisplay = false;
|
|
||||||
}
|
|
||||||
UpdateTokenDisplay(revisionResult);
|
|
||||||
ReportReasoningGuardResult(revisionResult);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(revisionResult.Response))
|
if (string.IsNullOrWhiteSpace(revisionResult.Response))
|
||||||
{
|
{
|
||||||
AppendLog("修订失败", "修订输出为空,保留原分析。", false);
|
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
|
FinishCurrentContent();
|
||||||
{
|
|
||||||
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();
|
|
||||||
|
|
||||||
// 追加已发现事实
|
// 追加本窗口已发现事实(同时进入全局 _findings,供后续段使用)
|
||||||
var entry = new StringBuilder();
|
var entry = new StringBuilder();
|
||||||
var summary = ClaimFindingsFormatter.ExtractSummary(finalResponse);
|
var summary = ClaimFindingsFormatter.ExtractSummary(finalResponse);
|
||||||
if (!string.IsNullOrWhiteSpace(summary))
|
if (!string.IsNullOrWhiteSpace(summary))
|
||||||
{
|
{
|
||||||
entry.AppendLine($"[小结] {summary}");
|
entry.AppendLine($"[小结] {summary}");
|
||||||
}
|
}
|
||||||
var claimsText = ClaimFindingsFormatter.Format(finalValidation.Claims);
|
var claimsText = ClaimFindingsFormatter.Format(finalValidation.Claims);
|
||||||
if (!string.IsNullOrWhiteSpace(claimsText))
|
if (!string.IsNullOrWhiteSpace(claimsText))
|
||||||
{
|
{
|
||||||
entry.AppendLine(claimsText);
|
entry.AppendLine(claimsText);
|
||||||
}
|
}
|
||||||
if (entry.Length > 0)
|
if (entry.Length > 0)
|
||||||
{
|
{
|
||||||
_findings.Add(entry.ToString().TrimEnd());
|
var entryText = entry.ToString().TrimEnd();
|
||||||
|
segmentFindings.Add(entryText);
|
||||||
|
_findings.Add(entryText);
|
||||||
|
}
|
||||||
|
|
||||||
|
_segmentResponses.Add(finalResponse);
|
||||||
|
AppendLog(
|
||||||
|
$"第{segmentIndex}段窗口 {windowIndex + 1}/{windows.Length} 完成。",
|
||||||
|
null,
|
||||||
|
true);
|
||||||
}
|
}
|
||||||
|
|
||||||
_segmentResponses.Add(finalResponse);
|
|
||||||
_lastSuccessfulSegment = _currentSegmentIndex;
|
_lastSuccessfulSegment = _currentSegmentIndex;
|
||||||
_currentSegmentIndex++;
|
_currentSegmentIndex++;
|
||||||
AppendLog($"第{segmentIndex}段完成。", null, true);
|
AppendLog($"第{segmentIndex}段完成。", null, true);
|
||||||
@@ -718,7 +754,6 @@ namespace AnotherReplayReader
|
|||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
|
|
||||||
UpdateTokenDisplay(result);
|
UpdateTokenDisplay(result);
|
||||||
ReportReasoningGuardResult(result);
|
|
||||||
|
|
||||||
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
|
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
|
||||||
if (_replayData is null)
|
if (_replayData is null)
|
||||||
@@ -775,7 +810,6 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(finalSummary);
|
UpdateTokenDisplay(finalSummary);
|
||||||
ReportReasoningGuardResult(finalSummary);
|
|
||||||
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
|
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,22 +891,11 @@ namespace AnotherReplayReader
|
|||||||
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
|
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReportReasoningGuardResult(AIAnalyze.Result result)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AppendLog("推理保护警告", result.ReasoningContinuationError, false, _currentStage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartThinkingBlock()
|
private void StartThinkingBlock()
|
||||||
{
|
{
|
||||||
_thinkingStartTime = DateTime.Now;
|
_thinkingStartTime = DateTime.Now;
|
||||||
var stage = _currentStage ?? "分析";
|
var stage = _currentStage ?? "分析";
|
||||||
var label = _nextThinkingIsContinuation ? "AI 续写中..." : "AI 思考中...";
|
var label = "AI 思考中...";
|
||||||
_nextThinkingIsContinuation = false;
|
|
||||||
var parent = _currentVersionAddBlock is null
|
var parent = _currentVersionAddBlock is null
|
||||||
? GetStageBlocks(_currentStage)
|
? GetStageBlocks(_currentStage)
|
||||||
: null;
|
: null;
|
||||||
@@ -1158,176 +1181,6 @@ namespace AnotherReplayReader
|
|||||||
return block.Substring(jsonStart, jsonEnd - jsonStart + 1).Trim();
|
return block.Substring(jsonStart, jsonEnd - jsonStart + 1).Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AppendReasoningDiagnostics(string payload)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(payload);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
var originalText = root.TryGetProperty("originalRequestJson", out var original)
|
|
||||||
&& original.ValueKind == JsonValueKind.String
|
|
||||||
? original.GetString()
|
|
||||||
: null;
|
|
||||||
var continuationText =
|
|
||||||
root.TryGetProperty("continuationRequestJson", out var continuation)
|
|
||||||
&& continuation.ValueKind == JsonValueKind.String
|
|
||||||
? continuation.GetString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var summary = BuildReasoningDiagnosticSummary(
|
|
||||||
originalText,
|
|
||||||
continuationText);
|
|
||||||
if (!string.IsNullOrWhiteSpace(summary))
|
|
||||||
{
|
|
||||||
AppendLog("推理保护诊断", summary, true, _currentStage);
|
|
||||||
}
|
|
||||||
if (!string.IsNullOrWhiteSpace(continuationText))
|
|
||||||
{
|
|
||||||
AppendLog(
|
|
||||||
"完整续写请求 JSON",
|
|
||||||
PrettyPrintJson(continuationText!),
|
|
||||||
true,
|
|
||||||
_currentStage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
AppendLog("推理保护诊断", payload, true, _currentStage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildReasoningDiagnosticSummary(
|
|
||||||
string? originalJson,
|
|
||||||
string? continuationJson)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine($"消息数:{GetMessageCount(originalJson)} → {GetMessageCount(continuationJson)}");
|
|
||||||
if (string.IsNullOrWhiteSpace(continuationJson))
|
|
||||||
{
|
|
||||||
return sb.ToString().TrimEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(continuationJson!);
|
|
||||||
if (!doc.RootElement.TryGetProperty("messages", out var messages)
|
|
||||||
|| messages.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
return sb.ToString().TrimEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = messages.GetArrayLength() - 1; i >= 0; --i)
|
|
||||||
{
|
|
||||||
var message = messages[i];
|
|
||||||
if (!message.TryGetProperty("tool_calls", out var toolCalls)
|
|
||||||
|| toolCalls.ValueKind != JsonValueKind.Array
|
|
||||||
|| toolCalls.GetArrayLength() == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var toolName = "unknown";
|
|
||||||
if (toolCalls[0].TryGetProperty("function", out var function)
|
|
||||||
&& function.TryGetProperty("name", out var name))
|
|
||||||
{
|
|
||||||
toolName = name.GetString() ?? toolName;
|
|
||||||
}
|
|
||||||
|
|
||||||
var reasoning = message.TryGetProperty("reasoning_content", out var reasoningProp)
|
|
||||||
? reasoningProp.GetString()
|
|
||||||
: null;
|
|
||||||
sb.AppendLine("新增 assistant 消息:");
|
|
||||||
sb.AppendLine($" - reasoning_content 长度:{reasoning?.Length ?? 0}");
|
|
||||||
if (!string.IsNullOrEmpty(reasoning))
|
|
||||||
{
|
|
||||||
var markerIndex = reasoning.IndexOf(
|
|
||||||
AiReasoningGuard.TruncationMarker,
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
var tail = markerIndex >= 0
|
|
||||||
? reasoning.Substring(markerIndex)
|
|
||||||
: "(未找到截断标记)";
|
|
||||||
sb.AppendLine($" - 末尾追加:{Excerpt(tail, 160)}");
|
|
||||||
}
|
|
||||||
sb.AppendLine($" - tool_calls:{toolName}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = messages.GetArrayLength() - 1; i >= 0; --i)
|
|
||||||
{
|
|
||||||
var message = messages[i];
|
|
||||||
if (!message.TryGetProperty("role", out var role)
|
|
||||||
|| role.GetString() != "tool")
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var toolCallId = message.TryGetProperty("tool_call_id", out var id)
|
|
||||||
? id.GetString()
|
|
||||||
: "?";
|
|
||||||
var content = message.TryGetProperty("content", out var contentProp)
|
|
||||||
? contentProp.GetString()
|
|
||||||
: null;
|
|
||||||
sb.AppendLine("新增 tool 消息:");
|
|
||||||
sb.AppendLine($" - tool_call_id:{toolCallId}");
|
|
||||||
sb.AppendLine($" - 指令开头:{Excerpt(content ?? "(空)", 160)}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 摘要解析失败时保留原始 JSON 回退。
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString().TrimEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetMessageCount(string? requestJson)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(requestJson))
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(requestJson!);
|
|
||||||
return doc.RootElement.TryGetProperty("messages", out var messages)
|
|
||||||
&& messages.ValueKind == JsonValueKind.Array
|
|
||||||
? messages.GetArrayLength()
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string PrettyPrintJson(string json)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
return JsonSerializer.Serialize(
|
|
||||||
doc.RootElement,
|
|
||||||
new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Excerpt(string text, int maxLength)
|
|
||||||
{
|
|
||||||
var normalized = text.Replace("\r", "").Replace("\n", " ");
|
|
||||||
return normalized.Length <= maxLength
|
|
||||||
? normalized
|
|
||||||
: normalized.Substring(0, maxLength) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void FlushPendingUiActions()
|
private void FlushPendingUiActions()
|
||||||
{
|
{
|
||||||
_isFlushingPendingUiActions = true;
|
_isFlushingPendingUiActions = true;
|
||||||
@@ -1547,36 +1400,9 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
AutoScroll();
|
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 thinkSb = new StringBuilder();
|
||||||
var contentSb = new StringBuilder();
|
var contentSb = new StringBuilder();
|
||||||
var errorSb = new StringBuilder();
|
var errorSb = new StringBuilder();
|
||||||
var guardTriggered = false;
|
|
||||||
while (_chunkQueue.TryDequeue(out var data))
|
while (_chunkQueue.TryDequeue(out var data))
|
||||||
{
|
{
|
||||||
var (chunk, time) = data;
|
var (chunk, time) = data;
|
||||||
@@ -1604,19 +1430,6 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
errorSb.AppendLine(chunk.Text);
|
errorSb.AppendLine(chunk.Text);
|
||||||
}
|
}
|
||||||
else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuard)
|
|
||||||
{
|
|
||||||
EndThinkingBlock();
|
|
||||||
_nextThinkingIsContinuation = true;
|
|
||||||
_pendingUiActions.Enqueue(() =>
|
|
||||||
AppendLog("推理保护", chunk.Text, false, _currentStage));
|
|
||||||
guardTriggered = true;
|
|
||||||
}
|
|
||||||
else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuardRequest)
|
|
||||||
{
|
|
||||||
_pendingUiActions.Enqueue(() =>
|
|
||||||
AppendReasoningDiagnostics(chunk.Text));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thinkSb.Length > 0)
|
if (thinkSb.Length > 0)
|
||||||
@@ -1635,10 +1448,6 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
AddErrorBlock(errorSb.ToString());
|
AddErrorBlock(errorSb.ToString());
|
||||||
}
|
}
|
||||||
if (guardTriggered)
|
|
||||||
{
|
|
||||||
_currentContent = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AutoScroll()
|
private void AutoScroll()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl x:Class="AnotherReplayReader.AIProviderSettingsControl"
|
<UserControl x:Class="AnotherReplayReader.AIProviderSettingsControl"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -143,7 +143,6 @@
|
|||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="100"/>
|
<ColumnDefinition Width="100"/>
|
||||||
@@ -165,34 +164,20 @@
|
|||||||
Content="支持 SSE 流式输出"
|
Content="支持 SSE 流式输出"
|
||||||
Margin="0,5"/>
|
Margin="0,5"/>
|
||||||
|
|
||||||
<Label Grid.Row="4" Grid.Column="0" Content="推理保护"/>
|
<Label Grid.Row="4" Grid.Column="0" Content="高级参数"/>
|
||||||
<StackPanel Grid.Row="4" Grid.Column="1" Orientation="Horizontal" Margin="0,5">
|
|
||||||
<CheckBox x:Name="_reasoningGuardCheck"
|
|
||||||
Content="启用"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Label Content="推理 token 阈值"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Margin="10,0,0,0"/>
|
|
||||||
<TextBox x:Name="_reasoningTokenLimitBox"
|
|
||||||
Width="80"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
ToolTip="留空使用 max(4096, max_tokens / 2);仅流式模型生效"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Label Grid.Row="5" Grid.Column="0" Content="高级参数"/>
|
|
||||||
<TextBox x:Name="_extraParamsBox"
|
<TextBox x:Name="_extraParamsBox"
|
||||||
Grid.Row="5" Grid.Column="1"
|
Grid.Row="4" Grid.Column="1"
|
||||||
MinHeight="80"
|
MinHeight="80"
|
||||||
AcceptsReturn="True"
|
AcceptsReturn="True"
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
<StackPanel Grid.Row="6" 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="OnApplyModelClick" Margin="2"/>
|
||||||
<Button Content="从已知模板填充" Click="OnFillFromKnownModelsClick" Margin="2"/>
|
<Button Content="从已知模板填充" Click="OnFillFromKnownModelsClick" Margin="2"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock x:Name="_modelStatusText"
|
<TextBlock x:Name="_modelStatusText"
|
||||||
Grid.Row="7" Grid.ColumnSpan="2"
|
Grid.Row="6" Grid.ColumnSpan="2"
|
||||||
Foreground="Gray" Margin="0,5"/>
|
Foreground="Gray" Margin="0,5"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -33,7 +33,19 @@ namespace AnotherReplayReader
|
|||||||
RefreshProviderList();
|
RefreshProviderList();
|
||||||
if (_settings.Providers.Count > 0)
|
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;
|
return _settings.Prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把当前选中的 Provider/模型保存到设置文件(供窗口关闭时调用,
|
||||||
|
/// 下次打开 AI 设置页时恢复上次选择)。
|
||||||
|
/// </summary>
|
||||||
|
public void SaveCurrentSelection()
|
||||||
|
{
|
||||||
|
if (_currentProvider is not null && _currentModel is not null)
|
||||||
|
{
|
||||||
|
_settings.SetCurrentSelection(_currentProvider, _currentModel);
|
||||||
|
_settings.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshPromptFields()
|
private void RefreshPromptFields()
|
||||||
{
|
{
|
||||||
_useCustomPromptCheck.IsChecked = _settings.Prompt.UseCustomSystemPrompt;
|
_useCustomPromptCheck.IsChecked = _settings.Prompt.UseCustomSystemPrompt;
|
||||||
@@ -102,6 +127,9 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e)
|
private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
|
// 记住当前模型选择,切换 Provider 后若新 Provider 存在同名模型则保持选择
|
||||||
|
var previousModelId = _settings.CurrentModelId;
|
||||||
|
|
||||||
_currentProvider = _providerListBox.SelectedItem as AiProvider;
|
_currentProvider = _providerListBox.SelectedItem as AiProvider;
|
||||||
if (_currentProvider is null)
|
if (_currentProvider is null)
|
||||||
{
|
{
|
||||||
@@ -115,7 +143,26 @@ namespace AnotherReplayReader
|
|||||||
_topPBox.Text = _currentProvider.DefaultTopP.ToString();
|
_topPBox.Text = _currentProvider.DefaultTopP.ToString();
|
||||||
_maxTokensBox.Text = _currentProvider.DefaultMaxTokens.ToString();
|
_maxTokensBox.Text = _currentProvider.DefaultMaxTokens.ToString();
|
||||||
|
|
||||||
|
// 记录上次选中的 Provider(模型在 OnModelSelectionChanged 中记录)
|
||||||
|
_settings.CurrentProviderName = _currentProvider.Name;
|
||||||
RefreshModelList();
|
RefreshModelList();
|
||||||
|
// 尝试恢复上一个选中的模型(启动恢复与 Provider 切换共用同一路径)
|
||||||
|
if (!string.IsNullOrWhiteSpace(previousModelId))
|
||||||
|
{
|
||||||
|
var previousItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, previousModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (previousItem is not null)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedItem = previousItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无任何模型/无可恢复目标时回退到第一个模型
|
||||||
|
if (_currentModel is null && _modelComboBox.Items.Count > 0)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAddProviderClick(object sender, RoutedEventArgs e)
|
private void OnAddProviderClick(object sender, RoutedEventArgs e)
|
||||||
@@ -147,6 +194,8 @@ namespace AnotherReplayReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
_settings.Providers.Remove(_currentProvider);
|
_settings.Providers.Remove(_currentProvider);
|
||||||
|
_settings.CurrentProviderName = null;
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
RefreshProviderList();
|
RefreshProviderList();
|
||||||
if (_settings.Providers.Count > 0)
|
if (_settings.Providers.Count > 0)
|
||||||
@@ -156,6 +205,7 @@ namespace AnotherReplayReader
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_currentProvider = null;
|
_currentProvider = null;
|
||||||
|
_currentModel = null;
|
||||||
ClearProviderFields();
|
ClearProviderFields();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +227,7 @@ namespace AnotherReplayReader
|
|||||||
int.TryParse(_maxTokensBox.Text, out int maxTokens);
|
int.TryParse(_maxTokensBox.Text, out int maxTokens);
|
||||||
_currentProvider.DefaultMaxTokens = maxTokens;
|
_currentProvider.DefaultMaxTokens = maxTokens;
|
||||||
|
|
||||||
|
_settings.CurrentProviderName = _currentProvider.Name;
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
RefreshProviderList();
|
RefreshProviderList();
|
||||||
_providerListBox.SelectedItem = _currentProvider;
|
_providerListBox.SelectedItem = _currentProvider;
|
||||||
@@ -215,10 +266,33 @@ namespace AnotherReplayReader
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// 当前 Provider 没有任何模型:清空当前模型,避免残留上一个 Provider 的模型
|
||||||
|
_currentModel = null;
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
ClearModelFields();
|
ClearModelFields();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在模型下拉框中选中指定模型;找不到时回退到第一个模型。
|
||||||
|
/// 仅供启动恢复使用(避免刷新列表后触发首次默认选中)。
|
||||||
|
/// </summary>
|
||||||
|
private void SelectModel(AiModel model)
|
||||||
|
{
|
||||||
|
var item = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, model.ModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (item is not null)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedItem = item;
|
||||||
|
}
|
||||||
|
else if (_modelComboBox.Items.Count > 0)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnModelSelectionChanged(object sender, SelectionChangedEventArgs e)
|
private void OnModelSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
var selected = _modelComboBox.SelectedItem as ModelDisplayItem;
|
var selected = _modelComboBox.SelectedItem as ModelDisplayItem;
|
||||||
@@ -226,19 +300,17 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
if (_currentModel is null)
|
if (_currentModel is null)
|
||||||
{
|
{
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
ClearModelFields();
|
ClearModelFields();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_settings.CurrentModelId = _currentModel.ModelId;
|
||||||
|
|
||||||
_modelIdBox.Text = _currentModel.ModelId;
|
_modelIdBox.Text = _currentModel.ModelId;
|
||||||
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
|
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
|
||||||
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
|
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
|
||||||
_supportsSseCheck.IsChecked = _currentModel.IsStream;
|
_supportsSseCheck.IsChecked = _currentModel.IsStream;
|
||||||
_reasoningGuardCheck.IsChecked = _currentModel.ReasoningGuardEnabled;
|
|
||||||
_reasoningTokenLimitBox.Text =
|
|
||||||
_currentModel.ReasoningGuardTokenLimit is { } guardLimit
|
|
||||||
? guardLimit.ToString()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// 显示 ExtraParameters 为缩进 JSON
|
// 显示 ExtraParameters 为缩进 JSON
|
||||||
var json = JsonSerializer.Serialize(
|
var json = JsonSerializer.Serialize(
|
||||||
@@ -293,8 +365,6 @@ namespace AnotherReplayReader
|
|||||||
ContextLength = known.ContextLength,
|
ContextLength = known.ContextLength,
|
||||||
ContextBudget = known.ContextBudget,
|
ContextBudget = known.ContextBudget,
|
||||||
IsStream = known.IsStream,
|
IsStream = known.IsStream,
|
||||||
ReasoningGuardEnabled = known.ReasoningGuardEnabled,
|
|
||||||
ReasoningGuardTokenLimit = known.ReasoningGuardTokenLimit,
|
|
||||||
ExtraParameters = new Dictionary<string, object>(known.ExtraParameters)
|
ExtraParameters = new Dictionary<string, object>(known.ExtraParameters)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -312,6 +382,18 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
RefreshModelList();
|
RefreshModelList();
|
||||||
|
// 保持上次选中的模型(若仍存在),否则回退到第一个
|
||||||
|
if (_settings.CurrentModelId is { } lastModelId)
|
||||||
|
{
|
||||||
|
var lastItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, lastModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (lastItem is not null)
|
||||||
|
{
|
||||||
|
_modelComboBox.SelectedItem = lastItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
_modelStatusText.Text = $"获取成功,共 {ids.Count} 个模型";
|
_modelStatusText.Text = $"获取成功,共 {ids.Count} 个模型";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -359,6 +441,8 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
_currentProvider.Models.Remove(_currentModel);
|
_currentProvider.Models.Remove(_currentModel);
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
|
// 被删除模型不再是上次选择;RefreshModelList 会回退到第一个模型
|
||||||
|
_settings.CurrentModelId = null;
|
||||||
RefreshModelList();
|
RefreshModelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,16 +467,6 @@ namespace AnotherReplayReader
|
|||||||
_currentModel.ContextBudget = null;
|
_currentModel.ContextBudget = null;
|
||||||
}
|
}
|
||||||
_currentModel.IsStream = _supportsSseCheck.IsChecked == true;
|
_currentModel.IsStream = _supportsSseCheck.IsChecked == true;
|
||||||
_currentModel.ReasoningGuardEnabled = _reasoningGuardCheck.IsChecked == true;
|
|
||||||
if (int.TryParse(_reasoningTokenLimitBox.Text, out int guardLimit)
|
|
||||||
&& guardLimit > 0)
|
|
||||||
{
|
|
||||||
_currentModel.ReasoningGuardTokenLimit = guardLimit;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_currentModel.ReasoningGuardTokenLimit = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -411,7 +485,13 @@ namespace AnotherReplayReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
|
// 模型 ID 已修改:更新持久化选择,避免下一次启动仍按旧 ID 解析
|
||||||
|
_settings.CurrentModelId = _currentModel.ModelId;
|
||||||
RefreshModelList();
|
RefreshModelList();
|
||||||
|
_modelComboBox.SelectedItem = _modelComboBox.Items
|
||||||
|
.OfType<ModelDisplayItem>()
|
||||||
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, _currentModel.ModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
MessageBox.Show("模型修改已保存", "信息", MessageBoxButton.OK);
|
MessageBox.Show("模型修改已保存", "信息", MessageBoxButton.OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,12 +512,11 @@ namespace AnotherReplayReader
|
|||||||
_currentModel.IsStream = known.IsStream;
|
_currentModel.IsStream = known.IsStream;
|
||||||
_currentModel.ContextLength = known.ContextLength;
|
_currentModel.ContextLength = known.ContextLength;
|
||||||
_currentModel.ContextBudget = known.ContextBudget;
|
_currentModel.ContextBudget = known.ContextBudget;
|
||||||
_currentModel.ReasoningGuardEnabled = known.ReasoningGuardEnabled;
|
|
||||||
_currentModel.ReasoningGuardTokenLimit = known.ReasoningGuardTokenLimit;
|
|
||||||
RefreshModelList();
|
RefreshModelList();
|
||||||
_modelComboBox.SelectedItem = _modelComboBox.Items
|
_modelComboBox.SelectedItem = _modelComboBox.Items
|
||||||
.OfType<ModelDisplayItem>()
|
.OfType<ModelDisplayItem>()
|
||||||
.FirstOrDefault(m => m.Model.ModelId == _currentModel.ModelId);
|
.FirstOrDefault(m => string.Equals(
|
||||||
|
m.Model.ModelId, _currentModel.ModelId, StringComparison.OrdinalIgnoreCase));
|
||||||
MessageBox.Show("已从已知模板填充", "信息", MessageBoxButton.OK);
|
MessageBox.Show("已从已知模板填充", "信息", MessageBoxButton.OK);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -452,8 +531,6 @@ namespace AnotherReplayReader
|
|||||||
_contextLengthBox.Text = "";
|
_contextLengthBox.Text = "";
|
||||||
_contextBudgetBox.Text = "";
|
_contextBudgetBox.Text = "";
|
||||||
_supportsSseCheck.IsChecked = false;
|
_supportsSseCheck.IsChecked = false;
|
||||||
_reasoningGuardCheck.IsChecked = false;
|
|
||||||
_reasoningTokenLimitBox.Text = "";
|
|
||||||
_extraParamsBox.Text = "";
|
_extraParamsBox.Text = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
日期:2026-08-23
|
日期:2026-08-23
|
||||||
范围:OpenCodeGo / `deepseek-v4-flash`,OpenAI 兼容 `/chat/completions`
|
范围:OpenCodeGo / `deepseek-v4-flash`,OpenAI 兼容 `/chat/completions`
|
||||||
状态:实验性研究,未集成到主项目
|
状态:实验性研究,未集成到主项目;主项目曾短暂集成推理保护,后因实测影响输出质量而移除(详见 `PLAN_ai_analysis_v2.md`)
|
||||||
|
|
||||||
## 摘要
|
## 摘要
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
结论是:**仅发送 `reasoning_content` 或“上一轮思维链”并不稳定;成功率最高的方案是伪造一段 tool call 历史,并把续写要求、输出格式和需要引用的内容放入 tool 结果中。**
|
结论是:**仅发送 `reasoning_content` 或“上一轮思维链”并不稳定;成功率最高的方案是伪造一段 tool call 历史,并把续写要求、输出格式和需要引用的内容放入 tool 结果中。**
|
||||||
|
|
||||||
与本研究相关的代码在 `AiV2.Tests/Program.cs` 中,默认不会执行。
|
与本研究相关的实验代码已从 `AiV2.Tests/Program.cs` 中移除,不再保留可执行实验入口。
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
@@ -232,9 +232,11 @@ system
|
|||||||
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
|
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
|
||||||
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
|
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
|
||||||
|
|
||||||
## 主项目集成状态(2026-08-23)
|
## 主项目集成状态(2026-08-23,历史记录;该功能已全部移除)
|
||||||
|
|
||||||
- 主项目已落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
|
> 以下内容是当时的集成状态,不代表当前代码。当前主项目已移除推理保护及所有相关代码/测试。
|
||||||
|
|
||||||
|
- 主项目曾落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
|
||||||
- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
|
- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
|
||||||
- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
|
- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
|
||||||
- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
|
- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
|
||||||
@@ -248,9 +250,7 @@ system
|
|||||||
|
|
||||||
## 专用测试状态
|
## 专用测试状态
|
||||||
|
|
||||||
- 测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
|
- 原测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
|
||||||
- 默认行为:不执行。
|
- 现状:已随推理保护一起移除,不再保留可执行实验入口。
|
||||||
- 启用条件:`ARR_AI_E2E=1` 且 `ARR_AI_E2E_TOOL=1`。
|
|
||||||
- 输出:Markdown 报告,路径通过 `ARR_AI_TOOL_REPORT_PATH` 指定。
|
|
||||||
|
|
||||||
主项目中的实验性 `ChatMessage` 扩展、`AiReasoningContinuationSettings`、UI 开关、回传/降级逻辑和 tool call 历史构造均已移除。
|
主项目中的实验性 `ChatMessage` 扩展、`AiReasoningContinuationSettings`、UI 开关、回传/降级逻辑和 tool call 历史构造均已移除。
|
||||||
|
|||||||
+224
-806
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
using AnotherReplayReader.Apm;
|
using AnotherReplayReader.Apm;
|
||||||
using AnotherReplayReader.ReplayFile;
|
using AnotherReplayReader.ReplayFile;
|
||||||
using AnotherReplayReader.Utils;
|
using AnotherReplayReader.Utils;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
@@ -99,6 +99,7 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_aiSettings.SaveCurrentSelection();
|
||||||
_cancellation.Cancel();
|
_cancellation.Cancel();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -111,6 +112,7 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_aiSettings.SaveCurrentSelection();
|
||||||
_cancellation.Cancel();
|
_cancellation.Cancel();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
+42
-16
@@ -3,9 +3,30 @@
|
|||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
- 日期:2026-08-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)
|
- 关联文档:[AI_REASONING_CONTINUATION_RESEARCH.md](AI_REASONING_CONTINUATION_RESEARCH.md)
|
||||||
|
|
||||||
|
## 实施状态修订(推理保护已移除)
|
||||||
|
|
||||||
|
- 实测表明:截断/修改 `reasoning_content` 并伪造 tool call 续写会影响输出质量,已取消该方案。
|
||||||
|
- 当前代码不再包含 `AiReasoningGuard`、`ReasoningGuardEnabled`/`ReasoningGuardTokenLimit` 设置、推理保护 UI、`ReasoningGuard` 测试或相关 E2E。
|
||||||
|
- 仍保留对模型返回的 `reasoning_content` 的流式展示与 usage 统计;但不会截断、改写、回传或让它触发额外请求。
|
||||||
|
- 解决长思考的策略改为:提高输出 token 上限 + 让模型专注更短的时间范围(段内焦点窗口),同时仍提供尽量长的切片上下文并鼓励跨时间关联。
|
||||||
|
|
||||||
|
## 实施状态修订(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)
|
## 实施状态(2026-08-20)
|
||||||
|
|
||||||
里程碑全部完成,代码已落地并通过 149 项单元测试(`AiV2.Tests`,见 §12 M7;启用真实回放诊断时为 151 项)。
|
里程碑全部完成,代码已落地并通过 149 项单元测试(`AiV2.Tests`,见 §12 M7;启用真实回放诊断时为 151 项)。
|
||||||
@@ -17,14 +38,14 @@
|
|||||||
| M3 回查机制 | ✅ | `[回查]` 标记、容错时间解析、切片提取、每段 3 次上限、失败降级 Info |
|
| M3 回查机制 | ✅ | `[回查]` 标记、容错时间解析、切片提取、每段 3 次上限、失败降级 Info |
|
||||||
| M4 验证修正 | ✅ | 所有权强/弱分层与 4 条规则、施法者归属(仅 `0x1FE/0x200`)、协议记录与校验、多 JSON 块合并、move 降级、首次出兵时间线、player 映射接线 |
|
| M4 验证修正 | ✅ | 所有权强/弱分层与 4 条规则、施法者归属(仅 `0x1FE/0x200`)、协议记录与校验、多 JSON 块合并、move 降级、首次出兵时间线、player 映射接线 |
|
||||||
| M5 知识修正 | ✅ | `knowledge_units_default.json` 按 mod 加载、旧提示词副作用修复、标签体系补全与加载校验、渲染按参战阵营过滤、用户知识 JSON 覆盖 |
|
| M5 知识修正 | ✅ | `knowledge_units_default.json` 按 mod 加载、旧提示词副作用修复、标签体系补全与加载校验、渲染按参战阵营过滤、用户知识 JSON 覆盖 |
|
||||||
| M6 修订 pass | ✅ | 段内 1 次修订、修订期间抑制流式显示、完成后替换段内容、UI 日志提示 |
|
| M6 修订 pass | ✅ | 段内 1 次修订、修订草稿实时流式显示、完成后草稿折叠、最终正文默认展开、UI 日志提示 |
|
||||||
| M7 测试与评估 | ✅/部分 | 单元测试完成;A/B 对比与估算校准需真实 API 运行(见 §13) |
|
| M7 测试与评估 | ✅/部分 | 单元测试完成;A/B 对比与估算校准需真实 API 运行(见 §13) |
|
||||||
|
|
||||||
**实施中的取舍与遗留**
|
**实施中的取舍与遗留**
|
||||||
|
|
||||||
- `Data/StringHashes.xml` 是随仓库分发的本地 SDK 临时快照(约 3.5MB / 47,860 条),后续应改为可配置路径或只打包需要的 hash 子集。
|
- `Data/StringHashes.xml` 是随仓库分发的本地 SDK 临时快照(约 3.5MB / 47,860 条),后续应改为可配置路径或只打包需要的 hash 子集。
|
||||||
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
|
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
|
||||||
- 修订 pass 的展示采用"实时流式 + 修订后整段替换";原隐藏修订决策中的"完全缓冲至验证完成"仍是开放项。
|
- 修订 pass 的展示采用"修订草稿实时流式 + 完成后草稿折叠、最终正文默认展开";原隐藏修订决策中的"完全缓冲"已改为"默认折叠中间草稿"。
|
||||||
- `Fatal` 在“所有机器可读声明块均无法解析”时产生;修订输出为空时保留原分析。
|
- `Fatal` 在“所有机器可读声明块均无法解析”时产生;修订输出为空时保留原分析。
|
||||||
- `MissingMachineReadableClaims` 为 Warning,并与其他 Warning/WeakEvidence 一样触发一次隐藏修订;是否保留该策略待 A/B 评估。
|
- `MissingMachineReadableClaims` 为 Warning,并与其他 Warning/WeakEvidence 一样触发一次隐藏修订;是否保留该策略待 A/B 评估。
|
||||||
- 测试工程 `AiV2.Tests` 通过 `ProjectReference` 引用主工程;构建时通过 `AiV2TestsBuilding=true` 跳过主工程的 DLL 移动目标。
|
- 测试工程 `AiV2.Tests` 通过 `ProjectReference` 引用主工程;构建时通过 `AiV2TestsBuilding=true` 跳过主工程的 DLL 移动目标。
|
||||||
@@ -48,7 +69,7 @@
|
|||||||
- `AiV2.Tests` 仅保留 `OpenCodeGoFakeToolCallE2e` 专用测试:默认跳过,需同时设置 `ARR_AI_E2E=1` 与 `ARR_AI_E2E_TOOL=1`。
|
- `AiV2.Tests` 仅保留 `OpenCodeGoFakeToolCallE2e` 专用测试:默认跳过,需同时设置 `ARR_AI_E2E=1` 与 `ARR_AI_E2E_TOOL=1`。
|
||||||
- 实验结果、推荐方案与数据表格见 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
- 实验结果、推荐方案与数据表格见 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
||||||
|
|
||||||
### 2026-08-23 推理保护实现
|
### 2026-08-23 推理保护实现(历史记录,后续已移除)
|
||||||
|
|
||||||
- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
|
- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
|
||||||
- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
|
- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
|
||||||
@@ -101,11 +122,12 @@
|
|||||||
| 上下文预算 | 每模型 `ContextBudget` 软上限,默认档位:≥1M → 160K;200K~256K → 100K;<200K → 只支持短录像(单 slice) |
|
| 上下文预算 | 每模型 `ContextBudget` 软上限,默认档位:≥1M → 160K;200K~256K → 100K;<200K → 只支持短录像(单 slice) |
|
||||||
| 模式 | 单一管线;"全量模式"取消,短录像 = 1 个 slice |
|
| 模式 | 单一管线;"全量模式"取消,短录像 = 1 个 slice |
|
||||||
| 分段 | 机械式(按 token 预算 + 事件数,带重叠);不再由 LLM 决定边界 |
|
| 分段 | 机械式(按 token 预算 + 事件数,带重叠);不再由 LLM 决定边界 |
|
||||||
|
| 段内焦点窗口 | 数据层 = 整段切片(尽量长);注意力层 = 每轮一个窗口(每段最多 5 个,目标 12K/窗);窗口可跨时间关联段内其他事件 |
|
||||||
| 总览轮 | 保留;输入为摘要 + 分段元数据(不读全量日志);输出允许跨段描述、跨段线索、回查建议 |
|
| 总览轮 | 保留;输入为摘要 + 分段元数据(不读全量日志);输出允许跨段描述、跨段线索、回查建议 |
|
||||||
| 回查机制 | 进 v1;允许模型按需请求远处原始区间 |
|
| 回查机制 | 进 v1;允许模型按需请求远处原始区间 |
|
||||||
| 缓存 | 稳定内容前置;跨段前缀 = system+摘要+总览;段内复用 = 前缀+slice(修订/回查共用) |
|
| 缓存 | 稳定内容前置;跨段前缀 = system+摘要+总览;段内复用 = 前缀+slice(修订/回查共用) |
|
||||||
| 128K 及以下 | 允许短录像(切片后为 1 个 slice 时自然工作),不承诺长录像质量 |
|
| 128K 及以下 | 允许短录像(切片后为 1 个 slice 时自然工作),不承诺长录像质量 |
|
||||||
| 修订 pass | 按隐藏修订方案在段内落地,每段最多 1 次 |
|
| 修订 pass | 按隐藏修订方案在窗口内落地,每窗口最多 1 次 |
|
||||||
|
|
||||||
## 4. 上下文预算策略
|
## 4. 上下文预算策略
|
||||||
|
|
||||||
@@ -163,16 +185,19 @@
|
|||||||
|
|
||||||
### 5.5 分段分析轮
|
### 5.5 分段分析轮
|
||||||
|
|
||||||
- 每段一个独立会话,消息顺序(缓存关键,稳定在前):
|
- 每段一个独立阶段,段内再按“焦点窗口”逐轮分析。窗口划分与数据范围分离:
|
||||||
`system → 对局摘要 → 总览输出 → slice_i → 已发现事实(1..i-1) → 段指令_i`
|
- **数据层(不变)**:每轮都提供当前段的完整切片 `slice_i`(尽量长、不超过上下文上限),用于跨时间关联。
|
||||||
- 段指令:段标题/概述 + "请重点分析第 N 段(起止时间),可回查远处区间";N=1 时改为"分析整局"。
|
- **注意力层(新增)**:`FocusPlanner` 把切片按 token 切成 1~5 个窗口(默认目标 12K/窗;≤24K 的切片不细分);每轮只“重点分析”一个窗口,且鼓励关联窗口外/跨时间事件。
|
||||||
|
- 消息顺序(缓存关键,稳定在前):
|
||||||
|
`system → 对局摘要 → 总览输出 → slice_i → 已发现事实(1..i-1 + 段内前窗口) → 窗口指令(含窗口时间范围/事件数)`
|
||||||
|
- 窗口指令:段标题/概述 + "请重点分析第 N 段第 k/n 窗口(起止时间),数据为整段切片,可回查远处区间"。
|
||||||
- 输出:自然语言分析 + `[机器可读声明]`(沿用现有 schema,见 §7.4 的解析修正)。
|
- 输出:自然语言分析 + `[机器可读声明]`(沿用现有 schema,见 §7.4 的解析修正)。
|
||||||
- 同一段的后续请求(修订、回查)复用同一消息列表。
|
- 同一窗口的后续请求(修订、回查)复用同一消息列表;窗口之间独立会话,但共享已发现事实。
|
||||||
|
|
||||||
### 5.6 已发现事实
|
### 5.6 已发现事实
|
||||||
|
|
||||||
- 每段分析完成后,由验证过的机器可读声明 + 3~5 句小结组成追加条目,每段 ≤ ~1K token。
|
- 每个窗口分析完成后,由验证过的机器可读声明 + 3~5 句小结组成追加条目,每项 ≤ ~1K token。
|
||||||
- 追加在消息尾部,不影响前缀缓存;是跨段关联的主要载体。
|
- 窗口小结追加在消息尾部,不影响前缀缓存;既是跨段关联的主要载体,也是同一段内窗口间的关联载体。
|
||||||
|
|
||||||
### 5.7 回查协议(v1)
|
### 5.7 回查协议(v1)
|
||||||
|
|
||||||
@@ -285,10 +310,10 @@
|
|||||||
|
|
||||||
## 10. 修订 pass(P11)
|
## 10. 修订 pass(P11)
|
||||||
|
|
||||||
- 段内执行:草稿 + 验证 issue + 相关事实 → 干净修正版;每段最多 1 次。
|
- 窗口内执行:草稿 + 验证 issue + 相关事实 → 干净修正版;每窗口最多 1 次。
|
||||||
- 修订后仍 `Fatal` → 回退显示原文 + 警告(`Fatal` 条件见 §7.7)。
|
- 修订后仍 `Fatal` → 回退显示原文 + 警告(`Fatal` 条件见 §7.7)。
|
||||||
- 修订请求复用段内会话(同一前缀,缓存友好)。
|
- 修订请求复用窗口会话(同一前缀,缓存友好)。
|
||||||
- UI:增加"验证器发现并修正 N 个问题"提示;沿用隐藏修订方案的缓冲决策(段内容缓冲至验证/修订完成)。
|
- UI:增加"验证器发现并修正 N 个问题"提示;修订草稿实时流式显示,完成后自动折叠,最终正文默认展开。
|
||||||
|
|
||||||
## 11. 设置与 UI
|
## 11. 设置与 UI
|
||||||
|
|
||||||
@@ -304,7 +329,7 @@
|
|||||||
3. **M3 回查机制**:标记解析(容错时间解析)、切片提取、限流、失败降级。
|
3. **M3 回查机制**:标记解析(容错时间解析)、切片提取、限流、失败降级。
|
||||||
4. **M4 验证修正**:所有权证据与规则(§7.1)、施法者归属(§7.2)、协议与选择指令(§7.3)、JSON 解析健壮性(§7.4)、move 降级(§7.5)、首次出兵时间线(§7.6)、严重度语义(§7.7)。
|
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)。
|
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)。
|
7. **M7 测试与评估**:单元测试(解析器/验证器/事实索引/分段)+ A/B 对比(§13)。
|
||||||
|
|
||||||
依赖关系:M2 先于 M3;M4/M5 可与 M2 并行;M6 依赖 M2 + M4;M7 覆盖全部。
|
依赖关系:M2 先于 M3;M4/M5 可与 M2 并行;M6 依赖 M2 + M4;M7 覆盖全部。
|
||||||
@@ -323,8 +348,9 @@
|
|||||||
- 总览轮质量影响后续所有段 → 摘要/采样质量需要迭代;失败降级路径见 §5.4。
|
- 总览轮质量影响后续所有段 → 摘要/采样质量需要迭代;失败降级路径见 §5.4。
|
||||||
- 回查滥用或格式不稳定 → 限流 + 失败降级(§5.7)。
|
- 回查滥用或格式不稳定 → 限流 + 失败降级(§5.7)。
|
||||||
- 修订后机器可读声明可能与正文不一致 → 修订轮要求同时重出声明并重新验证。
|
- 修订后机器可读声明可能与正文不一致 → 修订轮要求同时重出声明并重新验证。
|
||||||
|
- 段内焦点窗口依赖 `EventSpan` 时间索引;若索引缺失(防御性回退)则单窗口分析。
|
||||||
- 用户在运行中修改设置导致前缀变化 → 缓存失效,仅影响本次运行。
|
- 用户在运行中修改设置导致前缀变化 → 缓存失效,仅影响本次运行。
|
||||||
- 开放:段内容缓冲 vs 实时流式的最终 UI 决策;是否允许总览轮建议边界调整(v2 候选);`MissingMachineReadableClaims` 是否触发修复请求(§7.7)。
|
- 开放:是否允许总览轮建议边界调整(v2 候选);`MissingMachineReadableClaims` 是否触发修复请求(§7.7)。
|
||||||
|
|
||||||
## 15. 本计划不涉及
|
## 15. 本计划不涉及
|
||||||
|
|
||||||
|
|||||||
+83
-293
@@ -1,4 +1,4 @@
|
|||||||
using AnotherReplayReader.ReplayFile;
|
using AnotherReplayReader.ReplayFile;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
@@ -7,7 +7,6 @@ using System.Linq;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -26,48 +25,13 @@ namespace AnotherReplayReader.Utils
|
|||||||
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
|
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("reasoning_content"),
|
[property: System.Text.Json.Serialization.JsonPropertyName("reasoning_content"),
|
||||||
System.Text.Json.Serialization.JsonIgnore(
|
System.Text.Json.Serialization.JsonIgnore(
|
||||||
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null,
|
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null)
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("tool_calls"),
|
|
||||||
System.Text.Json.Serialization.JsonIgnore(
|
|
||||||
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<ChatMessage.ToolCall>? ToolCalls = null,
|
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("tool_call_id"),
|
|
||||||
System.Text.Json.Serialization.JsonIgnore(
|
|
||||||
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ToolCallId = null)
|
|
||||||
{
|
{
|
||||||
public sealed record ToolCall(
|
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("id")] string Id,
|
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("type")] string Type,
|
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("function")] ToolCallFunction Function);
|
|
||||||
|
|
||||||
public sealed record ToolCallFunction(
|
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("name")] string Name,
|
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("arguments")] string Arguments);
|
|
||||||
|
|
||||||
public static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
|
public static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
|
||||||
new(
|
new(
|
||||||
"assistant",
|
"assistant",
|
||||||
string.IsNullOrEmpty(content) ? null : content,
|
string.IsNullOrEmpty(content) ? null : content,
|
||||||
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
|
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
|
||||||
|
|
||||||
public static ChatMessage AssistantToolCall(
|
|
||||||
string? reasoningContent,
|
|
||||||
string toolCallId,
|
|
||||||
string toolName,
|
|
||||||
string arguments) =>
|
|
||||||
new(
|
|
||||||
"assistant",
|
|
||||||
null,
|
|
||||||
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent,
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
new ToolCall(
|
|
||||||
toolCallId,
|
|
||||||
"function",
|
|
||||||
new ToolCallFunction(toolName, arguments))
|
|
||||||
});
|
|
||||||
|
|
||||||
public static ChatMessage ToolResult(string toolCallId, string content) =>
|
|
||||||
new("tool", content, null, null, toolCallId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetSystemPrompt(
|
public static string GetSystemPrompt(
|
||||||
@@ -175,17 +139,18 @@ namespace AnotherReplayReader.Utils
|
|||||||
# 输出要求
|
# 输出要求
|
||||||
## 1. 总览阶段
|
## 1. 总览阶段
|
||||||
触发条件:用户输入包含:""请先对整局进行总览""
|
触发条件:用户输入包含:""请先对整局进行总览""
|
||||||
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
|
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
|
||||||
- 你的任务:
|
- 你的任务:
|
||||||
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
|
|
||||||
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
||||||
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
|
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
|
||||||
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
|
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
|
||||||
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
||||||
|
|
||||||
## 2. 分段分析、推理阶段
|
## 2. 分段分析、推理阶段
|
||||||
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END])""
|
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END])""
|
||||||
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
|
- 输入中包含:当前分段的原始操作记录切片、之前各阶段的已发现事实摘要,以及整局总览
|
||||||
|
- 程序会把一个分段按时间切分为若干“分析窗口”(每轮一个窗口)。你当前分析的是其中一个窗口,但完整的分段切片仍然是你能看到的数据范围
|
||||||
|
- 你的重点任务:分析当前窗口时间范围内的主要事件与上下文;但同时应主动查看并关联窗口之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
|
||||||
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
||||||
- 选取该阶段的主要事件,以及和它们的上下文
|
- 选取该阶段的主要事件,以及和它们的上下文
|
||||||
- 也可以选择数个其他有分析价值的事件
|
- 也可以选择数个其他有分析价值的事件
|
||||||
@@ -197,7 +162,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
||||||
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
||||||
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
||||||
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
|
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续分析窗口与后续分段参考
|
||||||
|
|
||||||
## 3. 最终总结阶段
|
## 3. 最终总结阶段
|
||||||
触发条件:用户输入包含:""请对以上内容进行总结""
|
触发条件:用户输入包含:""请对以上内容进行总结""
|
||||||
@@ -1069,9 +1034,11 @@ PlayerA: 开始出兵
|
|||||||
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
|
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("请先对整局进行总览。");
|
sb.AppendLine("请基于对局摘要对分段进行总览。");
|
||||||
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
|
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
|
||||||
sb.AppendLine("请描述整局走势(允许跨分段),并为每个分段给出标题与一句话概述。");
|
sb.AppendLine("本阶段不会获取原始操作记录。请只根据对局摘要和分段元数据,为每个分段给出标题与一句话概述。");
|
||||||
|
sb.AppendLine("如果某段在后续分析时可能需要对局摘要之外的原始区间,请在该段后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议。");
|
||||||
|
sb.AppendLine("不要展开推断对局摘要没有明确支持的内容。");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("[分段元数据]");
|
sb.AppendLine("[分段元数据]");
|
||||||
foreach (var slice in slices)
|
foreach (var slice in slices)
|
||||||
@@ -1079,7 +1046,7 @@ PlayerA: 开始出兵
|
|||||||
sb.AppendLine($"#{(slice.Index + 1)} {MatchDigestBuilder.FormatTime(slice.Start)}~{MatchDigestBuilder.FormatTime(slice.End)} 事件数 {slice.EventCount} 约 {slice.EstimatedTokens} token");
|
sb.AppendLine($"#{(slice.Index + 1)} {MatchDigestBuilder.FormatTime(slice.Start)}~{MatchDigestBuilder.FormatTime(slice.End)} 事件数 {slice.EventCount} 约 {slice.EstimatedTokens} token");
|
||||||
}
|
}
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("输出格式:先自由描述整局走势与跨段线索,然后输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`。");
|
sb.AppendLine("输出格式:直接输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段建议后续回查,在对应行后另起一行写 `回查: mm:ss~mm:ss`。不要输出整局叙述。");
|
||||||
return sb.ToString().Replace("\r", "");
|
return sb.ToString().Replace("\r", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,6 +1081,70 @@ PlayerA: 开始出兵
|
|||||||
return instruction.Trim().Replace("\r", "");
|
return instruction.Trim().Replace("\r", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 段内焦点窗口指令:数据范围 = 整个机械分段切片(尽量长,但不超过上下文上限),
|
||||||
|
/// 分析“重点”只落在窗口时间段内;同时鼓励模型跨时间关联窗口外的背景事件。
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildFocusWindowUserPromptV2(
|
||||||
|
int segmentIndex,
|
||||||
|
int totalSegments,
|
||||||
|
ReplaySlice slice,
|
||||||
|
int windowIndex,
|
||||||
|
int windowCount,
|
||||||
|
FocusWindow window,
|
||||||
|
int sliceEventCount,
|
||||||
|
int windowEventCount,
|
||||||
|
string? title,
|
||||||
|
string? description = null,
|
||||||
|
IEnumerable<string>? backqueryHints = null)
|
||||||
|
{
|
||||||
|
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
|
||||||
|
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
|
||||||
|
var windowBeginText = MatchDigestBuilder.FormatTime(window.Start);
|
||||||
|
var windowEndText = MatchDigestBuilder.FormatTime(window.End);
|
||||||
|
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
|
||||||
|
var descriptionLine = string.IsNullOrWhiteSpace(description) ? "" : $"\n段落概述:{description}";
|
||||||
|
var hintLine = backqueryHints is { } hints && hints.Any()
|
||||||
|
? "\n总览建议可回查区间:" + string.Join("、", hints)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
string windowInstruction;
|
||||||
|
string windowHeader;
|
||||||
|
if (windowCount <= 1)
|
||||||
|
{
|
||||||
|
windowInstruction =
|
||||||
|
$"请重点分析 {beginText} 至 {endText} 时间段的操作数据。";
|
||||||
|
windowHeader = "\n本段数据量适中,作为一个整体重点时间段分析。";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
windowInstruction =
|
||||||
|
$"请重点分析 {windowBeginText} 至 {windowEndText} 时间段的操作数据。";
|
||||||
|
windowHeader =
|
||||||
|
$"\n本段已按时间划分为 {windowCount} 个重点时间段,当前是第 {windowIndex + 1} 个(后续时间段会依次分析)。";
|
||||||
|
}
|
||||||
|
|
||||||
|
var instruction = @$"
|
||||||
|
{windowInstruction}
|
||||||
|
{windowHeader}
|
||||||
|
本重点时间段约有 {windowEventCount} 条操作信息。
|
||||||
|
下方已提供第{segmentIndex + 1}段({beginText}至{endText})的完整操作记录(约 {sliceEventCount} 条)作为数据与背景。{titleLine}{descriptionLine}
|
||||||
|
{hintLine}
|
||||||
|
你可以参考输入中的对局摘要、整局总览与之前各窗口/各段的已发现事实。
|
||||||
|
|
||||||
|
你的重点任务是分析本重点时间段内的主要事件与它们的上下文,但不要局限于此时间段:
|
||||||
|
- 请主动查找并关联本时间段之外、但处于本段完整记录中的相关事件。如果当前事件与更早或更晚的事件存在关联(例如生产、建造、打包/展开、技能释放的后续影响、部队调动),请结合这些跨时间事件进行分析。
|
||||||
|
- 如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
|
||||||
|
- 不要因为重点时间段短就把每一条操作都当作单独事件,也不要省略该时间段内的重要事件。
|
||||||
|
|
||||||
|
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对本时间段内的事件进行分析和推理。
|
||||||
|
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
|
||||||
|
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空 JSON 对象。
|
||||||
|
最后用一行 `[小结]` 输出 2~3 句本重点时间段最重要的结论。
|
||||||
|
";
|
||||||
|
return instruction.Trim().Replace("\r", "");
|
||||||
|
}
|
||||||
|
|
||||||
public static string BuildSummaryUserPromptV2(int totalEventCount)
|
public static string BuildSummaryUserPromptV2(int totalEventCount)
|
||||||
{
|
{
|
||||||
var instruction = $@"
|
var instruction = $@"
|
||||||
@@ -1210,8 +1241,6 @@ PlayerA: 开始出兵
|
|||||||
public enum AIChunkType
|
public enum AIChunkType
|
||||||
{
|
{
|
||||||
Reasoning,
|
Reasoning,
|
||||||
ReasoningGuard,
|
|
||||||
ReasoningGuardRequest,
|
|
||||||
Content,
|
Content,
|
||||||
Error,
|
Error,
|
||||||
Json
|
Json
|
||||||
@@ -1227,13 +1256,6 @@ PlayerA: 开始出兵
|
|||||||
{
|
{
|
||||||
public string Response;
|
public string Response;
|
||||||
public string Reasoning;
|
public string Reasoning;
|
||||||
public string? ReasoningFormatState;
|
|
||||||
public bool ReasoningInterrupted;
|
|
||||||
public bool ContinuationApplied;
|
|
||||||
public string? ReasoningContinuationError;
|
|
||||||
public string? RequestJson;
|
|
||||||
public string? ReasoningOriginalRequestJson;
|
|
||||||
public string? ReasoningContinuationRequestJson;
|
|
||||||
public int? PromptTokens;
|
public int? PromptTokens;
|
||||||
public int? CompletionTokens;
|
public int? CompletionTokens;
|
||||||
public int? TotalTokens;
|
public int? TotalTokens;
|
||||||
@@ -1262,226 +1284,19 @@ PlayerA: 开始出兵
|
|||||||
Action<AIChunk>? onChunk,
|
Action<AIChunk>? onChunk,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var model = requestContext.Model;
|
return await Task.Run(
|
||||||
var guardEnabled = model.ReasoningGuardEnabled && model.IsStream;
|
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
|
||||||
if (!guardEnabled)
|
|
||||||
{
|
|
||||||
return await Task.Run(
|
|
||||||
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokenLimit = AiReasoningGuard.GetEffectiveTokenLimit(
|
|
||||||
requestContext.Provider, model);
|
|
||||||
var firstResult = await Task.Run(
|
|
||||||
() => DoRequest(
|
|
||||||
_http,
|
|
||||||
messages,
|
|
||||||
requestContext,
|
|
||||||
onChunk,
|
|
||||||
cancellationToken,
|
|
||||||
tokenLimit),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (!firstResult.ReasoningInterrupted)
|
|
||||||
{
|
|
||||||
return firstResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
var continuationMessages = BuildReasoningContinuationMessages(
|
|
||||||
messages,
|
|
||||||
firstResult.Reasoning,
|
|
||||||
tokenLimit,
|
|
||||||
out var formatState);
|
|
||||||
firstResult.ReasoningFormatState = formatState;
|
|
||||||
var continuationContext = BuildReasoningToolContext(requestContext);
|
|
||||||
var originalRequestJson = firstResult.RequestJson;
|
|
||||||
var continuationRequestJson = SerializeRequestParams(
|
|
||||||
continuationMessages,
|
|
||||||
continuationContext,
|
|
||||||
writeIndented: true);
|
|
||||||
var continuationPromptTokens = 0;
|
|
||||||
foreach (var message in continuationMessages)
|
|
||||||
{
|
|
||||||
continuationPromptTokens += AiContextBudget.EstimateTokens(
|
|
||||||
(message.Content ?? string.Empty)
|
|
||||||
+ "\n"
|
|
||||||
+ (message.ReasoningContent ?? string.Empty));
|
|
||||||
}
|
|
||||||
var continuationCheck = AiContextBudget.CheckRequestUsage(
|
|
||||||
continuationPromptTokens,
|
|
||||||
requestContext.Provider,
|
|
||||||
continuationContext.Model);
|
|
||||||
var continuationBudgetWarning = string.Empty;
|
|
||||||
if (continuationCheck.Block)
|
|
||||||
{
|
|
||||||
var message = "续写请求未通过预算检查:" + continuationCheck.Message;
|
|
||||||
if (!string.IsNullOrEmpty(firstResult.Response))
|
|
||||||
{
|
|
||||||
firstResult.ReasoningContinuationError = message;
|
|
||||||
firstResult.ContinuationApplied = false;
|
|
||||||
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
|
|
||||||
firstResult.ReasoningContinuationRequestJson =
|
|
||||||
continuationRequestJson;
|
|
||||||
return firstResult;
|
|
||||||
}
|
|
||||||
throw new InvalidOperationException(message);
|
|
||||||
}
|
|
||||||
if (!continuationCheck.IsOk)
|
|
||||||
{
|
|
||||||
continuationBudgetWarning = continuationCheck.Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChunk?.Invoke(new AIChunk
|
|
||||||
{
|
|
||||||
Type = AIChunkType.ReasoningGuardRequest,
|
|
||||||
Text = JsonSerializer.Serialize(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
originalRequestJson,
|
|
||||||
continuationRequestJson
|
|
||||||
},
|
|
||||||
new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
Result secondResult = default;
|
|
||||||
var continuationError = string.Empty;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
secondResult = await Task.Run(
|
|
||||||
() => DoRequest(
|
|
||||||
_http,
|
|
||||||
continuationMessages,
|
|
||||||
continuationContext,
|
|
||||||
onChunk,
|
|
||||||
cancellationToken,
|
|
||||||
tokenLimit),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (
|
|
||||||
!cancellationToken.IsCancellationRequested
|
|
||||||
&& ex is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
continuationError = ex.Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(continuationError))
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(firstResult.Response))
|
|
||||||
{
|
|
||||||
firstResult.ReasoningContinuationError =
|
|
||||||
"续写请求失败,已保留首次部分正文:" + continuationError;
|
|
||||||
firstResult.ContinuationApplied = false;
|
|
||||||
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
|
|
||||||
firstResult.ReasoningContinuationRequestJson =
|
|
||||||
continuationRequestJson;
|
|
||||||
return firstResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"推理续写失败,且没有可用的部分回答:" + continuationError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (secondResult.ReasoningInterrupted)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(secondResult.Response))
|
|
||||||
{
|
|
||||||
secondResult.ReasoningContinuationError =
|
|
||||||
"续写仍超过推理上限,已使用续写部分正文。";
|
|
||||||
secondResult.ContinuationApplied = false;
|
|
||||||
secondResult.ReasoningOriginalRequestJson = originalRequestJson;
|
|
||||||
secondResult.ReasoningContinuationRequestJson =
|
|
||||||
continuationRequestJson;
|
|
||||||
return secondResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(firstResult.Response))
|
|
||||||
{
|
|
||||||
firstResult.ReasoningContinuationError =
|
|
||||||
"续写仍超过推理上限,已回退到首次部分正文。";
|
|
||||||
firstResult.ContinuationApplied = false;
|
|
||||||
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
|
|
||||||
firstResult.ReasoningContinuationRequestJson =
|
|
||||||
continuationRequestJson;
|
|
||||||
return firstResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"推理续写仍超过上限,且没有可用的部分回答。");
|
|
||||||
}
|
|
||||||
|
|
||||||
secondResult.ContinuationApplied = true;
|
|
||||||
secondResult.ReasoningOriginalRequestJson = originalRequestJson;
|
|
||||||
secondResult.ReasoningContinuationRequestJson =
|
|
||||||
continuationRequestJson;
|
|
||||||
if (!string.IsNullOrEmpty(continuationBudgetWarning))
|
|
||||||
{
|
|
||||||
secondResult.ReasoningContinuationError = continuationBudgetWarning;
|
|
||||||
}
|
|
||||||
return secondResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ImmutableList<ChatMessage> BuildReasoningContinuationMessages(
|
|
||||||
ImmutableList<ChatMessage> messages,
|
|
||||||
string reasoning,
|
|
||||||
int tokenLimit,
|
|
||||||
out string formatState)
|
|
||||||
{
|
|
||||||
var prepared = AiReasoningGuard.PrepareReasoning(reasoning, tokenLimit);
|
|
||||||
formatState = prepared.FormatState;
|
|
||||||
var instruction = AiReasoningGuard.BuildToolInstruction();
|
|
||||||
var arguments = JsonSerializer.Serialize(new { instruction });
|
|
||||||
return messages
|
|
||||||
.Add(ChatMessage.AssistantToolCall(
|
|
||||||
prepared.Text,
|
|
||||||
AiReasoningGuard.ToolCallId,
|
|
||||||
AiReasoningGuard.ToolName,
|
|
||||||
arguments))
|
|
||||||
.Add(ChatMessage.ToolResult(
|
|
||||||
AiReasoningGuard.ToolCallId,
|
|
||||||
AiReasoningGuard.BuildToolResult()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AiRequestContext BuildReasoningToolContext(
|
|
||||||
AiRequestContext context)
|
|
||||||
{
|
|
||||||
var model = new AiModel
|
|
||||||
{
|
|
||||||
ModelId = context.Model.ModelId,
|
|
||||||
DisplayName = context.Model.DisplayName,
|
|
||||||
IsStream = context.Model.IsStream,
|
|
||||||
ContextLength = context.Model.ContextLength,
|
|
||||||
ContextBudget = context.Model.ContextBudget,
|
|
||||||
ReasoningGuardEnabled = context.Model.ReasoningGuardEnabled,
|
|
||||||
ReasoningGuardTokenLimit = context.Model.ReasoningGuardTokenLimit,
|
|
||||||
ExtraParameters = new Dictionary<string, object>(
|
|
||||||
context.Model.ExtraParameters)
|
|
||||||
};
|
|
||||||
model.ExtraParameters["tools"] =
|
|
||||||
new[] { AiReasoningGuard.BuildToolDefinition() };
|
|
||||||
model.ExtraParameters["tool_choice"] = "none";
|
|
||||||
return new AiRequestContext(context.Provider, model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SerializeRequestParams(
|
private static string SerializeRequestParams(
|
||||||
ImmutableList<ChatMessage> messages,
|
ImmutableList<ChatMessage> messages,
|
||||||
AiRequestContext context,
|
AiRequestContext context)
|
||||||
bool writeIndented = false)
|
|
||||||
{
|
{
|
||||||
var requestParams = ProcessRequestParams(
|
var requestParams = ProcessRequestParams(
|
||||||
messages,
|
messages,
|
||||||
context.BuildRequestParams());
|
context.BuildRequestParams());
|
||||||
return JsonSerializer.Serialize(
|
return JsonSerializer.Serialize(requestParams);
|
||||||
requestParams,
|
|
||||||
writeIndented
|
|
||||||
? new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Result> DoRequest(
|
private static async Task<Result> DoRequest(
|
||||||
@@ -1489,8 +1304,7 @@ PlayerA: 开始出兵
|
|||||||
ImmutableList<ChatMessage> messages,
|
ImmutableList<ChatMessage> messages,
|
||||||
AiRequestContext requestContext,
|
AiRequestContext requestContext,
|
||||||
Action<AIChunk>? onChunk,
|
Action<AIChunk>? onChunk,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken)
|
||||||
int? reasoningTokenLimit = null)
|
|
||||||
{
|
{
|
||||||
var provider = requestContext.Provider;
|
var provider = requestContext.Provider;
|
||||||
var isStream = requestContext.Model.IsStream;
|
var isStream = requestContext.Model.IsStream;
|
||||||
@@ -1512,8 +1326,6 @@ PlayerA: 开始出兵
|
|||||||
var fullBuilder = new StringBuilder();
|
var fullBuilder = new StringBuilder();
|
||||||
var reasoningBuilder = new StringBuilder();
|
var reasoningBuilder = new StringBuilder();
|
||||||
var result = new Result();
|
var result = new Result();
|
||||||
result.RequestJson = inputJson;
|
|
||||||
var interrupted = false;
|
|
||||||
|
|
||||||
// 根据模式分别读取响应
|
// 根据模式分别读取响应
|
||||||
// if response.Content.Headers.ContentType is "text/event-stream", then it's stream mode, otherwise it's non-stream mode
|
// if response.Content.Headers.ContentType is "text/event-stream", then it's stream mode, otherwise it's non-stream mode
|
||||||
@@ -1558,20 +1370,6 @@ PlayerA: 开始出兵
|
|||||||
reasoningBuilder,
|
reasoningBuilder,
|
||||||
result,
|
result,
|
||||||
onChunk);
|
onChunk);
|
||||||
|
|
||||||
if (reasoningTokenLimit is { } guardLimit
|
|
||||||
&& guardLimit > 0
|
|
||||||
&& AiContextBudget.EstimateTokens(reasoningBuilder.ToString())
|
|
||||||
>= guardLimit)
|
|
||||||
{
|
|
||||||
interrupted = true;
|
|
||||||
onChunk?.Invoke(new AIChunk
|
|
||||||
{
|
|
||||||
Type = AIChunkType.ReasoningGuard,
|
|
||||||
Text = "检测到推理内容超过保护阈值,正在请求 AI 尽快收尾..."
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1589,14 +1387,6 @@ PlayerA: 开始出兵
|
|||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
result.Reasoning = reasoningBuilder.ToString();
|
result.Reasoning = reasoningBuilder.ToString();
|
||||||
result.ReasoningFormatState = AiReasoningGuard.DescribeFormatState(
|
|
||||||
result.Reasoning);
|
|
||||||
if (interrupted)
|
|
||||||
{
|
|
||||||
result.Response = fullBuilder.ToString();
|
|
||||||
result.ReasoningInterrupted = true;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullBuilder.Length == 0)
|
if (fullBuilder.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader.Utils
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 推理保护策略:截断 reasoning_content,并通过研究验证过的 tool call 历史
|
|
||||||
/// 让模型从已有推理自然收尾。
|
|
||||||
/// </summary>
|
|
||||||
internal static class AiReasoningGuard
|
|
||||||
{
|
|
||||||
public const string ToolName = "analysis_hint";
|
|
||||||
public const string TruncationMarker = "[INTERNAL_REASONING_TRUNCATED]";
|
|
||||||
public const string WrapUpStatement =
|
|
||||||
"我已经整理出足够的信息,现在立即结束内部推理,直接输出最终回答。";
|
|
||||||
public const string ToolCallId = "call_reasoning_guard";
|
|
||||||
public const int MinTokenLimit = 4096;
|
|
||||||
|
|
||||||
public static int GetEffectiveTokenLimit(AiProvider provider, AiModel model)
|
|
||||||
{
|
|
||||||
if (model.ReasoningGuardTokenLimit is { } explicitLimit && explicitLimit > 0)
|
|
||||||
{
|
|
||||||
return explicitLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.Max(MinTokenLimit, provider.DefaultMaxTokens / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string BuildToolInstruction()
|
|
||||||
{
|
|
||||||
return "请继续你本次 tool_calls 消息中携带的 reasoning_content。"
|
|
||||||
+ "你已经完成了足够的分析,现在进入收尾阶段:"
|
|
||||||
+ "立即停止继续展开推理,不要再逐条枚举输入事件,不要再做新的检查,也不要继续“再确认一下”。"
|
|
||||||
+ "请直接输出最终结果,包括正文、[机器可读声明] 和 [小结]。"
|
|
||||||
+ "如果某些细节不确定,就用证据等级说明。";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string BuildToolResult()
|
|
||||||
{
|
|
||||||
return "工具结果:" + BuildToolInstruction()
|
|
||||||
+ "\n你本次 tool_calls 消息中携带的 reasoning_content 的最后一句已经宣告收尾,"
|
|
||||||
+ "请立即执行,不要继续讨论是否还需要分析。"
|
|
||||||
+ "\n这份推理内容末尾有内部标记 " + TruncationMarker
|
|
||||||
+ ",表示前面的推理已被安全截断。"
|
|
||||||
+ "\n如果旧推理中存在未完成的 JSON、JSON 代码块或其他结构性内容,"
|
|
||||||
+ "请丢弃其未完成部分,并重新输出完整、格式正确的结果。"
|
|
||||||
+ "\n不要复述该标记。";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static object BuildToolDefinition()
|
|
||||||
{
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
type = "function",
|
|
||||||
function = new
|
|
||||||
{
|
|
||||||
name = ToolName,
|
|
||||||
description = "为当前内部推理提供收尾提示。",
|
|
||||||
parameters = new
|
|
||||||
{
|
|
||||||
type = "object",
|
|
||||||
properties = new
|
|
||||||
{
|
|
||||||
instruction = new
|
|
||||||
{
|
|
||||||
type = "string",
|
|
||||||
description = "模型应遵循的收尾提示。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required = new[] { "instruction" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 保留尽可能多的推理前缀,并在末尾追加截断标记与收尾决定。
|
|
||||||
/// </summary>
|
|
||||||
public static (string Text, string FormatState) PrepareReasoning(
|
|
||||||
string reasoning,
|
|
||||||
int tokenLimit)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(reasoning))
|
|
||||||
{
|
|
||||||
return ("\n\n" + TruncationMarker + "\n\n" + WrapUpStatement, "无格式问题");
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxIndex = FindMaxPrefixIndexByTokens(reasoning, tokenLimit);
|
|
||||||
var boundary = FindSafeBoundary(reasoning, maxIndex);
|
|
||||||
var prefix = reasoning.Substring(0, boundary).TrimEnd();
|
|
||||||
var formatState = DescribeFormatState(prefix);
|
|
||||||
return (
|
|
||||||
prefix + "\n\n" + TruncationMarker + "\n\n" + WrapUpStatement,
|
|
||||||
formatState);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string DescribeFormatState(string text)
|
|
||||||
{
|
|
||||||
var lines = new List<string>();
|
|
||||||
var codeFenceCount = CountOccurrences(text, "```");
|
|
||||||
if (codeFenceCount % 2 == 1)
|
|
||||||
{
|
|
||||||
lines.Add("代码围栏未闭合");
|
|
||||||
}
|
|
||||||
|
|
||||||
var braceDelta = CountDifference(text, '{', '}');
|
|
||||||
var bracketDelta = CountDifference(text, '[', ']');
|
|
||||||
if (braceDelta > 0 || bracketDelta > 0)
|
|
||||||
{
|
|
||||||
lines.Add("JSON/方括号结构可能未闭合");
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.Count == 0 ? "无格式问题" : string.Join(";", lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int FindMaxPrefixIndexByTokens(string text, int tokenLimit)
|
|
||||||
{
|
|
||||||
var low = 0;
|
|
||||||
var high = text.Length;
|
|
||||||
while (low < high)
|
|
||||||
{
|
|
||||||
var mid = (low + high + 1) / 2;
|
|
||||||
if (AiContextBudget.EstimateTokens(text.Substring(0, mid)) <= tokenLimit)
|
|
||||||
{
|
|
||||||
low = mid;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
high = mid - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return low;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int FindSafeBoundary(string text, int maxIndex)
|
|
||||||
{
|
|
||||||
const int searchBack = 160;
|
|
||||||
var start = Math.Max(0, maxIndex - searchBack);
|
|
||||||
for (var i = maxIndex - 1; i >= start; --i)
|
|
||||||
{
|
|
||||||
var c = text[i];
|
|
||||||
if (c == '\n' || c == '。' || c == ';' || c == ','
|
|
||||||
|| c == ':' || c == ';' || c == '.'
|
|
||||||
|| c == ',' || c == ')' || c == '}' || c == ']')
|
|
||||||
{
|
|
||||||
return i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return maxIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CountOccurrences(string text, string value)
|
|
||||||
{
|
|
||||||
var count = 0;
|
|
||||||
var index = 0;
|
|
||||||
while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
|
|
||||||
{
|
|
||||||
count++;
|
|
||||||
index += value.Length;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CountDifference(string text, char open, char close)
|
|
||||||
{
|
|
||||||
var delta = 0;
|
|
||||||
foreach (var c in text)
|
|
||||||
{
|
|
||||||
if (c == open)
|
|
||||||
{
|
|
||||||
delta++;
|
|
||||||
}
|
|
||||||
else if (c == close)
|
|
||||||
{
|
|
||||||
delta--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return delta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+50
-15
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -48,17 +48,6 @@ namespace AnotherReplayReader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ContextBudget { get; set; }
|
public int? ContextBudget { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 推理保护:累计 reasoning_content 达到阈值时中断流式响应并请求一次续写。
|
|
||||||
/// 默认关闭。
|
|
||||||
/// </summary>
|
|
||||||
public bool ReasoningGuardEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 推理保护的 token 阈值。null 或 0 使用派生值 max(4096, provider.DefaultMaxTokens / 2)。
|
|
||||||
/// </summary>
|
|
||||||
public int? ReasoningGuardTokenLimit { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, object> ExtraParameters { get; set; } = [];
|
public Dictionary<string, object> ExtraParameters { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -298,9 +287,55 @@ namespace AnotherReplayReader
|
|||||||
public List<AiProvider> Providers { get; set; } = [];
|
public List<AiProvider> Providers { get; set; } = [];
|
||||||
public AiPromptSettings Prompt { get; set; } = new();
|
public AiPromptSettings Prompt { get; set; } = new();
|
||||||
|
|
||||||
// 以下两个不持久化,由 UI 层维护当前选中项
|
/// <summary>
|
||||||
[System.Text.Json.Serialization.JsonIgnore]
|
/// 上次选中的 Provider 名称(持久化;用于下次打开设置页时恢复)。
|
||||||
public int CurrentProviderIndex { get; set; }
|
/// 按名称识别,Provider 被重命名/删除后自动回退到第一个 Provider。
|
||||||
|
/// </summary>
|
||||||
|
public string? CurrentProviderName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上次选中的模型 ID(持久化;与 <see cref="CurrentProviderName"/> 配合使用)。
|
||||||
|
/// 模型被删除后自动回退到该 Provider 的第一个模型。
|
||||||
|
/// </summary>
|
||||||
|
public string? CurrentModelId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新并持久化当前选中的 Provider 与模型。
|
||||||
|
/// </summary>
|
||||||
|
public void SetCurrentSelection(AiProvider provider, AiModel model)
|
||||||
|
{
|
||||||
|
CurrentProviderName = provider.Name;
|
||||||
|
CurrentModelId = model.ModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析持久化的上次选择。任一标识缺失或对应 Provider/Model 已不存在时返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public (AiProvider Provider, AiModel Model)? ResolveLastSelection()
|
||||||
|
{
|
||||||
|
var providerName = CurrentProviderName?.Trim();
|
||||||
|
var modelId = CurrentModelId?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(providerName) || string.IsNullOrEmpty(modelId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = Providers.FirstOrDefault(p =>
|
||||||
|
string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = provider.Models.FirstOrDefault(m =>
|
||||||
|
string.Equals(m.ModelId, modelId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (provider, model);
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly string ConfigPath = Path.Combine(
|
private static readonly string ConfigPath = Path.Combine(
|
||||||
AppContext.BaseDirectory,
|
AppContext.BaseDirectory,
|
||||||
|
|||||||
+358
-47
@@ -189,6 +189,154 @@ namespace AnotherReplayReader.Utils
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 段内的焦点窗口:模型每轮分析的重点时间范围,而不是数据切片的边界。
|
||||||
|
/// 数据层仍提供整个机械分段切片(上下文允许时尽量长),焦点窗口只决定“重点分析哪段时间”。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record FocusWindow(int Index, TimeSpan Start, TimeSpan End, int EventCount, int EstimatedTokens);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把机械分段切分为多个“焦点窗口”。与 MechanicalSegmenter 不同:
|
||||||
|
/// 焦点窗口不改变模型可见的数据范围,只划分每轮分析的重点,用于避免模型一次分析过长的时间段。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FocusPlanner
|
||||||
|
{
|
||||||
|
/// <summary>焦点窗口的目标 token 大小(近似)。</summary>
|
||||||
|
public const int DefaultWindowTokens = 12_000;
|
||||||
|
/// <summary>单个机械分段最多切分的焦点窗口数。</summary>
|
||||||
|
public const int MaxWindowsPerSlice = 5;
|
||||||
|
/// <summary>小于该 token 数的机械分段不再细分(直接作为单一焦点窗口)。</summary>
|
||||||
|
public const int MinSliceForSplitTokens = DefaultWindowTokens * 2;
|
||||||
|
|
||||||
|
public static ImmutableArray<FocusWindow> Plan(
|
||||||
|
ReplaySlice slice,
|
||||||
|
ImmutableArray<EventSpan> fullSpans)
|
||||||
|
{
|
||||||
|
if (slice.EventCount <= 0 || fullSpans.IsEmpty)
|
||||||
|
{
|
||||||
|
return ImmutableArray<FocusWindow>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接用切片自身的字符区间(StartIndex/Length)在 span 索引中定位,
|
||||||
|
// 避免按时间范围匹配与机械分段(含重叠)的实际内容不一致。
|
||||||
|
var startIndex = 0;
|
||||||
|
while (startIndex < fullSpans.Length
|
||||||
|
&& fullSpans[startIndex].StartIndex < slice.StartIndex)
|
||||||
|
{
|
||||||
|
startIndex++;
|
||||||
|
}
|
||||||
|
var endIndexExclusive = startIndex;
|
||||||
|
while (endIndexExclusive < fullSpans.Length
|
||||||
|
&& fullSpans[endIndexExclusive].StartIndex < slice.StartIndex + slice.Length)
|
||||||
|
{
|
||||||
|
endIndexExclusive++;
|
||||||
|
}
|
||||||
|
if (startIndex >= fullSpans.Length || endIndexExclusive <= startIndex)
|
||||||
|
{
|
||||||
|
// 回退:用切片自身的长度作为单一窗口(不应发生,防御性处理)。
|
||||||
|
return ImmutableArray.Create(
|
||||||
|
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若切片本身不大,或者时间太短,则单一窗口。
|
||||||
|
var totalTokens = 0;
|
||||||
|
for (var i = startIndex; i < endIndexExclusive; ++i)
|
||||||
|
{
|
||||||
|
totalTokens += fullSpans[i].EstimatedTokens;
|
||||||
|
}
|
||||||
|
if (totalTokens <= MinSliceForSplitTokens
|
||||||
|
|| endIndexExclusive - startIndex <= 1)
|
||||||
|
{
|
||||||
|
return ImmutableArray.Create(
|
||||||
|
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, totalTokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
var windows = new List<FocusWindow>();
|
||||||
|
var acc = 0;
|
||||||
|
var winStart = startIndex;
|
||||||
|
for (var i = startIndex; i < endIndexExclusive; ++i)
|
||||||
|
{
|
||||||
|
var span = fullSpans[i];
|
||||||
|
if (acc > 0 && acc + span.EstimatedTokens > DefaultWindowTokens
|
||||||
|
&& i - winStart >= 1)
|
||||||
|
{
|
||||||
|
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, i));
|
||||||
|
winStart = i;
|
||||||
|
acc = 0;
|
||||||
|
}
|
||||||
|
acc += span.EstimatedTokens;
|
||||||
|
}
|
||||||
|
if (winStart < endIndexExclusive)
|
||||||
|
{
|
||||||
|
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, endIndexExclusive));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过上限时合并尾部窗口(优先合并 token 较小的相邻窗口,保持时间顺序)。
|
||||||
|
while (windows.Count > MaxWindowsPerSlice)
|
||||||
|
{
|
||||||
|
var best = -1;
|
||||||
|
var bestTokens = int.MaxValue;
|
||||||
|
for (var i = 0; i < windows.Count - 1 && windows.Count > MaxWindowsPerSlice; ++i)
|
||||||
|
{
|
||||||
|
var merged = windows[i].EstimatedTokens + windows[i + 1].EstimatedTokens;
|
||||||
|
if (merged < bestTokens)
|
||||||
|
{
|
||||||
|
best = i;
|
||||||
|
bestTokens = merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
windows[best] = MergeWindows(windows[best], windows[best + 1]);
|
||||||
|
windows.RemoveAt(best + 1);
|
||||||
|
RenumberWindows(windows);
|
||||||
|
}
|
||||||
|
return windows.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocusWindow CreateWindow(
|
||||||
|
int index,
|
||||||
|
ReplaySlice slice,
|
||||||
|
ImmutableArray<EventSpan> fullSpans,
|
||||||
|
int start,
|
||||||
|
int endExclusive)
|
||||||
|
{
|
||||||
|
var tokens = 0;
|
||||||
|
var events = 0;
|
||||||
|
for (var j = start; j < endExclusive; ++j)
|
||||||
|
{
|
||||||
|
tokens += fullSpans[j].EstimatedTokens;
|
||||||
|
events++;
|
||||||
|
}
|
||||||
|
return new FocusWindow(
|
||||||
|
index,
|
||||||
|
fullSpans[start].Time,
|
||||||
|
fullSpans[endExclusive - 1].Time,
|
||||||
|
events,
|
||||||
|
tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocusWindow MergeWindows(FocusWindow a, FocusWindow b)
|
||||||
|
{
|
||||||
|
return new FocusWindow(
|
||||||
|
a.Index,
|
||||||
|
a.Start,
|
||||||
|
b.End,
|
||||||
|
a.EventCount + b.EventCount,
|
||||||
|
a.EstimatedTokens + b.EstimatedTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenumberWindows(List<FocusWindow> windows)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < windows.Count; ++i)
|
||||||
|
{
|
||||||
|
windows[i] = windows[i] with { Index = i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。
|
/// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -207,55 +355,109 @@ namespace AnotherReplayReader.Utils
|
|||||||
sb.AppendLine("# 玩家");
|
sb.AppendLine("# 玩家");
|
||||||
foreach (var kv in players)
|
foreach (var kv in players)
|
||||||
{
|
{
|
||||||
var factionName = ModData.GetFaction(mod, kv.Value.FactionId).Name;
|
var faction = ModData.GetFaction(mod, kv.Value.FactionId);
|
||||||
var kind = kv.Value.IsComputer ? "电脑" : "玩家";
|
var factionName = faction.Name;
|
||||||
sb.AppendLine($"- 玩家#{kv.Key} {kv.Value.PlayerName}({names[kv.Key]}),{factionName},队伍{kv.Value.Team},{kind}");
|
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();
|
||||||
|
|
||||||
sb.AppendLine("# 首次出兵时间表");
|
sb.AppendLine("# 首次出兵时间表(命令开始时间)");
|
||||||
foreach (var kv in factIndex.PlayerFirstProductionTime.OrderBy(k => k.Key))
|
if (factIndex.PlayerFirstProductionTime.IsEmpty)
|
||||||
{
|
{
|
||||||
var productions = kv.Value
|
sb.AppendLine("- (无)");
|
||||||
.OrderBy(x => x.Value)
|
}
|
||||||
.Take(15)
|
else
|
||||||
.Select(x => $"{x.Key}@{FormatTime(x.Value)}");
|
{
|
||||||
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):{string.Join("、", productions)}");
|
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();
|
||||||
|
|
||||||
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();
|
sb.AppendLine();
|
||||||
|
|
||||||
|
var unitRoles = BuildUnitRoleMap(factIndex);
|
||||||
sb.AppendLine("# 所有权证据(节选)");
|
sb.AppendLine("# 所有权证据(节选)");
|
||||||
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds.OrderBy(k => k.Key))
|
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 ids = kv.Value.OrderBy(x => x).Take(20);
|
||||||
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
|
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))
|
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 ids = kv.Value.OrderBy(x => x).Take(20);
|
||||||
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
|
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();
|
||||||
|
|
||||||
sb.AppendLine("# 打包/展开");
|
sb.AppendLine("# 打包/展开(按实际事件时间)");
|
||||||
var packUnits = factIndex.UnitIdSpecialPowers
|
var packEvents = factIndex.SpecialPowerEvents
|
||||||
.Where(kv2 => kv2.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
|
.Where(e => ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||||
.OrderBy(kv2 => kv2.Key);
|
|| ContainsIgnoreCase(e.PowerName, "UnpackReplaceSelf"))
|
||||||
foreach (var kv2 in packUnits)
|
.OrderBy(e => e.Time)
|
||||||
|
.ThenBy(e => e.PlayerIndex);
|
||||||
|
if (!packEvents.Any())
|
||||||
{
|
{
|
||||||
var firstTime = factIndex.UnitIdFirstObservedTime.TryGetValue(kv2.Key, out var t)
|
sb.AppendLine("- (无)");
|
||||||
? FormatTime(t)
|
}
|
||||||
: "?";
|
foreach (var e in packEvents)
|
||||||
sb.AppendLine($"- UnitId {kv2.Key}(首次出现 {firstTime}):{string.Join("、", kv2.Value.OrderBy(x => x))}");
|
{
|
||||||
|
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();
|
sb.AppendLine();
|
||||||
|
|
||||||
@@ -287,41 +489,150 @@ namespace AnotherReplayReader.Utils
|
|||||||
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
|
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
|
||||||
{
|
{
|
||||||
var result = new List<string>();
|
var result = new List<string>();
|
||||||
var currentTime = "";
|
var counts = new Dictionary<(string Player, string Category), int>();
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
var currentTime = string.Empty;
|
||||||
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
|
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
|
||||||
{
|
{
|
||||||
var line = rawLine.Trim();
|
if (result.Count >= maxEvents)
|
||||||
if (line.StartsWith("[") && line.Contains("]"))
|
|
||||||
{
|
{
|
||||||
var end = line.IndexOf(']');
|
break;
|
||||||
currentTime = line.Substring(1, end - 1);
|
}
|
||||||
var rest = line.Substring(end + 1).Trim();
|
|
||||||
if (IsKeyEventLine(rest))
|
var line = rawLine.TrimEnd();
|
||||||
{
|
var trimmed = line.Trim();
|
||||||
result.Add($"[{currentTime}] {rest}");
|
if (TimeStampPattern.IsMatch(trimmed))
|
||||||
if (result.Count >= maxEvents)
|
{
|
||||||
{
|
currentTime = TimeStampPattern.Match(trimmed).Groups[1].Value;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
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}");
|
continue;
|
||||||
if (result.Count >= maxEvents)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
return result.ToImmutableArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsKeyEventLine(string line) =>
|
private static string? GetEventCategory(string command)
|
||||||
line.Contains("开始建造") || line.Contains("摆放建筑") || line.Contains("出售建筑") ||
|
{
|
||||||
line.Contains("释放特殊能力") || line.Contains("选择协议") || line.Contains("开始出兵") ||
|
if (command.Contains("开始建造")) return "建造";
|
||||||
line.Contains("开始升级");
|
if (command.Contains("摆放建筑")) return "摆放";
|
||||||
|
if (command.Contains("出售建筑")) return "出售";
|
||||||
|
if (command.Contains("开始出兵")) return "生产";
|
||||||
|
if (command.Contains("开始升级")) return "升级";
|
||||||
|
if (command.Contains("选择协议")) return "协议";
|
||||||
|
if (command.Contains("释放特殊能力")) return "技能";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Regex TimeStampPattern = new(
|
||||||
|
@"^\[(\d+:\d+(?:\.\d+)?)\]$",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex CommandPattern = new(
|
||||||
|
@"^([^::,,]+)\s*[::,,]\s*(.+)$",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static Dictionary<uint, HashSet<string>> BuildUnitRoleMap(ReplayFactIndex factIndex)
|
||||||
|
{
|
||||||
|
var roles = new Dictionary<uint, HashSet<string>>();
|
||||||
|
void Add(uint unitId, string role)
|
||||||
|
{
|
||||||
|
if (!roles.TryGetValue(unitId, out var set))
|
||||||
|
{
|
||||||
|
set = new HashSet<string>();
|
||||||
|
roles[unitId] = set;
|
||||||
|
}
|
||||||
|
set.Add(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in factIndex.BuilderUnitIds)
|
||||||
|
{
|
||||||
|
Add(id, "建造者");
|
||||||
|
}
|
||||||
|
foreach (var id in factIndex.ProducerUnitIds)
|
||||||
|
{
|
||||||
|
Add(id, "出兵建筑");
|
||||||
|
}
|
||||||
|
foreach (var kv in factIndex.UnitIdSpecialPowers)
|
||||||
|
{
|
||||||
|
if (kv.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
|
||||||
|
{
|
||||||
|
Add(kv.Key, "打包/展开");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatUnitIdWithRole(
|
||||||
|
uint unitId,
|
||||||
|
Dictionary<uint, HashSet<string>> unitRoles)
|
||||||
|
{
|
||||||
|
if (!unitRoles.TryGetValue(unitId, out var roles) || roles.Count == 0)
|
||||||
|
{
|
||||||
|
return unitId.ToString();
|
||||||
|
}
|
||||||
|
return $"{unitId}({string.Join("/", roles.OrderBy(x => x))})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsObserver(
|
||||||
|
Mod mod,
|
||||||
|
ImmutableSortedDictionary<int, Player> players,
|
||||||
|
int playerIndex)
|
||||||
|
{
|
||||||
|
return players.TryGetValue(playerIndex, out var player)
|
||||||
|
&& ModData.GetFaction(mod, player.FactionId).Kind == FactionKind.Observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPackFactionConflict(
|
||||||
|
Mod mod,
|
||||||
|
ImmutableSortedDictionary<int, Player> players,
|
||||||
|
SpecialPowerEvent e)
|
||||||
|
{
|
||||||
|
if (!ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||||
|
|| !players.TryGetValue(e.PlayerIndex, out var player))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ModData.GetFaction(mod, player.FactionId).Name != "盟军";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsIgnoreCase(string text, string value) =>
|
||||||
|
text.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
public static string FormatTime(TimeSpan t) => $"{(int)t.TotalMinutes}:{t:ss\\.ff}";
|
public static string FormatTime(TimeSpan t) => $"{(int)t.TotalMinutes}:{t:ss\\.ff}";
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-14
@@ -6,6 +6,13 @@ using System.Linq;
|
|||||||
|
|
||||||
namespace AnotherReplayReader.Utils
|
namespace AnotherReplayReader.Utils
|
||||||
{
|
{
|
||||||
|
/// <summary>一次特殊能力事件(含真实发生时间与玩家),用于生成可读的打包/展开时间线。</summary>
|
||||||
|
internal sealed record SpecialPowerEvent(
|
||||||
|
TimeSpan Time,
|
||||||
|
int PlayerIndex,
|
||||||
|
uint UnitId,
|
||||||
|
string PowerName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Index of replay facts extracted from CommandChunk data.
|
/// Index of replay facts extracted from CommandChunk data.
|
||||||
/// Used by AIAnalysisValidation to cross-reference LLM claims against
|
/// Used by AIAnalysisValidation to cross-reference LLM claims against
|
||||||
@@ -19,6 +26,9 @@ namespace AnotherReplayReader.Utils
|
|||||||
/// <summary>Special powers used by each UnitId.</summary>
|
/// <summary>Special powers used by each UnitId.</summary>
|
||||||
public ImmutableDictionary<uint, ImmutableHashSet<string>> UnitIdSpecialPowers { get; }
|
public ImmutableDictionary<uint, ImmutableHashSet<string>> UnitIdSpecialPowers { get; }
|
||||||
|
|
||||||
|
/// <summary>按时间排序的特殊能力事件列表。</summary>
|
||||||
|
public ImmutableArray<SpecialPowerEvent> SpecialPowerEvents { get; }
|
||||||
|
|
||||||
/// <summary>UnitIds that appeared as builder ("建造者") in construction commands.</summary>
|
/// <summary>UnitIds that appeared as builder ("建造者") in construction commands.</summary>
|
||||||
public ImmutableHashSet<uint> BuilderUnitIds { get; }
|
public ImmutableHashSet<uint> BuilderUnitIds { get; }
|
||||||
|
|
||||||
@@ -43,6 +53,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
public ReplayFactIndex(
|
public ReplayFactIndex(
|
||||||
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
|
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
|
||||||
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
|
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
|
||||||
|
ImmutableArray<SpecialPowerEvent> specialPowerEvents,
|
||||||
ImmutableHashSet<uint> builderUnitIds,
|
ImmutableHashSet<uint> builderUnitIds,
|
||||||
ImmutableHashSet<uint> producerUnitIds,
|
ImmutableHashSet<uint> producerUnitIds,
|
||||||
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
|
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
|
||||||
@@ -53,6 +64,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
{
|
{
|
||||||
UnitIdFirstObservedTime = unitIdFirstObservedTime;
|
UnitIdFirstObservedTime = unitIdFirstObservedTime;
|
||||||
UnitIdSpecialPowers = unitIdSpecialPowers;
|
UnitIdSpecialPowers = unitIdSpecialPowers;
|
||||||
|
SpecialPowerEvents = specialPowerEvents;
|
||||||
BuilderUnitIds = builderUnitIds;
|
BuilderUnitIds = builderUnitIds;
|
||||||
ProducerUnitIds = producerUnitIds;
|
ProducerUnitIds = producerUnitIds;
|
||||||
PlayerFirstProductionTime = playerFirstProductionTime;
|
PlayerFirstProductionTime = playerFirstProductionTime;
|
||||||
@@ -68,6 +80,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
{
|
{
|
||||||
var unitFirstObserved = new Dictionary<uint, TimeSpan>();
|
var unitFirstObserved = new Dictionary<uint, TimeSpan>();
|
||||||
var unitSpecialPowers = new Dictionary<uint, HashSet<string>>();
|
var unitSpecialPowers = new Dictionary<uint, HashSet<string>>();
|
||||||
|
var specialPowerEvents = new List<SpecialPowerEvent>();
|
||||||
var builderUnits = new HashSet<uint>();
|
var builderUnits = new HashSet<uint>();
|
||||||
var producerUnits = new HashSet<uint>();
|
var producerUnits = new HashSet<uint>();
|
||||||
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
|
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
|
||||||
@@ -83,7 +96,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
foreach (var command in commands)
|
foreach (var command in commands)
|
||||||
{
|
{
|
||||||
ProcessCommand(time, command, stringHashTable,
|
ProcessCommand(time, command, stringHashTable,
|
||||||
unitFirstObserved, unitSpecialPowers,
|
unitFirstObserved, unitSpecialPowers, specialPowerEvents,
|
||||||
builderUnits, producerUnits,
|
builderUnits, producerUnits,
|
||||||
playerFirstProduction, playerSelected,
|
playerFirstProduction, playerSelected,
|
||||||
playerStrongOwnership, playerWeakOwnership,
|
playerStrongOwnership, playerWeakOwnership,
|
||||||
@@ -95,6 +108,11 @@ namespace AnotherReplayReader.Utils
|
|||||||
unitFirstObserved.ToImmutableDictionary(),
|
unitFirstObserved.ToImmutableDictionary(),
|
||||||
unitSpecialPowers.ToImmutableDictionary(
|
unitSpecialPowers.ToImmutableDictionary(
|
||||||
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
||||||
|
specialPowerEvents
|
||||||
|
.OrderBy(e => e.Time)
|
||||||
|
.ThenBy(e => e.PlayerIndex)
|
||||||
|
.ThenBy(e => e.UnitId)
|
||||||
|
.ToImmutableArray(),
|
||||||
builderUnits.ToImmutableHashSet(),
|
builderUnits.ToImmutableHashSet(),
|
||||||
producerUnits.ToImmutableHashSet(),
|
producerUnits.ToImmutableHashSet(),
|
||||||
playerFirstProduction.ToImmutableDictionary(
|
playerFirstProduction.ToImmutableDictionary(
|
||||||
@@ -115,6 +133,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
||||||
|
List<SpecialPowerEvent> specialPowerEvents,
|
||||||
HashSet<uint> builderUnits,
|
HashSet<uint> builderUnits,
|
||||||
HashSet<uint> producerUnits,
|
HashSet<uint> producerUnits,
|
||||||
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
||||||
@@ -148,7 +167,8 @@ namespace AnotherReplayReader.Utils
|
|||||||
// special power (target position and angle): 0x200 —— 布局确凿,ObjectId 是施法者
|
// special power (target position and angle): 0x200 —— 布局确凿,ObjectId 是施法者
|
||||||
case 0x200:
|
case 0x200:
|
||||||
RecordSpecialPower(time, command, player, stringHashTable,
|
RecordSpecialPower(time, command, player, stringHashTable,
|
||||||
unitFirstObserved, unitSpecialPowers, playerStrongOwnership);
|
unitFirstObserved, unitSpecialPowers, playerStrongOwnership,
|
||||||
|
specialPowerEvents);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// special power (target position): 0x1FF —— ObjectId 语义待核实,只记录"出现过"
|
// special power (target position): 0x1FF —— ObjectId 语义待核实,只记录"出现过"
|
||||||
@@ -163,7 +183,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
// start production: 0x205
|
// start production: 0x205
|
||||||
case 0x205:
|
case 0x205:
|
||||||
RecordProduction(time, command, player, unitFirstObserved,
|
RecordProduction(time, command, player, unitFirstObserved,
|
||||||
producerUnits, playerFirstProduction, playerStrongOwnership);
|
stringHashTable, producerUnits, playerFirstProduction, playerStrongOwnership);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// start construction: 0x207
|
// start construction: 0x207
|
||||||
@@ -208,7 +228,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
|
|
||||||
// 选择协议:全局生效,无 UnitId
|
// 选择协议:全局生效,无 UnitId
|
||||||
case 0x24E:
|
case 0x24E:
|
||||||
RecordTechChoice(time, command, player, playerTechChoices);
|
RecordTechChoice(time, command, player, stringHashTable, playerTechChoices);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// move: 0x214
|
// move: 0x214
|
||||||
@@ -263,7 +283,8 @@ namespace AnotherReplayReader.Utils
|
|||||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
||||||
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
|
List<SpecialPowerEvent> specialPowerEvents)
|
||||||
{
|
{
|
||||||
string? powerName = null;
|
string? powerName = null;
|
||||||
var unitIds = new List<uint>();
|
var unitIds = new List<uint>();
|
||||||
@@ -323,6 +344,8 @@ namespace AnotherReplayReader.Utils
|
|||||||
}
|
}
|
||||||
powers.Add(powerName);
|
powers.Add(powerName);
|
||||||
RecordPlayerOwnership(player, unitId, playerStrongOwnership);
|
RecordPlayerOwnership(player, unitId, playerStrongOwnership);
|
||||||
|
specialPowerEvents.Add(new SpecialPowerEvent(
|
||||||
|
time, player, unitId, powerName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +354,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
CommandChunk command,
|
CommandChunk command,
|
||||||
int player,
|
int player,
|
||||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
HashSet<uint> producerUnits,
|
HashSet<uint> producerUnits,
|
||||||
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
||||||
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
||||||
@@ -362,8 +386,14 @@ namespace AnotherReplayReader.Utils
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
|
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
|
||||||
|
or CommandArgumentType.Int32
|
||||||
|
or CommandArgumentType.UInt32
|
||||||
|
or CommandArgumentType.UInt32_2
|
||||||
when unitName is null:
|
when unitName is null:
|
||||||
unitName = entry.Value.ToString() ?? string.Empty;
|
if (TryReadCommandName(entry, stringHashTable, out var resolvedName))
|
||||||
|
{
|
||||||
|
unitName = resolvedName;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,7 +412,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
perPlayer = new Dictionary<string, TimeSpan>();
|
perPlayer = new Dictionary<string, TimeSpan>();
|
||||||
playerFirstProduction[player] = perPlayer;
|
playerFirstProduction[player] = perPlayer;
|
||||||
}
|
}
|
||||||
if (!perPlayer.ContainsKey(unitName))
|
if (unitName is not null && !perPlayer.ContainsKey(unitName))
|
||||||
{
|
{
|
||||||
perPlayer[unitName] = time;
|
perPlayer[unitName] = time;
|
||||||
}
|
}
|
||||||
@@ -552,18 +582,18 @@ namespace AnotherReplayReader.Utils
|
|||||||
TimeSpan time,
|
TimeSpan time,
|
||||||
CommandChunk command,
|
CommandChunk command,
|
||||||
int player,
|
int player,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
Dictionary<int, HashSet<string>> playerTechChoices)
|
Dictionary<int, HashSet<string>> playerTechChoices)
|
||||||
{
|
{
|
||||||
foreach (var entry in command.Data)
|
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
|
if (!TryReadCommandName(entry, stringHashTable, out var tech))
|
||||||
? entry.Value.ToString()
|
|
||||||
: entry.Value is string[] strings
|
|
||||||
? strings.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
|
|
||||||
: null;
|
|
||||||
if (string.IsNullOrWhiteSpace(tech))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -578,6 +608,72 @@ namespace AnotherReplayReader.Utils
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryReadCommandName(
|
||||||
|
CommandArgumentEntry entry,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
out string name)
|
||||||
|
{
|
||||||
|
name = string.Empty;
|
||||||
|
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
|
||||||
|
{
|
||||||
|
if (entry.Count == 1 && entry.Value is string single)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(single))
|
||||||
|
{
|
||||||
|
name = single;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (entry.Value is string[] values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
name = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryReadCommandNameAsHash(entry, stringHashTable, out name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadCommandNameAsHash(
|
||||||
|
CommandArgumentEntry entry,
|
||||||
|
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||||
|
out string name)
|
||||||
|
{
|
||||||
|
name = string.Empty;
|
||||||
|
IEnumerable<uint> hashes = entry.Type switch
|
||||||
|
{
|
||||||
|
CommandArgumentType.Int32 when entry.Count == 1 && entry.Value is int singleInt =>
|
||||||
|
new[] { unchecked((uint)singleInt) },
|
||||||
|
CommandArgumentType.Int32 when entry.Value is int[] ints =>
|
||||||
|
ints.Select(x => unchecked((uint)x)),
|
||||||
|
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
|
||||||
|
when entry.Count == 1 && entry.Value is uint singleUint =>
|
||||||
|
new[] { singleUint },
|
||||||
|
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
|
||||||
|
when entry.Value is uint[] uints =>
|
||||||
|
uints,
|
||||||
|
_ => Array.Empty<uint>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var hash in hashes)
|
||||||
|
{
|
||||||
|
if (stringHashTable.TryGetValue(hash, out var resolved)
|
||||||
|
&& !string.IsNullOrWhiteSpace(resolved))
|
||||||
|
{
|
||||||
|
name = resolved;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static void RecordObjectReferenceWithOwnership(
|
private static void RecordObjectReferenceWithOwnership(
|
||||||
TimeSpan time,
|
TimeSpan time,
|
||||||
CommandChunk command,
|
CommandChunk command,
|
||||||
|
|||||||
+6
-5
@@ -42,17 +42,18 @@
|
|||||||
# 输出要求
|
# 输出要求
|
||||||
## 1. 总览阶段
|
## 1. 总览阶段
|
||||||
触发条件:用户输入包含:"请先对整局进行总览"
|
触发条件:用户输入包含:"请先对整局进行总览"
|
||||||
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
|
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
|
||||||
- 你的任务:
|
- 你的任务:
|
||||||
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
|
|
||||||
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
||||||
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
|
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
|
||||||
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
|
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
|
||||||
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
||||||
|
|
||||||
## 2. 分段分析、推理阶段
|
## 2. 分段分析、推理阶段
|
||||||
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END])"
|
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END])"
|
||||||
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
|
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
|
||||||
|
- 程序会把一个分段按时间划分为若干“重点时间段”(每轮一个时间段)。你当前分析的是其中一个重点时间段,但切割出的完整分段切片仍然是你能看到的数据范围
|
||||||
|
- 你的重点任务:分析当前重点时间段内的主要事件与上下文;但同时应主动查看并关联该时间段之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
|
||||||
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
||||||
- 选取该阶段的主要事件,以及和它们的上下文
|
- 选取该阶段的主要事件,以及和它们的上下文
|
||||||
- 也可以选择数个其他有分析价值的事件
|
- 也可以选择数个其他有分析价值的事件
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
||||||
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
||||||
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
||||||
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
|
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续重点时间段与后续分段参考
|
||||||
|
|
||||||
## 3. 最终总结阶段
|
## 3. 最终总结阶段
|
||||||
触发条件:用户输入包含:"请对以上内容进行总结"
|
触发条件:用户输入包含:"请对以上内容进行总结"
|
||||||
|
|||||||
@@ -42,17 +42,18 @@
|
|||||||
# 输出要求
|
# 输出要求
|
||||||
## 1. 总览阶段
|
## 1. 总览阶段
|
||||||
触发条件:用户输入包含:"请先对整局进行总览"
|
触发条件:用户输入包含:"请先对整局进行总览"
|
||||||
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
|
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
|
||||||
- 你的任务:
|
- 你的任务:
|
||||||
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
|
|
||||||
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
||||||
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
|
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
|
||||||
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
|
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
|
||||||
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
||||||
|
|
||||||
## 2. 分段分析、推理阶段
|
## 2. 分段分析、推理阶段
|
||||||
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END])"
|
触发条件:用户输入类似于:"请重点分析第N段([BEGIN]至[END])"
|
||||||
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
|
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
|
||||||
|
- 程序会把一个分段按时间划分为若干“重点时间段”(每轮一个时间段)。你当前分析的是其中一个重点时间段,但切割出的完整分段切片仍然是你能看到的数据范围
|
||||||
|
- 你的重点任务:分析当前重点时间段内的主要事件与上下文;但同时应主动查看并关联该时间段之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
|
||||||
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
||||||
- 选取该阶段的主要事件,以及和它们的上下文
|
- 选取该阶段的主要事件,以及和它们的上下文
|
||||||
- 也可以选择数个其他有分析价值的事件
|
- 也可以选择数个其他有分析价值的事件
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
||||||
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
||||||
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
||||||
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
|
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续重点时间段与后续分段参考
|
||||||
|
|
||||||
## 3. 最终总结阶段
|
## 3. 最终总结阶段
|
||||||
触发条件:用户输入包含:"请对以上内容进行总结"
|
触发条件:用户输入包含:"请对以上内容进行总结"
|
||||||
|
|||||||
Reference in New Issue
Block a user