ai plan v2

This commit is contained in:
2026-08-21 15:34:24 +02:00
parent 9555423f51
commit d06a93f759
20 changed files with 3093 additions and 523 deletions
+540
View File
@@ -0,0 +1,540 @@
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();
}
}
}