| 1 | #nullable enable |
| 2 | using System.Text.Json; |
| 3 | using CascadeIDE.Models.AgentChat; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Chat; |
| 6 | |
| 7 | /// <summary>Детерминированная проекция SEDM-событий из append-only log (ADR 0173/0174 MLP).</summary> |
| 8 | public static class SedmEventProjector |
| 9 | { |
| 10 | public sealed record DecisionState( |
| 11 | Guid EventId, |
| 12 | SedmDecisionRecordedPayload Payload, |
| 13 | string Status); |
| 14 | |
| 15 | public sealed record WorklineProjection( |
| 16 | string WorklineId, |
| 17 | SedmContextCardMaterializedPayload? ContextCard, |
| 18 | SedmIntentCardRecordedPayload? IntentCard, |
| 19 | DecisionState? ActiveDecision, |
| 20 | IReadOnlyList<DecisionState> DecisionHistory); |
| 21 | |
| 22 | public sealed record SessionProjection( |
| 23 | IReadOnlyDictionary<string, WorklineProjection> ByWorkline, |
| 24 | int OpenWorklineCount); |
| 25 | |
| 26 | public static SessionProjection Project( |
| 27 | IReadOnlyList<ChatHistoryEvent> events, |
| 28 | Guid activeWorklineId, |
| 29 | IReadOnlyDictionary<Guid, string>? threadTitles = null, |
| 30 | int openWorklineCount = 1) |
| 31 | { |
| 32 | var worklines = new Dictionary<string, MutableWorkline>(StringComparer.OrdinalIgnoreCase); |
| 33 | var decisions = new Dictionary<string, DecisionState>(StringComparer.OrdinalIgnoreCase); |
| 34 | |
| 35 | foreach (var ev in events) |
| 36 | { |
| 37 | switch (ev.Kind) |
| 38 | { |
| 39 | case ChatHistoryEventKind.ContextCardMaterialized: |
| 40 | if (TryDeserialize<SedmContextCardMaterializedPayload>(ev.PayloadJson, out var ctx)) |
| 41 | GetOrAdd(worklines, ctx.WorklineId).ContextCard = ctx; |
| 42 | break; |
| 43 | |
| 44 | case ChatHistoryEventKind.IntentCardRecorded: |
| 45 | if (TryDeserialize<SedmIntentCardRecordedPayload>(ev.PayloadJson, out var intent)) |
| 46 | GetOrAdd(worklines, intent.WorklineId).IntentCard = intent; |
| 47 | break; |
| 48 | |
| 49 | case ChatHistoryEventKind.DecisionRecorded: |
| 50 | if (TryDeserialize<SedmDecisionRecordedPayload>(ev.PayloadJson, out var decision)) |
| 51 | { |
| 52 | var state = new DecisionState(ev.EventId, decision, NormalizeStatus(decision.Status)); |
| 53 | decisions[ev.EventId.ToString("N")] = state; |
| 54 | GetOrAdd(worklines, decision.WorklineId).DecisionHistory.Add(state); |
| 55 | } |
| 56 | break; |
| 57 | |
| 58 | case ChatHistoryEventKind.DecisionMarkedStale: |
| 59 | if (TryDeserialize<SedmDecisionLifecyclePayload>(ev.PayloadJson, out var stale) |
| 60 | && decisions.TryGetValue(stale.DecisionEventId, out var staleTarget)) |
| 61 | { |
| 62 | decisions[stale.DecisionEventId] = staleTarget with { Status = "stale" }; |
| 63 | ReplaceInHistory(worklines, stale.DecisionEventId, "stale"); |
| 64 | } |
| 65 | break; |
| 66 | |
| 67 | case ChatHistoryEventKind.DecisionSuperseded: |
| 68 | if (TryDeserialize<SedmDecisionLifecyclePayload>(ev.PayloadJson, out var superseded) |
| 69 | && decisions.TryGetValue(superseded.DecisionEventId, out var supersededTarget)) |
| 70 | { |
| 71 | decisions[superseded.DecisionEventId] = supersededTarget with { Status = "superseded" }; |
| 72 | ReplaceInHistory(worklines, superseded.DecisionEventId, "superseded"); |
| 73 | } |
| 74 | break; |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | var byWorkline = worklines.ToDictionary( |
| 79 | static kv => kv.Key, |
| 80 | static kv => ToProjection(kv.Value), |
| 81 | StringComparer.OrdinalIgnoreCase); |
| 82 | |
| 83 | if (activeWorklineId != Guid.Empty) |
| 84 | { |
| 85 | var activeKey = activeWorklineId.ToString("N"); |
| 86 | if (!byWorkline.ContainsKey(activeKey)) |
| 87 | { |
| 88 | var label = threadTitles is not null && threadTitles.TryGetValue(activeWorklineId, out var title) |
| 89 | ? title |
| 90 | : null; |
| 91 | byWorkline[activeKey] = new WorklineProjection( |
| 92 | activeKey, |
| 93 | null, |
| 94 | null, |
| 95 | null, |
| 96 | []); |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | return new SessionProjection(byWorkline, Math.Max(1, openWorklineCount)); |
| 101 | } |
| 102 | |
| 103 | public static WorklineProjection ResolveWorkline(SessionProjection projection, Guid worklineId) |
| 104 | { |
| 105 | if (worklineId == Guid.Empty) |
| 106 | return EmptyWorkline(""); |
| 107 | |
| 108 | var key = worklineId.ToString("N"); |
| 109 | return projection.ByWorkline.TryGetValue(key, out var wl) |
| 110 | ? wl |
| 111 | : EmptyWorkline(key); |
| 112 | } |
| 113 | |
| 114 | public static bool IsSameContextCard(SedmContextCardMaterializedPayload? left, SedmContextCardMaterializedPayload right) => |
| 115 | left is not null |
| 116 | && string.Equals(left.WorklineId, right.WorklineId, StringComparison.OrdinalIgnoreCase) |
| 117 | && string.Equals(left.Anchor.Path, right.Anchor.Path, StringComparison.OrdinalIgnoreCase) |
| 118 | && string.Equals(left.Anchor.Symbol, right.Anchor.Symbol, StringComparison.OrdinalIgnoreCase); |
| 119 | |
| 120 | private static WorklineProjection ToProjection(MutableWorkline state) |
| 121 | { |
| 122 | DecisionState? active = null; |
| 123 | for (var i = state.DecisionHistory.Count - 1; i >= 0; i--) |
| 124 | { |
| 125 | var candidate = state.DecisionHistory[i]; |
| 126 | if (string.Equals(candidate.Status, "active", StringComparison.OrdinalIgnoreCase)) |
| 127 | { |
| 128 | active = candidate; |
| 129 | break; |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | return new WorklineProjection( |
| 134 | state.WorklineId, |
| 135 | state.ContextCard, |
| 136 | state.IntentCard, |
| 137 | active, |
| 138 | state.DecisionHistory); |
| 139 | } |
| 140 | |
| 141 | private static WorklineProjection EmptyWorkline(string worklineId) => |
| 142 | new(worklineId, null, null, null, []); |
| 143 | |
| 144 | private static MutableWorkline GetOrAdd(Dictionary<string, MutableWorkline> worklines, string worklineId) |
| 145 | { |
| 146 | if (!worklines.TryGetValue(worklineId, out var wl)) |
| 147 | { |
| 148 | wl = new MutableWorkline(worklineId); |
| 149 | worklines[worklineId] = wl; |
| 150 | } |
| 151 | |
| 152 | return wl; |
| 153 | } |
| 154 | |
| 155 | private static void ReplaceInHistory( |
| 156 | Dictionary<string, MutableWorkline> worklines, |
| 157 | string decisionEventId, |
| 158 | string status) |
| 159 | { |
| 160 | foreach (var wl in worklines.Values) |
| 161 | { |
| 162 | for (var i = 0; i < wl.DecisionHistory.Count; i++) |
| 163 | { |
| 164 | if (!string.Equals(wl.DecisionHistory[i].EventId.ToString("N"), decisionEventId, StringComparison.OrdinalIgnoreCase)) |
| 165 | continue; |
| 166 | wl.DecisionHistory[i] = wl.DecisionHistory[i] with { Status = status }; |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | private static string NormalizeStatus(string? status) => |
| 172 | string.IsNullOrWhiteSpace(status) ? "active" : status.Trim().ToLowerInvariant(); |
| 173 | |
| 174 | private static bool TryDeserialize<T>(string payloadJson, out T value) |
| 175 | { |
| 176 | value = default!; |
| 177 | try |
| 178 | { |
| 179 | var parsed = JsonSerializer.Deserialize<T>(payloadJson, ChatHistoryJson.Options); |
| 180 | if (parsed is null) |
| 181 | return false; |
| 182 | value = parsed; |
| 183 | return true; |
| 184 | } |
| 185 | catch (JsonException) |
| 186 | { |
| 187 | return false; |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | private sealed class MutableWorkline(string worklineId) |
| 192 | { |
| 193 | public string WorklineId { get; } = worklineId; |
| 194 | public SedmContextCardMaterializedPayload? ContextCard { get; set; } |
| 195 | public SedmIntentCardRecordedPayload? IntentCard { get; set; } |
| 196 | public List<DecisionState> DecisionHistory { get; } = []; |
| 197 | } |
| 198 | } |
| 199 | |