@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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +9 -0
- package/dist/auth.js.map +1 -0
- package/dist/chunk-UGWDOGGH.js +319 -0
- package/dist/chunk-UGWDOGGH.js.map +1 -0
- package/dist/chunk-XKQTVIRE.js +671 -0
- package/dist/chunk-XKQTVIRE.js.map +1 -0
- package/dist/index.d.ts +1197 -0
- package/dist/index.js +17707 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
- package/dist/openai-FJYOQ7WO.js +384 -0
- package/dist/openai-FJYOQ7WO.js.map +1 -0
- package/package.json +73 -0
- package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
- package/src/analyses/derive.ts +642 -0
- package/src/analyses/index.ts +50 -0
- package/src/analyses/investigations.ts +277 -0
- package/src/analyses/tier0.ts +568 -0
- package/src/auth/dashboard-auth.ts +269 -0
- package/src/auth/ingest-auth.ts +147 -0
- package/src/durable-objects/tail-hub.test.ts +32 -0
- package/src/durable-objects/tail-hub.ts +158 -0
- package/src/framework/collector.ts +467 -0
- package/src/framework/env.ts +90 -0
- package/src/framework/logger.ts +81 -0
- package/src/index.ts +211 -0
- package/src/lib/action-aggregates.ts +1031 -0
- package/src/lib/action-identity.ts +106 -0
- package/src/lib/actions-store.ts +397 -0
- package/src/lib/ai-pricing.ts +80 -0
- package/src/lib/ai-store/calls.ts +148 -0
- package/src/lib/ai-store/evaluations.test.ts +47 -0
- package/src/lib/ai-store/evaluations.ts +125 -0
- package/src/lib/ai-store/helpers.ts +37 -0
- package/src/lib/ai-store/retention.ts +17 -0
- package/src/lib/ai-store/sessions.ts +277 -0
- package/src/lib/ai-store/spans.ts +109 -0
- package/src/lib/ai-store/types.ts +112 -0
- package/src/lib/ai-store.ts +76 -0
- package/src/lib/alerts-store.ts +458 -0
- package/src/lib/analyses-runner.ts +439 -0
- package/src/lib/analyses-store.test.ts +243 -0
- package/src/lib/analyses-store.ts +454 -0
- package/src/lib/ask.test.ts +302 -0
- package/src/lib/ask.ts +390 -0
- package/src/lib/blob-store-s3.ts +213 -0
- package/src/lib/blob-store.ts +366 -0
- package/src/lib/eval-cases-store.test.ts +171 -0
- package/src/lib/eval-cases-store.ts +1033 -0
- package/src/lib/evidence-references.test.ts +10 -0
- package/src/lib/evidence-references.ts +684 -0
- package/src/lib/hash.ts +26 -0
- package/src/lib/identity-index/action-lookups.ts +805 -0
- package/src/lib/identity-index/constants.ts +1 -0
- package/src/lib/identity-index/key-lookups.ts +199 -0
- package/src/lib/identity-index/mappers.ts +349 -0
- package/src/lib/identity-index/types.ts +196 -0
- package/src/lib/identity-index/user-lookup.ts +137 -0
- package/src/lib/identity-index.test.ts +624 -0
- package/src/lib/identity-index.ts +85 -0
- package/src/lib/json.ts +39 -0
- package/src/lib/llm.ts +263 -0
- package/src/lib/logs-store.ts +188 -0
- package/src/lib/metrics-store.test.ts +189 -0
- package/src/lib/metrics-store.ts +312 -0
- package/src/lib/narrate-gate.test.ts +260 -0
- package/src/lib/narrate-gate.ts +253 -0
- package/src/lib/openai.test.ts +300 -0
- package/src/lib/openai.ts +512 -0
- package/src/lib/otlp.ts +212 -0
- package/src/lib/parse-pprof.test.ts +64 -0
- package/src/lib/parse-pprof.ts +184 -0
- package/src/lib/payload-capture.ts +22 -0
- package/src/lib/projects-store.ts +207 -0
- package/src/lib/propagation-metric.test.ts +196 -0
- package/src/lib/propagation-metric.ts +175 -0
- package/src/lib/sql-db-dialect.test.ts +22 -0
- package/src/lib/sql-db-postgres.ts +229 -0
- package/src/lib/sql-db.ts +166 -0
- package/src/lib/stage6.test.ts +216 -0
- package/src/lib/store/export.ts +65 -0
- package/src/lib/store/helpers.ts +367 -0
- package/src/lib/store/ingest.ts +68 -0
- package/src/lib/store/issues.ts +158 -0
- package/src/lib/store/overview.ts +127 -0
- package/src/lib/store/retention.ts +10 -0
- package/src/lib/store/service-map.ts +384 -0
- package/src/lib/store/trace-detail.bench.ts +76 -0
- package/src/lib/store/trace-detail.test.ts +410 -0
- package/src/lib/store/trace-detail.ts +427 -0
- package/src/lib/store/trace-queries.test.ts +29 -0
- package/src/lib/store/trace-queries.ts +89 -0
- package/src/lib/store-overview.test.ts +77 -0
- package/src/lib/store.ts +96 -0
- package/src/lib/tail-publisher.ts +64 -0
- package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
- package/src/lib/test-utils/mem-sql-db.ts +86 -0
- package/src/lib/ua-parser.ts +61 -0
- package/src/lib/usage-store.ts +567 -0
- package/src/migrations/001_telemetry_spans.sql +28 -0
- package/src/migrations/002_usage_events.sql +24 -0
- package/src/migrations/003_usage_analytics_columns.sql +13 -0
- package/src/migrations/004_logs.sql +19 -0
- package/src/migrations/005_ai_calls.sql +25 -0
- package/src/migrations/006_user_profiles.sql +11 -0
- package/src/migrations/007_session_replay_metadata.sql +10 -0
- package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
- package/src/migrations/009_projects_and_keys.sql +22 -0
- package/src/migrations/010_signal_project_id.sql +15 -0
- package/src/migrations/011_default_project_seed.sql +3 -0
- package/src/migrations/012_alert_rules.sql +17 -0
- package/src/migrations/013_alert_evaluations.sql +13 -0
- package/src/migrations/014_alert_state.sql +9 -0
- package/src/migrations/015_metrics.sql +58 -0
- package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
- package/src/migrations/017_span_dropped_counts.sql +11 -0
- package/src/migrations/018_log_flags.sql +8 -0
- package/src/migrations/019_ai_span_payloads.sql +30 -0
- package/src/migrations/020_ai_span_evaluations.sql +33 -0
- package/src/migrations/021_ai_span_session.sql +13 -0
- package/src/migrations/022_span_log_session_id.sql +13 -0
- package/src/migrations/023_service_map_indexes.sql +32 -0
- package/src/migrations/024_analysis_results.sql +49 -0
- package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
- package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
- package/src/migrations/027_identity_propagation.sql +50 -0
- package/src/migrations/028_profile_blobs.sql +57 -0
- package/src/migrations/029_telemetry_sdk_name.sql +20 -0
- package/src/migrations/030_analysis_claim.sql +18 -0
- package/src/migrations/031_agent_action_graph.sql +154 -0
- package/src/migrations/032_payload_capture_flags.sql +8 -0
- package/src/migrations/033_eval_cases.sql +52 -0
- package/src/migrations/034_eval_case_results.sql +23 -0
- package/src/migrations/035_metric_exemplars.sql +33 -0
- package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
- package/src/migrations/037_eval_runs.sql +37 -0
- package/src/migrations/038_tool_call_evidence.sql +11 -0
- package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
- package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
- package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
- package/src/migrations-postgres/002_usage_events.sql +24 -0
- package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
- package/src/migrations-postgres/004_logs.sql +19 -0
- package/src/migrations-postgres/005_ai_calls.sql +25 -0
- package/src/migrations-postgres/006_user_profiles.sql +11 -0
- package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
- package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
- package/src/migrations-postgres/010_signal_project_id.sql +18 -0
- package/src/migrations-postgres/011_default_project_seed.sql +5 -0
- package/src/migrations-postgres/012_alert_rules.sql +17 -0
- package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
- package/src/migrations-postgres/014_alert_state.sql +9 -0
- package/src/migrations-postgres/015_metrics.sql +47 -0
- package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
- package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
- package/src/migrations-postgres/018_log_flags.sql +2 -0
- package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
- package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
- package/src/migrations-postgres/021_ai_span_session.sql +7 -0
- package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
- package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
- package/src/migrations-postgres/024_analysis_results.sql +41 -0
- package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
- package/src/migrations-postgres/027_identity_propagation.sql +27 -0
- package/src/migrations-postgres/028_profile_blobs.sql +48 -0
- package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
- package/src/migrations-postgres/030_analysis_claim.sql +5 -0
- package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
- package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
- package/src/migrations-postgres/033_eval_cases.sql +55 -0
- package/src/migrations-postgres/034_eval_case_results.sql +26 -0
- package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
- package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
- package/src/migrations-postgres/037_eval_runs.sql +40 -0
- package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
- package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
- package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
- package/src/migrations-postgres/README.md +40 -0
- package/src/otlp/acceptance.test.ts +373 -0
- package/src/otlp/decode/body.ts +127 -0
- package/src/otlp/decode/logs.ts +96 -0
- package/src/otlp/decode/metrics.ts +402 -0
- package/src/otlp/decode/traces.ts +92 -0
- package/src/otlp/decode/values.ts +150 -0
- package/src/otlp/decode.test.ts +800 -0
- package/src/otlp/decode.ts +11 -0
- package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
- package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
- package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
- package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
- package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
- package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
- package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
- package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
- package/src/otlp/response.test.ts +158 -0
- package/src/otlp/response.ts +129 -0
- package/src/plugins/_context.ts +15 -0
- package/src/plugins/action-graph-processor/enrichers.ts +45 -0
- package/src/plugins/action-graph-processor/redaction.ts +84 -0
- package/src/plugins/action-graph-processor.test.ts +1023 -0
- package/src/plugins/action-graph-processor.ts +1172 -0
- package/src/plugins/action-routes.test.ts +716 -0
- package/src/plugins/action-routes.ts +343 -0
- package/src/plugins/ai-receiver.ts +254 -0
- package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
- package/src/plugins/ai-span-payloads-processor.ts +180 -0
- package/src/plugins/alerts-evaluator.test.ts +91 -0
- package/src/plugins/alerts-evaluator.ts +328 -0
- package/src/plugins/alerts-routes.ts +101 -0
- package/src/plugins/analyses-routes.ts +138 -0
- package/src/plugins/ask-routes.ts +182 -0
- package/src/plugins/bot-filter.ts +19 -0
- package/src/plugins/connected-routes/manifest.ts +531 -0
- package/src/plugins/connected-routes.test.ts +1791 -0
- package/src/plugins/connected-routes.ts +1103 -0
- package/src/plugins/dashboard-routes.ts +41 -0
- package/src/plugins/default-span-enrichment.ts +92 -0
- package/src/plugins/eval-cases-routes.test.ts +391 -0
- package/src/plugins/eval-cases-routes.ts +529 -0
- package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
- package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
- package/src/plugins/evidence-retrieval-routes.ts +2179 -0
- package/src/plugins/gen-ai-normalizer.test.ts +680 -0
- package/src/plugins/gen-ai-normalizer.ts +302 -0
- package/src/plugins/identity-receiver.ts +96 -0
- package/src/plugins/issue-enrichment.ts +132 -0
- package/src/plugins/issue-insights.ts +81 -0
- package/src/plugins/logs-receiver.ts +160 -0
- package/src/plugins/metrics-receiver.ts +91 -0
- package/src/plugins/onboarding-routes.ts +54 -0
- package/src/plugins/otlp-receiver.ts +124 -0
- package/src/plugins/platform-routes.ts +165 -0
- package/src/plugins/profile-routes.ts +468 -0
- package/src/plugins/projects-routes.ts +92 -0
- package/src/plugins/query-routes.ts +220 -0
- package/src/plugins/redaction-processor.ts +132 -0
- package/src/plugins/replay-query-routes.test.ts +38 -0
- package/src/plugins/replay-query-routes.ts +199 -0
- package/src/plugins/replay-receiver.ts +81 -0
- package/src/plugins/tail-routes.test.ts +47 -0
- package/src/plugins/tail-routes.ts +42 -0
- package/src/plugins/timeline-routes.ts +244 -0
- package/src/plugins/ua-enrichment.ts +27 -0
- package/src/plugins/usage-normalization.ts +46 -0
- package/src/plugins/usage-privacy.ts +55 -0
- package/src/plugins/usage-query-routes.ts +102 -0
- package/src/plugins/usage-receiver.ts +129 -0
- package/src/plugins/users-query-routes.ts +78 -0
- 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
|
+
});
|