pramana-sdk 0.0.1__tar.gz

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 (52) hide show
  1. pramana_sdk-0.0.1/.gitignore +15 -0
  2. pramana_sdk-0.0.1/PKG-INFO +9 -0
  3. pramana_sdk-0.0.1/examples/demo_walkthrough.py +658 -0
  4. pramana_sdk-0.0.1/examples/simple_agent.py +81 -0
  5. pramana_sdk-0.0.1/pyproject.toml +27 -0
  6. pramana_sdk-0.0.1/src/pramana/__init__.py +147 -0
  7. pramana_sdk-0.0.1/src/pramana/adapters/__init__.py +0 -0
  8. pramana_sdk-0.0.1/src/pramana/adapters/anthropic.py +65 -0
  9. pramana_sdk-0.0.1/src/pramana/adapters/openai.py +94 -0
  10. pramana_sdk-0.0.1/src/pramana/buffer.py +72 -0
  11. pramana_sdk-0.0.1/src/pramana/call_site.py +43 -0
  12. pramana_sdk-0.0.1/src/pramana/canonical.py +77 -0
  13. pramana_sdk-0.0.1/src/pramana/cli.py +114 -0
  14. pramana_sdk-0.0.1/src/pramana/collector.py +91 -0
  15. pramana_sdk-0.0.1/src/pramana/context.py +57 -0
  16. pramana_sdk-0.0.1/src/pramana/doctor.py +156 -0
  17. pramana_sdk-0.0.1/src/pramana/interceptor.py +271 -0
  18. pramana_sdk-0.0.1/src/pramana/messaging.py +139 -0
  19. pramana_sdk-0.0.1/src/pramana/ports.py +11 -0
  20. pramana_sdk-0.0.1/src/pramana/replay.py +129 -0
  21. pramana_sdk-0.0.1/src/pramana/runtime.py +72 -0
  22. pramana_sdk-0.0.1/src/pramana/sinks/__init__.py +0 -0
  23. pramana_sdk-0.0.1/src/pramana/sinks/file.py +34 -0
  24. pramana_sdk-0.0.1/src/pramana/sinks/http.py +52 -0
  25. pramana_sdk-0.0.1/src/pramana/sinks/memory.py +15 -0
  26. pramana_sdk-0.0.1/src/pramana/spool.py +48 -0
  27. pramana_sdk-0.0.1/tests/bench/test_capture_overhead.py +38 -0
  28. pramana_sdk-0.0.1/tests/buffer/test_flood.py +66 -0
  29. pramana_sdk-0.0.1/tests/conftest.py +51 -0
  30. pramana_sdk-0.0.1/tests/durability/_sigterm_target.py +45 -0
  31. pramana_sdk-0.0.1/tests/durability/test_graceful_shutdown.py +55 -0
  32. pramana_sdk-0.0.1/tests/durability/test_ingest_restart.py +65 -0
  33. pramana_sdk-0.0.1/tests/e2e/test_cli_trace_show.py +36 -0
  34. pramana_sdk-0.0.1/tests/e2e/test_three_line_init_records_trace.py +65 -0
  35. pramana_sdk-0.0.1/tests/multiagent/conftest.py +14 -0
  36. pramana_sdk-0.0.1/tests/multiagent/test_cli_causal_order.py +53 -0
  37. pramana_sdk-0.0.1/tests/multiagent/test_independent_replay.py +126 -0
  38. pramana_sdk-0.0.1/tests/multiagent/test_two_agent_causal_order.py +166 -0
  39. pramana_sdk-0.0.1/tests/replay/__init__.py +0 -0
  40. pramana_sdk-0.0.1/tests/replay/conftest.py +78 -0
  41. pramana_sdk-0.0.1/tests/replay/test_anthropic_bit_exact.py +68 -0
  42. pramana_sdk-0.0.1/tests/replay/test_bit_exact.py +34 -0
  43. pramana_sdk-0.0.1/tests/replay/test_concurrency.py +65 -0
  44. pramana_sdk-0.0.1/tests/replay/test_divergence.py +69 -0
  45. pramana_sdk-0.0.1/tests/replay/test_network_kill_switch.py +31 -0
  46. pramana_sdk-0.0.1/tests/replay/test_streaming.py +37 -0
  47. pramana_sdk-0.0.1/tests/unit/test_anthropic_usage_attrs.py +81 -0
  48. pramana_sdk-0.0.1/tests/unit/test_call_site.py +23 -0
  49. pramana_sdk-0.0.1/tests/unit/test_canonical.py +24 -0
  50. pramana_sdk-0.0.1/tests/unit/test_collector_blobs.py +80 -0
  51. pramana_sdk-0.0.1/tests/unit/test_doctor.py +143 -0
  52. pramana_sdk-0.0.1/tests/unit/test_openai_usage_attrs.py +88 -0
