feat(ui): add decision-log reader and overlay components
This commit is contained in:
parent
c1259ecba4
commit
2dc711a55c
9 changed files with 469 additions and 0 deletions
120
ui/JevOverlay/Core/DecisionHistory.cs
Normal file
120
ui/JevOverlay/Core/DecisionHistory.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace JevOverlay.Core;
|
||||
|
||||
public readonly record struct DecisionKey(string Session, int Step);
|
||||
|
||||
public sealed class DecisionEntry(DecisionKey key, JsonElement record)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Pretty = new() { WriteIndented = true };
|
||||
public DecisionKey Key { get; } = key;
|
||||
public JsonElement Record { get; } = record;
|
||||
public string Action => Text(Record, "action");
|
||||
public string Source => Text(Record, "source", "unknown");
|
||||
public string Status { get; internal set; } = "Proposal only; execution is not recorded.";
|
||||
public string? SessionEnd { get; internal set; }
|
||||
public string Title => $"#{Key.Step} · {Source.ToUpperInvariant()} · {Text(Record, "state_type")}";
|
||||
|
||||
public string Summary()
|
||||
{
|
||||
string latency = "";
|
||||
var jev = Field(Record, "jev");
|
||||
if (Field(jev, "latency_s") is { ValueKind: JsonValueKind.Number } seconds &&
|
||||
seconds.TryGetDouble(out double number) && double.IsFinite(number))
|
||||
latency = $" · {number * 1000:0} ms";
|
||||
return Limit($"{Title}\nSession: {Key.Session} · {Text(Record, "ts")}\n" +
|
||||
$"Proposed: {Action}\nParameters: {Field(Record, "params")}\n" +
|
||||
$"{Status}\n" + (SessionEnd is null ? "" : $"{SessionEnd}\n") +
|
||||
$"\nReason: {Text(Record, "reason", "Not recorded")}\n" +
|
||||
$"Recorded confidence: {Field(Record, "confidence")?.ToString() ?? "—"}{latency}\n" +
|
||||
(string.IsNullOrEmpty(Text(Record, "error")) ? "" : $"Error: {Text(Record, "error")}\n") +
|
||||
"Confidence does not prove correctness.");
|
||||
}
|
||||
|
||||
public string Evidence()
|
||||
{
|
||||
var jev = Field(Record, "jev");
|
||||
if (jev is null || jev.Value.ValueKind == JsonValueKind.Null)
|
||||
return "No Jev response recorded for this decision.\nThe source field identifies the policy path.";
|
||||
return Limit("Recorded Jev response (not private reasoning).\n" +
|
||||
"The recorded answer gate may differ from the policy's final gate.\n\n" +
|
||||
JsonSerializer.Serialize(jev.Value, Pretty));
|
||||
}
|
||||
|
||||
internal static string Limit(string value) => value.Length <= 16000 ? value : value[..16000] + "\n[Display truncated]";
|
||||
internal static JsonElement? Field(JsonElement? obj, string name) =>
|
||||
obj is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(name, out var value)
|
||||
? value : null;
|
||||
internal static string Text(JsonElement? obj, string name, string fallback = "") =>
|
||||
Field(obj, name) is { ValueKind: JsonValueKind.String } value ? value.GetString()! : fallback;
|
||||
}
|
||||
|
||||
/// <summary>Joins recorded events by session and step, never by wall-clock time.</summary>
|
||||
public sealed class DecisionHistory
|
||||
{
|
||||
public const int Capacity = 100;
|
||||
private readonly List<DecisionEntry> entries = [];
|
||||
public IReadOnlyList<DecisionEntry> Entries => entries;
|
||||
public int SkippedLines { get; private set; }
|
||||
public string? Notice { get; private set; }
|
||||
|
||||
public void Apply(TailBatch batch)
|
||||
{
|
||||
if (batch.Reset)
|
||||
{
|
||||
entries.Clear();
|
||||
Notice = null;
|
||||
SkippedLines = 0;
|
||||
}
|
||||
SkippedLines += batch.SkippedLines;
|
||||
foreach (string line in batch.Lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(line);
|
||||
if (!Consume(document.RootElement)) SkippedLines++;
|
||||
}
|
||||
catch (JsonException) { SkippedLines++; }
|
||||
}
|
||||
}
|
||||
|
||||
private bool Consume(JsonElement root)
|
||||
{
|
||||
string session = DecisionEntry.Text(root, "session");
|
||||
string kind = DecisionEntry.Text(root, "event");
|
||||
if (session.Length == 0 || kind.Length == 0) return false;
|
||||
if (kind == "session_end")
|
||||
{
|
||||
string message = $"Session ended: {DecisionEntry.Text(root, "reason", "unspecified")}";
|
||||
foreach (var entry in entries.Where(e => e.Key.Session == session)) entry.SessionEnd = message;
|
||||
Notice = $"{session}: {message}";
|
||||
return true;
|
||||
}
|
||||
if (kind is not ("decide" or "action_error" or "action_rejected" or "model_error" or
|
||||
"fallback_error" or "policy_error" or "no_decision"))
|
||||
return true; // Other runner events do not describe a decision or its status.
|
||||
if (DecisionEntry.Field(root, "step") is not { ValueKind: JsonValueKind.Number } stepValue ||
|
||||
!stepValue.TryGetInt32(out int step) || step < 0) return false;
|
||||
var key = new DecisionKey(session, step);
|
||||
if (kind == "decide")
|
||||
{
|
||||
if (DecisionEntry.Text(root, "action").Length == 0) return false;
|
||||
entries.RemoveAll(entry => entry.Key == key);
|
||||
entries.Insert(0, new DecisionEntry(key, root.Clone()));
|
||||
if (entries.Count > Capacity) entries.RemoveAt(entries.Count - 1);
|
||||
Notice = null;
|
||||
}
|
||||
else if (kind is "action_error" or "action_rejected")
|
||||
{
|
||||
var entry = entries.Find(entry => entry.Key == key);
|
||||
string action = DecisionEntry.Text(root, "action");
|
||||
if (entry is not null && (action.Length == 0 || action == entry.Action))
|
||||
entry.Status = kind == "action_error"
|
||||
? $"Action request error; completion unknown: {DecisionEntry.Text(root, "error")}"
|
||||
: $"Action request rejected: {DecisionEntry.Text(root, "message")}";
|
||||
}
|
||||
else Notice = DecisionEntry.Limit($"{session} #{step} · {kind}: {DecisionEntry.Text(root, "error")}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue