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
+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,