| 1 | namespace CasaField.Core; |
| 2 | |
| 3 | public sealed record SnPorts(string Mode, IReadOnlyDictionary<string, double> Ports, bool CenOpen); |
| 4 | |
| 5 | public sealed record CenTarget( |
| 6 | string Kind, |
| 7 | string ConceptId, |
| 8 | string? DocPath, |
| 9 | string? Section, |
| 10 | string? File, |
| 11 | int? Line, |
| 12 | int? LineEnd); |
| 13 | |
| 14 | public sealed record AnswerBlock( |
| 15 | string ConceptId, |
| 16 | string Text, |
| 17 | string Section, |
| 18 | string DocPath, |
| 19 | int Score, |
| 20 | string ClaimKind); |
| 21 | |
| 22 | public sealed record CenTask( |
| 23 | string Intent, |
| 24 | string Action, |
| 25 | IReadOnlyList<string> Concepts, |
| 26 | IReadOnlyList<CenTarget> Targets, |
| 27 | double Confidence, |
| 28 | string? QueryToDmn, |
| 29 | IReadOnlyDictionary<string, double> TaskBand, |
| 30 | IReadOnlyList<AnswerBlock> AnswerBlocks); |
| 31 | |
| 32 | public sealed record HotPathResult( |
| 33 | double WallMs, |
| 34 | int FieldVersion, |
| 35 | bool Stale, |
| 36 | DecodedGrid Decoded, |
| 37 | SnPorts Sn, |
| 38 | CenTask Cen, |
| 39 | IReadOnlyList<ClaimNavItem> ClaimsNav); |
| 40 | |
| 41 | public static class SnCenRuntime |
| 42 | { |
| 43 | public static SnPorts Route(DecodedGrid decoded, IReadOnlyList<ClaimNavItem> nav, double gateThreshold = 0.35) |
| 44 | { |
| 45 | var conf = decoded.ConceptIds.Count > 0 ? 1.0 : 0.2; |
| 46 | var p2 = conf >= gateThreshold ? conf : conf * 0.5; |
| 47 | var ports = new Dictionary<string, double> |
| 48 | { |
| 49 | ["p1_dmn_to_sn"] = 1.0, |
| 50 | ["p2_sn_to_cen"] = p2, |
| 51 | ["p3_cen_to_dmn"] = conf >= gateThreshold ? 0.0 : 0.25, |
| 52 | }; |
| 53 | var mode = p2 >= gateThreshold ? "execute" : "probe"; |
| 54 | return new SnPorts(mode, ports, p2 >= gateThreshold); |
| 55 | } |
| 56 | |
| 57 | public static CenTask Plan( |
| 58 | string? query, |
| 59 | DecodedGrid decoded, |
| 60 | SnPorts sn, |
| 61 | IReadOnlyList<ClaimNavItem> nav) |
| 62 | { |
| 63 | var words = Tokenize(query); |
| 64 | var hits = new List<(int Score, ClaimNavItem Item)>(); |
| 65 | |
| 66 | foreach (var item in nav) |
| 67 | { |
| 68 | var score = ScoreClaim(query ?? "", words, item); |
| 69 | if (score > 0) |
| 70 | hits.Add((score, item)); |
| 71 | } |
| 72 | |
| 73 | hits.Sort((a, b) => b.Score.CompareTo(a.Score)); |
| 74 | var top = hits.Take(5).Select(h => h.Item).ToList(); |
| 75 | if (top.Count == 0 && nav.Count > 0) |
| 76 | top = nav.Take(3).ToList(); |
| 77 | |
| 78 | var concepts = top.Select(t => t.ConceptId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); |
| 79 | var targets = BuildTargets(top); |
| 80 | var action = targets.Any(t => t.Kind == "code") ? "open_code" |
| 81 | : targets.Any(t => t.Kind == "kb") ? "open_kb" |
| 82 | : hits.Count > 0 ? "answer_from_memory" |
| 83 | : "answer_from_memory"; |
| 84 | |
| 85 | var answerBlocks = hits |
| 86 | .Take(3) |
| 87 | .Select(h => new AnswerBlock( |
| 88 | h.Item.ConceptId, |
| 89 | h.Item.Text, |
| 90 | h.Item.Section, |
| 91 | h.Item.DocPath, |
| 92 | h.Score, |
| 93 | h.Item.ClaimKind ?? "claim")) |
| 94 | .ToList(); |
| 95 | |
| 96 | var conf = sn.Ports.GetValueOrDefault("p2_sn_to_cen"); |
| 97 | var taskBand = new Dictionary<string, double> |
| 98 | { |
| 99 | ["task.intent.query"] = string.IsNullOrEmpty(query) ? 0.0 : 1.0, |
| 100 | ["task.action.open_kb"] = targets.Any(t => t.Kind == "kb") ? 1.0 : 0.0, |
| 101 | ["task.action.open_code"] = targets.Any(t => t.Kind == "code") ? 1.0 : 0.0, |
| 102 | ["task.confidence"] = conf, |
| 103 | }; |
| 104 | |
| 105 | return new CenTask( |
| 106 | string.IsNullOrEmpty(query) ? "summarize" : "query", |
| 107 | action, |
| 108 | concepts, |
| 109 | targets, |
| 110 | conf, |
| 111 | hits.Count == 0 && !string.IsNullOrEmpty(query) ? $"clarify:{query[..Math.Min(120, query.Length)]}" : null, |
| 112 | taskBand, |
| 113 | answerBlocks); |
| 114 | } |
| 115 | |
| 116 | private static List<string> Tokenize(string? query) |
| 117 | { |
| 118 | if (string.IsNullOrWhiteSpace(query)) |
| 119 | return []; |
| 120 | return System.Text.RegularExpressions.Regex.Matches(query, @"[\w\u0400-\u04FF]+") |
| 121 | .Select(m => m.Value.ToLowerInvariant()) |
| 122 | .Where(w => w.Length > 2) |
| 123 | .ToList(); |
| 124 | } |
| 125 | |
| 126 | private static int ScoreClaim(string query, List<string> words, ClaimNavItem item) |
| 127 | { |
| 128 | var score = 0; |
| 129 | var cid = item.ConceptId.ToLowerInvariant(); |
| 130 | var sec = item.Section.ToLowerInvariant(); |
| 131 | var text = item.Text.ToLowerInvariant(); |
| 132 | var q = query.ToLowerInvariant(); |
| 133 | if (cid.Contains(q, StringComparison.Ordinal) || q.Contains(cid, StringComparison.Ordinal)) |
| 134 | score += 12; |
| 135 | foreach (var w in words) |
| 136 | { |
| 137 | if (cid.Contains(w, StringComparison.Ordinal)) score += 4; |
| 138 | if (sec.Contains(w, StringComparison.Ordinal)) score += 3; |
| 139 | if (text.Contains(w, StringComparison.Ordinal)) score += 2; |
| 140 | } |
| 141 | return score; |
| 142 | } |
| 143 | |
| 144 | private static List<CenTarget> BuildTargets(IReadOnlyList<ClaimNavItem> items) |
| 145 | { |
| 146 | var targets = new List<CenTarget>(); |
| 147 | foreach (var item in items) |
| 148 | { |
| 149 | targets.Add(new CenTarget("kb", item.ConceptId, item.DocPath, item.Section, null, null, null)); |
| 150 | if (item.CodeAnchors is null) |
| 151 | continue; |
| 152 | foreach (var a in item.CodeAnchors) |
| 153 | { |
| 154 | targets.Add(new CenTarget("code", item.ConceptId, null, null, a.File, a.Line, a.LineEnd)); |
| 155 | } |
| 156 | } |
| 157 | return targets; |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | public static class CasaFieldHotPath |
| 162 | { |
| 163 | public static HotPathResult Run(string storeDirectory, string? query = null) |
| 164 | { |
| 165 | var sw = System.Diagnostics.Stopwatch.StartNew(); |
| 166 | var fieldPath = Path.Combine(storeDirectory, "field_state.json"); |
| 167 | var snapshot = FieldStateLoader.Load(fieldPath, storeDirectory); |
| 168 | if (snapshot.Grid is null) |
| 169 | throw new InvalidOperationException("field_state has no grid"); |
| 170 | |
| 171 | var decoded = GridDecoder.Decode(snapshot.Grid.Cells, CasaCanonical.FieldMinVotes); |
| 172 | var sn = SnCenRuntime.Route(decoded, snapshot.ClaimsNav); |
| 173 | var cen = SnCenRuntime.Plan(query, decoded, sn, snapshot.ClaimsNav); |
| 174 | sw.Stop(); |
| 175 | return new HotPathResult( |
| 176 | Math.Round(sw.Elapsed.TotalMilliseconds, 2), |
| 177 | snapshot.FieldVersion, |
| 178 | snapshot.Stale, |
| 179 | decoded, |
| 180 | sn, |
| 181 | cen, |
| 182 | snapshot.ClaimsNav); |
| 183 | } |
| 184 | } |
| 185 | |