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 { /// 一段操作记录(一个时间分块)在全文中的位置与规模。 internal sealed record EventSpan(TimeSpan Time, int StartIndex, int Length, int EstimatedTokens); /// 机械分段得到的操作记录切片。 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); } /// /// 机械分段:按 token 预算切分操作记录,相邻段重叠前一段尾部。 /// internal static class MechanicalSegmenter { public const int MinSliceTokens = 2_000; public const int MaxSlices = 20; public static (ImmutableArray Slices, ImmutableArray Warnings) Slice( string fullText, ImmutableArray spans, int sliceTokenBudget, int overlapTokenBudget) { var warnings = ImmutableArray.CreateBuilder(); if (string.IsNullOrWhiteSpace(fullText) || spans.IsEmpty) { return (ImmutableArray.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 CompressNoiseSpans( string fullText, ImmutableArray spans) { var result = ImmutableArray.CreateBuilder(); 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 SliceCore( string fullText, ImmutableArray spans, int budget, int overlap) { var slices = new List(); 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 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); } } /// /// 段内的焦点窗口:模型每轮分析的重点时间范围,而不是数据切片的边界。 /// 数据层仍提供整个机械分段切片(上下文允许时尽量长),焦点窗口只决定“重点分析哪段时间”。 /// internal sealed record FocusWindow(int Index, TimeSpan Start, TimeSpan End, int EventCount, int EstimatedTokens); /// /// 把机械分段切分为多个“焦点窗口”。与 MechanicalSegmenter 不同: /// 焦点窗口不改变模型可见的数据范围,只划分每轮分析的重点,用于避免模型一次分析过长的时间段。 /// internal static class FocusPlanner { /// 焦点窗口的目标 token 大小(近似)。 public const int DefaultWindowTokens = 12_000; /// 单个机械分段最多切分的焦点窗口数。 public const int MaxWindowsPerSlice = 5; /// 小于该 token 数的机械分段不再细分(直接作为单一焦点窗口)。 public const int MinSliceForSplitTokens = DefaultWindowTokens * 2; public static ImmutableArray Plan( ReplaySlice slice, ImmutableArray fullSpans) { if (slice.EventCount <= 0 || fullSpans.IsEmpty) { return ImmutableArray.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(); 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 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 windows) { for (var i = 0; i < windows.Count; ++i) { windows[i] = windows[i] with { Index = i }; } } } /// /// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。 /// internal static class MatchDigestBuilder { public static string Build( ReplayFactIndex factIndex, ImmutableSortedDictionary players, Mod mod, ImmutableArray 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 SampleKeyEvents(string text, int maxEvents) { var result = new List(); var counts = new Dictionary<(string Player, string Category), int>(); var seen = new HashSet(); 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> BuildUnitRoleMap(ReplayFactIndex factIndex) { var roles = new Dictionary>(); void Add(uint unitId, string role) { if (!roles.TryGetValue(unitId, out var set)) { set = new HashSet(); 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> 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 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 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 BackqueryHints); internal sealed record OverviewResult( string Narrative, ImmutableArray Segments); /// 解析总览轮输出:[分段概述] 块之前的自由文本是整局叙述,块内是每段标题/概述。 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.Empty); } var narrative = response.Substring(0, markerIndex).Trim(); var builder = ImmutableArray.CreateBuilder(); var hints = new List(); 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(); current = new SegmentOverview(index, title, description, ImmutableArray.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()); } } /// 解析段回复中的 [回查] 标记(M3 使用)。 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(); } } /// 按时间区间从全文切出回查所需的原始记录片段。 internal static class BackquerySliceExtractor { public const int MaxBackqueryTokens = 10_000; public static (string? Text, string? Reason) Extract( string fullText, ImmutableArray 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); } } /// 容错的时间解析:失败返回 false,不抛异常。 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; } } /// 把验证过的机器可读声明格式化为"已发现事实",供后续分段引用。 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 => "已排除", _ => "未知", }; } /// 为修订 pass 生成与受影响声明相关的事实摘要。 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(); } } }