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
+110 -231
View File
@@ -17,21 +17,18 @@ namespace AnotherReplayReader.Utils
{
internal sealed class AIAnalyze
{
/// <summary>聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。</summary>
public sealed record ChatMessage(
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
[property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
public static string GetSystemPrompt(
Replay replay,
ImmutableSortedDictionary<int, Player> players,
AiPromptSettings? promptSettings = null)
{
var defaultPrompt = BuildDefaultSystemPrompt(replay, players);
// Try loading from knowledge_{mod}.md file (generated by tools/expand_knowledge.py).
// If the file exists, use it instead of the built-in string.
var modName = replay.Mod.ModName?.ToLowerInvariant();
var knowledgeModName = modName switch
{
"corona" => "corona",
_ => "default",
};
string defaultPrompt;
var knowledgeModName = GetKnowledgeModName(replay);
var knowledgePath = Path.Combine(AppContext.BaseDirectory, $"knowledge_{knowledgeModName}.md");
if (File.Exists(knowledgePath))
{
@@ -43,10 +40,25 @@ namespace AnotherReplayReader.Utils
var knowledge = KnowledgeSet.ForMod(knowledgeModName, AppContext.BaseDirectory);
defaultPrompt = knowledge.RenderAsPrompt(factionNames, mapId);
}
else
{
// 旧路径:知识文件缺失时的回退(副作用 MessageBox 只在这里执行)
defaultPrompt = BuildDefaultSystemPrompt(replay, players);
}
return ComposeSystemPrompt(defaultPrompt, promptSettings);
}
/// <summary>根据回放 mod 名得到知识文件名(default/corona)。</summary>
public static string GetKnowledgeModName(Replay replay)
{
return replay.Mod.ModName?.ToLowerInvariant() switch
{
"corona" => "corona",
_ => "default",
};
}
public static string ComposeSystemPrompt(string defaultPrompt, AiPromptSettings? promptSettings)
{
var customSystemPrompt = promptSettings?.CustomSystemPrompt ?? string.Empty;
@@ -106,33 +118,27 @@ namespace AnotherReplayReader.Utils
- 玩家 ID 以及操作,例如:`PlayerC: 重新选择单位`
- 操作参数或操作对象,例如:`[UnitId]239`
典型的玩家操作流程
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。这些操作的对象是玩家自己的单位
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。选择的对象通常是玩家自己的单位,但也可能点击选中敌方单位(此时只能查看血量,无法下达命令);被加入编队的单位几乎可以确定是玩家自己的单位
2. 执行操作:让当前被选中的单位执行某个任务,例如攻击、释放技能。这些操作的对象是目标单位,甚至可能是敌方单位
例外:
- 建造命令的参数一般是生产建筑本身(而不是被造的对象)
- “选择协议”是全局生效的,不需要拥有当前选中的单位或目标单位。
# 输出要求
## 1. 初始阶段
触发条件:用户输入包含:""判断是否需要分段处理数据""
- 进行简单的推理,列举你的发现,目标:判断玩家操作记录是否应该分段分析
- 输出:对玩家操作记录的分段,各个分段的开始时间和结束时间,以及简短介绍。分段应按照时间排序,分段之间可以有一定的重叠
- 输出示例:
```
[分段列表]
#1 [0:00.0]~[0:55.4] 开局
#2 [0:50.1]~[3:02.1] 开局(第二部分)
#3 [3:00]~[5:16] 前中期
#4 [5:10]~[10:13.12] 中期
```
- 输出示例(假如判断不需要分段):
```
[分段列表]
#1 [0:00.0]~[17:23] 从开局到玩家操作记录结束
```
## 1. 总览阶段
触发条件:用户输入包含:""请先对整局进行总览""
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:""请分析第N段([BEGIN]至[END]的数据""
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END]""
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
- 推理思考时:不要直接列出所有操作信息,可以先只列出一部分,然后按需向前以及向后“延申”
@@ -143,6 +149,7 @@ namespace AnotherReplayReader.Utils
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:""请对以上内容进行总结""
@@ -193,8 +200,9 @@ namespace AnotherReplayReader.Utils
- `produce|时间|单位名|出兵建筑UnitId` — 开始出兵,例如 `produce|0:14.66|AlliedScoutInfantry|291`
- `sell|时间|建筑UnitId` — 出售建筑,例如 `sell|2:21.93|255`
- `select|时间|单位UnitId` — 选择单位,例如 `select|1:24.13|587`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`。注意:move 证据不携带 UnitId,无法被程序验证,不能单独作为高置信结论的证据
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
@@ -1010,71 +1018,56 @@ PlayerA: 开始出兵
return sb.ToString().Replace("\r", "");
}
public static string BuildUserPrompt(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
string commandData,
out string prefix
)
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
{
var playerNamesForAI = PlayerNamesForAI(mod, players);
var playerData = players.Select(kv =>
{
var id = kv.Key;
var player = kv.Value;
var playerNameForAI = playerNamesForAI[id];
var prefix = player.IsComputer ? "电脑" : "玩家";
var name = player.IsComputer ? $"[{player.PlayerName}AI]" : player.PlayerName;
var factionName = ModData.GetFaction(mod, player.FactionId).Name;
if (player.Team >= 0)
{
return $"{prefix}#{id} {name} ({playerNameForAI}){factionName},队伍{player.Team}";
}
return $"{prefix}#{id} {name} ({playerNameForAI}){factionName}";
});
var sb = new StringBuilder();
sb.AppendLine("请你阅读并分析以下数据,首先进行初步分析,然后判断是否需要分段处理数据");
sb.AppendLine("玩家列表:");
sb.AppendLine(string.Join("\n", playerData));
sb.AppendLine("数据:");
prefix = sb.ToString();
sb.AppendLine(commandData);
sb.AppendLine("请先对整局进行总览。");
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
sb.AppendLine("请描述整局走势(允许跨分段),并为每个分段给出标题与一句话概述。");
sb.AppendLine();
sb.AppendLine("[分段元数据]");
foreach (var slice in slices)
{
sb.AppendLine($"#{(slice.Index + 1)} {MatchDigestBuilder.FormatTime(slice.Start)}~{MatchDigestBuilder.FormatTime(slice.End)} 事件数 {slice.EventCount} 约 {slice.EstimatedTokens} token");
}
sb.AppendLine();
sb.AppendLine("输出格式:先自由描述整局走势与跨段线索,然后输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`。");
return sb.ToString().Replace("\r", "");
}
public static string BuildSegmentUserPrompt(IReadOnlyList<Segment> segments, int currentSegmentIndex, int eventCount)
public static string BuildSegmentUserPromptV2(
int segmentIndex,
int totalSegments,
ReplaySlice slice,
int eventCount,
string? title)
{
if (currentSegmentIndex < 0 || currentSegmentIndex >= segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var segment = segments[currentSegmentIndex];
var beginText = currentSegmentIndex <= 0
? "游戏开始"
: segment.Start.ToString();
var endText = currentSegmentIndex >= segments.Count - 1
? "游戏结束"
: segment.End.ToString();
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
var instruction = @$"
请分析第{currentSegmentIndex + 1}段({beginText}至{endText})的数据。
阶段一共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接把{eventCount}条操作信息都列出来。
也不要把每一条操作信息都视作一个单独事件
你需要列出{beginText}至{endText}的主要事件、以及其他有分析价值的事件
请按照按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组。
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
";
return instruction.Trim().Replace("\r", "");
}
public static string BuildFinalUserPrompt(int totalEventCount)
public static string BuildSummaryUserPromptV2(int totalEventCount)
{
var instruction = $@"
请对以上内容进行总结。
基于:
- 你对于各阶段的推理分析
- 由我在对话刚开始时提供的原始数据(约{totalEventCount}个操作信息)。
- 整局总览与对局摘要
- 各分段的推理分析
- 各分段的已发现事实
(原始操作记录约{totalEventCount}条,未全部提供;如需要核实某个具体时间段,可以在总结中说明,由程序另行提供。)
请对各阶段的推理分析进行汇总。
判断是否需要对之前的分析进行补充,例如检查之前是否遗漏了某些重要的操作信息?
判断是否需要对之前的分析进行修正。
@@ -1084,6 +1077,36 @@ PlayerA: 开始出兵
return instruction.Trim().Replace("\r", "");
}
public static string BuildBackqueryUserPrompt(string sliceText)
{
return "以下是按你的 [回查] 请求提供的原始操作记录区间:\n\n" + sliceText;
}
public static string BuildRevisionUserPrompt(
string draft,
string validationIssues,
string relevantFacts)
{
var instruction = $@"
请重新检查你刚才的分析,并输出一份修正后的完整版本(正文 + 机器可读声明)。
机器校验发现以下问题:
{validationIssues}
相关事实:
{relevantFacts}
要求:
- 只输出修正后的分析本身,不要提及""修订""""抱歉""""之前回答有误""等字眼,不要复述本指令。
- 被机器校验否定的结论必须修正或降级;无法确定的结论标记为""不确定""或""可能""。
- 保留 [机器可读声明] JSON 代码块(与修正后的正文一致),并重新输出 [小结]。
- 原始草稿(供参考,不要原样照抄):
---
{draft}
---
";
return instruction.Trim().Replace("\r", "");
}
public static (int BytesCount, int EstimatedTokenCount) EstimateTokenCount(string text)
{
var bytesCount = Encoding.UTF8.GetByteCount(text);
@@ -1143,31 +1166,9 @@ PlayerA: 开始出兵
public string Text;
}
public record Segment(TimeSpan Start, TimeSpan End, string Description);
public record State(ImmutableList<object> Messages, ImmutableList<Segment> Segments, int CurrentSegment)
{
public static State Initial => new(ImmutableList<object>.Empty, ImmutableList<Segment>.Empty, -1);
public State AppendNewMessage(string role, string content)
{
var newMessages = Messages.Add(new
{
role,
content
});
return this with { Messages = newMessages };
}
public State AppendNewSegment(Segment segment)
{
var newSegments = Segments.Add(segment);
return this with { Segments = newSegments };
}
}
public struct Result
{
public string Response;
public State State;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
@@ -1175,9 +1176,6 @@ PlayerA: 开始出兵
}
private readonly HttpClient _http;
private State _state = State.Initial;
public State LastSuccessfulState => _state;
public AIAnalyze()
{
@@ -1187,98 +1185,27 @@ PlayerA: 开始出兵
};
}
public void SetState(State state)
{
_state = state;
}
public async Task<Result> AnalyzeAsync(
string instruction,
string text,
/// <summary>对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。</summary>
public async Task<Result> CompleteAsync(
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
var inputState = State.Initial
.AppendNewMessage("system", instruction)
.AppendNewMessage("user", text);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
var resultState = result.State;
var splitted = result.Response.Split('\n').ToList();
var titleIndex = splitted.FindIndex(l => l.Contains("[分段列表]"));
if (titleIndex == -1)
{
throw new Exception("AI分析失败");
}
// regex match two timespan in "[0:00.0]~[0:55.4]"
var timeSpanRegex = new Regex(@"\[([^]]+)\]~\[([^]]+)\]");
for (var i = titleIndex + 1; i < splitted.Count; ++i)
{
var line = splitted[i];
var match = timeSpanRegex.Match(line);
if (match.Success)
{
var startTimeText = match.Groups[1].Value;
var endTimeText = match.Groups[2].Value;
var start = ParseAITimeSpan(startTimeText);
var end = ParseAITimeSpan(endTimeText);
var description = line.Substring(match.Index + match.Length).Trim();
resultState = resultState.AppendNewSegment(new(start, end, description));
}
}
result.State = resultState with { CurrentSegment = 0 };
_state = result.State;
return result;
}
public async Task<Result> ContinueAnalyzeAsync(
string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
if (_state.CurrentSegment < 0 || _state.CurrentSegment >= _state.Segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var inputState = _state.AppendNewMessage("user", instruction);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
result.State = result.State with { CurrentSegment = _state.CurrentSegment + 1 };
_state = result.State;
return result;
}
public async Task<Result> FinishAnalyzeAsync(
string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
if (_state.CurrentSegment != _state.Segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var inputState = _state.AppendNewMessage("user", instruction);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
_state = result.State;
return result;
return await Task.Run(
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
cancellationToken);
}
private static async Task<Result> DoRequest(
HttpClient http,
State state,
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
var provider = requestContext.Provider;
var requestParams = ProcessRequestParams(state, requestContext.BuildRequestParams());
var requestParams = ProcessRequestParams(messages, requestContext.BuildRequestParams());
var isStream = requestContext.Model.IsStream;
var inputJson = JsonSerializer.Serialize(requestParams);
@@ -1351,7 +1278,6 @@ PlayerA: 开始出兵
}
var resultText = fullBuilder.ToString();
result.State = state.AppendNewMessage("assistant", resultText);
result.Response = resultText;
return result;
}
@@ -1454,12 +1380,12 @@ PlayerA: 开始出兵
}
private static Dictionary<string, object> ProcessRequestParams(
State state,
ImmutableList<ChatMessage> messages,
Dictionary<string, object> inputRequestParams)
{
return new Dictionary<string, object>(inputRequestParams)
{
["messages"] = state.Messages.ToArray(),
["messages"] = messages.ToArray(),
["stream"] = true,
["stream_options"] = new
{
@@ -1468,52 +1394,5 @@ PlayerA: 开始出兵
};
}
private static TimeSpan ParseAITimeSpan(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
throw new FormatException("Empty input");
}
input = input.Trim();
var parts = input.Split(':');
if (parts.Length == 0)
{
throw new FormatException("Invalid format");
}
// -----------------------------
// 1. 解析最后一段:seconds + fraction
// -----------------------------
if (!float.TryParse(parts.Last(), out float floatSeconds))
{
throw new FormatException("Invalid seconds");
}
int seconds = (int)floatSeconds;
int milliseconds = (int)Math.Round((floatSeconds - seconds) * 1000);
// -----------------------------
// 2. 累加前面的部分(从右往左)
// -----------------------------
long totalSeconds = seconds;
long multiplier = 60; // 每层递进:秒->分->时->天...
for (int i = parts.Length - 2; i >= 0; i--)
{
if (!long.TryParse(parts[i], out long value))
throw new FormatException($"Invalid number: {parts[i]}");
totalSeconds += value * multiplier;
multiplier *= 60;
}
var result = TimeSpan.FromSeconds(totalSeconds)
+ TimeSpan.FromMilliseconds(milliseconds);
return result;
}
}
}