@obsunified/collector 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/auth.d.ts +2 -0
  4. package/dist/auth.js +9 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/chunk-UGWDOGGH.js +319 -0
  7. package/dist/chunk-UGWDOGGH.js.map +1 -0
  8. package/dist/chunk-XKQTVIRE.js +671 -0
  9. package/dist/chunk-XKQTVIRE.js.map +1 -0
  10. package/dist/index.d.ts +1197 -0
  11. package/dist/index.js +17707 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
  14. package/dist/openai-FJYOQ7WO.js +384 -0
  15. package/dist/openai-FJYOQ7WO.js.map +1 -0
  16. package/package.json +73 -0
  17. package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
  18. package/src/analyses/derive.ts +642 -0
  19. package/src/analyses/index.ts +50 -0
  20. package/src/analyses/investigations.ts +277 -0
  21. package/src/analyses/tier0.ts +568 -0
  22. package/src/auth/dashboard-auth.ts +269 -0
  23. package/src/auth/ingest-auth.ts +147 -0
  24. package/src/durable-objects/tail-hub.test.ts +32 -0
  25. package/src/durable-objects/tail-hub.ts +158 -0
  26. package/src/framework/collector.ts +467 -0
  27. package/src/framework/env.ts +90 -0
  28. package/src/framework/logger.ts +81 -0
  29. package/src/index.ts +211 -0
  30. package/src/lib/action-aggregates.ts +1031 -0
  31. package/src/lib/action-identity.ts +106 -0
  32. package/src/lib/actions-store.ts +397 -0
  33. package/src/lib/ai-pricing.ts +80 -0
  34. package/src/lib/ai-store/calls.ts +148 -0
  35. package/src/lib/ai-store/evaluations.test.ts +47 -0
  36. package/src/lib/ai-store/evaluations.ts +125 -0
  37. package/src/lib/ai-store/helpers.ts +37 -0
  38. package/src/lib/ai-store/retention.ts +17 -0
  39. package/src/lib/ai-store/sessions.ts +277 -0
  40. package/src/lib/ai-store/spans.ts +109 -0
  41. package/src/lib/ai-store/types.ts +112 -0
  42. package/src/lib/ai-store.ts +76 -0
  43. package/src/lib/alerts-store.ts +458 -0
  44. package/src/lib/analyses-runner.ts +439 -0
  45. package/src/lib/analyses-store.test.ts +243 -0
  46. package/src/lib/analyses-store.ts +454 -0
  47. package/src/lib/ask.test.ts +302 -0
  48. package/src/lib/ask.ts +390 -0
  49. package/src/lib/blob-store-s3.ts +213 -0
  50. package/src/lib/blob-store.ts +366 -0
  51. package/src/lib/eval-cases-store.test.ts +171 -0
  52. package/src/lib/eval-cases-store.ts +1033 -0
  53. package/src/lib/evidence-references.test.ts +10 -0
  54. package/src/lib/evidence-references.ts +684 -0
  55. package/src/lib/hash.ts +26 -0
  56. package/src/lib/identity-index/action-lookups.ts +805 -0
  57. package/src/lib/identity-index/constants.ts +1 -0
  58. package/src/lib/identity-index/key-lookups.ts +199 -0
  59. package/src/lib/identity-index/mappers.ts +349 -0
  60. package/src/lib/identity-index/types.ts +196 -0
  61. package/src/lib/identity-index/user-lookup.ts +137 -0
  62. package/src/lib/identity-index.test.ts +624 -0
  63. package/src/lib/identity-index.ts +85 -0
  64. package/src/lib/json.ts +39 -0
  65. package/src/lib/llm.ts +263 -0
  66. package/src/lib/logs-store.ts +188 -0
  67. package/src/lib/metrics-store.test.ts +189 -0
  68. package/src/lib/metrics-store.ts +312 -0
  69. package/src/lib/narrate-gate.test.ts +260 -0
  70. package/src/lib/narrate-gate.ts +253 -0
  71. package/src/lib/openai.test.ts +300 -0
  72. package/src/lib/openai.ts +512 -0
  73. package/src/lib/otlp.ts +212 -0
  74. package/src/lib/parse-pprof.test.ts +64 -0
  75. package/src/lib/parse-pprof.ts +184 -0
  76. package/src/lib/payload-capture.ts +22 -0
  77. package/src/lib/projects-store.ts +207 -0
  78. package/src/lib/propagation-metric.test.ts +196 -0
  79. package/src/lib/propagation-metric.ts +175 -0
  80. package/src/lib/sql-db-dialect.test.ts +22 -0
  81. package/src/lib/sql-db-postgres.ts +229 -0
  82. package/src/lib/sql-db.ts +166 -0
  83. package/src/lib/stage6.test.ts +216 -0
  84. package/src/lib/store/export.ts +65 -0
  85. package/src/lib/store/helpers.ts +367 -0
  86. package/src/lib/store/ingest.ts +68 -0
  87. package/src/lib/store/issues.ts +158 -0
  88. package/src/lib/store/overview.ts +127 -0
  89. package/src/lib/store/retention.ts +10 -0
  90. package/src/lib/store/service-map.ts +384 -0
  91. package/src/lib/store/trace-detail.bench.ts +76 -0
  92. package/src/lib/store/trace-detail.test.ts +410 -0
  93. package/src/lib/store/trace-detail.ts +427 -0
  94. package/src/lib/store/trace-queries.test.ts +29 -0
  95. package/src/lib/store/trace-queries.ts +89 -0
  96. package/src/lib/store-overview.test.ts +77 -0
  97. package/src/lib/store.ts +96 -0
  98. package/src/lib/tail-publisher.ts +64 -0
  99. package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
  100. package/src/lib/test-utils/mem-sql-db.ts +86 -0
  101. package/src/lib/ua-parser.ts +61 -0
  102. package/src/lib/usage-store.ts +567 -0
  103. package/src/migrations/001_telemetry_spans.sql +28 -0
  104. package/src/migrations/002_usage_events.sql +24 -0
  105. package/src/migrations/003_usage_analytics_columns.sql +13 -0
  106. package/src/migrations/004_logs.sql +19 -0
  107. package/src/migrations/005_ai_calls.sql +25 -0
  108. package/src/migrations/006_user_profiles.sql +11 -0
  109. package/src/migrations/007_session_replay_metadata.sql +10 -0
  110. package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
  111. package/src/migrations/009_projects_and_keys.sql +22 -0
  112. package/src/migrations/010_signal_project_id.sql +15 -0
  113. package/src/migrations/011_default_project_seed.sql +3 -0
  114. package/src/migrations/012_alert_rules.sql +17 -0
  115. package/src/migrations/013_alert_evaluations.sql +13 -0
  116. package/src/migrations/014_alert_state.sql +9 -0
  117. package/src/migrations/015_metrics.sql +58 -0
  118. package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
  119. package/src/migrations/017_span_dropped_counts.sql +11 -0
  120. package/src/migrations/018_log_flags.sql +8 -0
  121. package/src/migrations/019_ai_span_payloads.sql +30 -0
  122. package/src/migrations/020_ai_span_evaluations.sql +33 -0
  123. package/src/migrations/021_ai_span_session.sql +13 -0
  124. package/src/migrations/022_span_log_session_id.sql +13 -0
  125. package/src/migrations/023_service_map_indexes.sql +32 -0
  126. package/src/migrations/024_analysis_results.sql +49 -0
  127. package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
  128. package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
  129. package/src/migrations/027_identity_propagation.sql +50 -0
  130. package/src/migrations/028_profile_blobs.sql +57 -0
  131. package/src/migrations/029_telemetry_sdk_name.sql +20 -0
  132. package/src/migrations/030_analysis_claim.sql +18 -0
  133. package/src/migrations/031_agent_action_graph.sql +154 -0
  134. package/src/migrations/032_payload_capture_flags.sql +8 -0
  135. package/src/migrations/033_eval_cases.sql +52 -0
  136. package/src/migrations/034_eval_case_results.sql +23 -0
  137. package/src/migrations/035_metric_exemplars.sql +33 -0
  138. package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
  139. package/src/migrations/037_eval_runs.sql +37 -0
  140. package/src/migrations/038_tool_call_evidence.sql +11 -0
  141. package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
  142. package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
  143. package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
  144. package/src/migrations-postgres/002_usage_events.sql +24 -0
  145. package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
  146. package/src/migrations-postgres/004_logs.sql +19 -0
  147. package/src/migrations-postgres/005_ai_calls.sql +25 -0
  148. package/src/migrations-postgres/006_user_profiles.sql +11 -0
  149. package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
  150. package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
  151. package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
  152. package/src/migrations-postgres/010_signal_project_id.sql +18 -0
  153. package/src/migrations-postgres/011_default_project_seed.sql +5 -0
  154. package/src/migrations-postgres/012_alert_rules.sql +17 -0
  155. package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
  156. package/src/migrations-postgres/014_alert_state.sql +9 -0
  157. package/src/migrations-postgres/015_metrics.sql +47 -0
  158. package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
  159. package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
  160. package/src/migrations-postgres/018_log_flags.sql +2 -0
  161. package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
  162. package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
  163. package/src/migrations-postgres/021_ai_span_session.sql +7 -0
  164. package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
  165. package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
  166. package/src/migrations-postgres/024_analysis_results.sql +41 -0
  167. package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
  168. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
  169. package/src/migrations-postgres/027_identity_propagation.sql +27 -0
  170. package/src/migrations-postgres/028_profile_blobs.sql +48 -0
  171. package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
  172. package/src/migrations-postgres/030_analysis_claim.sql +5 -0
  173. package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
  174. package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
  175. package/src/migrations-postgres/033_eval_cases.sql +55 -0
  176. package/src/migrations-postgres/034_eval_case_results.sql +26 -0
  177. package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
  178. package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
  179. package/src/migrations-postgres/037_eval_runs.sql +40 -0
  180. package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
  181. package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
  182. package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
  183. package/src/migrations-postgres/README.md +40 -0
  184. package/src/otlp/acceptance.test.ts +373 -0
  185. package/src/otlp/decode/body.ts +127 -0
  186. package/src/otlp/decode/logs.ts +96 -0
  187. package/src/otlp/decode/metrics.ts +402 -0
  188. package/src/otlp/decode/traces.ts +92 -0
  189. package/src/otlp/decode/values.ts +150 -0
  190. package/src/otlp/decode.test.ts +800 -0
  191. package/src/otlp/decode.ts +11 -0
  192. package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
  193. package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
  194. package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
  195. package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
  196. package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
  197. package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
  198. package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
  199. package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
  200. package/src/otlp/response.test.ts +158 -0
  201. package/src/otlp/response.ts +129 -0
  202. package/src/plugins/_context.ts +15 -0
  203. package/src/plugins/action-graph-processor/enrichers.ts +45 -0
  204. package/src/plugins/action-graph-processor/redaction.ts +84 -0
  205. package/src/plugins/action-graph-processor.test.ts +1023 -0
  206. package/src/plugins/action-graph-processor.ts +1172 -0
  207. package/src/plugins/action-routes.test.ts +716 -0
  208. package/src/plugins/action-routes.ts +343 -0
  209. package/src/plugins/ai-receiver.ts +254 -0
  210. package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
  211. package/src/plugins/ai-span-payloads-processor.ts +180 -0
  212. package/src/plugins/alerts-evaluator.test.ts +91 -0
  213. package/src/plugins/alerts-evaluator.ts +328 -0
  214. package/src/plugins/alerts-routes.ts +101 -0
  215. package/src/plugins/analyses-routes.ts +138 -0
  216. package/src/plugins/ask-routes.ts +182 -0
  217. package/src/plugins/bot-filter.ts +19 -0
  218. package/src/plugins/connected-routes/manifest.ts +531 -0
  219. package/src/plugins/connected-routes.test.ts +1791 -0
  220. package/src/plugins/connected-routes.ts +1103 -0
  221. package/src/plugins/dashboard-routes.ts +41 -0
  222. package/src/plugins/default-span-enrichment.ts +92 -0
  223. package/src/plugins/eval-cases-routes.test.ts +391 -0
  224. package/src/plugins/eval-cases-routes.ts +529 -0
  225. package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
  226. package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
  227. package/src/plugins/evidence-retrieval-routes.ts +2179 -0
  228. package/src/plugins/gen-ai-normalizer.test.ts +680 -0
  229. package/src/plugins/gen-ai-normalizer.ts +302 -0
  230. package/src/plugins/identity-receiver.ts +96 -0
  231. package/src/plugins/issue-enrichment.ts +132 -0
  232. package/src/plugins/issue-insights.ts +81 -0
  233. package/src/plugins/logs-receiver.ts +160 -0
  234. package/src/plugins/metrics-receiver.ts +91 -0
  235. package/src/plugins/onboarding-routes.ts +54 -0
  236. package/src/plugins/otlp-receiver.ts +124 -0
  237. package/src/plugins/platform-routes.ts +165 -0
  238. package/src/plugins/profile-routes.ts +468 -0
  239. package/src/plugins/projects-routes.ts +92 -0
  240. package/src/plugins/query-routes.ts +220 -0
  241. package/src/plugins/redaction-processor.ts +132 -0
  242. package/src/plugins/replay-query-routes.test.ts +38 -0
  243. package/src/plugins/replay-query-routes.ts +199 -0
  244. package/src/plugins/replay-receiver.ts +81 -0
  245. package/src/plugins/tail-routes.test.ts +47 -0
  246. package/src/plugins/tail-routes.ts +42 -0
  247. package/src/plugins/timeline-routes.ts +244 -0
  248. package/src/plugins/ua-enrichment.ts +27 -0
  249. package/src/plugins/usage-normalization.ts +46 -0
  250. package/src/plugins/usage-privacy.ts +55 -0
  251. package/src/plugins/usage-query-routes.ts +102 -0
  252. package/src/plugins/usage-receiver.ts +129 -0
  253. package/src/plugins/users-query-routes.ts +78 -0
  254. package/src/plugins/utm-enrichment.ts +36 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Gate-predicate evaluator for the RFC 0002 Stage 3 narrate layer.
