This commit is contained in:
2026-08-22 00:59:46 +02:00
parent 9250528442
commit a2f0bcb371
6 changed files with 292 additions and 6 deletions
+59 -1
View File
@@ -110,6 +110,7 @@ namespace AnotherReplayReader
// Token 累计
private int _totalPromptTokens;
private int _totalCompletionTokens;
private int _totalReasoningTokens;
// ---------- properties ----------
// 外部注入:每次请求前调用获取最新配置
@@ -220,6 +221,7 @@ namespace AnotherReplayReader
_totalPromptTokens = 0;
_totalCompletionTokens = 0;
_totalReasoningTokens = 0;
StopUiTimer();
UpdateButtons();
@@ -673,6 +675,59 @@ namespace AnotherReplayReader
_linkedCts.Token);
UpdateTokenDisplay(result);
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
if (_replayData is null)
{
return;
}
var backqueries = BackqueryParser.Parse(result.Response);
if (backqueries.IsEmpty)
{
return;
}
const int maxSummaryBackqueries = 3;
var pendingTexts = new List<string>();
foreach (var (start, end) in backqueries)
{
if (pendingTexts.Count >= maxSummaryBackqueries)
{
AppendLog("总结回查限制", "总结回查区间数已达上限,剩余区间已忽略。", false);
break;
}
var (text, reason) = BackquerySliceExtractor.Extract(_replayData, _eventSpans, start, end);
if (text is null)
{
AppendLog("总结回查失败", reason ?? "未知原因", false);
continue;
}
pendingTexts.Add(
$"[回查 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)}]\n" + text);
}
if (pendingTexts.Count == 0)
{
return;
}
_phaseText.Text = "正在根据总结回查补充信息...";
var backqueryMessages = messages
.Add(new AIAnalyze.ChatMessage("assistant", result.Response))
.Add(new AIAnalyze.ChatMessage(
"user",
AIAnalyze.BuildBackqueryUserPrompt(string.Join("\n\n", pendingTexts))));
AppendLog(
"总结回查",
$"已提供 {pendingTexts.Count} 个区间,正在生成最终总结。",
true);
CheckAndLogContextUsage(backqueryMessages, requestContext);
var finalSummary = await _analyzer!.CompleteAsync(
backqueryMessages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(finalSummary);
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
}
// ---- v2 管线辅助 ----
@@ -1050,13 +1105,16 @@ namespace AnotherReplayReader
{
_totalPromptTokens += result.PromptTokens ?? 0;
_totalCompletionTokens += result.CompletionTokens ?? 0;
_totalReasoningTokens += result.ReasoningTokens ?? 0;
var total = _totalPromptTokens + _totalCompletionTokens;
_currentTokensText.Text =
$"上次请求 Token:输入 {FormatNumber(result.PromptTokens, false)}"
+ $" 输出 {FormatNumber(result.CompletionTokens, false)}";
+ $" 输出 {FormatNumber(result.CompletionTokens, false)}"
+ (result.ReasoningTokens is { } reasoning ? $" 推理 {FormatNumber(reasoning, false)}" : "");
_conversationTokensText.Text =
$"累计 Token:输入 {FormatNumber(_totalPromptTokens, false)}"
+ $" 输出 {FormatNumber(_totalCompletionTokens, false)}"
+ (_totalReasoningTokens > 0 ? $" 推理 {FormatNumber(_totalReasoningTokens, false)}" : "")
+ $" 总计 {FormatNumber(total, false)}";
}
+48
View File
@@ -133,6 +133,27 @@ namespace AiV2.Tests
// 小预算 → 至少 MinSliceTokens 才切
var (minSlices, _) = MechanicalSegmenter.Slice(fullText, spans.ToImmutable(), 100, 50);
Program.Assert(!minSlices.IsEmpty, "小预算仍应有切片");
// 超过 20 段时优先压缩纯选择块
var mixedBuilder = ImmutableArray.CreateBuilder<EventSpan>();
var mixedTextBuilder = new StringBuilder();
for (var i = 0; i < 30; ++i)
{
var text = $"[{i}:00]\nPlayerA: 选择单位\n [UnitId]{i}\n\n";
mixedBuilder.Add(new EventSpan(TimeSpan.FromMinutes(i), mixedTextBuilder.Length, text.Length, 1000));
mixedTextBuilder.Append(text);
}
for (var i = 30; i < 60; ++i)
{
var text = $"[{i}:00]\nPlayerA: 开始建造\n [UnitId]{i}\n\n";
mixedBuilder.Add(new EventSpan(TimeSpan.FromMinutes(i), mixedTextBuilder.Length, text.Length, 1000));
mixedTextBuilder.Append(text);
}
var mixedText = mixedTextBuilder.ToString();
var (mixedSlices, mixedWarnings) = MechanicalSegmenter.Slice(
mixedText, mixedBuilder.ToImmutable(), 2000, 400);
Program.Assert(mixedSlices.Length <= MechanicalSegmenter.MaxSlices, "压缩后分段数不超过上限");
Program.Assert(mixedWarnings.Any(w => w.Contains("过滤纯选择")), "超过上限时先压缩噪声块");
}
}
@@ -455,6 +476,23 @@ namespace AiV2.Tests
var conflictingPower = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":7,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|7\"]}]}\n```", index);
Program.Assert(conflictingPower.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction
&& i.Message.Contains("SpecialPower_UnpackReplaceSelf")), "记录过其他能力 → Contradiction");
// 事件声明:引用不存在的 UnitId → Warning
var badEventUnit = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"某事件\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_Nonexistent|999\"]}]}\n```", index);
Program.Assert(badEventUnit.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidMachineReadableClaims
&& i.Severity == AIValidationSeverity.Warning
&& i.Message.Contains("999")), "事件声明引用不存在 UnitId → Warning");
// 事件声明:技能与事实索引冲突 → Contradiction
var badEventPower = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"基地迁移\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|7\"]}]}\n```", index);
Program.Assert(badEventPower.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction
&& i.Severity == AIValidationSeverity.Contradiction), "事件声明技能冲突 → Contradiction");
// 事件声明:协议未在任何玩家选择中观察到 → Warning
var badEventTech = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"协议事件\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"protocol|0:02.00|PlayerTech_Unknown\"]}]}\n```", index);
Program.Assert(badEventTech.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidMachineReadableClaims
&& i.Severity == AIValidationSeverity.Warning
&& i.Message.Contains("PlayerTech_Unknown")), "事件声明未观察协议 → Warning");
}
private static AIValidationResult Validate(string response, ReplayFactIndex index) =>
@@ -608,6 +646,11 @@ namespace AiV2.Tests
Program.Assert(prompt.Contains("基地车"), "结构化渲染包含盟军单位");
Program.Assert(!prompt.Contains("神州常用建筑"), "未参战阵营(神州)被过滤");
Program.Assert(prompt.Contains("地图参数") || prompt.Contains("出生点"), "地图知识保留");
var corona = KnowledgeSet.ForMod("corona", AppContext.BaseDirectory);
var coronaPrompt = corona.RenderAsPrompt(new[] { "盟军" }, "map_mp_2_rao1");
Program.Assert(coronaPrompt.Contains("盟军"), "Corona 盟军知识保留");
Program.Assert(!coronaPrompt.Contains("神州常用建筑"), "Corona 未参战阵营(神州)被过滤");
}
}
@@ -709,6 +752,11 @@ namespace AiV2.Tests
{
public static void Run()
{
if (Environment.GetEnvironmentVariable("ARR_E2E_REPLAY") != "1")
{
Console.WriteLine(" [跳过] 未设置 ARR_E2E_REPLAY=1,跳过真实回放诊断");
return;
}
var replayPath = @"C:\Users\lanyi\Documents\Red Alert 3\Replays\安洁莉娜.(C)_VS_机枢舞者(A)[1V1][无限岛][2026_06_16 05_57][ra3battle.net].RA3Replay";
if (!File.Exists(replayPath))
{
+9 -1
View File
@@ -35,7 +35,15 @@
- 预算检查改为“估算 prompt + 输出/推理余量”,并在回查/修订前重新检查;`stream` 标志尊重模型配置。
- 事实索引:编队所有权按玩家隔离;接入 `0x1F6/0x22A`;unpack 歧义规则只作用于 MCV/基地车类实体。
- 知识:`aliases/alsoProducedBy` 已解析;摘要补充协议选择与所有权证据;总览的段落描述和回查提示会传入分段指令。
- 测试:当前 `AiV2.Tests` 144 项断言全部通过。
- 测试:当前 `AiV2.Tests` 默认 149 项断言全部通过;启用 `ARR_E2E_REPLAY=1` 时为 151 项
### 2026-08-22 第二轮修订
- `eventClaims/timelineClaims` 现在也会校验:不存在 UnitId、技能与事实索引冲突、协议未在任何玩家选择中观察到都会产生验证问题。
- 总结轮支持一次回查:模型可请求远处原始区间,程序在同一总结会话中追加提供。
- 机械分段超过上限时先过滤纯选择/编队事件块,压缩无效才放宽预算。
- Corona flat 文本现在也按参战阵营过滤。
- `AiV2.Tests` 的真实回放诊断改为默认跳过(设置 `ARR_E2E_REPLAY=1` 启用);UI 状态栏显示推理 token。
## 1. 背景与目标
+114
View File
@@ -135,6 +135,7 @@ namespace AnotherReplayReader.Utils
if (factIndex is not null)
{
ValidateTimelineConsistency(claims, factIndex, aiNameToPlayerIndex, structuredKnowledge, issues);
ValidateSimpleClaimEvidence(claims, factIndex, issues);
}
return new AIValidationResult(claims, issues.ToImmutable());
}
@@ -711,6 +712,119 @@ namespace AnotherReplayReader.Utils
}
}
/// <summary>事件/时间线声明只有 claim + evidence,没有 player,因此只校验不依赖归属的事实。</summary>
private static void ValidateSimpleClaimEvidence(
AIMachineReadableClaims claims,
ReplayFactIndex factIndex,
ImmutableArray<AIValidationIssue>.Builder issues)
{
foreach (var claim in claims.EventClaims)
{
ValidateSimpleClaim(claim.Claim, claim.Evidence, "事件声明", factIndex, issues);
}
foreach (var claim in claims.TimelineClaims)
{
ValidateSimpleClaim(claim.Claim, claim.Evidence, "时间线声明", factIndex, issues);
}
}
private static void ValidateSimpleClaim(
string claimText,
ImmutableArray<string> evidenceStrings,
string kind,
ReplayFactIndex factIndex,
ImmutableArray<AIValidationIssue>.Builder issues)
{
foreach (var ev in ParseAllEvidence(evidenceStrings))
{
if (ev.Type == AIEvidenceType.Protocol)
{
var techName = ev.GetTechName();
if (string.IsNullOrWhiteSpace(techName))
{
continue;
}
var allTechChoices = new HashSet<string>(
factIndex.PlayerTechChoices.Values.SelectMany(set => set),
StringComparer.OrdinalIgnoreCase);
if (allTechChoices.Count > 0 && !allTechChoices.Contains(techName))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"{kind}“{claimText}”引用了未在任何玩家选择中观察到的协议“{techName}”。"));
}
continue;
}
if (ev.Type == AIEvidenceType.Power)
{
var unitIdText = ev.GetUnitId();
if (unitIdText is null || !uint.TryParse(unitIdText, out var unitId))
{
continue;
}
var powerName = ev.GetSpecialPowerName();
if (string.IsNullOrWhiteSpace(powerName))
{
continue;
}
if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"{kind}“{claimText}”的证据引用了回放中不存在的 UnitId {unitIdText}。",
unitIdText));
continue;
}
if (factIndex.UnitIdSpecialPowers.TryGetValue(unitId, out var actualPowers))
{
if (!actualPowers.Contains(powerName))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"{kind}“{claimText}”声称 UnitId {unitIdText} 使用了“{powerName}”,但该 UnitId 在回放中使用过:{string.Join("", actualPowers.OrderBy(x => x))}。",
unitIdText));
}
}
else
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.WeakEvidence,
$"{kind}“{claimText}”声称 UnitId {unitIdText} 使用了“{powerName}”,但该 UnitId 未观察到任何特殊能力。",
unitIdText));
}
continue;
}
if (ev.Type is AIEvidenceType.Build
or AIEvidenceType.Place
or AIEvidenceType.Produce
or AIEvidenceType.Select
or AIEvidenceType.Sell)
{
var unitIdText = ev.GetUnitId();
if (unitIdText is null || !uint.TryParse(unitIdText, out var unitId))
{
continue;
}
if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"{kind}“{claimText}”的证据引用了回放中不存在的 UnitId {unitIdText}。",
unitIdText));
}
}
}
}
private static void ValidateOwnership(
AIUnitClaim claim,
uint unitId,
+1
View File
@@ -432,6 +432,7 @@ namespace AnotherReplayReader.Utils
foreach (var entry in ByScope(KnowledgeScopeKind.Global))
{
var text = entry.Id.StartsWith("knowledge-text-", StringComparison.Ordinal)
|| entry.Id.StartsWith("knowledge-file-", StringComparison.Ordinal)
? FilterFlatTextByFactions(entry.Text, factionNames)
: entry.Text;
sb.AppendLine(text.Trim());
+61 -4
View File
@@ -51,14 +51,71 @@ namespace AnotherReplayReader.Utils
if (slices.Count > MaxSlices)
{
var totalTokens = spans.Sum(s => s.EstimatedTokens);
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(totalTokens / (double)MaxSlices));
slices = SliceCore(fullText, spans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
warnings.Add($"分段数超过上限 {MaxSlices},已放宽单段预算到 {raisedBudget:N0} token。");
// 先尝试压缩噪声事件块(纯选择/编队类),避免一超限就放宽预算。
var compressedSpans = CompressNoiseSpans(fullText, spans);
if (compressedSpans.Length < spans.Length && !compressedSpans.IsEmpty)
{
var compressedSlices = SliceCore(fullText, compressedSpans, budget, overlap);
if (compressedSlices.Count <= MaxSlices)
{
warnings.Add($"分段数超过上限 {MaxSlices},已过滤纯选择/编队事件块,压缩到 {compressedSlices.Count} 段。");
return (compressedSlices.ToImmutableArray(), warnings.ToImmutable());
}
var compressedTokens = compressedSpans.Sum(s => s.EstimatedTokens);
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(compressedTokens / (double)MaxSlices));
slices = SliceCore(fullText, compressedSpans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
warnings.Add($"分段数仍超过上限 {MaxSlices},已过滤噪声并放宽单段预算到 {raisedBudget:N0} token。");
}
else
{
var totalTokens = spans.Sum(s => s.EstimatedTokens);
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(totalTokens / (double)MaxSlices));
slices = SliceCore(fullText, spans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
warnings.Add($"分段数超过上限 {MaxSlices},已放宽单段预算到 {raisedBudget:N0} token。");
}
}
return (slices.ToImmutableArray(), warnings.ToImmutable());
}
private static ImmutableArray<EventSpan> CompressNoiseSpans(
string fullText,
ImmutableArray<EventSpan> spans)
{
var result = ImmutableArray.CreateBuilder<EventSpan>();
foreach (var span in spans)
{
if (!IsNoiseEventBlock(fullText.Substring(span.StartIndex, span.Length)))
{
result.Add(span);
}
}
return result.ToImmutable();
}
private static bool IsNoiseEventBlock(string text)
{
var hasCommand = false;
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("[", StringComparison.Ordinal))
{
continue;
}
// 命令行通常形如 "PlayerA: 选择单位";测试/简写文本把非空行也视作命令。
hasCommand = true;
if (line.IndexOf("选择", StringComparison.Ordinal) < 0
&& line.IndexOf("编队", StringComparison.Ordinal) < 0
&& line.IndexOf("取消选择", StringComparison.Ordinal) < 0)
{
return false;
}
}
return hasCommand;
}
private static List<ReplaySlice> SliceCore(
string fullText,
ImmutableArray<EventSpan> spans,