Files
AnotherReplayReader/Utils/AiV2Pipeline.cs
T
2026-08-21 15:34:24 +02:00

541 lines
21 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.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 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<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>
/// 确定性对局摘要:由 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 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<string> SampleKeyEvents(string text, int maxEvents)
{
var result = new List<string>();
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<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], 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();
}
}
}