wip
This commit is contained in:
+86
-19
@@ -295,8 +295,8 @@ namespace AnotherReplayReader
|
|||||||
var budget = AiContextBudget.GetContextBudget(model);
|
var budget = AiContextBudget.GetContextBudget(model);
|
||||||
var hardCap = model.ContextLength > 0
|
var hardCap = model.ContextLength > 0
|
||||||
? (int)(model.ContextLength * AiContextBudget.HardUsageRatio)
|
? (int)(model.ContextLength * AiContextBudget.HardUsageRatio)
|
||||||
: 48_000;
|
: 128_000; // 上下文未知时的保守默认;仅用于“预算为 0 时的单 slice”判断。
|
||||||
var effectiveBudget = budget > 0 ? budget : hardCap;
|
var effectiveBudget = budget > 0 ? budget : 0;
|
||||||
var headroom = AiContextBudget.GetOutputHeadroom(requestContext.Provider, model);
|
var headroom = AiContextBudget.GetOutputHeadroom(requestContext.Provider, model);
|
||||||
var fixedOverhead = AiContextBudget.EstimateTokens(_systemPrompt)
|
var fixedOverhead = AiContextBudget.EstimateTokens(_systemPrompt)
|
||||||
+ 4_000 // digest
|
+ 4_000 // digest
|
||||||
@@ -304,7 +304,25 @@ namespace AnotherReplayReader
|
|||||||
+ 1_500 // instruction
|
+ 1_500 // instruction
|
||||||
+ 10_000 // findings reserve
|
+ 10_000 // findings reserve
|
||||||
+ headroom;
|
+ 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(
|
var (slices, sliceWarnings) = MechanicalSegmenter.Slice(
|
||||||
replayData, eventSpans, sliceBudget, sliceBudget / 12);
|
replayData, eventSpans, sliceBudget, sliceBudget / 12);
|
||||||
_slices = slices;
|
_slices = slices;
|
||||||
@@ -321,7 +339,7 @@ namespace AnotherReplayReader
|
|||||||
"v2 管线准备",
|
"v2 管线准备",
|
||||||
$"上下文预算: {FormatNumber(budget, false)} token(0 = 不支持长录像)\r\n"
|
$"上下文预算: {FormatNumber(budget, false)} token(0 = 不支持长录像)\r\n"
|
||||||
+ $"分段预算: {FormatNumber(sliceBudget, false)} token,实际切分 {_slices.Length} 段\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}]",
|
+ $"[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
||||||
false);
|
false);
|
||||||
|
|
||||||
@@ -334,15 +352,25 @@ namespace AnotherReplayReader
|
|||||||
var overviewMessages = BuildSystemMessages(_systemPrompt, _digest, null)
|
var overviewMessages = BuildSystemMessages(_systemPrompt, _digest, null)
|
||||||
.Add(new AIAnalyze.ChatMessage("user", AIAnalyze.BuildOverviewUserPrompt(_slices)));
|
.Add(new AIAnalyze.ChatMessage("user", AIAnalyze.BuildOverviewUserPrompt(_slices)));
|
||||||
CheckAndLogContextUsage(overviewMessages, requestContext);
|
CheckAndLogContextUsage(overviewMessages, requestContext);
|
||||||
var overviewResult = await _analyzer.CompleteAsync(
|
AIAnalyze.Result overviewResult = default;
|
||||||
overviewMessages,
|
var overviewAttempt = 0;
|
||||||
requestContext,
|
do
|
||||||
OnChunk,
|
{
|
||||||
_linkedCts.Token);
|
overviewResult = await _analyzer.CompleteAsync(
|
||||||
UpdateTokenDisplay(overviewResult);
|
overviewMessages,
|
||||||
_overview = OverviewParser.Parse(overviewResult.Response);
|
requestContext,
|
||||||
_overviewNarrative = _overview.Narrative;
|
OnChunk,
|
||||||
AppendLog("整局总览", overviewResult.Response, false);
|
_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)
|
if (_overview.Segments.IsEmpty)
|
||||||
{
|
{
|
||||||
AppendLog("总览解析警告", "未解析到 [分段概述] 块,将直接使用机械分段。", false);
|
AppendLog("总览解析警告", "未解析到 [分段概述] 块,将直接使用机械分段。", false);
|
||||||
@@ -413,11 +441,22 @@ namespace AnotherReplayReader
|
|||||||
_lastContentParagraph = null;
|
_lastContentParagraph = null;
|
||||||
|
|
||||||
var eventCount = _eventCounts?.Query(slice.Start, slice.End) ?? slice.EventCount;
|
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 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(
|
var instruction = AIAnalyze.BuildSegmentUserPromptV2(
|
||||||
_currentSegmentIndex, totalSegments, slice, eventCount, title);
|
_currentSegmentIndex,
|
||||||
|
totalSegments,
|
||||||
|
slice,
|
||||||
|
eventCount,
|
||||||
|
title,
|
||||||
|
overviewEntry?.Description,
|
||||||
|
overviewEntry?.BackqueryHints);
|
||||||
|
|
||||||
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
|
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
|
||||||
.Add(new AIAnalyze.ChatMessage("user", sliceText));
|
.Add(new AIAnalyze.ChatMessage("user", sliceText));
|
||||||
@@ -441,6 +480,8 @@ namespace AnotherReplayReader
|
|||||||
string segmentResponse;
|
string segmentResponse;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
|
// 回查会增长同一会话的消息列表,每次请求前都重新做预算检查。
|
||||||
|
CheckAndLogContextUsage(segmentMessages, requestContext);
|
||||||
var segmentResult = await _analyzer!.CompleteAsync(
|
var segmentResult = await _analyzer!.CompleteAsync(
|
||||||
segmentMessages,
|
segmentMessages,
|
||||||
requestContext,
|
requestContext,
|
||||||
@@ -506,10 +547,13 @@ namespace AnotherReplayReader
|
|||||||
if (validationResult.RequiresRevision)
|
if (validationResult.RequiresRevision)
|
||||||
{
|
{
|
||||||
var issueCount = validationResult.Issues.Count(
|
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(
|
AppendLog(
|
||||||
$"第{segmentIndex}段修订",
|
$"第{segmentIndex}段修订",
|
||||||
$"验证器发现 {issueCount} 个矛盾问题,正在请求 AI 修正...",
|
$"验证器发现 {issueCount} 个需要修正/降级的问题,正在请求 AI 修正...",
|
||||||
false);
|
false);
|
||||||
var relevantFacts = RelevantFactsFormatter.Format(validationResult.Claims, _factIndex!);
|
var relevantFacts = RelevantFactsFormatter.Format(validationResult.Claims, _factIndex!);
|
||||||
var revisionPrompt = AIAnalyze.BuildRevisionUserPrompt(
|
var revisionPrompt = AIAnalyze.BuildRevisionUserPrompt(
|
||||||
@@ -655,7 +699,7 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
total += AiContextBudget.EstimateTokens(message.Content);
|
total += AiContextBudget.EstimateTokens(message.Content);
|
||||||
}
|
}
|
||||||
var check = AiContextBudget.CheckPromptUsage(
|
var check = AiContextBudget.CheckRequestUsage(
|
||||||
total, requestContext.Provider, requestContext.Model);
|
total, requestContext.Provider, requestContext.Model);
|
||||||
if (check.Block)
|
if (check.Block)
|
||||||
{
|
{
|
||||||
@@ -678,6 +722,29 @@ namespace AnotherReplayReader
|
|||||||
return text.Substring(0, chars) + "\n…(已截断)";
|
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)
|
private void OnChunk(AIAnalyze.AIChunk chunk)
|
||||||
{
|
{
|
||||||
|
|||||||
+53
-1
@@ -259,16 +259,34 @@ namespace AiV2.Tests
|
|||||||
// 无 JSON → Warning
|
// 无 JSON → Warning
|
||||||
var noJson = AIAnalysisValidation.ValidateMachineReadableClaims("纯文本回复");
|
var noJson = AIAnalysisValidation.ValidateMachineReadableClaims("纯文本回复");
|
||||||
Program.Assert(noJson.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingMachineReadableClaims), "无声明产生 Warning");
|
Program.Assert(noJson.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingMachineReadableClaims), "无声明产生 Warning");
|
||||||
|
Program.Assert(noJson.RequiresRevision, "无声明现在会触发一次修订");
|
||||||
|
|
||||||
// 高置信无证据 → MissingEvidence
|
// 高置信无证据 → MissingEvidence
|
||||||
var highNoEvidence = AIAnalysisValidation.ValidateMachineReadableClaims(
|
var highNoEvidence = AIAnalysisValidation.ValidateMachineReadableClaims(
|
||||||
"[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}]}\n```");
|
"[机器可读声明]\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.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingEvidence), "高置信无证据");
|
||||||
|
Program.Assert(highNoEvidence.RequiresRevision, "高置信无证据会触发修订");
|
||||||
|
|
||||||
// 低置信无备选/待确认 → MissingAlternative
|
// 低置信无备选/待确认 → MissingAlternative
|
||||||
var lowNoAlt = AIAnalysisValidation.ValidateMachineReadableClaims(
|
var lowNoAlt = AIAnalysisValidation.ValidateMachineReadableClaims(
|
||||||
"[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"possible\",\"evidence\":[\"select|1:00|1\"]}]}\n```");
|
"[机器可读声明]\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.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, "超过预算 → 警告");
|
Program.Assert(!warn.Block && !warn.IsOk, "超过预算 → 警告");
|
||||||
var ok = AiContextBudget.CheckPromptUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 });
|
var ok = AiContextBudget.CheckPromptUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 });
|
||||||
Program.Assert(ok.IsOk, "预算内 → 通过");
|
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("建造者"), "建造者段");
|
||||||
Program.Assert(digest.Contains("第1段"), "分段元数据");
|
Program.Assert(digest.Contains("第1段"), "分段元数据");
|
||||||
Program.Assert(digest.Contains("开始建造建筑"), "关键事件采样");
|
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(389)
|
||||||
&& index.UnitIdFirstObservedTime.ContainsKey(587)
|
&& index.UnitIdFirstObservedTime.ContainsKey(587)
|
||||||
&& index.UnitIdFirstObservedTime.ContainsKey(322), "首次出现记录");
|
&& 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
|
Program.Assert(knowledge.GetEntity("AlliedMCV") is { } mcv
|
||||||
&& mcv.HasTag(KnowledgeTag.Builder)
|
&& mcv.HasTag(KnowledgeTag.Builder)
|
||||||
&& mcv.HasTag(KnowledgeTag.Unpack), "AlliedMCV 标签");
|
&& 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 文本)
|
// 不存在的 mod → null(无结构化数据时不剥离 flat 文本)
|
||||||
Program.Assert(StructuredKnowledge.GetForMod("corona") is null, "corona 无结构化文件 → null");
|
Program.Assert(StructuredKnowledge.GetForMod("corona") is null, "corona 无结构化文件 → null");
|
||||||
@@ -601,9 +650,12 @@ namespace AiV2.Tests
|
|||||||
Program.Assert(overview.Contains("[分段概述]"), "总览轮要求 [分段概述] 块");
|
Program.Assert(overview.Contains("[分段概述]"), "总览轮要求 [分段概述] 块");
|
||||||
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("第1/1段"), "段指令编号");
|
||||||
Program.Assert(segment.Contains("开局"), "段标题");
|
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("[小结]"), "小结要求");
|
Program.Assert(segment.Contains("[小结]"), "小结要求");
|
||||||
Program.Assert(segment.Contains("[机器可读声明]"), "机器可读声明要求");
|
Program.Assert(segment.Contains("[机器可读声明]"), "机器可读声明要求");
|
||||||
|
|||||||
@@ -67,6 +67,10 @@
|
|||||||
<Content Include="knowledge_units_default.json">
|
<Content Include="knowledge_units_default.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<!-- 临时快照:StringHashes 来自本地 RA3-MODSDK-X;后续应改为可配置路径或只打包需要的 hash 子集。 -->
|
||||||
|
<Content Include="Data\StringHashes.xml">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Target Name="CustomAfterBuild" AfterTargets="Build" Condition="'$(AiV2TestsBuilding)' != 'true'">
|
<Target Name="CustomAfterBuild" AfterTargets="Build" Condition="'$(AiV2TestsBuilding)' != 'true'">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Context
|
# Context
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史术语表,部分定义已与 v2 管线不一致,不再作为当前依据,正文不再更新;当前以 `PLAN_ai_analysis_v2.md` 和代码为准。
|
||||||
|
|
||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
### Operation Fact
|
### Operation Fact
|
||||||
|
|||||||
+47871
File diff suppressed because it is too large
Load Diff
+20
-2
@@ -121,7 +121,20 @@ namespace AnotherReplayReader
|
|||||||
|
|
||||||
public void LoadStringHashes()
|
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);
|
XDocument doc = XDocument.Parse(stringHashes);
|
||||||
XNamespace ns = "uri:ea.com:eala:asset";
|
XNamespace ns = "uri:ea.com:eala:asset";
|
||||||
var table = doc
|
var table = doc
|
||||||
@@ -175,7 +188,7 @@ namespace AnotherReplayReader
|
|||||||
_cached = text;
|
_cached = text;
|
||||||
_cachedPrefixSums = prefixSums;
|
_cachedPrefixSums = prefixSums;
|
||||||
_cachedSpans = spans;
|
_cachedSpans = spans;
|
||||||
_cachedFactIndex = ReplayFactIndex.Build(_model.Commands, _stringHashes);
|
_cachedFactIndex = await Task.Run(() => ReplayFactIndex.Build(_model.Commands, _stringHashes));
|
||||||
// display KB and K tokens in _tokenUsageLabel
|
// display KB and K tokens in _tokenUsageLabel
|
||||||
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
|
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
|
||||||
}
|
}
|
||||||
@@ -454,6 +467,11 @@ namespace AnotherReplayReader
|
|||||||
MessageBox.Show(this, "请先选择录像");
|
MessageBox.Show(this, "请先选择录像");
|
||||||
return;
|
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)
|
if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums || _cachedFactIndex is not { } factIndex)
|
||||||
{
|
{
|
||||||
MessageBox.Show(this, "请先生成文本");
|
MessageBox.Show(this, "请先生成文本");
|
||||||
|
|||||||
+11
-2
@@ -22,12 +22,21 @@
|
|||||||
|
|
||||||
**实施中的取舍与遗留**
|
**实施中的取舍与遗留**
|
||||||
|
|
||||||
|
- `Data/StringHashes.xml` 是随仓库分发的本地 SDK 临时快照(约 3.5MB / 47,860 条),后续应改为可配置路径或只打包需要的 hash 子集。
|
||||||
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
|
- Corona 结构化知识(`knowledge_units_corona.json`)尚未编写:Corona 当前走 flat 文本(不剥离、不注入结构化条目),验证回退到启发式。
|
||||||
- 修订 pass 的展示采用"实时流式 + 修订后整段替换";ADR 0001 的"完全缓冲至验证完成"仍是开放项。
|
- 修订 pass 的展示采用"实时流式 + 修订后整段替换";ADR 0001 的"完全缓冲至验证完成"仍是开放项。
|
||||||
- `Fatal` 严重度暂不产生 issue,语义保留为修订失败兜底(修订输出为空时保留原分析)。
|
- `Fatal` 在“所有机器可读声明块均无法解析”时产生;修订输出为空时保留原分析。
|
||||||
- `MissingMachineReadableClaims` 仍为 Warning(仅记录),是否触发"修复请求"待定。
|
- `MissingMachineReadableClaims` 为 Warning,并与其他 Warning/WeakEvidence 一样触发一次隐藏修订;是否保留该策略待 A/B 评估。
|
||||||
- 测试工程 `AiV2.Tests` 通过 `ProjectReference` 引用主工程;构建时通过 `AiV2TestsBuilding=true` 跳过主工程的 DLL 移动目标。
|
- 测试工程 `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. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|
||||||
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地(ADR 0001)。
|
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地(ADR 0001)。
|
||||||
|
|||||||
@@ -86,8 +86,13 @@ namespace AnotherReplayReader.Utils
|
|||||||
public static AIValidationResult Empty { get; } =
|
public static AIValidationResult Empty { get; } =
|
||||||
new(AIMachineReadableClaims.Empty, ImmutableArray<AIValidationIssue>.Empty);
|
new(AIMachineReadableClaims.Empty, ImmutableArray<AIValidationIssue>.Empty);
|
||||||
|
|
||||||
|
// 除 Info 外,任何需要模型修正/降级的验证问题都触发一次隐藏修订;
|
||||||
|
// 一次修订后仍遗留的问题只记录,不再循环请求。
|
||||||
public bool RequiresRevision =>
|
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;
|
public bool HasIssues => !Issues.IsEmpty;
|
||||||
}
|
}
|
||||||
@@ -118,6 +123,10 @@ namespace AnotherReplayReader.Utils
|
|||||||
var claims = ParseClaims(jsonBlocks, issues);
|
var claims = ParseClaims(jsonBlocks, issues);
|
||||||
if (claims is null)
|
if (claims is null)
|
||||||
{
|
{
|
||||||
|
issues.Add(new AIValidationIssue(
|
||||||
|
AIValidationSeverity.Fatal,
|
||||||
|
AIValidationIssueKind.InvalidMachineReadableClaims,
|
||||||
|
"所有机器可读声明块都无法解析,当前输出不可用于自动验证。"));
|
||||||
return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable());
|
return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +523,13 @@ namespace AnotherReplayReader.Utils
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只有“基地车/主基地”这类同时具备 pack/unpack 的实体才存在 MCV vs 矿车的歧义;
|
||||||
|
// 矿车本身只有 unpack,不应被这条规则误伤。
|
||||||
|
if (!LooksLikeUnpackAmbiguousUnit(claim.Claim))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
issues.Add(new AIValidationIssue(
|
issues.Add(new AIValidationIssue(
|
||||||
AIValidationSeverity.WeakEvidence,
|
AIValidationSeverity.WeakEvidence,
|
||||||
AIValidationIssueKind.MissingAlternative,
|
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(
|
private static void ValidateTimelineConsistency(
|
||||||
AIMachineReadableClaims claims,
|
AIMachineReadableClaims claims,
|
||||||
ReplayFactIndex factIndex,
|
ReplayFactIndex factIndex,
|
||||||
|
|||||||
+24
-10
@@ -204,7 +204,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
|
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
|
||||||
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
|
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
|
||||||
|
|
||||||
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
|
如果没有可验证推测,请输出空 JSON 对象(三个字段均为空数组)。不要在 JSON 里写注释。
|
||||||
|
|
||||||
# 推理指南
|
# 推理指南
|
||||||
推理需要分成多个阶段
|
推理需要分成多个阶段
|
||||||
@@ -1040,20 +1040,27 @@ PlayerA: 开始出兵
|
|||||||
int totalSegments,
|
int totalSegments,
|
||||||
ReplaySlice slice,
|
ReplaySlice slice,
|
||||||
int eventCount,
|
int eventCount,
|
||||||
string? title)
|
string? title,
|
||||||
|
string? description = null,
|
||||||
|
IEnumerable<string>? backqueryHints = null)
|
||||||
{
|
{
|
||||||
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
|
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
|
||||||
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
|
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
|
||||||
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
|
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 = @$"
|
var instruction = @$"
|
||||||
请重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
|
请重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
|
||||||
本段共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}
|
本段共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}{descriptionLine}
|
||||||
|
{hintLine}
|
||||||
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实。
|
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实。
|
||||||
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
|
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
|
||||||
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
|
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
|
||||||
|
|
||||||
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
|
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
|
||||||
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组。
|
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空 JSON 对象。
|
||||||
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
|
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
|
||||||
";
|
";
|
||||||
return instruction.Trim().Replace("\r", "");
|
return instruction.Trim().Replace("\r", "");
|
||||||
@@ -1383,15 +1390,22 @@ PlayerA: 开始出兵
|
|||||||
ImmutableList<ChatMessage> messages,
|
ImmutableList<ChatMessage> messages,
|
||||||
Dictionary<string, object> inputRequestParams)
|
Dictionary<string, object> inputRequestParams)
|
||||||
{
|
{
|
||||||
return new Dictionary<string, object>(inputRequestParams)
|
var parameters = new Dictionary<string, object>(inputRequestParams)
|
||||||
{
|
{
|
||||||
["messages"] = messages.ToArray(),
|
["messages"] = messages.ToArray()
|
||||||
["stream"] = true,
|
};
|
||||||
["stream_options"] = new
|
// 尊重模型配置里的 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
|
include_usage = true
|
||||||
}
|
};
|
||||||
};
|
}
|
||||||
|
return parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,29 +43,53 @@ namespace AnotherReplayReader.Utils
|
|||||||
public static int EstimateTokens(string text) =>
|
public static int EstimateTokens(string text) =>
|
||||||
(int)Math.Ceiling(AIAnalyze.EstimateTokenCount(text).EstimatedTokenCount * EstimatorSafetyFactor);
|
(int)Math.Ceiling(AIAnalyze.EstimateTokenCount(text).EstimatedTokenCount * EstimatorSafetyFactor);
|
||||||
|
|
||||||
/// <summary>请求护栏:超过硬上限返回 Block,超过软预算返回 Warn,否则 null。</summary>
|
/// <summary>请求护栏:只按 prompt 估算检查(保留给旧调用/测试使用)。</summary>
|
||||||
public static ContextCheckResult CheckPromptUsage(
|
public static ContextCheckResult CheckPromptUsage(
|
||||||
int estimatedPromptTokens,
|
int estimatedPromptTokens,
|
||||||
AiProvider provider,
|
AiProvider provider,
|
||||||
AiModel model)
|
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)
|
if (model.ContextLength > 0)
|
||||||
{
|
{
|
||||||
var hardLimit = (int)(model.ContextLength * HardUsageRatio);
|
var hardLimit = (int)(model.ContextLength * HardUsageRatio);
|
||||||
if (estimatedPromptTokens > hardLimit)
|
if (estimatedTotal > hardLimit)
|
||||||
{
|
{
|
||||||
return new ContextCheckResult(
|
return new ContextCheckResult(
|
||||||
true,
|
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);
|
var budget = GetContextBudget(model);
|
||||||
if (budget > 0 && estimatedPromptTokens > budget)
|
if (budget > 0 && estimatedTotal > budget)
|
||||||
{
|
{
|
||||||
return new ContextCheckResult(
|
return new ContextCheckResult(
|
||||||
false,
|
false,
|
||||||
$"估算输入 {estimatedPromptTokens:N0} token 超过上下文预算 {budget:N0}(可在模型设置中调整),长录像将自动分段,超出部分会被压缩。");
|
includeOutputHeadroom
|
||||||
|
? $"估算用量(输入 {estimatedPromptTokens:N0} + 输出余量 {headroom:N0} = {estimatedTotal:N0})超过上下文预算 {budget:N0}(可在模型设置中调整)。"
|
||||||
|
: $"估算输入 {estimatedPromptTokens:N0} token 超过上下文预算 {budget:N0}(可在模型设置中调整),长录像将自动分段,超出部分会被压缩。");
|
||||||
}
|
}
|
||||||
return ContextCheckResult.Ok;
|
return ContextCheckResult.Ok;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -123,6 +123,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
ImmutableArray<string> Tags,
|
ImmutableArray<string> Tags,
|
||||||
ImmutableArray<SpecialPowerInfo> SpecialPowers,
|
ImmutableArray<SpecialPowerInfo> SpecialPowers,
|
||||||
ImmutableArray<string> ProducedBy,
|
ImmutableArray<string> ProducedBy,
|
||||||
|
ImmutableArray<string> Aliases,
|
||||||
string Text)
|
string Text)
|
||||||
{
|
{
|
||||||
public bool IsBuilding => HasTag(KnowledgeTag.Structure);
|
public bool IsBuilding => HasTag(KnowledgeTag.Structure);
|
||||||
@@ -243,6 +244,15 @@ namespace AnotherReplayReader.Utils
|
|||||||
.OrderBy(e => e.Faction, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(e => e.Faction, StringComparer.OrdinalIgnoreCase)
|
||||||
.ThenBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToImmutableArray();
|
.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(
|
var byFaction = builtin.ToImmutableDictionary(
|
||||||
kv => kv.Key,
|
kv => kv.Key,
|
||||||
kv => kv.Value.Values.OrderBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
|
kv => kv.Value.Values.OrderBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
|
||||||
@@ -255,7 +265,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new StructuredKnowledge(
|
return new StructuredKnowledge(
|
||||||
allEntities.ToImmutableDictionary(e => e.AssetName, e => e, StringComparer.OrdinalIgnoreCase),
|
byAsset.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||||
allEntities,
|
allEntities,
|
||||||
byFaction,
|
byFaction,
|
||||||
unknownTagsArray);
|
unknownTagsArray);
|
||||||
@@ -306,6 +316,12 @@ namespace AnotherReplayReader.Utils
|
|||||||
unknownTagsLocal.Add(tag);
|
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(
|
var ek = new EntityKnowledge(
|
||||||
assetName,
|
assetName,
|
||||||
GetString(el, "displayName") ?? "",
|
GetString(el, "displayName") ?? "",
|
||||||
@@ -313,7 +329,8 @@ namespace AnotherReplayReader.Utils
|
|||||||
isBuilding ? null : GetString(el, "tier"),
|
isBuilding ? null : GetString(el, "tier"),
|
||||||
tags,
|
tags,
|
||||||
ParseSpecialPowers(el),
|
ParseSpecialPowers(el),
|
||||||
isBuilding ? ImmutableArray<string>.Empty : GetStringArray(el, "producedBy"),
|
isBuilding ? ImmutableArray<string>.Empty : combinedProducedBy,
|
||||||
|
GetStringArray(el, "aliases"),
|
||||||
GetString(el, "text") ?? "");
|
GetString(el, "text") ?? "");
|
||||||
entries[assetName] = ek;
|
entries[assetName] = ek;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -2,6 +2,7 @@ using AnotherReplayReader.ReplayFile;
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
@@ -166,6 +167,28 @@ namespace AnotherReplayReader.Utils
|
|||||||
}
|
}
|
||||||
sb.AppendLine();
|
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("# 打包/展开");
|
sb.AppendLine("# 打包/展开");
|
||||||
var packUnits = factIndex.UnitIdSpecialPowers
|
var packUnits = factIndex.UnitIdSpecialPowers
|
||||||
.Where(kv2 => kv2.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
|
.Where(kv2 => kv2.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
|
||||||
@@ -414,7 +437,9 @@ namespace AnotherReplayReader.Utils
|
|||||||
}
|
}
|
||||||
input = input.Trim();
|
input = input.Trim();
|
||||||
var parts = input.Split(':');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ namespace AnotherReplayReader.Utils
|
|||||||
var playerStrongOwnership = new Dictionary<int, HashSet<uint>>();
|
var playerStrongOwnership = new Dictionary<int, HashSet<uint>>();
|
||||||
var playerWeakOwnership = new Dictionary<int, HashSet<uint>>();
|
var playerWeakOwnership = new Dictionary<int, HashSet<uint>>();
|
||||||
var playerTechChoices = new Dictionary<int, HashSet<string>>();
|
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)
|
foreach (var (time, commands) in timeline)
|
||||||
{
|
{
|
||||||
@@ -121,7 +122,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
Dictionary<int, HashSet<uint>> playerWeakOwnership,
|
Dictionary<int, HashSet<uint>> playerWeakOwnership,
|
||||||
Dictionary<int, HashSet<string>> playerTechChoices,
|
Dictionary<int, HashSet<string>> playerTechChoices,
|
||||||
Dictionary<int, HashSet<uint>> controlGroups)
|
Dictionary<long, HashSet<uint>> controlGroups)
|
||||||
{
|
{
|
||||||
var player = command.PlayerIndex;
|
var player = command.PlayerIndex;
|
||||||
var cmdId = command.CommandId;
|
var cmdId = command.CommandId;
|
||||||
@@ -130,6 +131,10 @@ namespace AnotherReplayReader.Utils
|
|||||||
{
|
{
|
||||||
// select unit(s): 0x1F5
|
// select unit(s): 0x1F5
|
||||||
case 0x1F5:
|
case 0x1F5:
|
||||||
|
// 选择相同单位(W):ObjectId 语义与选择相同,作为弱所有权
|
||||||
|
case 0x1F6:
|
||||||
|
// 选择所有单位(Q):若能解析出 UnitId,同样作为弱所有权
|
||||||
|
case 0x22A:
|
||||||
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected, playerWeakOwnership);
|
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected, playerWeakOwnership);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -460,7 +465,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
int player,
|
int player,
|
||||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
Dictionary<int, HashSet<uint>> controlGroups)
|
Dictionary<long, HashSet<uint>> controlGroups)
|
||||||
{
|
{
|
||||||
// Data[0]: Int32 编队号;Data[1..]: 成员 ObjectId
|
// Data[0]: Int32 编队号;Data[1..]: 成员 ObjectId
|
||||||
int? groupNumber = null;
|
int? groupNumber = null;
|
||||||
@@ -498,7 +503,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var group = new HashSet<uint>(members);
|
var group = new HashSet<uint>(members);
|
||||||
controlGroups[groupNumber.Value] = group;
|
controlGroups[ControlGroupKey(player, groupNumber.Value)] = group;
|
||||||
foreach (var id in members)
|
foreach (var id in members)
|
||||||
{
|
{
|
||||||
TryRecordFirstObserved(id, time, unitFirstObserved);
|
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||||
@@ -512,7 +517,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
int player,
|
int player,
|
||||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||||
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||||
Dictionary<int, HashSet<uint>> controlGroups)
|
Dictionary<long, HashSet<uint>> controlGroups)
|
||||||
{
|
{
|
||||||
foreach (var entry in command.Data)
|
foreach (var entry in command.Data)
|
||||||
{
|
{
|
||||||
@@ -529,7 +534,7 @@ namespace AnotherReplayReader.Utils
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (controlGroups.TryGetValue(groupNumber.Value, out var members))
|
if (controlGroups.TryGetValue(ControlGroupKey(player, groupNumber.Value), out var members))
|
||||||
{
|
{
|
||||||
foreach (var id in 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(
|
private static void RecordTechChoice(
|
||||||
TimeSpan time,
|
TimeSpan time,
|
||||||
CommandChunk command,
|
CommandChunk command,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# AI Analysis WIP
|
# AI Analysis WIP
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史设计/进度记录,不再作为当前依据,正文不再更新;当前以 `PLAN_ai_analysis_v2.md` 和代码为准。
|
||||||
|
|
||||||
## User Need
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR 0001: Hidden Revision Pass for AI Analysis
|
# ADR 0001: Hidden Revision Pass for AI Analysis
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史决策记录,其中的“实现状态”已过时;当前实现以代码和 `PLAN_ai_analysis_v2.md` 为准,正文不再更新。
|
||||||
|
|
||||||
**Date:** 2026-07-06
|
**Date:** 2026-07-06
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR 0002: Structured Game Knowledge for Prompt and Validation
|
# ADR 0002: Structured Game Knowledge for Prompt and Validation
|
||||||
|
|
||||||
|
> ⚠️ 已归档(2026-08-21):本文是历史决策记录,其设想与当前实现存在差异;当前实现以代码和 `PLAN_ai_analysis_v2.md` 为准,正文不再更新。
|
||||||
|
|
||||||
**Date:** 2026-07-07
|
**Date:** 2026-07-07
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|||||||
+2
-2
@@ -119,7 +119,7 @@
|
|||||||
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
|
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
|
||||||
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
|
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
|
||||||
|
|
||||||
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
|
如果没有可验证推测,请输出空 JSON 对象(三个字段均为空数组)。不要在 JSON 里写注释。
|
||||||
|
|
||||||
# 推理指南
|
# 推理指南
|
||||||
推理需要分成多个阶段
|
推理需要分成多个阶段
|
||||||
@@ -736,4 +736,4 @@ PlayerA: 开始出兵
|
|||||||
地图边界(X):0~5490
|
地图边界(X):0~5490
|
||||||
地图边界(Y):0~4250
|
地图边界(Y):0~4250
|
||||||
|
|
||||||
中立建筑:[UnitId]的范围从10~227的单位都属于地图物件或中立建筑
|
中立建筑:[UnitId]的范围从10~227的单位都属于地图物件或中立建筑
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
|
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
|
||||||
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
|
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
|
||||||
|
|
||||||
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
|
如果没有可验证推测,请输出空 JSON 对象(三个字段均为空数组)。不要在 JSON 里写注释。
|
||||||
|
|
||||||
# 推理指南
|
# 推理指南
|
||||||
推理需要分成多个阶段
|
推理需要分成多个阶段
|
||||||
@@ -725,4 +725,4 @@ PlayerA: 开始出兵
|
|||||||
|
|
||||||
地图中央点:(X=2650,Y=1900,Z=280)
|
地图中央点:(X=2650,Y=1900,Z=280)
|
||||||
地图边界(X):0~5300
|
地图边界(X):0~5300
|
||||||
地图边界(Y):0~3800
|
地图边界(Y):0~3800
|
||||||
|
|||||||
Reference in New Issue
Block a user