deepseek wip
This commit is contained in:
+5
-1
@@ -62,6 +62,7 @@ namespace AnotherReplayReader
|
||||
private CancellationTokenSource? _linkedCts;
|
||||
private AIAnalyze? _analyzer;
|
||||
private TimeIndexedPrefixSums? _eventCounts;
|
||||
private ReplayFactIndex? _factIndex;
|
||||
|
||||
// 分析状态
|
||||
private bool _isRunning;
|
||||
@@ -165,6 +166,7 @@ namespace AnotherReplayReader
|
||||
_blockCountBeforeSegment = 0;
|
||||
_analyzer = null;
|
||||
_eventCounts = null;
|
||||
_factIndex = null;
|
||||
|
||||
_document.Blocks.Clear();
|
||||
_updateCurrentThinkingSection = null;
|
||||
@@ -198,6 +200,7 @@ namespace AnotherReplayReader
|
||||
ImmutableSortedDictionary<int, Player> players,
|
||||
string replayData,
|
||||
TimeIndexedPrefixSums eventCounts,
|
||||
ReplayFactIndex factIndex,
|
||||
CancellationToken externalToken)
|
||||
{
|
||||
if (_isRunning)
|
||||
@@ -245,6 +248,7 @@ namespace AnotherReplayReader
|
||||
|
||||
_analyzer = new AIAnalyze();
|
||||
_eventCounts = eventCounts;
|
||||
_factIndex = factIndex;
|
||||
|
||||
FinishCurrentContent();
|
||||
var requestContext = GetRequestContext();
|
||||
@@ -359,7 +363,7 @@ namespace AnotherReplayReader
|
||||
requestContext,
|
||||
OnChunk,
|
||||
_linkedCts.Token);
|
||||
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(segmentResult.Response);
|
||||
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(segmentResult.Response, _factIndex);
|
||||
if (validationResult.HasIssues)
|
||||
{
|
||||
AppendLog(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<UseWPF>true</UseWPF>
|
||||
<IncludePackageReferencesDuringMarkupCompilation>true</IncludePackageReferencesDuringMarkupCompilation>
|
||||
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
|
||||
+14
-3
@@ -5,11 +5,22 @@
|
||||
### Operation Fact
|
||||
A fact directly extracted from replay command data, such as command time, player, command name, UnitId, asset id, special power id, production queue, or target position.
|
||||
|
||||
### UnitId (ObjectId)
|
||||
The numeric identifier of a unit instance in the replay. `ObjectId` in raw replay data and `UnitId` in the AI analysis context refer to the same thing: a number (e.g., `239`, `246`) that identifies a specific unit or building instance during the game session. The AI is tasked with guessing what game asset type (e.g., `AlliedBarracks`, `CelestialScoutDrone`) a given UnitId corresponds to.
|
||||
|
||||
### UnitId Guess
|
||||
A hypothesis about what game object a UnitId represents. A UnitId Guess is not a fact unless it is directly supported by replay data or game rules.
|
||||
A hypothesis about what game object (asset type) a UnitId (numeric identifier) represents. A UnitId Guess is not a fact unless it is directly supported by replay data or game rules.
|
||||
|
||||
### Operation
|
||||
Any player action recorded in the replay log, including selection (select unit, create/select control group), command (move, attack, ability use), production/construction (start building, place building, start producing), and miscellaneous actions (select protocol, stance switch, rally point set). All are operations; the distinction between "运营类" (economy/construction) and non-operational operations in the AI prompt is a pragmatic optimization to reduce reasoning cost, not a domain concept.
|
||||
|
||||
### Evidence Level
|
||||
The confidence assigned to a UnitId Guess or tactical conclusion. Valid levels are: confirmed, highly likely, possible, uncertain, and ruled out.
|
||||
The confidence assigned to a UnitId Guess or tactical conclusion. Valid levels are: confirmed (确定), highly likely (高度可能), possible (可能), uncertain (不确定), and ruled out (已排除).
|
||||
|
||||
> **Note:** The Chinese prompt text uses "不确定" (not "待确认"/pending confirmation) to match the `Uncertain` enum value. There is no implied promise of future confirmation — uncertainty simply means the current evidence is insufficient for a stronger conclusion.
|
||||
|
||||
### Analysis Segment
|
||||
A time-bounded section of the replay operations, chosen by the LLM during the initial assessment phase. Segments are defined by start/end timestamps and may overlap. The LLM decides the segmentation based on observed gameplay phases (e.g., opening, early-mid game, mid game). Each segment is analyzed in a separate AI request round.
|
||||
|
||||
### Validation Rule
|
||||
A deterministic rule that checks LLM claims against Operation Facts and known game rules.
|
||||
@@ -18,4 +29,4 @@ A deterministic rule that checks LLM claims against Operation Facts and known ga
|
||||
A machine-detected problem in an LLM claim, such as a direct contradiction, weak evidence, missing alternative, or impossible timeline.
|
||||
|
||||
### Revision Pass
|
||||
A hidden LLM request that receives the prior draft, validation issues, and relevant Operation Facts, then produces a corrected analysis without exposing apology or correction chatter to the user.
|
||||
(Partially implemented) A hidden LLM request that receives the prior draft, validation issues, and relevant Operation Facts, then produces a corrected analysis without exposing apology or correction chatter to the user. Currently, validation issues are detected and logged, but no automatic revision pass is triggered. The revision logic still needs to be wired into `AIChatPanel`.
|
||||
|
||||
+4
-2
@@ -78,6 +78,7 @@ namespace AnotherReplayReader
|
||||
private Model _model = new();
|
||||
private string? _cached;
|
||||
private TimeIndexedPrefixSums? _cachedPrefixSums;
|
||||
private ReplayFactIndex? _cachedFactIndex;
|
||||
|
||||
public EventDump()
|
||||
{
|
||||
@@ -176,6 +177,7 @@ namespace AnotherReplayReader
|
||||
_textBox.Text = text;
|
||||
_cached = text;
|
||||
_cachedPrefixSums = prefixSums;
|
||||
_cachedFactIndex = ReplayFactIndex.Build(_model.Commands, _stringHashes);
|
||||
// display KB and K tokens in _tokenUsageLabel
|
||||
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
|
||||
}
|
||||
@@ -436,7 +438,7 @@ namespace AnotherReplayReader
|
||||
MessageBox.Show(this, "请先选择录像");
|
||||
return;
|
||||
}
|
||||
if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums)
|
||||
if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums || _cachedFactIndex is not { } factIndex)
|
||||
{
|
||||
MessageBox.Show(this, "请先生成文本");
|
||||
return;
|
||||
@@ -451,7 +453,7 @@ namespace AnotherReplayReader
|
||||
_aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!;
|
||||
_aiPanel.GetPromptSettings = _aiSettings.GetPromptSettings;
|
||||
|
||||
await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, _cancellation.Token);
|
||||
await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, factIndex, _cancellation.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+288
-14
@@ -95,7 +95,9 @@ namespace AnotherReplayReader.Utils
|
||||
@"```(?:json)?\s*(\{[\s\S]*?\})\s*```",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static AIValidationResult ValidateMachineReadableClaims(string response)
|
||||
public static AIValidationResult ValidateMachineReadableClaims(
|
||||
string response,
|
||||
ReplayFactIndex? factIndex = null)
|
||||
{
|
||||
var issues = ImmutableArray.CreateBuilder<AIValidationIssue>();
|
||||
var json = ExtractJsonObject(response);
|
||||
@@ -115,6 +117,11 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
|
||||
ValidateClaimSelfConsistency(claims, issues);
|
||||
ValidateUnpackAmbiguity(claims, issues);
|
||||
if (factIndex is not null)
|
||||
{
|
||||
ValidateTimelineConsistency(claims, factIndex, issues);
|
||||
}
|
||||
return new AIValidationResult(claims, issues.ToImmutable());
|
||||
}
|
||||
|
||||
@@ -180,11 +187,11 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
|
||||
return new AIMachineReadableClaims(
|
||||
ReadUnitClaims(root),
|
||||
ReadSimpleClaims(root, "eventClaims")
|
||||
ReadUnitClaims(root, issues),
|
||||
ReadSimpleClaims(root, "eventClaims", issues)
|
||||
.Select(c => new AIEventClaim(c.Claim, c.EvidenceLevel, c.Evidence))
|
||||
.ToImmutableArray(),
|
||||
ReadSimpleClaims(root, "timelineClaims")
|
||||
ReadSimpleClaims(root, "timelineClaims", issues)
|
||||
.Select(c => new AITimelineClaim(c.Claim, c.EvidenceLevel, c.Evidence))
|
||||
.ToImmutableArray());
|
||||
}
|
||||
@@ -198,7 +205,9 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<AIUnitClaim> ReadUnitClaims(JsonElement root)
|
||||
private const int MaxUnitClaims = 10;
|
||||
|
||||
private static ImmutableArray<AIUnitClaim> ReadUnitClaims(JsonElement root, ImmutableArray<AIValidationIssue>.Builder issues)
|
||||
{
|
||||
if (!TryGetArray(root, "unitClaims", out var unitClaims))
|
||||
{
|
||||
@@ -206,6 +215,7 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
|
||||
var result = ImmutableArray.CreateBuilder<AIUnitClaim>();
|
||||
var totalCount = 0;
|
||||
foreach (var item in unitClaims.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
@@ -213,15 +223,30 @@ namespace AnotherReplayReader.Utils
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCount++;
|
||||
if (result.Count >= MaxUnitClaims)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new AIUnitClaim(
|
||||
ReadFlexibleString(item, "unitId"),
|
||||
ReadFlexibleString(item, "player"),
|
||||
ReadFlexibleString(item, "claim"),
|
||||
ReadEvidenceLevel(item),
|
||||
ReadEvidenceLevel(item, issues),
|
||||
ReadStringArray(item, "evidence"),
|
||||
ReadStringArray(item, "alternatives"),
|
||||
ReadStringArray(item, "needsConfirmation")));
|
||||
}
|
||||
|
||||
if (totalCount > MaxUnitClaims)
|
||||
{
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.Info,
|
||||
AIValidationIssueKind.InvalidMachineReadableClaims,
|
||||
$"unitClaims 包含 {totalCount} 条声明,仅处理前 {MaxUnitClaims} 条,其余已忽略。"));
|
||||
}
|
||||
|
||||
return result.ToImmutable();
|
||||
}
|
||||
|
||||
@@ -230,7 +255,7 @@ namespace AnotherReplayReader.Utils
|
||||
AIEvidenceLevel EvidenceLevel,
|
||||
ImmutableArray<string> Evidence);
|
||||
|
||||
private static ImmutableArray<SimpleClaim> ReadSimpleClaims(JsonElement root, string propertyName)
|
||||
private static ImmutableArray<SimpleClaim> ReadSimpleClaims(JsonElement root, string propertyName, ImmutableArray<AIValidationIssue>.Builder issues)
|
||||
{
|
||||
if (!TryGetArray(root, propertyName, out var claims))
|
||||
{
|
||||
@@ -238,6 +263,13 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
|
||||
var result = ImmutableArray.CreateBuilder<SimpleClaim>();
|
||||
var maxClaims = propertyName switch
|
||||
{
|
||||
"eventClaims" => 5,
|
||||
"timelineClaims" => 3,
|
||||
_ => 50,
|
||||
};
|
||||
var totalCount = 0;
|
||||
foreach (var item in claims.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
@@ -245,11 +277,32 @@ namespace AnotherReplayReader.Utils
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCount++;
|
||||
if (result.Count >= maxClaims)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var claim = ReadFlexibleString(item, "claim");
|
||||
if (string.IsNullOrWhiteSpace(claim) && propertyName == "eventClaims")
|
||||
{
|
||||
claim = ReadFlexibleString(item, "event");
|
||||
}
|
||||
|
||||
result.Add(new SimpleClaim(
|
||||
ReadFlexibleString(item, "claim"),
|
||||
ReadEvidenceLevel(item),
|
||||
claim,
|
||||
ReadEvidenceLevel(item, issues),
|
||||
ReadStringArray(item, "evidence")));
|
||||
}
|
||||
|
||||
if (totalCount > maxClaims)
|
||||
{
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.Info,
|
||||
AIValidationIssueKind.InvalidMachineReadableClaims,
|
||||
$"{propertyName} 包含 {totalCount} 条声明,仅处理前 {maxClaims} 条,其余已忽略。"));
|
||||
}
|
||||
|
||||
return result.ToImmutable();
|
||||
}
|
||||
|
||||
@@ -296,6 +349,216 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
}
|
||||
|
||||
#region Structured evidence parsing
|
||||
|
||||
internal enum AIEvidenceType
|
||||
{
|
||||
Build,
|
||||
Place,
|
||||
Produce,
|
||||
Sell,
|
||||
Select,
|
||||
Move,
|
||||
Power,
|
||||
Unknown
|
||||
}
|
||||
|
||||
internal sealed record StructuredEvidence(
|
||||
AIEvidenceType Type,
|
||||
string Time,
|
||||
ImmutableArray<string> Parameters,
|
||||
string Raw)
|
||||
{
|
||||
public string? GetSpecialPowerName() =>
|
||||
Type == AIEvidenceType.Power && Parameters.Length >= 1 ? Parameters[0] : null;
|
||||
|
||||
public string? GetUnitId() => Type switch
|
||||
{
|
||||
AIEvidenceType.Build or AIEvidenceType.Place when Parameters.Length >= 2 => Parameters[1],
|
||||
AIEvidenceType.Produce when Parameters.Length >= 2 => Parameters[1],
|
||||
AIEvidenceType.Power when Parameters.Length >= 2 => Parameters[1],
|
||||
AIEvidenceType.Sell when Parameters.Length >= 1 => Parameters[0],
|
||||
AIEvidenceType.Select when Parameters.Length >= 1 => Parameters[0],
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public string? GetAssetName() => Type switch
|
||||
{
|
||||
AIEvidenceType.Build or AIEvidenceType.Place or AIEvidenceType.Produce
|
||||
when Parameters.Length >= 1 => Parameters[0],
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly Regex _structuredEvidenceRegex = new(
|
||||
@"^(build|place|produce|sell|select|move|power)\|([^|]+(?:\|(?!\|).*)?)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
internal static StructuredEvidence ParseStructuredEvidence(string text)
|
||||
{
|
||||
var match = _structuredEvidenceRegex.Match(text.Trim());
|
||||
if (!match.Success)
|
||||
{
|
||||
return new StructuredEvidence(AIEvidenceType.Unknown, string.Empty, ImmutableArray<string>.Empty, text);
|
||||
}
|
||||
|
||||
var type = match.Groups[1].Value.ToLowerInvariant() switch
|
||||
{
|
||||
"build" => AIEvidenceType.Build,
|
||||
"place" => AIEvidenceType.Place,
|
||||
"produce" => AIEvidenceType.Produce,
|
||||
"sell" => AIEvidenceType.Sell,
|
||||
"select" => AIEvidenceType.Select,
|
||||
"move" => AIEvidenceType.Move,
|
||||
"power" => AIEvidenceType.Power,
|
||||
_ => AIEvidenceType.Unknown,
|
||||
};
|
||||
|
||||
var rest = match.Groups[2].Value;
|
||||
var parts = rest.Split('|');
|
||||
var time = parts.Length >= 1 ? parts[0].Trim() : string.Empty;
|
||||
var parameters = parts.Skip(1).Select(p => p.Trim()).ToImmutableArray();
|
||||
|
||||
return new StructuredEvidence(type, time, parameters, text);
|
||||
}
|
||||
|
||||
internal static ImmutableArray<StructuredEvidence> ParseAllEvidence(ImmutableArray<string> evidenceStrings)
|
||||
{
|
||||
return evidenceStrings
|
||||
.Select(ParseStructuredEvidence)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation rules
|
||||
|
||||
private static void ValidateUnpackAmbiguity(
|
||||
AIMachineReadableClaims claims,
|
||||
ImmutableArray<AIValidationIssue>.Builder issues)
|
||||
{
|
||||
foreach (var claim in claims.UnitClaims)
|
||||
{
|
||||
if (claim.EvidenceLevel is not (AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var evidence = ParseAllEvidence(claim.Evidence);
|
||||
var hasUnpack = evidence.Any(e =>
|
||||
e.GetSpecialPowerName() is string p &&
|
||||
p.IndexOf("UnpackReplaceSelf", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
if (!hasUnpack)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hasPack = evidence.Any(e =>
|
||||
e.GetSpecialPowerName() is string p &&
|
||||
p.IndexOf("PackReplaceSelf", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
if (hasPack)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.WeakEvidence,
|
||||
AIValidationIssueKind.MissingAlternative,
|
||||
$"UnitId {claim.UnitId} 使用了 UnpackReplaceSelf 但证据中无对应 PackReplaceSelf。UnpackReplaceSelf 可能对应基地车展开或矿车展开成指挥中心,建议降低置信度或添加 alternative。",
|
||||
claim.UnitId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTimelineConsistency(
|
||||
AIMachineReadableClaims claims,
|
||||
ReplayFactIndex factIndex,
|
||||
ImmutableArray<AIValidationIssue>.Builder issues)
|
||||
{
|
||||
foreach (var claim in claims.UnitClaims)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.UnitId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!uint.TryParse(claim.UnitId, out var unitId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 1: UnitId referenced in claim exists in the replay
|
||||
if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId))
|
||||
{
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.Warning,
|
||||
AIValidationIssueKind.InvalidMachineReadableClaims,
|
||||
$"UnitId {claim.UnitId} 在回放数据中从未出现过,AI 可能编造了不存在的 UnitId。",
|
||||
claim.UnitId));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 2: Verify special power evidence against fact index
|
||||
var evidence = ParseAllEvidence(claim.Evidence);
|
||||
foreach (var ev in evidence)
|
||||
{
|
||||
if (ev.Type != AIEvidenceType.Power)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var evUnitIdStr = ev.GetUnitId();
|
||||
if (evUnitIdStr is null || !uint.TryParse(evUnitIdStr, out var evUnitId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var powerName = ev.GetSpecialPowerName();
|
||||
if (powerName is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Does this UnitId exist in the fact index?
|
||||
if (!factIndex.UnitIdFirstObservedTime.ContainsKey(evUnitId))
|
||||
{
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.Info,
|
||||
AIValidationIssueKind.InvalidMachineReadableClaims,
|
||||
$"证据引用了回放中不存在的 UnitId {evUnitIdStr}。",
|
||||
claim.UnitId));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Did this UnitId actually use this special power?
|
||||
if (factIndex.UnitIdSpecialPowers.TryGetValue(evUnitId, out var actualPowers)
|
||||
&& !actualPowers.Contains(powerName))
|
||||
{
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.Contradiction,
|
||||
AIValidationIssueKind.UnitCapabilityContradiction,
|
||||
$"UnitId {evUnitIdStr} 在回放中使用过以下特殊能力:{string.Join(", ", actualPowers.OrderBy(x => x))},但 AI 声称其使用了“{powerName}”——此能力未在该 UnitId 上观察到。",
|
||||
claim.UnitId));
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: UnitId used as builder vs claim
|
||||
var isBuilderInReplay = factIndex.BuilderUnitIds.Contains(unitId);
|
||||
var claimLooksLikeBuilder = claim.Claim.IndexOf("MCV", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| claim.Claim.IndexOf("基地车", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| claim.Claim.IndexOf("Nanocore", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| claim.Claim.IndexOf("纳米核心", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| claim.Claim.IndexOf("builder", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| claim.Claim.IndexOf("建造者", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
if (claimLooksLikeBuilder && !isBuilderInReplay)
|
||||
{
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.WeakEvidence,
|
||||
AIValidationIssueKind.UnitCapabilityContradiction,
|
||||
$"AI 推测 UnitId {claim.UnitId} 是“{claim.Claim}”(推测是建造单位),但该 UnitId 在回放中从未作为建造者(建造建筑)出现。",
|
||||
claim.UnitId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static bool TryGetArray(JsonElement root, string propertyName, out JsonElement array)
|
||||
{
|
||||
if (root.TryGetProperty(propertyName, out array)
|
||||
@@ -308,23 +571,34 @@ namespace AnotherReplayReader.Utils
|
||||
return false;
|
||||
}
|
||||
|
||||
private static AIEvidenceLevel ReadEvidenceLevel(JsonElement item)
|
||||
private static AIEvidenceLevel ReadEvidenceLevel(JsonElement item, ImmutableArray<AIValidationIssue>.Builder issues)
|
||||
{
|
||||
var value = ReadFlexibleString(item, "evidenceLevel");
|
||||
return NormalizeEvidenceLevel(value);
|
||||
return NormalizeEvidenceLevel(value, issues);
|
||||
}
|
||||
|
||||
private static AIEvidenceLevel NormalizeEvidenceLevel(string value)
|
||||
private static AIEvidenceLevel NormalizeEvidenceLevel(string value, ImmutableArray<AIValidationIssue>.Builder issues)
|
||||
{
|
||||
value = value.Trim().Replace("_", "").Replace("-", "").Replace(" ", "");
|
||||
return value.ToLowerInvariant() switch
|
||||
var result = value.ToLowerInvariant() switch
|
||||
{
|
||||
"confirmed" or "确定" => AIEvidenceLevel.Confirmed,
|
||||
"highlylikely" or "high" or "高度可能" => AIEvidenceLevel.HighlyLikely,
|
||||
"possible" or "可能" => AIEvidenceLevel.Possible,
|
||||
"ruledout" or "excluded" or "已排除" => AIEvidenceLevel.RuledOut,
|
||||
_ => AIEvidenceLevel.Uncertain,
|
||||
_ => (AIEvidenceLevel?)null,
|
||||
};
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
issues.Add(new AIValidationIssue(
|
||||
AIValidationSeverity.Info,
|
||||
AIValidationIssueKind.InvalidEvidenceLevel,
|
||||
$"无法识别的证据等级 \"{value.Trim()}\",已降级为不确定。"));
|
||||
return AIEvidenceLevel.Uncertain;
|
||||
}
|
||||
|
||||
private static string ReadFlexibleString(JsonElement item, string propertyName)
|
||||
|
||||
+32
-6
@@ -57,7 +57,7 @@ namespace AnotherReplayReader.Utils
|
||||
- 你只能根据用户提供的操作记录、玩家信息、下方游戏规则和明确给出的背景知识进行分析。
|
||||
- 不要使用现实世界常识或其他 RTS 游戏常识覆盖这里的游戏设定。例如:步兵、直升机、建筑水陆摆放、运输能力、两栖能力都必须以这里的规则和单位描述为准。
|
||||
- 不确定时必须保留多个候选,不要为了让解说流畅而过早下定论。
|
||||
- 对 UnitId、单位类型、战术意图的判断必须区分证据等级:确定、高度可能、可能、待确认、已排除。
|
||||
- 对 UnitId、单位类型、战术意图的判断必须区分证据等级:确定、高度可能、可能、不确定、已排除。
|
||||
- 每个关键推理都应当包含支持证据;如果存在会推翻该推理的反证,也要主动指出。
|
||||
- 如果某个技能或行为可以对应多个单位,先列出候选,并说明还需要哪些后续迹象才能确认。
|
||||
- 对已经被操作记录直接否定的判断必须修正或放弃,不要坚持原结论。
|
||||
@@ -130,6 +130,7 @@ namespace AnotherReplayReader.Utils
|
||||
# 机器可读声明
|
||||
仅限于:分段分析阶段(第2阶段)
|
||||
如果你对 UnitId、关键事件或时间线做出了可验证推测,请在回答末尾附加下面格式。
|
||||
请限制推测数量:UnitId 推测不超过 10 个,事件推测不超过 5 个,时间线推测不超过 3 个。
|
||||
必须先输出一行`[机器可读声明]`,然后输出一个 JSON 代码块:
|
||||
[机器可读声明]
|
||||
```json
|
||||
@@ -140,15 +141,40 @@ namespace AnotherReplayReader.Utils
|
||||
""player"": ""PlayerA"",
|
||||
""claim"": ""AlliedMCV"",
|
||||
""evidenceLevel"": ""possible"",
|
||||
""evidence"": [""8:30 使用 SpecialPower_UnpackReplaceSelf""],
|
||||
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246"", ""power|1:41.00|SpecialPower_UnpackReplaceSelf|123""],
|
||||
""alternatives"": [""AlliedMiner 展开后的指挥中心""],
|
||||
""needsConfirmation"": [""是否曾使用 SpecialPower_PackReplaceSelf"", ""后续是否作为建造者出现""]
|
||||
""needsConfirmation"": [""是否曾作为建造者出现""]
|
||||
}
|
||||
],
|
||||
""eventClaims"": [],
|
||||
""timelineClaims"": []
|
||||
""eventClaims"": [
|
||||
{
|
||||
""claim"": ""PlayerA 主基地打包并开始迁移"",
|
||||
""evidenceLevel"": ""confirmed"",
|
||||
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246""]
|
||||
}
|
||||
],
|
||||
""timelineClaims"": [
|
||||
{
|
||||
""claim"": ""PlayerA 在 2 分钟内完成基地迁移"",
|
||||
""evidenceLevel"": ""confirmed"",
|
||||
""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246"", ""power|1:41.00|SpecialPower_UnpackReplaceSelf|123""]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- `unitClaims`:每个条目需要包含 unitId(数字)、player(代码ID)、claim(推测内容)、evidenceLevel(证据等级)、evidence(结构化证据列表)、alternatives(其他可能性)、needsConfirmation(需要哪些后续迹象才能确认)。
|
||||
- `eventClaims`:每个条目需要包含 claim(事件描述)、evidenceLevel(证据等级)、evidence(结构化证据列表)。
|
||||
- `timelineClaims`:每个条目需要包含 claim(时间线描述)、evidenceLevel(证据等级)、evidence(结构化证据列表)。
|
||||
|
||||
**evidence 格式**:每条 evidence 必须是以下 pipe 分隔格式之一,不允许使用自然语言描述:
|
||||
- `build|时间|建筑名|建造者UnitId` — 开始建造建筑,例如 `build|0:01.26|AlliedBarracks|246`
|
||||
- `place|时间|建筑名|建造者UnitId|x,y,z` — 摆放建筑,例如 `place|0:01.46|AlliedBarracks|246|1905,2231,210`
|
||||
- `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`
|
||||
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
|
||||
|
||||
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
|
||||
|
||||
# 推理指南
|
||||
@@ -1015,7 +1041,7 @@ PlayerA: 开始出兵
|
||||
你需要列出{beginText}至{endText}的主要事件、以及其他有分析价值的事件。
|
||||
请按照按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
|
||||
|
||||
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测
|
||||
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
|
||||
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组。
|
||||
";
|
||||
return instruction.Trim().Replace("\r", "");
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of replay facts extracted from CommandChunk data.
|
||||
/// Used by AIAnalysisValidation to cross-reference LLM claims against
|
||||
/// actual replay operations.
|
||||
/// </summary>
|
||||
internal sealed class ReplayFactIndex
|
||||
{
|
||||
/// <summary>First time a UnitId was observed in any command.</summary>
|
||||
public ImmutableDictionary<uint, TimeSpan> UnitIdFirstObservedTime { get; }
|
||||
|
||||
/// <summary>Special powers used by each UnitId.</summary>
|
||||
public ImmutableDictionary<uint, ImmutableHashSet<string>> UnitIdSpecialPowers { get; }
|
||||
|
||||
/// <summary>UnitIds that appeared as builder ("建造者") in construction commands.</summary>
|
||||
public ImmutableHashSet<uint> BuilderUnitIds { get; }
|
||||
|
||||
/// <summary>UnitIds that appeared as production structures ("出兵建筑").</summary>
|
||||
public ImmutableHashSet<uint> ProducerUnitIds { get; }
|
||||
|
||||
/// <summary>Per player, per unit asset name, first production start time.</summary>
|
||||
public ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> PlayerFirstProductionTime { get; }
|
||||
|
||||
/// <summary>Per player, which UnitIds they have selected.</summary>
|
||||
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerSelectedUnitIds { get; }
|
||||
|
||||
public ReplayFactIndex(
|
||||
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
|
||||
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
|
||||
ImmutableHashSet<uint> builderUnitIds,
|
||||
ImmutableHashSet<uint> producerUnitIds,
|
||||
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
|
||||
ImmutableDictionary<int, ImmutableHashSet<uint>> playerSelectedUnitIds)
|
||||
{
|
||||
UnitIdFirstObservedTime = unitIdFirstObservedTime;
|
||||
UnitIdSpecialPowers = unitIdSpecialPowers;
|
||||
BuilderUnitIds = builderUnitIds;
|
||||
ProducerUnitIds = producerUnitIds;
|
||||
PlayerFirstProductionTime = playerFirstProductionTime;
|
||||
PlayerSelectedUnitIds = playerSelectedUnitIds;
|
||||
}
|
||||
|
||||
public static ReplayFactIndex Build(
|
||||
ImmutableArray<(TimeSpan Time, ImmutableArray<CommandChunk> Commands)> timeline,
|
||||
IReadOnlyDictionary<uint, string> stringHashTable)
|
||||
{
|
||||
var unitFirstObserved = new Dictionary<uint, TimeSpan>();
|
||||
var unitSpecialPowers = new Dictionary<uint, HashSet<string>>();
|
||||
var builderUnits = new HashSet<uint>();
|
||||
var producerUnits = new HashSet<uint>();
|
||||
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
|
||||
var playerSelected = new Dictionary<int, HashSet<uint>>();
|
||||
|
||||
foreach (var (time, commands) in timeline)
|
||||
{
|
||||
foreach (var command in commands)
|
||||
{
|
||||
ProcessCommand(time, command, stringHashTable,
|
||||
unitFirstObserved, unitSpecialPowers,
|
||||
builderUnits, producerUnits,
|
||||
playerFirstProduction, playerSelected);
|
||||
}
|
||||
}
|
||||
|
||||
return new ReplayFactIndex(
|
||||
unitFirstObserved.ToImmutableDictionary(),
|
||||
unitSpecialPowers.ToImmutableDictionary(
|
||||
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
||||
builderUnits.ToImmutableHashSet(),
|
||||
producerUnits.ToImmutableHashSet(),
|
||||
playerFirstProduction.ToImmutableDictionary(
|
||||
kv => kv.Key, kv => kv.Value.ToImmutableDictionary()),
|
||||
playerSelected.ToImmutableDictionary(
|
||||
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()));
|
||||
}
|
||||
|
||||
private static void ProcessCommand(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
||||
HashSet<uint> builderUnits,
|
||||
HashSet<uint> producerUnits,
|
||||
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
||||
Dictionary<int, HashSet<uint>> playerSelected)
|
||||
{
|
||||
var player = command.PlayerIndex;
|
||||
var cmdId = command.CommandId;
|
||||
|
||||
switch (cmdId)
|
||||
{
|
||||
// select unit(s): 0x1F5
|
||||
case 0x1F5:
|
||||
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected);
|
||||
break;
|
||||
|
||||
// special power (no target): 0x1FE
|
||||
case 0x1FE:
|
||||
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
|
||||
break;
|
||||
|
||||
// special power (target position): 0x1FF
|
||||
case 0x1FF:
|
||||
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
|
||||
break;
|
||||
|
||||
// special power (target position and angle): 0x200
|
||||
case 0x200:
|
||||
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
|
||||
break;
|
||||
|
||||
// special power (target unit): 0x201
|
||||
case 0x201:
|
||||
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
|
||||
break;
|
||||
|
||||
// special power (one or more targets): 0x232
|
||||
case 0x232:
|
||||
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
|
||||
break;
|
||||
|
||||
// start production: 0x205
|
||||
case 0x205:
|
||||
RecordProduction(time, command, player, unitFirstObserved, producerUnits, playerFirstProduction);
|
||||
break;
|
||||
|
||||
// start construction: 0x207
|
||||
case 0x207:
|
||||
RecordConstruction(time, command, unitFirstObserved, builderUnits);
|
||||
break;
|
||||
|
||||
// place building: 0x209
|
||||
case 0x209:
|
||||
RecordPlaceBuilding(time, command, unitFirstObserved, builderUnits);
|
||||
break;
|
||||
|
||||
// sell building: 0x20A
|
||||
case 0x20A:
|
||||
RecordObjectReference(time, command, unitFirstObserved);
|
||||
break;
|
||||
|
||||
// move: 0x214
|
||||
case 0x214:
|
||||
// attack move: 0x215
|
||||
case 0x215:
|
||||
// These commands operate on currently selected units.
|
||||
// The target is a position, not a UnitId.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordSelectUnit(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
int player,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
Dictionary<int, HashSet<uint>> playerSelected)
|
||||
{
|
||||
// Data layout for 0x1F5:
|
||||
// Data[0]: Bool (isReplace), if count > 0 the rest are ObjectIds
|
||||
// Data[1..]: ObjectIds of selected units
|
||||
foreach (var entry in command.Data)
|
||||
{
|
||||
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
|
||||
{
|
||||
if (entry.Count == 1)
|
||||
{
|
||||
var unitId = (uint)entry.Value;
|
||||
TryRecordFirstObserved(unitId, time, unitFirstObserved);
|
||||
RecordPlayerSelection(player, unitId, playerSelected);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var id in (uint[])entry.Value)
|
||||
{
|
||||
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||
RecordPlayerSelection(player, id, playerSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordSpecialPower(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
Dictionary<uint, HashSet<string>> unitSpecialPowers)
|
||||
{
|
||||
string? powerName = null;
|
||||
var unitIds = new List<uint>();
|
||||
|
||||
foreach (var entry in command.Data)
|
||||
{
|
||||
switch (entry.Type)
|
||||
{
|
||||
case CommandArgumentType.Int32 when powerName is null:
|
||||
{
|
||||
// First Int32 is the special power hash ID
|
||||
var hash = unchecked((uint)(int)entry.Value);
|
||||
powerName = stringHashTable.TryGetValue(hash, out var name)
|
||||
? name
|
||||
: $"Hash_{hash:X8}";
|
||||
break;
|
||||
}
|
||||
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2:
|
||||
{
|
||||
if (entry.Count == 1)
|
||||
{
|
||||
var id = (uint)entry.Value;
|
||||
if (id != 0) unitIds.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var id in (uint[])entry.Value)
|
||||
{
|
||||
if (id != 0) unitIds.Add(id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (powerName is null || unitIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var unitId in unitIds)
|
||||
{
|
||||
TryRecordFirstObserved(unitId, time, unitFirstObserved);
|
||||
if (!unitSpecialPowers.TryGetValue(unitId, out var powers))
|
||||
{
|
||||
powers = new HashSet<string>();
|
||||
unitSpecialPowers[unitId] = powers;
|
||||
}
|
||||
powers.Add(powerName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordProduction(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
int player,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
HashSet<uint> producerUnits,
|
||||
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction)
|
||||
{
|
||||
uint? producerId = null;
|
||||
string? unitName = null;
|
||||
|
||||
foreach (var entry in command.Data)
|
||||
{
|
||||
switch (entry.Type)
|
||||
{
|
||||
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
|
||||
when entry.Count == 1 && producerId is null:
|
||||
producerId = (uint)entry.Value;
|
||||
break;
|
||||
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
|
||||
when unitName is null:
|
||||
unitName = entry.Value.ToString() ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (producerId.HasValue)
|
||||
{
|
||||
TryRecordFirstObserved(producerId.Value, time, unitFirstObserved);
|
||||
producerUnits.Add(producerId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(unitName))
|
||||
{
|
||||
if (!playerFirstProduction.TryGetValue(player, out var perPlayer))
|
||||
{
|
||||
perPlayer = new Dictionary<string, TimeSpan>();
|
||||
playerFirstProduction[player] = perPlayer;
|
||||
}
|
||||
if (!perPlayer.ContainsKey(unitName))
|
||||
{
|
||||
perPlayer[unitName] = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordConstruction(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
HashSet<uint> builderUnits)
|
||||
{
|
||||
// Data[0]: ObjectId (builder)
|
||||
// Data[1]: AsciiString (building name)
|
||||
RecordBuilder(time, command, unitFirstObserved, builderUnits);
|
||||
}
|
||||
|
||||
private static void RecordPlaceBuilding(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
HashSet<uint> builderUnits)
|
||||
{
|
||||
// Data[0]: ObjectId (builder)
|
||||
// Data[1]: AsciiString (building name)
|
||||
// Data[2]: Int32 (count)
|
||||
// Data[3]: Vector3 (position)
|
||||
// Data[4]: Float32 (angle)
|
||||
RecordBuilder(time, command, unitFirstObserved, builderUnits);
|
||||
}
|
||||
|
||||
private static void RecordBuilder(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
HashSet<uint> builderUnits)
|
||||
{
|
||||
foreach (var entry in command.Data)
|
||||
{
|
||||
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
|
||||
&& entry.Count == 1)
|
||||
{
|
||||
var id = (uint)entry.Value;
|
||||
if (id != 0)
|
||||
{
|
||||
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||
builderUnits.Add(id);
|
||||
}
|
||||
return; // only first ObjectId is the builder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordObjectReference(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved)
|
||||
{
|
||||
foreach (var entry in command.Data)
|
||||
{
|
||||
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
|
||||
{
|
||||
if (entry.Count == 1)
|
||||
{
|
||||
TryRecordFirstObserved((uint)entry.Value, time, unitFirstObserved);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var id in (uint[])entry.Value)
|
||||
{
|
||||
TryRecordFirstObserved(id, time, unitFirstObserved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordPlayerSelection(int player, uint unitId, Dictionary<int, HashSet<uint>> playerSelected)
|
||||
{
|
||||
if (!playerSelected.TryGetValue(player, out var set))
|
||||
{
|
||||
set = new HashSet<uint>();
|
||||
playerSelected[player] = set;
|
||||
}
|
||||
set.Add(unitId);
|
||||
}
|
||||
|
||||
private static void TryRecordFirstObserved(uint unitId, TimeSpan time, Dictionary<uint, TimeSpan> unitFirstObserved)
|
||||
{
|
||||
if (unitId == 0) return;
|
||||
if (unitFirstObserved.ContainsKey(unitId)) return;
|
||||
unitFirstObserved[unitId] = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,29 @@ Examples discussed:
|
||||
- A UnitId claimed as an aircraft should be challenged if the same UnitId is observed using an unpack/deploy skill.
|
||||
- A UnitId claimed as a bomber should be challenged if it is operated before the player starts producing their first bomber.
|
||||
|
||||
### Documented Example: MCV vs Miner Ambiguity (Allied)
|
||||
|
||||
Observed replay sequence:
|
||||
1. UnitId 246 (confirmed main base) → `PackReplaceSelf`
|
||||
2. Player selects UnitId 587
|
||||
3. UnitId 587 → `UnpackReplaceSelf`
|
||||
4. AI claims: `587 = AlliedMCV`, evidenceLevel: `confirmed`
|
||||
|
||||
**Why this cannot be definitively resolved:**
|
||||
- After base 246 packs, the engine creates a new MCV (UnitId A). The miner (UnitId B) also exists on the map.
|
||||
- When the player selects 587, we cannot prove 587 = A vs 587 = B.
|
||||
- After `UnpackReplaceSelf`, 587 is replaced by yet another UnitId (C if MCV→base, D if miner→command hub).
|
||||
- Even if we later see C building things (`开始建造建筑 [UnitId]C(建造者)`), there is no replay-observable link connecting C back to 587.
|
||||
- Allied MCV in mobile form has no unique observable ability that would distinguish it from a miner.
|
||||
|
||||
**Conclusion:** There is **no deterministic validation rule** that can confirm an Allied MCV claim from replay operations alone. The upper bound for any such claim is `possible`, and an alternative (miner command hub) must always be listed.
|
||||
|
||||
**Contrast with other factions:**
|
||||
- Soviet/Japan/神州 MCVs may have different observable behaviors (e.g., unique deploy animations, different upgrade paths) — each faction needs independent analysis.
|
||||
|
||||
**Validation rule (negative check only):**
|
||||
- If a claim says `confirmed` or `highly likely` for AlliedMCV based only on `PackReplaceSelf → UnpackReplaceSelf` sequence, flag as **overconfident** (WeakEvidence). Downgrade recommendation: `possible` with miner command hub as alternative.
|
||||
|
||||
### Prompt Decisions
|
||||
|
||||
The default prompt should explicitly require:
|
||||
@@ -110,7 +133,23 @@ Reasoning:
|
||||
- A custom line format would be easier for a trivial parser but would become fragile once nested data is needed.
|
||||
- The app can tolerate partial or missing JSON by logging validation issues instead of failing the whole analysis.
|
||||
|
||||
Current expected shape:
|
||||
### Evidence Format Decision (2026-07-06)
|
||||
|
||||
We decided to move from free-form evidence text to a **structured pipe-delimited format**:
|
||||
|
||||
```
|
||||
type|time|param1|param2|...
|
||||
```
|
||||
|
||||
Supported types: `build`, `place`, `produce`, `sell`, `select`, `move`, `power`.
|
||||
|
||||
Reasoning:
|
||||
- Free-form text could not be programmatically validated without NLP.
|
||||
- Structured evidence can be parsed deterministically with a simple regex.
|
||||
- Enables deterministic validation rules like unpack-ambiguity checking.
|
||||
- The format is simple enough for AI models to follow reliably.
|
||||
|
||||
Current expected shape (all three claim types now have schema definitions in the prompt):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -125,8 +164,20 @@ Current expected shape:
|
||||
"needsConfirmation": ["是否曾使用 SpecialPower_PackReplaceSelf", "后续是否作为建造者出现"]
|
||||
}
|
||||
],
|
||||
"eventClaims": [],
|
||||
"timelineClaims": []
|
||||
"eventClaims": [
|
||||
{
|
||||
"claim": "PlayerA 主基地打包并开始迁移",
|
||||
"evidenceLevel": "confirmed",
|
||||
"evidence": ["1:24.00 SpecialPower_PackReplaceSelf", "后续移动和展开操作"]
|
||||
}
|
||||
],
|
||||
"timelineClaims": [
|
||||
{
|
||||
"claim": "PlayerA 在开局 2 分钟内完成了基地迁移",
|
||||
"evidenceLevel": "confirmed",
|
||||
"evidence": ["1:24.00 打包", "1:41.00 展开"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -139,7 +190,13 @@ The prompt asks the AI to output:
|
||||
```
|
||||
```
|
||||
|
||||
The parser first looks for the last fenced JSON block near `[机器可读声明]`, then falls back to the last `{...}` block.
|
||||
The parser first looks for the last fenced JSON block near `[机器可读声明]`, then falls back to the last `{...}`.
|
||||
|
||||
**Known format issue (fixed):** The original prompt only defined `unitClaims` entries; `eventClaims` and `timelineClaims` were shown as empty arrays. The AI therefore invented its own fields (e.g., `"event"` / `"time"` instead of `"claim"`), which the parser silently ignored. Fixed by:
|
||||
1. Adding full schema definitions for all three claim types in the prompt.
|
||||
2. Making the parser accept `"event"` as a fallback for `"claim"` in `eventClaims`.
|
||||
|
||||
**Claim count limits added:** Prompt instructs the AI to limit output (unitClaims ≤ 10, eventClaims ≤ 5, timelineClaims ≤ 3). The parser enforces these caps and emits Info-level issues if the AI exceeds them.
|
||||
|
||||
## Validation We Can Do
|
||||
|
||||
@@ -150,6 +207,9 @@ Format validation:
|
||||
- Missing machine-readable claims.
|
||||
- JSON parse failure.
|
||||
- Root value is not an object.
|
||||
- Claim count limits with truncation warnings.
|
||||
- `eventClaims` accepts both `"claim"` and `"event"` as field names.
|
||||
- Unknown `evidenceLevel` values logged as Info issue, fallback to `Uncertain`.
|
||||
|
||||
Self-consistency validation:
|
||||
|
||||
@@ -158,15 +218,39 @@ Self-consistency validation:
|
||||
- High-confidence UnitId guess without evidence.
|
||||
- Low-confidence UnitId guess without alternatives or needed confirmation.
|
||||
|
||||
### Evidence Format (Structured)
|
||||
|
||||
The `evidence` field now uses a structured pipe-delimited format instead of free-form text:
|
||||
|
||||
```
|
||||
build|time|assetName|builderUnitId
|
||||
place|time|assetName|builderUnitId|x,y,z
|
||||
produce|time|unitName|producerUnitId
|
||||
sell|time|unitId
|
||||
select|time|unitId
|
||||
move|time|x,y,z
|
||||
power|time|powerName|unitId
|
||||
```
|
||||
|
||||
This format is parsed by `ParseStructuredEvidence()` into a `StructuredEvidence` record with typed `AIEvidenceType` enum. Parsing uses a single regex and is fully deterministic.
|
||||
|
||||
**Backward compatibility:** The parser silently returns `Unknown` type for strings that don't match the structured format. No validation rules currently fire on unknown-typed evidence, so it degrades gracefully but invisibly.
|
||||
|
||||
### Near-Term Validations
|
||||
|
||||
These need replay facts extracted from `CommandChunk` or an intermediate fact index:
|
||||
|
||||
- UnitId special power contradictions.
|
||||
- UnitId production timeline contradictions.
|
||||
- UnitId used as builder vs claimed as non-builder unit.
|
||||
- UnitId production timeline contradictions (e.g., "bomber" claimed before first bomber production — needs game knowledge of which unit names are bombers).
|
||||
- Claims that use game knowledge not present in rules, such as transport/amphibious/building-placement abilities.
|
||||
- Missing alternatives for ambiguous skills such as Allied unpack.
|
||||
- **Overconfidence detection:** Claims with `confirmed`/`highly likely` that lack sufficient evidence given what is knowable from replay data alone (e.g., claiming AlliedMCV as `confirmed`).
|
||||
|
||||
### Implemented via Fact Index
|
||||
|
||||
The `ReplayFactIndex` now powers these checks:
|
||||
|
||||
- **Special power contradiction:** Evidence `power|...|SomePower|unitId` is cross-checked against the actual special powers observed for that UnitId. If the power was never used, a `Contradiction` issue is emitted.
|
||||
- **UnitId existence:** Warns if a claim references a UnitId never seen in any replay command.
|
||||
- **Builder consistency:** If a claim describes a unit as MCV/builder but the UnitId was never observed as a builder, emits `WeakEvidence`.
|
||||
|
||||
### Suggested Fact Index
|
||||
|
||||
@@ -203,6 +287,37 @@ Implemented:
|
||||
- JSON extraction and parsing
|
||||
- initial self-consistency checks
|
||||
- Added per-segment validation logging in `AIChatPanel`.
|
||||
- Fixed inconsistent prompt ↔ parser schema for `eventClaims`/`timelineClaims`:
|
||||
- Added full schema definitions for all three claim types in the system prompt.
|
||||
- Parser now accepts `"event"` as fallback for `"claim"` in `eventClaims`.
|
||||
- Both prompt and parser enforce claim count limits (10 unit, 5 event, 3 timeline) with truncation warnings.
|
||||
- Unknown `evidenceLevel` values now produce an Info-level validation issue (fallback to `Uncertain`).
|
||||
- Created ADR 0001 documenting the hidden revision pass design decision.
|
||||
- Recorded MCV vs Miner ambiguity as a documented validation scenario.
|
||||
- Evidence format changed from free-form text to structured pipe-delimited format:
|
||||
- 7 evidence types: `build`, `place`, `produce`, `sell`, `select`, `move`, `power`.
|
||||
- Prompt updated to require structured format only.
|
||||
- Added `StructuredEvidence` record and `ParseStructuredEvidence()` parser.
|
||||
- Added `ParseAllEvidence()` to convert all evidence strings for a claim.
|
||||
- Added first validation rule `ValidateUnpackAmbiguity()`:
|
||||
- Flags `confirmed`/`highly likely` claims that use `UnpackReplaceSelf` without matching `PackReplaceSelf`.
|
||||
- Emits `WeakEvidence`/`MissingAlternative` — the unpack could be MCV deploy or miner command hub deploy.
|
||||
- If `PackReplaceSelf` IS present in the same claim's evidence, the chain is consistent and no flag.
|
||||
- Created `Utils/ReplayFactIndex.cs` — builds a fact index from raw `CommandChunk` data:
|
||||
- `UnitIdFirstObservedTime`: first time each UnitId appears in any command.
|
||||
- `UnitIdSpecialPowers`: set of special powers used by each UnitId.
|
||||
- `BuilderUnitIds`: UnitIds that appeared as builder in construction commands.
|
||||
- `ProducerUnitIds`: UnitIds that appeared as production structures.
|
||||
- `PlayerFirstProductionTime`: per player, first production time for each unit asset name.
|
||||
- `PlayerSelectedUnitIds`: which UnitIds each player has selected.
|
||||
- Plumbed `ReplayFactIndex` through the analysis pipeline:
|
||||
- Built in `EventDump.ShowPlainText()` from `CommandChunk` + string hash table.
|
||||
- Passed to `AIChatPanel.StartAnalysisAsync()` as new parameter.
|
||||
- Forwarded to `AIAnalysisValidation.ValidateMachineReadableClaims()`.
|
||||
- Added `ValidateTimelineConsistency()` — three checks using fact index:
|
||||
1. **UnitId existence check:** Warns if a claim references a UnitId never seen in the replay.
|
||||
2. **Special power verification:** Cross-references `power|...` evidence entries against actual special powers observed for that UnitId; emits `Contradiction` if the claim says a UnitId used a power it never used.
|
||||
3. **Builder consistency check:** If a claim describes a unit as MCV/builder/Nanocore but that UnitId was never observed as a builder, emits `WeakEvidence`.
|
||||
|
||||
Build status:
|
||||
|
||||
@@ -219,15 +334,17 @@ Build status:
|
||||
- Should validation issues be visible by default, or only in an advanced/debug foldout?
|
||||
- How much game-unit knowledge should live in code versus prompt text?
|
||||
- Should the first verifier use hardcoded RA3/Corona knowledge, or should it load a small unit capability table from data files?
|
||||
- How should free-form evidence strings be programmatically validated? (See "Known Limitation" above.)
|
||||
|
||||
## Suggested Next Steps
|
||||
|
||||
1. Build a replay fact index from `CommandChunk`.
|
||||
2. Add first deterministic validation rules:
|
||||
- ambiguous Allied unpack
|
||||
- pack/unpack consistency
|
||||
- UnitId used as builder
|
||||
- first production time vs first operation time
|
||||
1. ✅ Build a replay fact index from `CommandChunk` — done (`ReplayFactIndex`).
|
||||
2. ✅ Add first deterministic validation rules:
|
||||
- ✅ ambiguous Allied unpack — done (`ValidateUnpackAmbiguity`).
|
||||
- ✅ pack/unpack consistency — covered by unpack rule.
|
||||
- ✅ UnitId used as builder — done (builder consistency check in `ValidateTimelineConsistency`).
|
||||
- ✅ special power contradictions — done (special power verification in `ValidateTimelineConsistency`).
|
||||
- ⬜ first production time vs first operation time — needs game knowledge of unit type names (e.g., "which names are bombers").
|
||||
3. Decide whether to buffer per-segment content before display.
|
||||
4. Add one hidden revision pass for `Contradiction` issues.
|
||||
5. Add validation summary UI, such as "验证器发现并修正 N 个问题".
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADR 0001: Hidden Revision Pass for AI Analysis
|
||||
|
||||
**Date:** 2026-07-06
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The AI analysis feature sends player operation logs to an LLM and displays the analysis in `AIChatPanel`. LLM output is inherently unreliable — the model may make contradictory claims, use incorrect game knowledge, or miss alternative interpretations.
|
||||
|
||||
We considered several approaches to handle problematic output:
|
||||
|
||||
1. **Show raw output, let the user judge.** Simplest, but puts the burden on the user to spot errors.
|
||||
2. **Show a corrected version alongside the original.** Transparent, but confusing — two competing analyses.
|
||||
3. **Reject the entire segment and retry from scratch.** Wastes the prior reasoning; may produce similar mistakes.
|
||||
4. **Hidden revision pass:** Send the draft, validation issues, and replay facts back to the AI, asking for a clean corrected version without apology text.
|
||||
|
||||
We chose option 4 because:
|
||||
- The user sees only one coherent analysis.
|
||||
- Prior reasoning is preserved and refined, not discarded.
|
||||
- The user is not exposed to "sorry, my previous answer was wrong" chatter.
|
||||
|
||||
## Decision
|
||||
|
||||
- Implement a **hidden revision pass** for segments that have `Contradiction` or `Fatal` validation issues.
|
||||
- The revision prompt includes: the original draft, the list of validation issues (with severity and kind), and relevant replay facts for the affected claims.
|
||||
- The model is instructed to output a clean corrected analysis (natural language + machine-readable claims) **without** acknowledging the revision.
|
||||
- Limit to **one revision pass per segment** to avoid infinite loops.
|
||||
- If the revision still has `Fatal` issues, fall back to displaying the original with a warning.
|
||||
- Per-segment content is **buffered** during validation — the user sees the final content only after validation and optional revision complete.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: User sees a single, cleaner analysis.
|
||||
- Positive: Prior reasoning is reused, saving tokens vs. re-analyzing from scratch.
|
||||
- Negative: Adds latency (one extra round trip) for segments that need revision.
|
||||
- Negative: Increases token usage for revised segments (draft + revision prompt + corrected output).
|
||||
- Negative: Hidden correction may reduce user trust if they discover it — consider a subtle indicator like "验证器发现并修正 N 个问题".
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Not yet implemented. Validation issues are detected and logged in `AIChatPanel`, but no automatic revision pass is triggered. The revision prompt construction and retry orchestration still need to be wired in.
|
||||
Reference in New Issue
Block a user