Files
AnotherReplayReader/Utils/AiV2Pipeline.cs
T

934 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
namespace AnotherReplayReader.Utils
{
/// <summary>一段操作记录(一个时间分块)在全文中的位置与规模。</summary>
internal sealed record EventSpan(TimeSpan Time, int StartIndex, int Length, int EstimatedTokens);
/// <summary>机械分段得到的操作记录切片。</summary>
internal sealed record ReplaySlice(
int Index,
TimeSpan Start,
TimeSpan End,
int StartIndex,
int Length,
int EventCount,
int EstimatedTokens)
{
public string GetText(string fullText) => fullText.Substring(StartIndex, Length);
}
/// <summary>
/// 机械分段:按 token 预算切分操作记录,相邻段重叠前一段尾部。
/// </summary>
internal static class MechanicalSegmenter
{
public const int MinSliceTokens = 2_000;
public const int MaxSlices = 20;
public static (ImmutableArray<ReplaySlice> Slices, ImmutableArray<string> Warnings) Slice(
string fullText,
ImmutableArray<EventSpan> spans,
int sliceTokenBudget,
int overlapTokenBudget)
{
var warnings = ImmutableArray.CreateBuilder<string>();
if (string.IsNullOrWhiteSpace(fullText) || spans.IsEmpty)
{
return (ImmutableArray<ReplaySlice>.Empty, warnings.ToImmutable());
}
var budget = Math.Max(sliceTokenBudget, MinSliceTokens);
var overlap = Math.Max(overlapTokenBudget, 200);
var slices = SliceCore(fullText, spans, budget, overlap);
if (slices.Count > MaxSlices)
{
// 先尝试压缩噪声事件块(纯选择/编队类),避免一超限就放宽预算。
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,
int budget,
int overlap)
{
var slices = new List<ReplaySlice>();
var segStart = 0;
var i = 0;
var acc = 0;
while (i < spans.Length)
{
var span = spans[i];
if (acc > 0 && acc + span.EstimatedTokens > budget && acc >= MinSliceTokens)
{
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, i));
// 下一段起点:重叠前一段尾部(重叠总 token ≤ overlap
var overlapStart = i;
var overlapTokens = 0;
for (var j = i - 1; j >= segStart; --j)
{
if (overlapTokens + spans[j].EstimatedTokens > overlap)
{
break;
}
overlapTokens += spans[j].EstimatedTokens;
overlapStart = j;
}
segStart = overlapStart;
acc = 0;
for (var j = segStart; j < i; ++j)
{
acc += spans[j].EstimatedTokens;
}
continue;
}
acc += span.EstimatedTokens;
++i;
}
if (segStart < spans.Length)
{
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, spans.Length));
}
return slices;
}
private static ReplaySlice CreateSlice(
int index,
string fullText,
ImmutableArray<EventSpan> spans,
int start,
int endExclusive)
{
var first = spans[start];
var last = spans[endExclusive - 1];
var tokens = 0;
for (var j = start; j < endExclusive; ++j)
{
tokens += spans[j].EstimatedTokens;
}
return new ReplaySlice(
index,
first.Time,
last.Time,
first.StartIndex,
last.StartIndex + last.Length - first.StartIndex,
endExclusive - start,
tokens);
}
}
/// <summary>
/// 段内的焦点窗口:模型每轮分析的重点时间范围,而不是数据切片的边界。
/// 数据层仍提供整个机械分段切片(上下文允许时尽量长),焦点窗口只决定“重点分析哪段时间”。
/// </summary>
internal sealed record FocusWindow(int Index, TimeSpan Start, TimeSpan End, int EventCount, int EstimatedTokens);
/// <summary>
/// 把机械分段切分为多个“焦点窗口”。与 MechanicalSegmenter 不同:
/// 焦点窗口不改变模型可见的数据范围,只划分每轮分析的重点,用于避免模型一次分析过长的时间段。
/// </summary>
internal static class FocusPlanner
{
/// <summary>焦点窗口的目标 token 大小(近似)。</summary>
public const int DefaultWindowTokens = 12_000;
/// <summary>单个机械分段最多切分的焦点窗口数。</summary>
public const int MaxWindowsPerSlice = 5;
/// <summary>小于该 token 数的机械分段不再细分(直接作为单一焦点窗口)。</summary>
public const int MinSliceForSplitTokens = DefaultWindowTokens * 2;
public static ImmutableArray<FocusWindow> Plan(
ReplaySlice slice,
ImmutableArray<EventSpan> fullSpans)
{
if (slice.EventCount <= 0 || fullSpans.IsEmpty)
{
return ImmutableArray<FocusWindow>.Empty;
}
// 直接用切片自身的字符区间(StartIndex/Length)在 span 索引中定位,
// 避免按时间范围匹配与机械分段(含重叠)的实际内容不一致。
var startIndex = 0;
while (startIndex < fullSpans.Length
&& fullSpans[startIndex].StartIndex < slice.StartIndex)
{
startIndex++;
}
var endIndexExclusive = startIndex;
while (endIndexExclusive < fullSpans.Length
&& fullSpans[endIndexExclusive].StartIndex < slice.StartIndex + slice.Length)
{
endIndexExclusive++;
}
if (startIndex >= fullSpans.Length || endIndexExclusive <= startIndex)
{
// 回退:用切片自身的长度作为单一窗口(不应发生,防御性处理)。
return ImmutableArray.Create(
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens));
}
// 若切片本身不大,或者时间太短,则单一窗口。
var totalTokens = 0;
for (var i = startIndex; i < endIndexExclusive; ++i)
{
totalTokens += fullSpans[i].EstimatedTokens;
}
if (totalTokens <= MinSliceForSplitTokens
|| endIndexExclusive - startIndex <= 1)
{
return ImmutableArray.Create(
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, totalTokens));
}
var windows = new List<FocusWindow>();
var acc = 0;
var winStart = startIndex;
for (var i = startIndex; i < endIndexExclusive; ++i)
{
var span = fullSpans[i];
if (acc > 0 && acc + span.EstimatedTokens > DefaultWindowTokens
&& i - winStart >= 1)
{
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, i));
winStart = i;
acc = 0;
}
acc += span.EstimatedTokens;
}
if (winStart < endIndexExclusive)
{
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, endIndexExclusive));
}
// 超过上限时合并尾部窗口(优先合并 token 较小的相邻窗口,保持时间顺序)。
while (windows.Count > MaxWindowsPerSlice)
{
var best = -1;
var bestTokens = int.MaxValue;
for (var i = 0; i < windows.Count - 1 && windows.Count > MaxWindowsPerSlice; ++i)
{
var merged = windows[i].EstimatedTokens + windows[i + 1].EstimatedTokens;
if (merged < bestTokens)
{
best = i;
bestTokens = merged;
}
}
if (best < 0)
{
break;
}
windows[best] = MergeWindows(windows[best], windows[best + 1]);
windows.RemoveAt(best + 1);
RenumberWindows(windows);
}
return windows.ToImmutableArray();
}
private static FocusWindow CreateWindow(
int index,
ReplaySlice slice,
ImmutableArray<EventSpan> fullSpans,
int start,
int endExclusive)
{
var tokens = 0;
var events = 0;
for (var j = start; j < endExclusive; ++j)
{
tokens += fullSpans[j].EstimatedTokens;
events++;
}
return new FocusWindow(
index,
fullSpans[start].Time,
fullSpans[endExclusive - 1].Time,
events,
tokens);
}
private static FocusWindow MergeWindows(FocusWindow a, FocusWindow b)
{
return new FocusWindow(
a.Index,
a.Start,
b.End,
a.EventCount + b.EventCount,
a.EstimatedTokens + b.EstimatedTokens);
}
private static void RenumberWindows(List<FocusWindow> windows)
{
for (var i = 0; i < windows.Count; ++i)
{
windows[i] = windows[i] with { Index = i };
}
}
}
/// <summary>
/// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。
/// </summary>
internal static class MatchDigestBuilder
{
public static string Build(
ReplayFactIndex factIndex,
ImmutableSortedDictionary<int, Player> players,
Mod mod,
ImmutableArray<ReplaySlice> slices,
string fullText)
{
var sb = new StringBuilder();
var names = AIAnalyze.PlayerNamesForAI(mod, players);
sb.AppendLine("# 玩家");
foreach (var kv in players)
{
var faction = ModData.GetFaction(mod, kv.Value.FactionId);
var factionName = faction.Name;
if (faction.Kind == FactionKind.Observer)
{
sb.AppendLine(
$"- 玩家#{kv.Key} {kv.Value.PlayerName}{names[kv.Key]}),"
+ $"{factionName},解说员(观战),不参与对局");
}
else
{
var kind = kv.Value.IsComputer ? "电脑" : "玩家";
var teamText = kv.Value.Team < 0 ? "无队伍" : $"队伍{kv.Value.Team}";
sb.AppendLine(
$"- 玩家#{kv.Key} {kv.Value.PlayerName}{names[kv.Key]}),"
+ $"{factionName}{teamText}{kind}");
}
}
sb.AppendLine();
sb.AppendLine("# 首次出兵时间表(命令开始时间)");
if (factIndex.PlayerFirstProductionTime.IsEmpty)
{
sb.AppendLine("- (无)");
}
else
{
foreach (var kv in factIndex.PlayerFirstProductionTime.OrderBy(k => k.Key))
{
var productions = kv.Value
.OrderBy(x => x.Value)
.Take(15)
.Select(x => $"{x.Key}@{FormatTime(x.Value)}");
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):{string.Join("", productions)}");
}
}
sb.AppendLine();
sb.AppendLine("# 协议选择");
if (factIndex.PlayerTechChoices.IsEmpty)
{
sb.AppendLine("- (无)");
}
else
{
foreach (var kv in factIndex.PlayerTechChoices.OrderBy(k => k.Key))
{
sb.AppendLine($"- 玩家#{kv.Key}{names[kv.Key]}):{string.Join("", kv.Value.OrderBy(x => x))}");
}
}
sb.AppendLine();
var unitRoles = BuildUnitRoleMap(factIndex);
sb.AppendLine("# 所有权证据(节选)");
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds.OrderBy(k => k.Key))
{
if (IsObserver(mod, players, kv.Key))
{
continue;
}
var ids = kv.Value.OrderBy(x => x).Take(20);
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
sb.AppendLine(
$"- 玩家#{kv.Key}{names[kv.Key]}):强证据 {kv.Value.Count} 个 UnitId"
+ $"{string.Join("", ids.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix}");
}
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds.OrderBy(k => k.Key))
{
if (IsObserver(mod, players, kv.Key))
{
continue;
}
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.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix}");
}
sb.AppendLine();
sb.AppendLine("# 打包/展开(按实际事件时间)");
var packEvents = factIndex.SpecialPowerEvents
.Where(e => ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|| ContainsIgnoreCase(e.PowerName, "UnpackReplaceSelf"))
.OrderBy(e => e.Time)
.ThenBy(e => e.PlayerIndex);
if (!packEvents.Any())
{
sb.AppendLine("- (无)");
}
foreach (var e in packEvents)
{
var action = ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
? "Pack"
: "Unpack";
var playerName = names.TryGetValue(e.PlayerIndex, out var name)
? name
: $"玩家#{e.PlayerIndex}";
var conflict = IsPackFactionConflict(mod, players, e)
? " [阵营冲突,需回查]"
: string.Empty;
sb.AppendLine(
$"- 玩家#{e.PlayerIndex}{playerName}UnitId {e.UnitId}"
+ $"{action}@{FormatTime(e.Time)}{conflict}");
}
sb.AppendLine();
sb.AppendLine("# 建造者/出兵建筑");
sb.AppendLine($"- 建造者:{string.Join("", factIndex.BuilderUnitIds.OrderBy(x => x).Take(20))}");
sb.AppendLine($"- 出兵建筑:{string.Join("", factIndex.ProducerUnitIds.OrderBy(x => x).Take(20))}");
sb.AppendLine();
sb.AppendLine("# 分段");
foreach (var slice in slices)
{
sb.AppendLine($"- 第{slice.Index + 1}段:{FormatTime(slice.Start)}~{FormatTime(slice.End)},事件数 {slice.EventCount},约 {slice.EstimatedTokens:N0} token");
}
sb.AppendLine();
sb.AppendLine("# 各段关键事件采样");
foreach (var slice in slices)
{
sb.AppendLine($"## 第{slice.Index + 1}段");
foreach (var line in SampleKeyEvents(slice.GetText(fullText), 6))
{
sb.AppendLine($"- {line}");
}
}
return sb.ToString().Replace("\r", "");
}
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
{
var result = new List<string>();
var counts = new Dictionary<(string Player, string Category), int>();
var seen = new HashSet<string>();
var currentTime = string.Empty;
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
{
if (result.Count >= maxEvents)
{
break;
}
var line = rawLine.TrimEnd();
var trimmed = line.Trim();
if (TimeStampPattern.IsMatch(trimmed))
{
currentTime = TimeStampPattern.Match(trimmed).Groups[1].Value;
continue;
}
// 参数/续行统一跳过,避免把 [UnitId]... 当作事件
if (string.IsNullOrWhiteSpace(trimmed)
|| line.StartsWith(" ", StringComparison.Ordinal)
|| line.StartsWith("\t", StringComparison.Ordinal)
|| trimmed.StartsWith("[", StringComparison.Ordinal))
{
continue;
}
var commandMatch = CommandPattern.Match(trimmed);
if (!commandMatch.Success)
{
continue;
}
var player = commandMatch.Groups[1].Value.Trim();
var command = commandMatch.Groups[2].Value.Trim();
var category = GetEventCategory(command);
if (category is null)
{
continue;
}
if (!seen.Add(player + "|" + command))
{
continue;
}
var key = (player, category);
counts.TryGetValue(key, out var count);
if (count >= 2)
{
continue;
}
counts[key] = count + 1;
result.Add($"[{currentTime}] {player}: {command}");
}
return result.ToImmutableArray();
}
private static string? GetEventCategory(string command)
{
if (command.Contains("开始建造")) return "建造";
if (command.Contains("摆放建筑")) return "摆放";
if (command.Contains("出售建筑")) return "出售";
if (command.Contains("开始出兵")) return "生产";
if (command.Contains("开始升级")) return "升级";
if (command.Contains("选择协议")) return "协议";
if (command.Contains("释放特殊能力")) return "技能";
return null;
}
private static readonly Regex TimeStampPattern = new(
@"^\[(\d+:\d+(?:\.\d+)?)\]$",
RegexOptions.Compiled);
private static readonly Regex CommandPattern = new(
@"^([^:,]+)\s*[:,]\s*(.+)$",
RegexOptions.Compiled);
private static Dictionary<uint, HashSet<string>> BuildUnitRoleMap(ReplayFactIndex factIndex)
{
var roles = new Dictionary<uint, HashSet<string>>();
void Add(uint unitId, string role)
{
if (!roles.TryGetValue(unitId, out var set))
{
set = new HashSet<string>();
roles[unitId] = set;
}
set.Add(role);
}
foreach (var id in factIndex.BuilderUnitIds)
{
Add(id, "建造者");
}
foreach (var id in factIndex.ProducerUnitIds)
{
Add(id, "出兵建筑");
}
foreach (var kv in factIndex.UnitIdSpecialPowers)
{
if (kv.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
{
Add(kv.Key, "打包/展开");
}
}
return roles;
}
private static string FormatUnitIdWithRole(
uint unitId,
Dictionary<uint, HashSet<string>> unitRoles)
{
if (!unitRoles.TryGetValue(unitId, out var roles) || roles.Count == 0)
{
return unitId.ToString();
}
return $"{unitId}({string.Join("/", roles.OrderBy(x => x))})";
}
private static bool IsObserver(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
int playerIndex)
{
return players.TryGetValue(playerIndex, out var player)
&& ModData.GetFaction(mod, player.FactionId).Kind == FactionKind.Observer;
}
private static bool IsPackFactionConflict(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
SpecialPowerEvent e)
{
if (!ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|| !players.TryGetValue(e.PlayerIndex, out var player))
{
return false;
}
return ModData.GetFaction(mod, player.FactionId).Name != "盟军";
}
private static bool ContainsIgnoreCase(string text, string value) =>
text.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
public static string FormatTime(TimeSpan t) => $"{(int)t.TotalMinutes}:{t:ss\\.ff}";
}
internal sealed record SegmentOverview(
int Index,
string Title,
string Description,
ImmutableArray<string> BackqueryHints);
internal sealed record OverviewResult(
string Narrative,
ImmutableArray<SegmentOverview> Segments);
/// <summary>解析总览轮输出:[分段概述] 块之前的自由文本是整局叙述,块内是每段标题/概述。</summary>
internal static class OverviewParser
{
private const string Marker = "[分段概述]";
public static OverviewResult Parse(string response)
{
var markerIndex = response.LastIndexOf(Marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
{
return new OverviewResult(response.Trim(), ImmutableArray<SegmentOverview>.Empty);
}
var narrative = response.Substring(0, markerIndex).Trim();
var builder = ImmutableArray.CreateBuilder<SegmentOverview>();
var hints = new List<string>();
SegmentOverview? current = null;
foreach (var rawLine in response.Substring(markerIndex + Marker.Length).Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var match = Regex.Match(line, @"^#(\d+)\s*(.*)$");
if (match.Success)
{
if (current is not null)
{
builder.Add(current);
}
var index = int.Parse(match.Groups[1].Value);
var rest = match.Groups[2].Value.Trim();
var sep = rest.IndexOfAny(new[] { '', ':' });
var title = sep > 0 ? rest.Substring(0, sep).Trim() : rest;
var description = sep > 0 ? rest.Substring(sep + 1).Trim() : "";
hints = new List<string>();
current = new SegmentOverview(index, title, description, ImmutableArray<string>.Empty);
continue;
}
if (current is not null && line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
{
var sepIndex = line.IndexOfAny(new[] { '', ':' });
if (sepIndex >= 0)
{
hints.Add(line.Substring(sepIndex + 1).Trim());
}
current = current with { BackqueryHints = hints.ToImmutableArray() };
}
}
if (current is not null)
{
builder.Add(current);
}
return new OverviewResult(narrative, builder.ToImmutable());
}
}
/// <summary>解析段回复中的 [回查] 标记(M3 使用)。</summary>
internal static class BackqueryParser
{
public static ImmutableArray<(TimeSpan Start, TimeSpan End)> Parse(string response)
{
var result = ImmutableArray.CreateBuilder<(TimeSpan, TimeSpan)>();
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (line.IndexOf("[回查]", StringComparison.OrdinalIgnoreCase) < 0
&& !line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var content = line.Replace("[回查]", "").Trim();
var sep = content.IndexOfAny(new[] { '', ':' });
if (sep >= 0 && content.Substring(0, sep).Trim().Equals("回查", StringComparison.OrdinalIgnoreCase))
{
content = content.Substring(sep + 1).Trim();
}
foreach (Match m in Regex.Matches(content, @"(\d+:\d+(?:\.\d+)?)\s*~\s*(\d+:\d+(?:\.\d+)?)"))
{
if (AiTimeParser.TryParse(m.Groups[1].Value, out var start)
&& AiTimeParser.TryParse(m.Groups[2].Value, out var end))
{
result.Add((start, end));
}
}
}
return result.ToImmutable();
}
}
/// <summary>按时间区间从全文切出回查所需的原始记录片段。</summary>
internal static class BackquerySliceExtractor
{
public const int MaxBackqueryTokens = 10_000;
public static (string? Text, string? Reason) Extract(
string fullText,
ImmutableArray<EventSpan> spans,
TimeSpan start,
TimeSpan end)
{
if (spans.IsEmpty)
{
return (null, "回放没有事件索引,无法回查。");
}
var first = -1;
var last = -1;
for (var i = 0; i < spans.Length; ++i)
{
if (first < 0 && spans[i].Time >= start)
{
first = i;
}
if (spans[i].Time <= end)
{
last = i;
continue;
}
break;
}
if (first < 0 || last < 0 || last < first)
{
return (null, $"区间 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)} 内没有事件。");
}
var text = fullText.Substring(
spans[first].StartIndex,
spans[last].StartIndex + spans[last].Length - spans[first].StartIndex);
var tokens = AiContextBudget.EstimateTokens(text);
if (tokens > MaxBackqueryTokens)
{
return (null, $"回查区间过大(约 {tokens:N0} token,上限 {MaxBackqueryTokens:N0})。");
}
return (text, null);
}
}
/// <summary>容错的时间解析:失败返回 false,不抛异常。</summary>
internal static class AiTimeParser
{
public static bool TryParse(string input, out TimeSpan result)
{
result = TimeSpan.Zero;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
input = input.Trim();
var parts = input.Split(':');
if (parts.Length == 0
|| !float.TryParse(parts[parts.Length - 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)
|| seconds < 0)
{
return false;
}
var totalSeconds = (long)(int)seconds;
var millis = (int)Math.Round((seconds - (int)seconds) * 1000);
long multiplier = 60;
for (var i = parts.Length - 2; i >= 0; --i)
{
if (!long.TryParse(parts[i], out var value) || value < 0)
{
return false;
}
totalSeconds += value * multiplier;
multiplier *= 60;
}
result = TimeSpan.FromSeconds(totalSeconds) + TimeSpan.FromMilliseconds(millis);
return true;
}
}
/// <summary>把验证过的机器可读声明格式化为"已发现事实",供后续分段引用。</summary>
internal static class ClaimFindingsFormatter
{
public static string Format(AIMachineReadableClaims claims)
{
var sb = new StringBuilder();
foreach (var claim in claims.UnitClaims)
{
sb.AppendLine($"- UnitId {claim.UnitId}{claim.Player}):推测 {claim.Claim},证据等级 {LevelName(claim.EvidenceLevel)}");
if (!claim.Evidence.IsEmpty)
{
sb.AppendLine($" 证据:{string.Join("", claim.Evidence)}");
}
if (!claim.Alternatives.IsEmpty)
{
sb.AppendLine($" 备选:{string.Join("", claim.Alternatives)}");
}
if (!claim.NeedsConfirmation.IsEmpty)
{
sb.AppendLine($" 待确认:{string.Join("", claim.NeedsConfirmation)}");
}
}
foreach (var claim in claims.EventClaims)
{
sb.AppendLine($"- 事件:{claim.Claim}{LevelName(claim.EvidenceLevel)}");
}
foreach (var claim in claims.TimelineClaims)
{
sb.AppendLine($"- 时间线:{claim.Claim}{LevelName(claim.EvidenceLevel)}");
}
return sb.ToString().TrimEnd();
}
public static string? ExtractSummary(string response)
{
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (line.StartsWith("[小结]", StringComparison.OrdinalIgnoreCase))
{
var text = line.Substring("[小结]".Length).Trim();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
return null;
}
private static string LevelName(AIEvidenceLevel level) => level switch
{
AIEvidenceLevel.Confirmed => "确定",
AIEvidenceLevel.HighlyLikely => "高度可能",
AIEvidenceLevel.Possible => "可能",
AIEvidenceLevel.Uncertain => "不确定",
AIEvidenceLevel.RuledOut => "已排除",
_ => "未知",
};
}
/// <summary>为修订 pass 生成与受影响声明相关的事实摘要。</summary>
internal static class RelevantFactsFormatter
{
public static string Format(AIMachineReadableClaims claims, ReplayFactIndex factIndex)
{
var sb = new StringBuilder();
foreach (var claim in claims.UnitClaims)
{
if (!uint.TryParse(claim.UnitId, out var unitId)
|| !factIndex.UnitIdFirstObservedTime.TryGetValue(unitId, out var first))
{
continue;
}
sb.AppendLine($"- UnitId {claim.UnitId}:首次出现 {MatchDigestBuilder.FormatTime(first)}"
+ (factIndex.UnitIdSpecialPowers.TryGetValue(unitId, out var powers) && powers.Count > 0
? $"观察到的能力:{string.Join("", powers.OrderBy(x => x))}"
: "未观察到特殊能力"));
if (factIndex.BuilderUnitIds.Contains(unitId))
{
sb.AppendLine(" 该 UnitId 曾作为建造者出现");
}
if (factIndex.ProducerUnitIds.Contains(unitId))
{
sb.AppendLine(" 该 UnitId 曾作为出兵建筑出现");
}
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds)
{
if (kv.Value.Contains(unitId))
{
sb.AppendLine($" 玩家 {kv.Key} 对它有强所有权证据");
}
}
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds)
{
if (kv.Value.Contains(unitId))
{
sb.AppendLine($" 玩家 {kv.Key} 对它有弱所有权证据(选中过)");
}
}
}
return sb.ToString().TrimEnd();
}
}
}