This commit is contained in:
2026-08-22 00:41:17 +02:00
parent d06a93f759
commit 9250528442
18 changed files with 48210 additions and 53 deletions
+86 -19
View File
@@ -295,8 +295,8 @@ namespace AnotherReplayReader
var budget = AiContextBudget.GetContextBudget(model);
var hardCap = model.ContextLength > 0
? (int)(model.ContextLength * AiContextBudget.HardUsageRatio)
: 48_000;
var effectiveBudget = budget > 0 ? budget : hardCap;
: 128_000; // 上下文未知时的保守默认;仅用于“预算为 0 时的单 slice”判断。
var effectiveBudget = budget > 0 ? budget : 0;
var headroom = AiContextBudget.GetOutputHeadroom(requestContext.Provider, model);
var fixedOverhead = AiContextBudget.EstimateTokens(_systemPrompt)
+ 4_000 // digest
@@ -304,7 +304,25 @@ namespace AnotherReplayReader
+ 1_500 // instruction
+ 10_000 // findings reserve
+ headroom;
var sliceBudget = Math.Max(effectiveBudget - fixedOverhead, MechanicalSegmenter.MinSliceTokens);
var logTokens = AiContextBudget.EstimateTokens(replayData);
int sliceBudget;
if (effectiveBudget <= 0)
{
// 没有上下文预算(128K 及以下或未知):只允许短录像整局作为一个 slice。
if (logTokens + fixedOverhead > hardCap)
{
throw new InvalidOperationException(
$"模型没有足够的上下文预算({FormatNumber(budget, false)} token),日志约 {FormatNumber(logTokens, false)} token"
+ $"加上固定开销后超过单 slice 上限 {FormatNumber(hardCap, false)} token。"
+ "请改用更长上下文的模型,或缩短操作记录。");
}
sliceBudget = Math.Max(logTokens, MechanicalSegmenter.MinSliceTokens);
AppendLog("上下文预算提示", "模型上下文较短,仅按单 slice 处理整局;长录像质量不保证。", false);
}
else
{
sliceBudget = Math.Max(effectiveBudget - fixedOverhead, MechanicalSegmenter.MinSliceTokens);
}
var (slices, sliceWarnings) = MechanicalSegmenter.Slice(
replayData, eventSpans, sliceBudget, sliceBudget / 12);
_slices = slices;
@@ -321,7 +339,7 @@ namespace AnotherReplayReader
"v2 管线准备",
$"上下文预算: {FormatNumber(budget, false)} token0 = 不支持长录像)\r\n"
+ $"分段预算: {FormatNumber(sliceBudget, false)} token,实际切分 {_slices.Length} 段\r\n"
+ $"日志总量: {FormatNumber(AiContextBudget.EstimateTokens(replayData), false)} token\r\n"
+ $"日志总量: {FormatNumber(logTokens, false)} token\r\n"
+ $"[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
false);
@@ -334,15 +352,25 @@ namespace AnotherReplayReader
var overviewMessages = BuildSystemMessages(_systemPrompt, _digest, null)
.Add(new AIAnalyze.ChatMessage("user", AIAnalyze.BuildOverviewUserPrompt(_slices)));
CheckAndLogContextUsage(overviewMessages, requestContext);
var overviewResult = await _analyzer.CompleteAsync(
overviewMessages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(overviewResult);
_overview = OverviewParser.Parse(overviewResult.Response);
_overviewNarrative = _overview.Narrative;
AppendLog("整局总览", overviewResult.Response, false);
AIAnalyze.Result overviewResult = default;
var overviewAttempt = 0;
do
{
overviewResult = await _analyzer.CompleteAsync(
overviewMessages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(overviewResult);
_overview = OverviewParser.Parse(overviewResult.Response);
_overviewNarrative = _overview.Narrative;
AppendLog(
overviewAttempt == 0 ? "整局总览" : "整局总览(重试)",
overviewResult.Response,
false);
overviewAttempt++;
}
while (_overview.Segments.IsEmpty && overviewAttempt < 2);
if (_overview.Segments.IsEmpty)
{
AppendLog("总览解析警告", "未解析到 [分段概述] 块,将直接使用机械分段。", false);
@@ -413,11 +441,22 @@ namespace AnotherReplayReader
_lastContentParagraph = null;
var eventCount = _eventCounts?.Query(slice.Start, slice.End) ?? slice.EventCount;
var title = _overview.Segments.FirstOrDefault(s => s.Index == segmentIndex)?.Title;
var overviewEntry = _overview.Segments.FirstOrDefault(s => s.Index == segmentIndex);
var title = overviewEntry?.Title;
var sliceText = slice.GetText(_replayData);
var findingsText = string.Join("\n\n", _findings);
var modelBudget = AiContextBudget.GetContextBudget(requestContext.Model);
var maxFindingsTokens = modelBudget > 0
? Math.Min(10_000, Math.Max(2_000, modelBudget / 10))
: 4_000;
var findingsText = LimitFindings(_findings, maxFindingsTokens);
var instruction = AIAnalyze.BuildSegmentUserPromptV2(
_currentSegmentIndex, totalSegments, slice, eventCount, title);
_currentSegmentIndex,
totalSegments,
slice,
eventCount,
title,
overviewEntry?.Description,
overviewEntry?.BackqueryHints);
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
.Add(new AIAnalyze.ChatMessage("user", sliceText));
@@ -441,6 +480,8 @@ namespace AnotherReplayReader
string segmentResponse;
while (true)
{
// 回查会增长同一会话的消息列表,每次请求前都重新做预算检查。
CheckAndLogContextUsage(segmentMessages, requestContext);
var segmentResult = await _analyzer!.CompleteAsync(
segmentMessages,
requestContext,
@@ -506,10 +547,13 @@ namespace AnotherReplayReader
if (validationResult.RequiresRevision)
{
var issueCount = validationResult.Issues.Count(
i => i.Severity == AIValidationSeverity.Contradiction);
i => i.Severity is AIValidationSeverity.Contradiction
or AIValidationSeverity.Fatal
or AIValidationSeverity.Warning
or AIValidationSeverity.WeakEvidence);
AppendLog(
$"第{segmentIndex}段修订",
$"验证器发现 {issueCount} 个矛盾问题,正在请求 AI 修正...",
$"验证器发现 {issueCount} 个需要修正/降级的问题,正在请求 AI 修正...",
false);
var relevantFacts = RelevantFactsFormatter.Format(validationResult.Claims, _factIndex!);
var revisionPrompt = AIAnalyze.BuildRevisionUserPrompt(
@@ -655,7 +699,7 @@ namespace AnotherReplayReader
{
total += AiContextBudget.EstimateTokens(message.Content);
}
var check = AiContextBudget.CheckPromptUsage(
var check = AiContextBudget.CheckRequestUsage(
total, requestContext.Provider, requestContext.Model);
if (check.Block)
{
@@ -678,6 +722,29 @@ namespace AnotherReplayReader
return text.Substring(0, chars) + "\n…(已截断)";
}
/// <summary>已发现事实按 token 预算保留最近若干条,避免跨段事实无限增长。</summary>
private static string LimitFindings(IReadOnlyList<string> findings, int maxTokens)
{
if (findings.Count == 0)
{
return string.Empty;
}
var selected = new List<string>();
var totalTokens = 0;
for (var i = findings.Count - 1; i >= 0; --i)
{
var entry = findings[i];
var tokens = AiContextBudget.EstimateTokens(entry);
if (selected.Count > 0 && totalTokens + tokens > maxTokens)
{
break;
}
selected.Insert(0, entry);
totalTokens += tokens;
}
return string.Join("\n\n", selected);
}
// ---- 块追加与折叠 ----
private void OnChunk(AIAnalyze.AIChunk chunk)
{
+53 -1
View File
@@ -259,16 +259,34 @@ namespace AiV2.Tests
// 无 JSON → Warning
var noJson = AIAnalysisValidation.ValidateMachineReadableClaims("纯文本回复");
Program.Assert(noJson.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingMachineReadableClaims), "无声明产生 Warning");
Program.Assert(noJson.RequiresRevision, "无声明现在会触发一次修订");
// 高置信无证据 → MissingEvidence
var highNoEvidence = AIAnalysisValidation.ValidateMachineReadableClaims(
"[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}]}\n```");
Program.Assert(highNoEvidence.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingEvidence), "高置信无证据");
Program.Assert(highNoEvidence.RequiresRevision, "高置信无证据会触发修订");
// 低置信无备选/待确认 → MissingAlternative
var lowNoAlt = AIAnalysisValidation.ValidateMachineReadableClaims(
"[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"possible\",\"evidence\":[\"select|1:00|1\"]}]}\n```");
Program.Assert(lowNoAlt.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingAlternative), "低置信无备选");
Program.Assert(lowNoAlt.RequiresRevision, "低置信无备选会触发修订");
// 仅 Info(例如声明数量超限)不触发修订
var infoOnlyJson = "[机器可读声明]\n```json\n{\"eventClaims\":["
+ string.Join(",", Enumerable.Range(1, 6).Select(i =>
$"{{\"claim\":\"事件{i}\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}}"))
+ "]}\n```";
var infoOnly = AIAnalysisValidation.ValidateMachineReadableClaims(infoOnlyJson);
Program.Assert(!infoOnly.RequiresRevision, "仅 Info 不触发修订");
// 全部 JSON 块都不可解析 → Fatal
var allInvalid = AIAnalysisValidation.ValidateMachineReadableClaims(
"[机器可读声明]\n```json\n{\"bad\": }\n```");
Program.Assert(allInvalid.Issues.Any(i => i.Severity == AIValidationSeverity.Fatal),
"全无效 JSON 产生 Fatal");
Program.Assert(allInvalid.RequiresRevision, "Fatal 触发修订");
}
}
@@ -311,6 +329,14 @@ namespace AiV2.Tests
Program.Assert(!warn.Block && !warn.IsOk, "超过预算 → 警告");
var ok = AiContextBudget.CheckPromptUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 });
Program.Assert(ok.IsOk, "预算内 → 通过");
// 含输出余量的请求检查:输入 170K + 余量 32K 超过 200K 模型的 90% 硬上限
var blockTotal = AiContextBudget.CheckRequestUsage(170_000, provider, new AiModel { ContextLength = 200_000 });
Program.Assert(blockTotal.Block, "输入+输出余量超过硬上限 → 拒绝");
var warnTotal = AiContextBudget.CheckRequestUsage(160_000, provider, new AiModel { ContextLength = 1_000_000 });
Program.Assert(!warnTotal.Block && !warnTotal.IsOk, "输入+输出余量超过预算 → 警告");
var okTotal = AiContextBudget.CheckRequestUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 });
Program.Assert(okTotal.IsOk, "输入+输出余量在预算内 → 通过");
}
}
@@ -347,6 +373,8 @@ namespace AiV2.Tests
Program.Assert(digest.Contains("建造者"), "建造者段");
Program.Assert(digest.Contains("第1段"), "分段元数据");
Program.Assert(digest.Contains("开始建造建筑"), "关键事件采样");
Program.Assert(digest.Contains("# 协议选择"), "摘要包含协议选择");
Program.Assert(digest.Contains("# 所有权证据(节选)"), "摘要包含所有权证据");
}
}
@@ -528,6 +556,23 @@ namespace AiV2.Tests
&& index.UnitIdFirstObservedTime.ContainsKey(389)
&& index.UnitIdFirstObservedTime.ContainsKey(587)
&& index.UnitIdFirstObservedTime.ContainsKey(322), "首次出现记录");
// 编队按玩家隔离 + 0x1F6/0x22A 选择类命令
var secondTimeline = ImmutableArray.Create(
(TimeSpan.FromSeconds(1), ImmutableArray.Create(
MakeChunk(0x1FA, 4, Int(3), Obj(401)))),
(TimeSpan.FromSeconds(2), ImmutableArray.Create(
MakeChunk(0x1FB, 5, Int(3)))),
(TimeSpan.FromSeconds(3), ImmutableArray.Create(
MakeChunk(0x1F6, 4, Obj(777)),
MakeChunk(0x22A, 4, Obj(888)))));
var secondIndex = ReplayFactIndex.Build(secondTimeline, new Dictionary<uint, string>());
Program.Assert(secondIndex.PlayerStrongOwnershipUnitIds.TryGetValue(4, out var p4Strong)
&& p4Strong.Contains(401), "P4 创建编队 → 强证据");
Program.Assert(!secondIndex.PlayerStrongOwnershipUnitIds.TryGetValue(5, out _)
|| !secondIndex.PlayerStrongOwnershipUnitIds[5].Contains(401), "P5 选择同号编队不应继承 P4 成员");
Program.Assert(secondIndex.PlayerWeakOwnershipUnitIds.TryGetValue(4, out var p4Weak)
&& p4Weak.Contains(777) && p4Weak.Contains(888), "0x1F6/0x22A → 弱所有权");
}
}
@@ -543,6 +588,10 @@ namespace AiV2.Tests
Program.Assert(knowledge.GetEntity("AlliedMCV") is { } mcv
&& mcv.HasTag(KnowledgeTag.Builder)
&& mcv.HasTag(KnowledgeTag.Unpack), "AlliedMCV 标签");
Program.Assert(knowledge.GetEntity("AlliedAntiInfantryVehicle_Ground") is { } aliasEntity
&& aliasEntity.AssetName == "AlliedAntiInfantryVehicle", "别名可解析到同一实体");
Program.Assert(knowledge.GetEntity("AlliedAntiInfantryVehicle") is { } acv
&& acv.ProducedBy.Contains("AlliedWarFactory"), "alsoProducedBy 合并进 ProducedBy");
// 不存在的 mod → null(无结构化数据时不剥离 flat 文本)
Program.Assert(StructuredKnowledge.GetForMod("corona") is null, "corona 无结构化文件 → null");
@@ -601,9 +650,12 @@ namespace AiV2.Tests
Program.Assert(overview.Contains("[分段概述]"), "总览轮要求 [分段概述] 块");
Program.Assert(overview.Contains("不要修改"), "总览轮不修改边界");
var segment = AIAnalyze.BuildSegmentUserPromptV2(0, 1, slice, 500, "开局");
var segment = AIAnalyze.BuildSegmentUserPromptV2(
0, 1, slice, 500, "开局", "前期平稳发育", new[] { "1:20~1:45" });
Program.Assert(segment.Contains("第1/1段"), "段指令编号");
Program.Assert(segment.Contains("开局"), "段标题");
Program.Assert(segment.Contains("前期平稳发育"), "段概述");
Program.Assert(segment.Contains("1:20~1:45"), "总览回查提示");
Program.Assert(segment.Contains("[回查]"), "回查说明");
Program.Assert(segment.Contains("[小结]"), "小结要求");
Program.Assert(segment.Contains("[机器可读声明]"), "机器可读声明要求");
+4
View File
@@ -67,6 +67,10 @@
<Content Include="knowledge_units_default.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- 临时快照:StringHashes 来自本地 RA3-MODSDK-X;后续应改为可配置路径或只打包需要的 hash 子集。 -->
<Content Include="Data\StringHashes.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Target Name="CustomAfterBuild" AfterTargets="Build" Condition="'$(AiV2TestsBuilding)' != 'true'">
<ItemGroup>
+2
View File
@@ -1,5 +1,7 @@
# Context
> ⚠️ 已归档(2026-08-21):本文是历史术语表,部分定义已与 v2 管线不一致,不再作为当前依据,正文不再更新;当前以 `PLAN_ai_analysis_v2.md` 和代码为准。
## Glossary
### Operation Fact
+47871
View File
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -121,7 +121,20 @@ namespace AnotherReplayReader
public void LoadStringHashes()
{
var stringHashes = File.ReadAllText(@"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml");
// 临时方案:优先使用随程序分发的快照,找不到再回退到本地 SDK。
// 该快照后续应改为可配置路径,或只打包当前命令实际用到的 hash 子集。
var bundledPath = Path.Combine(AppContext.BaseDirectory, "Data", "StringHashes.xml");
var externalPath = @"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml";
var sourcePath = File.Exists(bundledPath) ? bundledPath : externalPath;
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException(
"未找到 StringHashes.xml;请检查程序目录下的 Data\\StringHashes.xml,或安装 RA3-MODSDK-X。",
sourcePath);
}
Debug.Instance.DebugMessage += $"StringHashes 来源: {sourcePath}\r\n";
var stringHashes = File.ReadAllText(sourcePath);
XDocument doc = XDocument.Parse(stringHashes);
XNamespace ns = "uri:ea.com:eala:asset";
var table = doc
@@ -175,7 +188,7 @@ namespace AnotherReplayReader
_cached = text;
_cachedPrefixSums = prefixSums;
_cachedSpans = spans;
_cachedFactIndex = ReplayFactIndex.Build(_model.Commands, _stringHashes);
_cachedFactIndex = await Task.Run(() => ReplayFactIndex.Build(_model.Commands, _stringHashes));
// display KB and K tokens in _tokenUsageLabel
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
}
@@ -454,6 +467,11 @@ namespace AnotherReplayReader
MessageBox.Show(this, "请先选择录像");
return;
}
if (_model.Level == CompactLevel.NoCompact)
{
MessageBox.Show(this, "AI 分析需要使用 AI 压缩级别(ForAI 或 VeryCompactedForAI),请先在右上角切换。");
return;
}
if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums || _cachedFactIndex is not { } factIndex)
{
MessageBox.Show(this, "请先生成文本");
+11 -2
View File
@@ -22,12 +22,21 @@
**实施中的取舍与遗留**
- `Data/StringHashes.xml` 是随仓库分发的本地 SDK 临时快照(约 3.5MB / 47,860 条),后续应改为可配置路径或只打包需要的 hash 子集。
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
- 修订 pass 的展示采用"实时流式 + 修订后整段替换"ADR 0001 的"完全缓冲至验证完成"仍是开放项。
- `Fatal` 严重度暂不产生 issue,语义保留为修订失败兜底(修订输出为空时保留原分析
- `MissingMachineReadableClaims` 为 Warning(仅记录),是否触发"修复请求"待定
- `Fatal` 在“所有机器可读声明块均无法解析”时产生;修订输出为空时保留原分析。
- `MissingMachineReadableClaims` 为 Warning,并与其他 Warning/WeakEvidence 一样触发一次隐藏修订;是否保留该策略待 A/B 评估
- 测试工程 `AiV2.Tests` 通过 `ProjectReference` 引用主工程;构建时通过 `AiV2TestsBuilding=true` 跳过主工程的 DLL 移动目标。
## 实施状态修订(2026-08-22
- “重新分析”策略:除 `Info` 外,`Warning/WeakEvidence/Contradiction/Fatal` 都触发一次隐藏修订;修订后遗留问题仅记录,不循环请求。
- 预算检查改为“估算 prompt + 输出/推理余量”,并在回查/修订前重新检查;`stream` 标志尊重模型配置。
- 事实索引:编队所有权按玩家隔离;接入 `0x1F6/0x22A`;unpack 歧义规则只作用于 MCV/基地车类实体。
- 知识:`aliases/alsoProducedBy` 已解析;摘要补充协议选择与所有权证据;总览的段落描述和回查提示会传入分段指令。
- 测试:当前 `AiV2.Tests` 为 144 项断言全部通过。
## 1. 背景与目标
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地(ADR 0001)。
+41 -1
View File
@@ -86,8 +86,13 @@ namespace AnotherReplayReader.Utils
public static AIValidationResult Empty { get; } =
new(AIMachineReadableClaims.Empty, ImmutableArray<AIValidationIssue>.Empty);
// 除 Info 外,任何需要模型修正/降级的验证问题都触发一次隐藏修订;
// 一次修订后仍遗留的问题只记录,不再循环请求。
public bool RequiresRevision =>
Issues.Any(i => i.Severity is AIValidationSeverity.Contradiction or AIValidationSeverity.Fatal);
Issues.Any(i => i.Severity is AIValidationSeverity.Contradiction
or AIValidationSeverity.Fatal
or AIValidationSeverity.Warning
or AIValidationSeverity.WeakEvidence);
public bool HasIssues => !Issues.IsEmpty;
}
@@ -118,6 +123,10 @@ namespace AnotherReplayReader.Utils
var claims = ParseClaims(jsonBlocks, issues);
if (claims is null)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Fatal,
AIValidationIssueKind.InvalidMachineReadableClaims,
"所有机器可读声明块都无法解析,当前输出不可用于自动验证。"));
return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable());
}
@@ -514,6 +523,13 @@ namespace AnotherReplayReader.Utils
continue;
}
// 只有“基地车/主基地”这类同时具备 pack/unpack 的实体才存在 MCV vs 矿车的歧义;
// 矿车本身只有 unpack,不应被这条规则误伤。
if (!LooksLikeUnpackAmbiguousUnit(claim.Claim))
{
continue;
}
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.MissingAlternative,
@@ -522,6 +538,30 @@ namespace AnotherReplayReader.Utils
}
}
private static bool LooksLikeUnpackAmbiguousUnit(string claimText)
{
if (claimText.IndexOf("MCV", StringComparison.OrdinalIgnoreCase) >= 0
|| claimText.IndexOf("基地车", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
var structured = StructuredKnowledge.Instance;
if (structured is not null)
{
foreach (var entity in structured.EntitiesWithTag(KnowledgeTag.Pack))
{
if (claimText.IndexOf(entity.AssetName, StringComparison.OrdinalIgnoreCase) >= 0
|| (!string.IsNullOrWhiteSpace(entity.DisplayName)
&& claimText.IndexOf(entity.DisplayName, StringComparison.OrdinalIgnoreCase) >= 0))
{
return true;
}
}
}
return false;
}
private static void ValidateTimelineConsistency(
AIMachineReadableClaims claims,
ReplayFactIndex factIndex,
+24 -10
View File
@@ -204,7 +204,7 @@ namespace AnotherReplayReader.Utils
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
如果没有可验证推测,输出空 JSON 对象(三个字段均为空数组。不要在 JSON 里写注释。
# 推理指南
推理需要分成多个阶段
@@ -1040,20 +1040,27 @@ PlayerA: 开始出兵
int totalSegments,
ReplaySlice slice,
int eventCount,
string? title)
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 titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
var descriptionLine = string.IsNullOrWhiteSpace(description) ? "" : $"\n段落概述:{description}";
var hintLine = backqueryHints is { } hints && hints.Any()
? "\n总览建议可回查区间:" + string.Join("、", hints)
: "";
var instruction = @$"
请重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
本段共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}
本段共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}{descriptionLine}
{hintLine}
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实。
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空 JSON 对象
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
";
return instruction.Trim().Replace("\r", "");
@@ -1383,15 +1390,22 @@ PlayerA: 开始出兵
ImmutableList<ChatMessage> messages,
Dictionary<string, object> inputRequestParams)
{
return new Dictionary<string, object>(inputRequestParams)
var parameters = new Dictionary<string, object>(inputRequestParams)
{
["messages"] = messages.ToArray(),
["stream"] = true,
["stream_options"] = new
["messages"] = messages.ToArray()
};
// 尊重模型配置里的 IsStream;只有流式请求才附加 stream_options
// 避免非 SSE 端点收到 stream=true 后失败。
if (inputRequestParams.TryGetValue("stream", out var streamValue)
&& streamValue is bool isStream
&& isStream)
{
parameters["stream_options"] = new
{
include_usage = true
}
};
};
}
return parameters;
}
}
+29 -5
View File
@@ -43,29 +43,53 @@ namespace AnotherReplayReader.Utils
public static int EstimateTokens(string text) =>
(int)Math.Ceiling(AIAnalyze.EstimateTokenCount(text).EstimatedTokenCount * EstimatorSafetyFactor);
/// <summary>请求护栏:超过硬上限返回 Block,超过软预算返回 Warn,否则 null。</summary>
/// <summary>请求护栏:只按 prompt 估算检查(保留给旧调用/测试使用)。</summary>
public static ContextCheckResult CheckPromptUsage(
int estimatedPromptTokens,
AiProvider provider,
AiModel model)
{
return CheckUsage(estimatedPromptTokens, provider, model, includeOutputHeadroom: false);
}
/// <summary>请求护栏:把输出/推理余量也算入总用量。</summary>
public static ContextCheckResult CheckRequestUsage(
int estimatedPromptTokens,
AiProvider provider,
AiModel model)
{
return CheckUsage(estimatedPromptTokens, provider, model, includeOutputHeadroom: true);
}
private static ContextCheckResult CheckUsage(
int estimatedPromptTokens,
AiProvider provider,
AiModel model,
bool includeOutputHeadroom)
{
var headroom = includeOutputHeadroom ? GetOutputHeadroom(provider, model) : 0;
var estimatedTotal = estimatedPromptTokens + headroom;
if (model.ContextLength > 0)
{
var hardLimit = (int)(model.ContextLength * HardUsageRatio);
if (estimatedPromptTokens > hardLimit)
if (estimatedTotal > hardLimit)
{
return new ContextCheckResult(
true,
$"估算输入 {estimatedPromptTokens:N0} token 超过模型上下文 {model.ContextLength:N0} 的 90%,已拒绝发起请求。请改用更长上下文的模型,或缩短操作记录。");
includeOutputHeadroom
? $"估算用量(输入 {estimatedPromptTokens:N0} + 输出余量 {headroom:N0} = {estimatedTotal:N0})超过模型上下文 {model.ContextLength:N0} 的 90%,已拒绝发起请求。请改用更长上下文的模型,或缩短操作记录。"
: $"估算输入 {estimatedPromptTokens:N0} token 超过模型上下文 {model.ContextLength:N0} 的 90%,已拒绝发起请求。请改用更长上下文的模型,或缩短操作记录。");
}
}
var budget = GetContextBudget(model);
if (budget > 0 && estimatedPromptTokens > budget)
if (budget > 0 && estimatedTotal > budget)
{
return new ContextCheckResult(
false,
$"估算输入 {estimatedPromptTokens:N0} token 超过上下文预算 {budget:N0}(可在模型设置中调整),长录像将自动分段,超出部分会被压缩。");
includeOutputHeadroom
? $"估算用量(输入 {estimatedPromptTokens:N0} + 输出余量 {headroom:N0} = {estimatedTotal:N0})超过上下文预算 {budget:N0}(可在模型设置中调整)。"
: $"估算输入 {estimatedPromptTokens:N0} token 超过上下文预算 {budget:N0}(可在模型设置中调整),长录像将自动分段,超出部分会被压缩。");
}
return ContextCheckResult.Ok;
}
+19 -2
View File
@@ -123,6 +123,7 @@ namespace AnotherReplayReader.Utils
ImmutableArray<string> Tags,
ImmutableArray<SpecialPowerInfo> SpecialPowers,
ImmutableArray<string> ProducedBy,
ImmutableArray<string> Aliases,
string Text)
{
public bool IsBuilding => HasTag(KnowledgeTag.Structure);
@@ -243,6 +244,15 @@ namespace AnotherReplayReader.Utils
.OrderBy(e => e.Faction, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var byAsset = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
foreach (var entity in allEntities)
{
byAsset[entity.AssetName] = entity;
foreach (var alias in entity.Aliases)
{
byAsset[alias] = entity;
}
}
var byFaction = builtin.ToImmutableDictionary(
kv => kv.Key,
kv => kv.Value.Values.OrderBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
@@ -255,7 +265,7 @@ namespace AnotherReplayReader.Utils
}
return new StructuredKnowledge(
allEntities.ToImmutableDictionary(e => e.AssetName, e => e, StringComparer.OrdinalIgnoreCase),
byAsset.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
allEntities,
byFaction,
unknownTagsArray);
@@ -306,6 +316,12 @@ namespace AnotherReplayReader.Utils
unknownTagsLocal.Add(tag);
}
}
var producedBy = GetStringArray(el, "producedBy");
var alsoProducedBy = GetStringArray(el, "alsoProducedBy");
var combinedProducedBy = producedBy
.Concat(alsoProducedBy)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var ek = new EntityKnowledge(
assetName,
GetString(el, "displayName") ?? "",
@@ -313,7 +329,8 @@ namespace AnotherReplayReader.Utils
isBuilding ? null : GetString(el, "tier"),
tags,
ParseSpecialPowers(el),
isBuilding ? ImmutableArray<string>.Empty : GetStringArray(el, "producedBy"),
isBuilding ? ImmutableArray<string>.Empty : combinedProducedBy,
GetStringArray(el, "aliases"),
GetString(el, "text") ?? "");
entries[assetName] = ek;
}
+26 -1
View File
@@ -2,6 +2,7 @@ using AnotherReplayReader.ReplayFile;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
@@ -166,6 +167,28 @@ namespace AnotherReplayReader.Utils
}
sb.AppendLine();
sb.AppendLine("# 协议选择");
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("# 所有权证据(节选)");
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds.OrderBy(k => k.Key))
{
var ids = kv.Value.OrderBy(x => x).Take(20);
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):强证据 {kv.Value.Count} 个 UnitId{string.Join("", ids)}{suffix}");
}
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds.OrderBy(k => k.Key))
{
var ids = kv.Value.OrderBy(x => x).Take(20);
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):弱证据 {kv.Value.Count} 个 UnitId{string.Join("", ids)}{suffix}");
}
sb.AppendLine();
sb.AppendLine("# 打包/展开");
var packUnits = factIndex.UnitIdSpecialPowers
.Where(kv2 => kv2.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
@@ -414,7 +437,9 @@ namespace AnotherReplayReader.Utils
}
input = input.Trim();
var parts = input.Split(':');
if (parts.Length == 0 || !float.TryParse(parts[parts.Length - 1], out var seconds) || seconds < 0)
if (parts.Length == 0
|| !float.TryParse(parts[parts.Length - 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)
|| seconds < 0)
{
return false;
}
+14 -6
View File
@@ -75,7 +75,8 @@ namespace AnotherReplayReader.Utils
var playerStrongOwnership = new Dictionary<int, HashSet<uint>>();
var playerWeakOwnership = new Dictionary<int, HashSet<uint>>();
var playerTechChoices = new Dictionary<int, HashSet<string>>();
var controlGroups = new Dictionary<int, HashSet<uint>>();
// 编队号在同一局内可能被不同玩家复用,因此键必须包含玩家。
var controlGroups = new Dictionary<long, HashSet<uint>>();
foreach (var (time, commands) in timeline)
{
@@ -121,7 +122,7 @@ namespace AnotherReplayReader.Utils
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> playerWeakOwnership,
Dictionary<int, HashSet<string>> playerTechChoices,
Dictionary<int, HashSet<uint>> controlGroups)
Dictionary<long, HashSet<uint>> controlGroups)
{
var player = command.PlayerIndex;
var cmdId = command.CommandId;
@@ -130,6 +131,10 @@ namespace AnotherReplayReader.Utils
{
// select unit(s): 0x1F5
case 0x1F5:
// 选择相同单位(W):ObjectId 语义与选择相同,作为弱所有权
case 0x1F6:
// 选择所有单位(Q):若能解析出 UnitId,同样作为弱所有权
case 0x22A:
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected, playerWeakOwnership);
break;
@@ -460,7 +465,7 @@ namespace AnotherReplayReader.Utils
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> controlGroups)
Dictionary<long, HashSet<uint>> controlGroups)
{
// Data[0]: Int32 编队号;Data[1..]: 成员 ObjectId
int? groupNumber = null;
@@ -498,7 +503,7 @@ namespace AnotherReplayReader.Utils
return;
}
var group = new HashSet<uint>(members);
controlGroups[groupNumber.Value] = group;
controlGroups[ControlGroupKey(player, groupNumber.Value)] = group;
foreach (var id in members)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
@@ -512,7 +517,7 @@ namespace AnotherReplayReader.Utils
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> controlGroups)
Dictionary<long, HashSet<uint>> controlGroups)
{
foreach (var entry in command.Data)
{
@@ -529,7 +534,7 @@ namespace AnotherReplayReader.Utils
{
continue;
}
if (controlGroups.TryGetValue(groupNumber.Value, out var members))
if (controlGroups.TryGetValue(ControlGroupKey(player, groupNumber.Value), out var members))
{
foreach (var id in members)
{
@@ -540,6 +545,9 @@ namespace AnotherReplayReader.Utils
}
}
private static long ControlGroupKey(int player, int group) =>
((long)player << 32) | (uint)group;
private static void RecordTechChoice(
TimeSpan time,
CommandChunk command,
+2
View File
@@ -1,5 +1,7 @@
# AI Analysis WIP
> ⚠️ 已归档(2026-08-21):本文是历史设计/进度记录,不再作为当前依据,正文不再更新;当前以 `PLAN_ai_analysis_v2.md` 和代码为准。
## User Need
The application is adding AI analysis for Red Alert 3 replay operation logs. The current flow sends player information and a compacted operation log to a chat-completion-compatible LLM, then displays the analysis in `AIChatPanel`.
+2
View File
@@ -1,5 +1,7 @@
# ADR 0001: Hidden Revision Pass for AI Analysis
> ⚠️ 已归档(2026-08-21):本文是历史决策记录,其中的“实现状态”已过时;当前实现以代码和 `PLAN_ai_analysis_v2.md` 为准,正文不再更新。
**Date:** 2026-07-06
## Status
@@ -1,5 +1,7 @@
# ADR 0002: Structured Game Knowledge for Prompt and Validation
> ⚠️ 已归档(2026-08-21):本文是历史决策记录,其设想与当前实现存在差异;当前实现以代码和 `PLAN_ai_analysis_v2.md` 为准,正文不再更新。
**Date:** 2026-07-07
## Status
+1 -1
View File
@@ -119,7 +119,7 @@
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
如果没有可验证推测,输出空 JSON 对象(三个字段均为空数组。不要在 JSON 里写注释。
# 推理指南
推理需要分成多个阶段
+1 -1
View File
@@ -119,7 +119,7 @@
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
如果没有可验证推测,输出空 JSON 对象(三个字段均为空数组。不要在 JSON 里写注释。
# 推理指南
推理需要分成多个阶段