@@ -0,0 +1,15 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pramana/
5
+ .pytest_cache/
6
+ web/node_modules/
7
+ web/dist/
8
+ # Secrets. The trailing slash this line used to have (`.env/`) matched only a
9
+ # *directory* named .env, never the file — which is how .env ended up committed
10
+ # in a1434d1 with a live Postgres password and S3 keys in it. Adding it here
11
+ # does not remove it from history: those credentials must be rotated, and the
12
+ # commit purged, separately.
13
+ .env
14
+ .env.*
15
+ !.env.example
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: pramana-sdk
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: pramana-core
6
+ Requires-Dist: pramana-proto
7
+ Requires-Dist: pramana-store
8
+ Requires-Dist: requests>=2.32
9
+ Requires-Dist: zstandard>=0.22
@@ -0,0 +1,658 @@
1
+ """One-command demo dataset — run this before showing Pramana to anyone.
2
+
3
+ Records four traces against a live pramana-ingest, mints evidence bundles for
4
+ the clean ones, and prints exactly what to click through.
5
+
6
+ The flagship trace is a **five-agent loan underwriting run**: a supervisor that
7
+ fans work out to four specialists, each with its own tool calls, then
8
+ synthesizes a decision. That shape was chosen because it is what real agentic
9
+ systems actually look like — a hub with specialists, not two agents passing a
10
+ baton — and because lending is *regulated*: a lender must be able to explain a
11
+ declined application. "Here is provably what the system did, and why" is the
12
+ thing Pramana sells, so the demo data should be a case where somebody genuinely
13
+ needs it.
14
+
15
+ Uses fake vendor clients shaped like the real OpenAI SDK (no OPENAI_API_KEY
16
+ needed) so this runs anywhere, offline, in a few seconds.
17
+
18
+ PRAMANA_TENANT_ID=demo uv run --package pramana-sdk python sdk-python/examples/demo_walkthrough.py
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import time
25
+ import uuid
26
+ from pathlib import Path
27
+
28
+ import requests
29
+ from pramana import context, interceptor, messaging, runtime
30
+ from pramana.context import TraceContext
31
+ from pramana.sinks.http import HttpSink
32
+ from pramana_proto.v1.event_pb2 import LLM_CALL, TOOL_EXEC
33
+ from pramana_store.blob import LocalBlobStore, blob_store_factory_from_env
34
+ from pramana_store.postgres import PostgresEventRepository
35
+
36
+ TENANT_ID = os.environ.get("PRAMANA_TENANT_ID", "demo")
37
+ INGEST_URL = os.environ.get("PRAMANA_INGEST_URL", "http://localhost:8081/v1/events:batch")
38
+ API_URL = os.environ.get("PRAMANA_API_URL", "http://localhost:8082")
39
+ CONNINFO = os.environ.get("PRAMANA_PG", "postgresql://postgres@127.0.0.1:55432/pramana")
40
+ BLOB_DIR = Path(os.environ.get("PRAMANA_BLOB_DIR", ".pramana/blobs"))
41
+ DEMO_KEYS_FILE = Path(".pramana/demo-keys.txt")
42
+
43
+
44
+ def _engineer_key() -> str | None:
45
+ if not DEMO_KEYS_FILE.exists():
46
+ return None
47
+ for line in DEMO_KEYS_FILE.read_text().splitlines():
48
+ if line.startswith("engineer="):
49
+ return line.split("=", 1)[1].strip()
50
+ return None
51
+
52
+
53
+ def _require_engineer_key() -> str:
54
+ """Ingest authenticates every write now (docs/plan.md §15), so this demo
55
+ cannot record without a key. Fail with the command that mints one rather
56
+ than letting every batch come back 401 from a background thread.
57
+ """
58
+ key = os.environ.get("PRAMANA_API_KEY") or _engineer_key()
59
+ if not key:
60
+ raise SystemExit(
61
+ f"No engineer API key found. Set PRAMANA_API_KEY, or create {DEMO_KEYS_FILE} by running:\n"
62
+ " uv run --package pramana-api python deploy/seed_demo.py"
63
+ )
64
+ return key
65
+
66
+
67
+ def approx_tokens(*texts: str) -> int:
68
+ return max(1, round(sum(len(t.split()) for t in texts) * 1.3))
69
+
70
+
71
+ def latency_for(text: str, base: float = 0.1) -> float:
72
+ return base + min(len(text) * 0.001, 0.6) # kept short: this runs before every demo
73
+
74
+
75
+ # Call-site depth matters here, and it is the one subtle thing in this file.
76
+ #
77
+ # `capture_call_site` walks a fixed number of frames up to find "the line the
78
+ # user called from". In a real integration that works out because
79
+ # `pramana.instrument(client)` patches the vendor method directly, so there is
80
+ # exactly one wrapper frame. These helpers stand in for that wrapper — which is
81
+ # why `llm_call`/`tool_call` call `intercepted_call` *directly*: one frame, so
82
+ # the captured call site is the line in the scenario function below. Two
83
+ # different lines get two different call sites, and the same line called twice
84
+ # gets one call site with ordinals 0 and 1.
85
+ #
86
+ # `_intercept` deliberately adds one more frame, for the opposite reason — see
87
+ # `call_policy_decision`.
88
+ def _intercept(fn, **kwargs):
89
+ return interceptor.intercepted_call(fn, **kwargs)
90
+
91
+
92
+ def make_wire():
93
+ box: dict = {}
94
+
95
+ def send(envelope):
96
+ box["envelope"] = envelope
97
+ return "ack"
98
+
99
+ def receive():
100
+ return box["envelope"]
101
+
102
+ return send, receive
103
+
104
+
105
+ def start_recording(tenant_id: str):
106
+ from pramana.buffer import EventBuffer
107
+ from pramana.collector import Collector
108
+ from pramana.spool import Spooler
109
+
110
+ store = LocalBlobStore(BLOB_DIR, tenant_id=tenant_id)
111
+ # Without a spooler, Collector.flush_once() re-raises on any transient
112
+ # failure (a slow/unreachable sink) instead of retrying — which kills
113
+ # the background thread silently and drops whatever was still queued.
114
+ # pramana.init() always configures one for exactly this reason; this
115
+ # hand-rolled setup needs to match it.
116
+ spooler = Spooler(BLOB_DIR.parent / "spool" / f"{tenant_id}.spool")
117
+ buffer = EventBuffer(spooler=spooler)
118
+ collector = Collector(
119
+ buffer,
120
+ HttpSink(INGEST_URL, tenant_id=tenant_id, api_key=_require_engineer_key()),
121
+ spooler=spooler,
122
+ blob_store=store,
123
+ )
124
+ runtime.configure(tenant_id=tenant_id, blob_store=store, buffer=buffer, collector=collector)
125
+ collector.start()
126
+ return collector
127
+
128
+
129
+ def llm_call(model, messages, content, extra_attrs=None):
130
+ text = " ".join(m["content"] for m in messages)
131
+
132
+ def fn():
133
+ time.sleep(latency_for(content))
134
+ return {"choices": [{"message": {"content": content}}]}
135
+
136
+ attrs = {
137
+ "model": model,
138
+ "vendor": "openai",
139
+ "tokens_in": str(approx_tokens(text)),
140
+ "tokens_out": str(approx_tokens(content)),
141
+ **(extra_attrs or {}),
142
+ }
143
+ return interceptor.intercepted_call(
144
+ fn, raw_input={"model": model, "messages": messages}, kind=LLM_CALL, attrs=attrs
145
+ )
146
+
147
+
148
+ def tool_call(name, args, result, latency: float = 0.05):
149
+ def fn():
150
+ time.sleep(latency)
151
+ return result
152
+
153
+ # `tool` in attrs, not just as a payload key: attrs is the documented
154
+ # extension point (docs/plan.md §3.1) and it is what the trace views can
155
+ # read without fetching every payload, so the timeline can say
156
+ # "bureau_pull" instead of "TOOL_EXEC".
157
+ return interceptor.intercepted_call(
158
+ fn, raw_input={name: args}, kind=TOOL_EXEC, attrs={"tool": name}
159
+ )
160
+
161
+
162
+ def send_to(from_ctx, to_ctx, payload, to_agent, from_agent):
163
+ """One directed handoff: MSG_SEND on the sender, MSG_RECV on the receiver.
164
+
165
+ Returns the delivered payload. The vector clock rides along inside the
166
+ envelope, so `pramana_core.sequencer.total_order` can reconstruct causal
167
+ order across all five lanes from the clocks alone — never wall time.
168
+ """
169
+ send, receive = make_wire()
170
+ context.set_current(from_ctx)
171
+ messaging.send_message(send, payload, to_agent_id=to_agent)
172
+ context.set_current(to_ctx)
173
+ return messaging.receive_message(receive, from_agent_id=from_agent)
174
+
175
+
176
+ # --------------------------------------------------------------------------
177
+ # Trace 1 — five-agent loan underwriting
178
+ # --------------------------------------------------------------------------
179
+
180
+ APPLICATION = {
181
+ "application_id": "LN-2026-08841",
182
+ "applicant": "R. Okonkwo",
183
+ "requested_amount_usd": 28000,
184
+ "purpose": "used vehicle purchase",
185
+ "term_months": 60,
186
+ "stated_annual_income_usd": 74000,
187
+ "employment": "Full-time, 3y 2m at current employer",
188
+ }
189
+
190
+ POLICY_PROMPT = {
191
+ "role": "system",
192
+ "content": (
193
+ "You are the underwriting policy engine. Approve only if ALL hold: FICO >= 660, "
194
+ "debt-to-income after the new loan <= 43%, no sanctions or PEP match, and fraud risk "
195
+ "score < 60. If you decline, cite the specific failing criterion — an applicant is "
196
+ "legally entitled to the reason."
197
+ ),
198
+ }
199
+
200
+
201
+ def record_loan_underwriting() -> str:
202
+ """Supervisor + four specialists, each with real tool calls and a handoff.
203
+
204
+ This is the trace to open in the Simulation tab: five lanes, message arcs
205
+ crossing between them, and a retry that shares a call site with its first
206
+ attempt.
207
+ """
208
+ trace_id = f"run-{uuid.uuid4()}"
209
+ collector = start_recording(TENANT_ID)
210
+
211
+ orch = TraceContext(trace_id=trace_id, agent_id="orchestrator", mode="record")
212
+ kyc = TraceContext(trace_id=trace_id, agent_id="kyc-agent", mode="record")
213
+ credit = TraceContext(trace_id=trace_id, agent_id="credit-agent", mode="record")
214
+ fraud = TraceContext(trace_id=trace_id, agent_id="fraud-agent", mode="record")
215
+ policy = TraceContext(trace_id=trace_id, agent_id="policy-agent", mode="record")
216
+
217
+ # --- supervisor plans the run ---
218
+ context.set_current(orch)
219
+ llm_call(
220
+ "gpt-4o",
221
+ [
222
+ {"role": "system", "content": "You orchestrate loan underwriting. Delegate to specialists, then decide."},
223
+ {"role": "user", "content": f"Underwrite application {APPLICATION['application_id']}: {APPLICATION}"},
224
+ ],
225
+ "Plan: run KYC and credit in that order, then fraud screening, then hand all three findings to "
226
+ "the policy engine for the decision and adverse-action reasoning.",
227
+ extra_attrs={"cost_usd": "0.0042"},
228
+ )
229
+
230
+ # --- KYC specialist ---
231
+ task = send_to(orch, kyc, {"task": "Verify identity and screen sanctions/PEP", "application": APPLICATION}, "kyc-agent", "orchestrator")
232
+ identity = tool_call(
233
+ "identity_verify",
234
+ {"name": task["application"]["applicant"], "doc_type": "passport"},
235
+ {
236
+ "match": True,
237
+ "confidence": 0.97,
238
+ "document_valid": True,
239
+ "address_match": True,
240
+ "source": "govt-registry-api",
241
+ },
242
+ )
243
+ sanctions = tool_call(
244
+ "sanctions_screen",
245
+ {"name": task["application"]["applicant"], "lists": ["OFAC", "UN", "EU", "PEP"]},
246
+ {"ofac": "no_match", "un": "no_match", "eu": "no_match", "pep": "no_match", "screened_at": "2026-08-18T09:14:02Z"},
247
+ latency=0.09,
248
+ )
249
+ kyc_finding = (
250
+ "Identity verified at 0.97 confidence with a valid passport and matching address. "
251
+ "No hits on OFAC, UN, EU or PEP lists. KYC: PASS."
252
+ )
253
+ llm_call("gpt-4o-mini", [{"role": "user", "content": f"Summarize: {identity} {sanctions}"}], kyc_finding)
254
+ send_to(kyc, orch, {"finding": kyc_finding, "status": "pass"}, "orchestrator", "kyc-agent")
255
+
256
+ # --- credit specialist, with a bureau timeout and retry ---
257
+ task = send_to(orch, credit, {"task": "Pull bureau report and compute DTI", "application": APPLICATION}, "credit-agent", "orchestrator")
258
+ # A real retry loop: the bureau times out, then succeeds on the second
259
+ # attempt. Because both attempts happen at the *same source line*, they
260
+ # share one `call_site_id` and differ only by ordinal (0 then 1) — which is
261
+ # exactly the case that makes a global sequence number useless as a replay
262
+ # key, and why resolution is (call_site_id, ordinal) instead (docs/plan.md D3).
263
+ bureau_responses = [
264
+ {"error": "upstream_timeout", "retryable": True, "waited_ms": 5000},
265
+ {
266
+ "fico": 712,
267
+ "open_accounts": 6,
268
+ "total_monthly_debt_usd": 1840,
269
+ "delinquencies_24m": 0,
270
+ "oldest_account_years": 9.5,
271
+ "hard_inquiries_6m": 1,
272
+ },
273
+ ]
274
+ bureau: dict = {}
275
+ for attempt in bureau_responses:
276
+ bureau = tool_call(
277
+ "bureau_pull",
278
+ {"applicant": task["application"]["applicant"], "bureau": "experian"},
279
+ attempt,
280
+ latency=0.12,
281
+ )
282
+ if "error" not in bureau:
283
+ break
284
+ dti = tool_call(
285
+ "compute_dti",
286
+ {"monthly_income_usd": 6166, "existing_debt_usd": 1840, "new_loan_payment_usd": 512},
287
+ {"dti_before": 0.298, "dti_after": 0.381, "new_payment_usd": 512, "method": "monthly_obligations / gross_monthly_income"},
288
+ )
289
+ credit_finding = (
290
+ "FICO 712 (above the 660 floor). Six open accounts, no delinquencies in 24 months, "
291
+ "9.5-year credit history. DTI moves from 29.8% to 38.1% with the new $512 payment — "
292
+ "inside the 43% ceiling. Note: the first bureau pull timed out and was retried. Credit: PASS."
293
+ )
294
+ llm_call(
295
+ "gpt-4o",
296
+ [{"role": "user", "content": f"Assess: {bureau} {dti}"}],
297
+ credit_finding,
298
+ extra_attrs={"cost_usd": "0.0061"},
299
+ )
300
+ send_to(credit, orch, {"finding": credit_finding, "status": "pass", "fico": 712, "dti_after": 0.381}, "orchestrator", "credit-agent")
301
+
302
+ # --- fraud specialist ---
303
+ task = send_to(orch, fraud, {"task": "Screen for application fraud", "application": APPLICATION}, "fraud-agent", "orchestrator")
304
+ device = tool_call(
305
+ "device_fingerprint",
306
+ {"application_id": task["application"]["application_id"]},
307
+ {"device_reputation": "clean", "vpn": False, "emulator": False, "prior_applications_this_device": 1},
308
+ )
309
+ velocity = tool_call(
310
+ "velocity_check",
311
+ {"ssn_hash": "…9f2c", "window_days": 30},
312
+ {"applications_30d": 2, "distinct_lenders": 2, "threshold": 5, "flag": False},
313
+ )
314
+ fraud_finding = (
315
+ "Device reputation clean, no VPN or emulator, one prior application from this device. "
316
+ "Two applications across two lenders in 30 days, well under the threshold of five. "
317
+ "Composite fraud risk score 18/100. Fraud: PASS."
318
+ )
319
+ llm_call("gpt-4o-mini", [{"role": "user", "content": f"Score: {device} {velocity}"}], fraud_finding)
320
+ send_to(fraud, orch, {"finding": fraud_finding, "status": "pass", "risk_score": 18}, "orchestrator", "fraud-agent")
321
+
322
+ # --- policy engine decides ---
323
+ task = send_to(
324
+ orch,
325
+ policy,
326
+ {
327
+ "task": "Apply lending policy and produce the decision",
328
+ "kyc": kyc_finding,
329
+ "credit": credit_finding,
330
+ "fraud": fraud_finding,
331
+ "metrics": {"fico": 712, "dti_after": 0.381, "fraud_score": 18, "sanctions": "no_match"},
332
+ },
333
+ "policy-agent",
334
+ "orchestrator",
335
+ )
336
+ rules = tool_call(
337
+ "policy_lookup",
338
+ {"product": "auto_loan_used", "version": "2026.07"},
339
+ {
340
+ "min_fico": 660,
341
+ "max_dti": 0.43,
342
+ "max_fraud_score": 60,
343
+ "sanctions_must_be": "no_match",
344
+ "policy_version": "2026.07",
345
+ },
346
+ )
347
+ decision = call_policy_decision(
348
+ lambda: {
349
+ "choices": [
350
+ {
351
+ "message": {
352
+ "content": (
353
+ "APPROVED. FICO 712 clears the 660 minimum. Post-loan DTI of 38.1% is within "
354
+ "the 43% ceiling. Fraud score 18 is well below 60. No sanctions or PEP match. "
355
+ "Approved at $28,000 over 60 months."
356
+ )
357
+ }
358
+ }
359
+ ]
360
+ },
361
+ metrics=task["metrics"],
362
+ rules=rules,
363
+ )
364
+ send_to(policy, orch, {"decision": decision["choices"][0]["message"]["content"], "outcome": "approved"}, "orchestrator", "policy-agent")
365
+
366
+ # --- supervisor records the final decision ---
367
+ context.set_current(orch)
368
+ llm_call(
369
+ "gpt-4o",
370
+ [{"role": "user", "content": f"Finalize: {decision['choices'][0]['message']['content']}"}],
371
+ "Application LN-2026-08841 approved for $28,000 over 60 months at 38.1% post-loan DTI. "
372
+ "All four specialist checks passed. Decision and supporting findings recorded for audit.",
373
+ extra_attrs={"cost_usd": "0.0038"},
374
+ )
375
+
376
+ collector.shutdown()
377
+ runtime.reset()
378
+ return trace_id
379
+
380
+
381
+ def call_policy_decision(fn, metrics, rules):
382
+ """The policy engine's decision call — deliberately its own function.
383
+
384
+ Replay resolves by call site, so keeping this one call in one place is what
385
+ lets `record_policy_threshold_change` re-run *exactly this* decision under a
386
+ changed policy prompt and get a divergence pinned to it.
387
+ """
388
+ def timed():
389
+ # A real model call is not instantaneous, and this is the event the
390
+ # divergence demo points at — it should not be the one row with no bar.
391
+ time.sleep(0.22)
392
+ return fn()
393
+
394
+ return _intercept(
395
+ timed,
396
+ raw_input={
397
+ "model": "gpt-4o",
398
+ "messages": [POLICY_PROMPT, {"role": "user", "content": f"metrics={metrics} rules={rules}"}],
399
+ },
400
+ kind=LLM_CALL,
401
+ attrs={"model": "gpt-4o", "vendor": "openai", "tokens_in": "180", "tokens_out": "64", "cost_usd": "0.0031"},
402
+ )
403
+
404
+
405
+ # --------------------------------------------------------------------------
406
+ # Trace 2 — the divergence: tightening one policy threshold flips a decision
407
+ # --------------------------------------------------------------------------
408
+
409
+ TIGHTENED_POLICY_PROMPT = {
410
+ "role": "system",
411
+ "content": (
412
+ "You are the underwriting policy engine. Approve only if ALL hold: FICO >= 660, "
413
+ "debt-to-income after the new loan <= 36%, no sanctions or PEP match, and fraud risk "
414
+ "score < 60. If you decline, cite the specific failing criterion — an applicant is "
415
+ "legally entitled to the reason."
416
+ ),
417
+ }
418
+
419
+
420
+ def record_policy_threshold_change() -> str:
421
+ """Record an approval, then replay it after the DTI ceiling drops 43% -> 36%.
422
+
423
+ This is the demo that lands with a regulated buyer: one threshold changed in
424
+ one prompt, and an applicant who was approved is now declined. Pramana names
425
+ the exact call site where the behaviour changed and diffs the inputs, rather
426
+ than leaving someone to guess which of a hundred prompt edits did it.
427
+
428
+ Recorded under CONTINUE_AND_FLAG so the divergence is stored rather than
429
+ raised — the run completes and the flag is the artifact.
430
+ """
431
+ trace_id = f"run-{uuid.uuid4()}"
432
+ collector = start_recording(TENANT_ID)
433
+ context.set_current(TraceContext(trace_id=trace_id, agent_id="policy-agent", mode="record"))
434
+
435
+ metrics = {"fico": 712, "dti_after": 0.381, "fraud_score": 18, "sanctions": "no_match"}
436
+ rules = {"min_fico": 660, "max_dti": 0.43, "max_fraud_score": 60, "policy_version": "2026.07"}
437
+
438
+ call_policy_decision(
439
+ lambda: {
440
+ "choices": [
441
+ {
442
+ "message": {
443
+ "content": (
444
+ "APPROVED. Post-loan DTI of 38.1% is within the 43% ceiling; FICO 712 clears "
445
+ "660; fraud score 18 is below 60. Approved at $28,000 over 60 months."
446
+ )
447
+ }
448
+ }
449
+ ]
450
+ },
451
+ metrics=metrics,
452
+ rules=rules,
453
+ )
454
+
455
+ collector.shutdown()
456
+ runtime.reset()
457
+
458
+ # --- replay the same decision under the tightened policy ---
459
+ store = (
460
+ blob_store_factory_from_env()(TENANT_ID)
461
+ if os.environ.get("PRAMANA_BLOB_BACKEND") == "s3"
462
+ else LocalBlobStore(BLOB_DIR, tenant_id=TENANT_ID)
463
+ )
464
+ repo = PostgresEventRepository(CONNINFO, tenant_id=TENANT_ID)
465
+ runtime.configure(tenant_id=TENANT_ID, blob_store=store, repo=repo)
466
+ context.set_current(
467
+ TraceContext(trace_id=trace_id, agent_id="policy-agent", mode="replay", policy="CONTINUE_AND_FLAG")
468
+ )
469
+
470
+ def boom():
471
+ raise AssertionError("replay must never call the real model")
472
+
473
+ # Same call site, same ordinal — but the policy prompt and the DTI ceiling
474
+ # both changed, so the input hash no longer matches what was recorded.
475
+ global POLICY_PROMPT
476
+ original_prompt = POLICY_PROMPT
477
+ POLICY_PROMPT = TIGHTENED_POLICY_PROMPT
478
+ try:
479
+ call_policy_decision(
480
+ boom,
481
+ metrics=metrics,
482
+ rules={"min_fico": 660, "max_dti": 0.36, "max_fraud_score": 60, "policy_version": "2026.09-draft"},
483
+ )
484
+ finally:
485
+ POLICY_PROMPT = original_prompt
486
+ runtime.reset()
487
+
488
+ return trace_id
489
+
490
+
491
+ # --------------------------------------------------------------------------
492
+ # Trace 3 — single-agent support conversation (the Conversation tab)
493
+ # --------------------------------------------------------------------------
494
+
495
+
496
+ def record_support_conversation() -> str:
497
+ trace_id = f"run-{uuid.uuid4()}"
498
+ collector = start_recording(TENANT_ID)
499
+ context.set_current(TraceContext(trace_id=trace_id, agent_id="support-agent", mode="record"))
500
+
501
+ system_msg = {
502
+ "role": "system",
503
+ "content": (
504
+ "You are a customer support agent for Northwind Traders. Use the available tools to "
505
+ "look up order and policy information before answering. Be concise, friendly, and accurate."
506
+ ),
507
+ }
508
+ user_msg = {
509
+ "role": "user",
510
+ "content": (
511
+ "Hi, I'd like a refund for order #48213 — the espresso machine arrived with a cracked "
512
+ "carafe. Can you also remind me what your return window is?"
513
+ ),
514
+ }
515
+
516
+ lead_in = "Let me check your order details and our return policy for you."
517
+ llm_call("gpt-4o", [system_msg, user_msg], lead_in)
518
+
519
+ order = tool_call(
520
+ "lookup_order",
521
+ {"order_id": "48213"},
522
+ {
523
+ "order_id": "48213",
524
+ "customer": "J. Alvarez",
525
+ "items": [{"sku": "ESP-900", "name": "Northwind Pro Espresso Machine", "qty": 1, "price_usd": 249.00}],
526
+ "status": "delivered",
527
+ "delivered_at": "2026-08-10",
528
+ "carrier": "UPS",
529
+ "tracking": "1Z999AA10123456784",
530
+ },
531
+ )
532
+ policy = tool_call(
533
+ "lookup_policy",
534
+ {"topic": "returns"},
535
+ {
536
+ "topic": "returns",
537
+ "summary": (
538
+ "Items may be returned within 30 days of delivery for a full refund if unused, or "
539
+ "immediately for items that arrive damaged or defective, no restocking fee."
540
+ ),
541
+ "damaged_item_process": (
542
+ "Support can issue a prepaid return label and refund upon photo verification of the "
543
+ "damage; no need to wait for the item to be received back."
544
+ ),
545
+ "url": "https://support.northwindtraders.example/policies/returns",
546
+ },
547
+ )
548
+
549
+ final_answer = (
550
+ "I'm sorry about the cracked carafe — that's definitely covered. Since your espresso machine "
551
+ "(order #48213) arrived damaged, you don't need to wait out the normal 30-day return window; "
552
+ "I can issue a prepaid return label right away and process a full refund of $249.00 once we "
553
+ "get photo confirmation of the damage. Could you reply with 1-2 photos of the cracked carafe "
554
+ "so I can get that started?"
555
+ )
556
+ history = [
557
+ system_msg,
558
+ user_msg,
559
+ {"role": "assistant", "content": lead_in},
560
+ {"role": "tool", "content": str(order)},
561
+ {"role": "tool", "content": str(policy)},
562
+ ]
563
+ llm_call("gpt-4o", history, final_answer)
564
+
565
+ collector.shutdown()
566
+ runtime.reset()
567
+ return trace_id
568
+
569
+
570
+ def create_evidence_bundle(trace_id: str, event_count: int, engineer_key: str) -> str | None:
571
+ resp = requests.post(
572
+ f"{API_URL}/v1/evidence",
573
+ headers={"Authorization": f"Bearer {engineer_key}"},
574
+ json={"trace_id": trace_id, "from_seq": 0, "to_seq": event_count - 1},
575
+ timeout=10,
576
+ )
577
+ if resp.status_code != 200:
578
+ print(f" (evidence bundle failed for {trace_id}: {resp.status_code} {resp.text})")
579
+ return None
580
+ return resp.json()["bundle_id"]
581
+
582
+
583
+ def _event_count(trace_id: str) -> int:
584
+ return len(PostgresEventRepository(CONNINFO, tenant_id=TENANT_ID).list_by_trace(trace_id))
585
+
586
+
587
+ def main() -> None:
588
+ print("Recording demo traces against", INGEST_URL, "...")
589
+
590
+ loan_trace = record_loan_underwriting()
591
+ print(f" loan underwriting: {loan_trace}")
592
+ print(" 5 agents (orchestrator + kyc/credit/fraud/policy), 8 tool calls, a bureau retry")
593
+
594
+ divergence_trace = record_policy_threshold_change()
595
+ print(f" policy tightening: {divergence_trace}")
596
+ print(" DTI ceiling 43% -> 36%; the same applicant flips approved -> declined")
597
+
598
+ support_trace = record_support_conversation()
599
+ print(f" support conversation: {support_trace} (single agent, 2 tool calls)")
600
+
601
+ time.sleep(1.5) # let the collector's final flush land before we read counts back
602
+
603
+ engineer_key = _engineer_key()
604
+ bundles: dict[str, str | None] = {}
605
+ if engineer_key:
606
+ print("\nMinting evidence bundles...")
607
+ for t in (loan_trace, support_trace):
608
+ n = _event_count(t)
609
+ if n:
610
+ bundles[t] = create_evidence_bundle(t, n, engineer_key)
611
+ else:
612
+ print(f"\n(no {DEMO_KEYS_FILE} found — skipping evidence bundles; run deploy/seed_demo.py first)")
613
+
614
+ print("\n" + "=" * 76)
615
+ print("Demo ready. Open http://localhost:5173 and connect with:")
616
+ print(f" API URL: {API_URL}")
617
+ if engineer_key:
618
+ print(f" API key (engineer): {engineer_key}")
619
+ print()
620
+ print("Walk it in this order — each step shows something the next one builds on:")
621
+ print()
622
+ print(f" 1. {loan_trace}")
623
+ print(" Simulation tab. Five swimlanes, one per agent, with message arcs between them.")
624
+ print(" Scrub the timeline to watch the fan-out: orchestrator dispatches to KYC, then")
625
+ print(" credit, then fraud, then hands all three findings to the policy engine.")
626
+ print(" Ordering comes from vector clocks, not timestamps — so it stays correct even")
627
+ print(" though these agents ran concurrently.")
628
+ print()
629
+ print(" Then: Timeline tab -> find the two 'bureau_pull' calls. Same call site, ordinals")
630
+ print(" 0 and 1: the first timed out, the second succeeded. Retries are why replay keys")
631
+ print(" on (call site, ordinal) instead of a sequence number.")
632
+ print()
633
+ print(f" 2. {divergence_trace}")
634
+ print(" Divergences tab. One threshold changed in one prompt — the DTI ceiling went from")
635
+ print(" 43% to 36% — and this applicant went from approved to declined. Pramana names the")
636
+ print(" exact call site and diffs the inputs. This is the question 'which prompt edit")
637
+ print(" broke it?' answered in one screen instead of a week.")
638
+ print()
639
+ print(f" 3. {support_trace}")
640
+ print(" Conversation tab. The simple single-agent case, rendered as a readable chat with")
641
+ print(" tool calls folded inline.")
642
+ print()
643
+ if any(bundles.values()):
644
+ print(f" 4. Evidence tab on {loan_trace[:20]}...")
645
+ print(" Export a bundle, then paste the signer public key and hit Verify — it")
646
+ print(" recomputes the hash chain, Merkle root, and Ed25519 signature in your browser.")
647
+ print(" Then 'Download' and run `pramana-verify bundle.json --pubkey <hex>` offline.")
648
+ print(" That's the pitch for a regulated buyer: they can check the record without")
649
+ print(" trusting your servers. For a declined loan, that is the audit trail.")
650
+ print()
651
+ print(" 5. Agent graph tab. Hub-and-spoke topology across every trace this tenant has")
652
+ print(" recorded — orchestrator at the centre, four specialists around it, line weight")
653
+ print(" by message volume.")
654
+ print("=" * 76)
655
+
656
+
657
+ if __name__ == "__main__":
658
+ main()