using AnotherReplayReader.ReplayFile; using System; using System.Collections.Generic; using System.Collections.Immutable; 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 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 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); } } /// /// 确定性对局摘要:由 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 factionName = ModData.GetFaction(mod, kv.Value.FactionId).Name; var kind = kv.Value.IsComputer ? "电脑" : "玩家"; sb.AppendLine($"- 玩家#{kv.Key} {kv.Value.PlayerName}({names[kv.Key]}),{factionName},队伍{kv.Value.Team},{kind}"); } sb.AppendLine(); sb.AppendLine("# 首次出兵时间表"); 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("# 打包/展开"); var packUnits = factIndex.UnitIdSpecialPowers .Where(kv2 => kv2.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf"))) .OrderBy(kv2 => kv2.Key); foreach (var kv2 in packUnits) { var firstTime = factIndex.UnitIdFirstObservedTime.TryGetValue(kv2.Key, out var t) ? FormatTime(t) : "?"; sb.AppendLine($"- UnitId {kv2.Key}(首次出现 {firstTime}):{string.Join("、", kv2.Value.OrderBy(x => x))}"); } 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 currentTime = ""; foreach (var rawLine in text.Replace("\r", "").Split('\n')) { var line = rawLine.Trim(); if (line.StartsWith("[") && line.Contains("]")) { var end = line.IndexOf(']'); currentTime = line.Substring(1, end - 1); var rest = line.Substring(end + 1).Trim(); if (IsKeyEventLine(rest)) { result.Add($"[{currentTime}] {rest}"); if (result.Count >= maxEvents) { break; } } continue; } if (IsKeyEventLine(line)) { result.Add($"[{currentTime}] {line}"); if (result.Count >= maxEvents) { break; } } } return result.ToImmutableArray(); } private static bool IsKeyEventLine(string line) => line.Contains("开始建造") || line.Contains("摆放建筑") || line.Contains("出售建筑") || line.Contains("释放特殊能力") || line.Contains("选择协议") || line.Contains("开始出兵") || line.Contains("开始升级"); 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], 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(); } } }