3
+ *
4
+ * The gate is the single most important cost+noise control in the RFC.
5
+ * Naive: every panel narrates every run → 600 sentences/hour at 10
6
+ * panels. Real: only narrate when the system has actually changed.
7
+ *
8
+ * Mini-language (kept small on purpose):
9
+ *
10
+ * status_changed
11
+ * delta_pct<op><number> op ∈ {>, >=, <, <=, ==, !=}
12
+ * signature_changed
13
+ * always (skip the gate, still LLM call)
14
+ * never (don't narrate)
15
+ * <atom> && <atom> [&& ...]
16
+ * <atom> || <atom> [|| ...]
17
+ *
18
+ * `&&` binds tighter than `||` (standard precedence). No parens to
19
+ * keep the parser tiny — if you need parens, split into two analyses.
20
+ *
21
+ * `evaluate()` returns one of three intents the runner needs:
22
+ * - "call" : run the LLM
23
+ * - "reuse" : keep previous narrative if any, no LLM call
24
+ * - "skip" : narrative is null this run
25
+ *
26
+ * Why three states (not just true/false): when the gate doesn't fire
27
+ * we still want to *show* the previous narrative ("(unchanged for 8 min)")
28
+ * rather than blanking the panel. That's the "reuse" branch.
29
+ */
30
+
31
+ import type { AnalysisResult } from "@obsunified/types";
32
+
33
+ export type NarrativeIntent = "call" | "reuse" | "skip";
34
+
35
+ interface GateInputs {
36
+ current: AnalysisResult;
37
+ previous: AnalysisResult | null;
38
+ }
39
+
40
+ const COMPARATORS = ["<=", ">=", "==", "!=", "<", ">"] as const;
41
+ type Comparator = (typeof COMPARATORS)[number];
42
+
43
+ interface DeltaPctAtom {
44
+ kind: "delta_pct";
45
+ op: Comparator;
46
+ value: number;
47
+ }
48
+
49
+ interface FlagAtom {
50
+ kind: "status_changed" | "signature_changed" | "always" | "never";
51
+ }
52
+
53
+ type Atom = DeltaPctAtom | FlagAtom;
54
+
55
+ interface ParseError {
56
+ kind: "error";
57
+ message: string;
58
+ }
59
+
60
+ type ParseResult = { kind: "ok"; conjunctions: Atom[][] } | ParseError;
61
+
62
+ const stripWhitespace = (s: string) => s.replace(/\s+/g, "");
63
+
64
+ const parseAtom = (raw: string): Atom | ParseError => {
65
+ const t = stripWhitespace(raw);
66
+ if (!t) return { kind: "error", message: "empty atom" };
67
+ if (t === "status_changed") return { kind: "status_changed" };
68
+ if (t === "signature_changed") return { kind: "signature_changed" };
69
+ if (t === "always") return { kind: "always" };
70
+ if (t === "never") return { kind: "never" };
71
+
72
+ if (t.startsWith("delta_pct")) {
73
+ const rest = t.slice("delta_pct".length);
74
+ // Probe operators longest-first so `>=` doesn't match as `>`.
75
+ for (const op of COMPARATORS) {
76
+ if (rest.startsWith(op)) {
77
+ const numStr = rest.slice(op.length);
78
+ const value = Number(numStr);
79
+ if (!Number.isFinite(value)) {
80
+ return {
81
+ kind: "error",
82
+ message: `delta_pct: not a number after ${op}: "${numStr}"`,
83
+ };
84
+ }
85
+ return { kind: "delta_pct", op, value };
86
+ }
87
+ }
88
+ return {
89
+ kind: "error",
90
+ message: `delta_pct: missing comparator (>, >=, <, <=, ==, !=)`,
91
+ };
92
+ }
93
+
94
+ return { kind: "error", message: `unknown atom: "${t}"` };
95
+ };
96
+
97
+ /**
98
+ * Parse `a && b || c && d` → `[[a, b], [c, d]]` (DNF, OR over ANDs).
99
+ * Returned errors point at the offending atom, not character position —
100
+ * good enough given how short these predicates are.
101
+ */
102
+ export function parsePredicate(predicate: string): ParseResult {
103
+ const orGroups = predicate.split("||");
104
+ const conjunctions: Atom[][] = [];
105
+ for (const group of orGroups) {
106
+ const atoms: Atom[] = [];
107
+ const parts = group.split("&&");
108
+ for (const part of parts) {
109
+ const atom = parseAtom(part);
110
+ if ("kind" in atom && atom.kind === "error") return atom;
111
+ atoms.push(atom as Atom);
112
+ }
113
+ conjunctions.push(atoms);
114
+ }
115
+ return { kind: "ok", conjunctions };
116
+ }
117
+
118
+ const compare = (op: Comparator, a: number, b: number): boolean => {
119
+ switch (op) {
120
+ case ">":
121
+ return a > b;
122
+ case ">=":
123
+ return a >= b;
124
+ case "<":
125
+ return a < b;
126
+ case "<=":
127
+ return a <= b;
128
+ case "==":
129
+ return a === b;
130
+ case "!=":
131
+ return a !== b;
132
+ }
133
+ };
134
+
135
+ const evalAtom = (atom: Atom, { current, previous }: GateInputs): boolean => {
136
+ switch (atom.kind) {
137
+ case "always":
138
+ return true;
139
+ case "never":
140
+ return false;
141
+ case "status_changed":
142
+ if (previous === null) return true;
143
+ return current.status !== previous.status;
144
+ case "signature_changed":
145
+ if (previous === null) return true;
146
+ return current.narrativeSignature !== previous.narrativeSignature;
147
+ case "delta_pct": {
148
+ const dp = current.deltaPct;
149
+ if (dp === null || !Number.isFinite(dp)) return false;
150
+ return compare(atom.op, Math.abs(dp), atom.value);
151
+ }
152
+ }
153
+ };
154
+
155
+ /**
156
+ * Decide what to do with the narrative this run.
157
+ *
158
+ * - spec missing or `never` → skip
159
+ * - first ever run AND spec present → call (no previous to compare)
160
+ * - signature unchanged → reuse previous narrative
161
+ * (the LLM would say the same
162
+ * thing — don't pay for it)
163
+ * - gate predicate evaluates true → call
164
+ * - otherwise → reuse if previous narrative
165
+ * exists, else skip
166
+ *
167
+ * `signature_changed` short-circuits to skip when signatures match,
168
+ * even if other clauses would fire — this keeps the cache invariant
169
+ * "same signature ⇒ same narrative" airtight.
170
+ */
171
+ export function evaluateGate(
172
+ predicate: string | undefined,
173
+ inputs: GateInputs,
174
+ ): NarrativeIntent {
175
+ const expr = (predicate ?? "status_changed").trim();
176
+ if (expr === "" || expr === "never") return "skip";
177
+
178
+ const { current, previous } = inputs;
179
+
180
+ // Cache: same signature as last result ⇒ reuse the previous narrative
181
+ // without LLM call. Even with `always` we don't pay twice for the same
182
+ // state of the world.
183
+ if (
184
+ previous?.narrative &&
185
+ current.narrativeSignature !== null &&
186
+ current.narrativeSignature === previous.narrativeSignature
187
+ ) {
188
+ return "reuse";
189
+ }
190
+
191
+ // First-run-with-spec — no previous to compare to, so narrate.
192
+ if (previous === null) return "call";
193
+
194
+ const parsed = parsePredicate(expr);
195
+ if (parsed.kind === "error") {
196
+ // Don't blow up the runner over a malformed predicate; fall back to
197
+ // "status_changed" which is the conservative default.
198
+ console.log(`[narrate-gate] bad predicate "${expr}": ${parsed.message}`);
199
+ const fallback = parsePredicate("status_changed");
200
+ if (fallback.kind === "error") return "skip"; // unreachable
201
+ return evaluateConjunctions(fallback.conjunctions, inputs)
202
+ ? "call"
203
+ : previous.narrative
204
+ ? "reuse"
205
+ : "skip";
206
+ }
207
+
208
+ const fired = evaluateConjunctions(parsed.conjunctions, inputs);
209
+ if (fired) return "call";
210
+ return previous.narrative ? "reuse" : "skip";
211
+ }
212
+
213
+ function evaluateConjunctions(
214
+ conjunctions: Atom[][],
215
+ inputs: GateInputs,
216
+ ): boolean {
217
+ // DNF: OR over ANDs.
218
+ for (const group of conjunctions) {
219
+ if (group.every((atom) => evalAtom(atom, inputs))) return true;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ /**
225
+ * Compute a narrative_signature from a result. The signature is what we
226
+ * compare to decide "did the world actually change in a way the narrative
227
+ * would describe?" — so it includes status, the rounded primary value,
228
+ * and any caller-supplied identifiers from the payload (top services,
229
+ * top errors, etc).
230
+ *
231
+ * Rounded coarsely so that 1.241% vs 1.243% don't both pay for an LLM call.
232
+ */
233
+ export function computeSignature(input: {
234
+ status: string;
235
+ primaryValue: number | null;
236
+ baselineValue: number | null;
237
+ payload: Record<string, unknown>;
238
+ }): string {
239
+ const round = (v: number | null): string => {
240
+ if (v === null || !Number.isFinite(v)) return "n";
241
+ // 3 sig figs for small numbers, 1% buckets for percentages we present
242
+ // as such. Keep it simple — over-rounding hurts cache hit rate, under
243
+ // hurts cost.
244
+ if (Math.abs(v) >= 100) return Math.round(v).toString();
245
+ if (Math.abs(v) >= 1) return v.toFixed(1);
246
+ return v.toFixed(2);
247
+ };
248
+ const sigPayload =
249
+ typeof input.payload?.signatureKey === "string"
250
+ ? (input.payload.signatureKey as string)
251
+ : "";
252
+ return `${input.status}|${round(input.primaryValue)}|${round(input.baselineValue)}|${sigPayload}`;
253
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * OpenAI provider adapter tests.
3
+ *
4
+ * Same coverage as ask.test.ts (Anthropic side), with the OpenAI
5
+ * Chat Completions / function-calling shape:
6
+ *
7
+ * - assistant turn that emits `tool_calls`
8
+ * - tool result message with role: "tool" and tool_call_id
9
+ * - finish_reason: "stop" terminates the loop
10
+ */
11
+
12
+ import type { AnalysisDefinition, AnalysisResult } from "@obsunified/types";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { runAsk } from "./ask";
15
+
16
+ const def = (
17
+ overrides: Partial<AnalysisDefinition> = {},
18
+ ): AnalysisDefinition => ({
19
+ id: "overall_error_rate",
20
+ title: "Overall error rate",
21
+ group: "Health",
22
+ source: "tier0",
23
+ view: "tile",
24
+ refreshSeconds: 60,
25
+ ...overrides,
26
+ });
27
+
28
+ const result = (overrides: Partial<AnalysisResult> = {}): AnalysisResult => ({
29
+ analysisId: "overall_error_rate",
30
+ projectId: "default",
31
+ generatedAt: "2026-04-29T00:00:00Z",
32
+ paramsHash: null,
33
+ status: "warn",
34
+ primaryValue: 0.05,
35
+ baselineValue: 0.01,
36
+ deltaPct: 400,
37
+ payload: {},
38
+ narrative: null,
39
+ narrativeSignature: "warn|0.05|0.01|",
40
+ durationMs: 5,
41
+ ...overrides,
42
+ });
43
+
44
+ const llm = {
45
+ provider: "openai" as const,
46
+ apiKey: "test-key",
47
+ model: "gpt-4o-mini",
48
+ };
49
+
50
+ const mockFetchSequence = (responses: Array<Record<string, unknown>>) => {
51
+ let i = 0;
52
+ return vi.fn(async (_url: unknown, init: unknown) => {
53
+ // Capture last init for header assertions
54
+ (globalThis as Record<string, unknown>).__lastInit = init;
55
+ return {
56
+ ok: true,
57
+ json: async () => responses[i++] ?? responses[responses.length - 1],
58
+ text: async () => "",
59
+ } as unknown as Response;
60
+ });
61
+ };
62
+
63
+ describe("runAsk (openai)", () => {
64
+ const originalFetch = globalThis.fetch;
65
+ beforeEach(() => {
66
+ // fetch replaced per test
67
+ });
68
+ afterEach(() => {
69
+ globalThis.fetch = originalFetch;
70
+ });
71
+
72
+ it("uses Authorization: Bearer and chat/completions endpoint", async () => {
73
+ globalThis.fetch = mockFetchSequence([
74
+ {
75
+ choices: [
76
+ {
77
+ finish_reason: "stop",
78
+ message: {
79
+ role: "assistant",
80
+ content: "errors at 5% in last 5 minutes (overall_error_rate)",
81
+ },
82
+ },
83
+ ],
84
+ },
85
+ ]) as typeof fetch;
86
+
87
+ const out = await runAsk("any errors?", {
88
+ llm,
89
+ listAnalyses: async () => [],
90
+ getLatestResult: async () => null,
91
+ });
92
+
93
+ expect(out.answer).toContain("errors at 5%");
94
+ expect(out.error).toBeNull();
95
+ const init = (globalThis as Record<string, unknown>).__lastInit as {
96
+ headers: Record<string, string>;
97
+ };
98
+ expect(init.headers.Authorization).toBe("Bearer test-key");
99
+ });
100
+
101
+ it("invokes list_analyses then run_analysis then synthesizes", async () => {
102
+ globalThis.fetch = mockFetchSequence([
103
+ // turn 1: assistant requests list_analyses
104
+ {
105
+ choices: [
106
+ {
107
+ finish_reason: "tool_calls",
108
+ message: {
109
+ role: "assistant",
110
+ content: null,
111
+ tool_calls: [
112
+ {
113
+ id: "call_1",
114
+ type: "function",
115
+ function: {
116
+ name: "list_analyses",
117
+ arguments: '{"group":"Health"}',
118
+ },
119
+ },
120
+ ],
121
+ },
122
+ },
123
+ ],
124
+ },
125
+ // turn 2: assistant requests run_analysis
126
+ {
127
+ choices: [
128
+ {
129
+ finish_reason: "tool_calls",
130
+ message: {
131
+ role: "assistant",
132
+ content: null,
133
+ tool_calls: [
134
+ {
135
+ id: "call_2",
136
+ type: "function",
137
+ function: {
138
+ name: "run_analysis",
139
+ arguments: '{"id":"overall_error_rate"}',
140
+ },
141
+ },
142
+ ],
143
+ },
144
+ },
145
+ ],
146
+ },
147
+ // turn 3: assistant writes the final answer
148
+ {
149
+ choices: [
150
+ {
151
+ finish_reason: "stop",
152
+ message: {
153
+ role: "assistant",
154
+ content:
155
+ "errors at 5% in the last 5 minutes (overall_error_rate)",
156
+ },
157
+ },
158
+ ],
159
+ },
160
+ ]) as typeof fetch;
161
+
162
+ const def0 = def();
163
+ const r0 = result();
164
+ const out = await runAsk("any errors?", {
165
+ llm,
166
+ listAnalyses: async () => [def0],
167
+ getLatestResult: async (id) =>
168
+ id === def0.id ? { definition: def0, result: r0 } : null,
169
+ });
170
+
171
+ expect(out.answer).toContain("errors at 5%");
172
+ expect(out.queries).toHaveLength(2);
173
+ expect(out.queries.map((q) => q.tool)).toEqual([
174
+ "list_analyses",
175
+ "run_analysis",
176
+ ]);
177
+ expect(out.evidence).toHaveLength(1);
178
+ expect(out.evidence[0]?.analysisId).toBe("overall_error_rate");
179
+ expect(out.evidenceReferences).toEqual([
180
+ expect.objectContaining({
181
+ evidenceId: "ask:analysis:overall_error_rate",
182
+ entityKind: "analysis",
183
+ entityId: "overall_error_rate",
184
+ route: "#/investigate/overall_error_rate",
185
+ source: "ask.run_analysis",
186
+ confidence: 1,
187
+ }),
188
+ ]);
189
+ });
190
+
191
+ it("tolerates malformed tool arguments (parses to empty object)", async () => {
192
+ globalThis.fetch = mockFetchSequence([
193
+ {
194
+ choices: [
195
+ {
196
+ finish_reason: "tool_calls",
197
+ message: {
198
+ role: "assistant",
199
+ content: null,
200
+ tool_calls: [
201
+ {
202
+ id: "call_x",
203
+ type: "function",
204
+ function: {
205
+ name: "run_analysis",
206
+ arguments: "{not json",
207
+ },
208
+ },
209
+ ],
210
+ },
211
+ },
212
+ ],
213
+ },
214
+ {
215
+ choices: [
216
+ {
217
+ finish_reason: "stop",
218
+ message: {
219
+ role: "assistant",
220
+ content: "the request was missing an id",
221
+ },
222
+ },
223
+ ],
224
+ },
225
+ ]) as typeof fetch;
226
+
227
+ const out = await runAsk("err?", {
228
+ llm,
229
+ listAnalyses: async () => [],
230
+ getLatestResult: async () => null,
231
+ });
232
+
233
+ expect(out.answer).toContain("missing an id");
234
+ expect(out.queries).toHaveLength(1);
235
+ });
236
+
237
+ it("hits the iteration cap and returns an error", async () => {
238
+ const loopForever = {
239
+ choices: [
240
+ {
241
+ finish_reason: "tool_calls",
242
+ message: {
243
+ role: "assistant",
244
+ content: null,
245
+ tool_calls: [
246
+ {
247
+ id: "call_x",
248
+ type: "function",
249
+ function: {
250
+ name: "list_analyses",
251
+ arguments: "{}",
252
+ },
253
+ },
254
+ ],
255
+ },
256
+ },
257
+ ],
258
+ };
259
+ globalThis.fetch = mockFetchSequence([
260
+ loopForever,
261
+ loopForever,
262
+ loopForever,
263
+ loopForever,
264
+ loopForever,
265
+ loopForever,
266
+ ]) as typeof fetch;
267
+
268
+ const out = await runAsk("loop", {
269
+ llm,
270
+ listAnalyses: async () => [def()],
271
+ getLatestResult: async () => null,
272
+ });
273
+
274
+ expect(out.answer).toBeNull();
275
+ expect(out.error).toMatch(/iteration cap/);
276
+ expect(out.queries.length).toBeGreaterThanOrEqual(5);
277
+ });
278
+
279
+ it("treats a stop turn with empty content as 'no answer'", async () => {
280
+ globalThis.fetch = mockFetchSequence([
281
+ {
282
+ choices: [
283
+ {
284
+ finish_reason: "stop",
285
+ message: { role: "assistant", content: "" },
286
+ },
287
+ ],
288
+ },
289
+ ]) as typeof fetch;
290
+
291
+ const out = await runAsk("hmm", {
292
+ llm,
293
+ listAnalyses: async () => [],
294
+ getLatestResult: async () => null,
295
+ });
296
+
297
+ expect(out.answer).toBeNull();
298
+ expect(out.error).toBe("model returned no text");
299
+ });
300
+ });