pinecall-runtime 0.1.6__py3-none-any.whl

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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/log/drift.py ADDED
@@ -0,0 +1,550 @@
1
+ """Drift: each day's stage latencies and verdicts by agent and version, folded at the seal."""
2
+
3
+ import logging
4
+ from collections.abc import Iterable, Mapping, Sequence
5
+ from dataclasses import asdict, dataclass, field
6
+ from datetime import UTC, date, datetime
7
+ from hashlib import sha256
8
+ from typing import Literal
9
+
10
+ from psycopg.rows import DictRow
11
+ from psycopg.types.json import Jsonb
12
+ from pydantic import TypeAdapter
13
+
14
+ from pinecall.domain.errors import DeclarationRefused
15
+ from pinecall.domain.names import JsonObject
16
+ from pinecall.domain.scope import Scope
17
+ from pinecall.log import _histogram, queries, refold
18
+ from pinecall.log.store import entry_of
19
+ from pinecall.postgres.pool import Connection, Pool
20
+ from pinecall.wire.events import AgentTurnEnded, UserTurnEnded, event_of
21
+ from pinecall.wire.frames import Entry
22
+ from pinecall.wire.metrics import TurnMetadata
23
+ from pinecall.wire.rest.usage import (
24
+ DriftJudge,
25
+ DriftStage,
26
+ InsightsStage,
27
+ JudgeTally,
28
+ StageTally,
29
+ )
30
+ from pinecall.wire.scores import CallScore
31
+
32
+ type Stage = Literal["stt", "llm", "tts"]
33
+
34
+
35
+ # A turn's stage, the vendor and the model its report named ('' for none).
36
+ type StageKey = tuple[Stage, str, str]
37
+
38
+
39
+ # A judge by name and by the hash of the question it asked.
40
+ type JudgeKey = tuple[str, str]
41
+
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ # The table cannot key a null: a call that ran on no version of its scope's settings is 0.
47
+ NO_VERSION = 0
48
+
49
+
50
+ # skipped and deferred were never settled, so they count on neither side.
51
+ SETTLED = frozenset({"held", "broken"})
52
+
53
+
54
+ # Calls named per read of the heads while rebuilding; each is folded in its own transaction.
55
+ A_PAGE = 500
56
+
57
+
58
+ # Taken once per call: a second fold of the same call finds its row and changes only what differs.
59
+ CLAIMED = """
60
+ insert into drift_calls (call, org, env, holder, agent, day, config_version, verdicts)
61
+ values (%(call)s, %(org)s, %(env)s, %(holder)s, %(agent)s, %(day)s, %(version)s, %(verdicts)s)
62
+ on conflict (call) do nothing
63
+ returning call
64
+ """
65
+
66
+
67
+ COUNTED = "select verdicts from drift_calls where call = %(call)s for update"
68
+
69
+
70
+ RECOUNTED = "update drift_calls set verdicts = %(verdicts)s where call = %(call)s"
71
+
72
+
73
+ # Two histograms add bucket by bucket (log/_histogram.py); both always have its BUCKETS.
74
+ STAGES_ADDED = """
75
+ insert into stage_days as kept (org, env, holder, agent, day, config_version, stage, vendor,
76
+ model, turns, buckets, confidence_sum, confidence_turns)
77
+ select %(org)s, %(env)s, %(holder)s, %(agent)s, %(day)s, %(version)s, sample.stage,
78
+ sample.vendor, sample.model, sample.turns, sample.buckets, sample.confidence_sum,
79
+ sample.confidence_turns
80
+ from jsonb_to_recordset(%(samples)s) as sample(stage text, vendor text, model text,
81
+ turns integer, buckets integer[], confidence_sum double precision, confidence_turns integer)
82
+ on conflict (org, env, holder, day, agent, config_version, stage, vendor, model) do update set
83
+ turns = kept.turns + excluded.turns,
84
+ buckets = (select array_agg(mine + theirs order by at)
85
+ from unnest(kept.buckets, excluded.buckets) with ordinality as bucket(mine, theirs,
86
+ at)),
87
+ confidence_sum = kept.confidence_sum + excluded.confidence_sum,
88
+ confidence_turns = kept.confidence_turns + excluded.confidence_turns
89
+ """
90
+
91
+
92
+ # A verdict counted is +1 on its side; a verdict replaced is -1 on the side it was counted on.
93
+ VERDICTS_ADDED = """
94
+ insert into judge_days as kept (org, env, holder, agent, day, config_version, judge, criteria,
95
+ held, broken)
96
+ select %(org)s, %(env)s, %(holder)s, %(agent)s, %(day)s, %(version)s, verdict.judge,
97
+ verdict.criteria, verdict.held, verdict.broken
98
+ from jsonb_to_recordset(%(verdicts)s) as verdict(judge text, criteria text, held integer,
99
+ broken integer)
100
+ on conflict (org, env, holder, day, agent, config_version, judge, criteria) do update set
101
+ held = kept.held + excluded.held,
102
+ broken = kept.broken + excluded.broken
103
+ """
104
+
105
+
106
+ # A side is a day, a version, or both; one agent's rows in the scope.
107
+ SIDE_STAGES = """
108
+ select stage, vendor, model, config_version, turns, buckets, confidence_sum, confidence_turns
109
+ from stage_days
110
+ where org = %(org)s and env = %(env)s and holder = %(holder)s and agent = %(agent)s
111
+ and (%(day)s::date is null or day = %(day)s)
112
+ and (%(version)s::integer is null or config_version = %(version)s)
113
+ """
114
+
115
+
116
+ SIDE_VERDICTS = """
117
+ select judge, criteria, config_version, held, broken
118
+ from judge_days
119
+ where org = %(org)s and env = %(env)s and holder = %(holder)s and agent = %(agent)s
120
+ and (%(day)s::date is null or day = %(day)s)
121
+ and (%(version)s::integer is null or config_version = %(version)s)
122
+ """
123
+
124
+
125
+ WINDOW_STAGES = """
126
+ select stage, vendor, model, turns, buckets, confidence_sum, confidence_turns
127
+ from stage_days
128
+ where org = %(org)s and env = %(env)s and holder = %(holder)s
129
+ and day >= %(first)s and day <= %(last)s and (%(agent)s::text is null or agent = %(agent)s)
130
+ """
131
+
132
+
133
+ # A rebuild forgets what it will fold again, in one transaction, then folds call by call.
134
+ FORGET = """
135
+ with calls as (
136
+ delete from drift_calls
137
+ where (%(org)s::text is null or org = %(org)s) and (%(since)s::date is null or day >= %(since)s)
138
+ ), stages as (
139
+ delete from stage_days
140
+ where (%(org)s::text is null or org = %(org)s) and (%(since)s::date is null or day >= %(since)s)
141
+ )
142
+ delete from judge_days
143
+ where (%(org)s::text is null or org = %(org)s) and (%(since)s::date is null or day >= %(since)s)
144
+ """
145
+
146
+
147
+ SEALED_PAGE = """
148
+ select log from call_log_head
149
+ where call is not null and sealed and started_at is not null and log > %(after)s
150
+ and (%(org)s::text is null or org = %(org)s)
151
+ and (%(since)s::float8 is null or started_at >= %(since)s)
152
+ order by log
153
+ limit %(limit)s
154
+ """
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class StageSample:
159
+ """One stage on one vendor and model: its turns, their seconds counted, the ears' confidence."""
160
+
161
+ turns: int = 0
162
+ buckets: tuple[int, ...] = tuple(_histogram.empty())
163
+ confidence_sum: float = 0.0
164
+ confidence_turns: int = 0
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class Verdict:
169
+ """One judge's settled verdict on a call, under the hash of the question it asked."""
170
+
171
+ judge: str
172
+ criteria: str
173
+ held: bool
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class JudgeRead:
178
+ """One judge on one side: its verdicts held and settled, and the questions it asked."""
179
+
180
+ held: int
181
+ judged: int
182
+ criteria: frozenset[str]
183
+
184
+ @property
185
+ def pass_rate(self) -> float | None:
186
+ """The share of its settled verdicts that held; None when none settled."""
187
+ return self.held / self.judged if self.judged else None
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class Side:
192
+ """One side of a drift: a day, a version, or both."""
193
+
194
+ day: date | None = None
195
+ version: int | None = None
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class Tally:
200
+ """One side of an agent's drift: its stages and judges, and the versions its calls ran."""
201
+
202
+ stages: dict[StageKey, StageSample] = field(default_factory=dict[StageKey, StageSample])
203
+ judges: dict[str, JudgeRead] = field(default_factory=dict[str, JudgeRead])
204
+ versions: frozenset[int] = frozenset()
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class Rebuilt:
209
+ """What a rebuild folded again."""
210
+
211
+ calls: int
212
+ folded: int
213
+
214
+
215
+ VERDICTS: TypeAdapter[list[Verdict]] = TypeAdapter(list[Verdict])
216
+
217
+
218
+ # The seal's: a call is folded once, and folded again (judged again) only its verdicts change.
219
+ async def fold(pool: Pool, call: str, entries: Sequence[Entry], score: CallScore) -> bool:
220
+ """Count a sealed call's stages and verdicts into its day; False for a call never started."""
221
+ corner = await queries.scope_of_call(pool, call)
222
+ if corner is None or corner.scope is None or corner.started_at is None:
223
+ return False
224
+ where = _where(corner.scope, corner.agent, _day_of(corner.started_at), corner.versions.config)
225
+ verdicts = _verdicts_of(score)
226
+ async with pool.connection() as connection, connection.transaction():
227
+ claimed = await connection.execute(
228
+ CLAIMED,
229
+ {**where, "call": call, "verdicts": Jsonb(VERDICTS.dump_python(verdicts, mode="json"))},
230
+ )
231
+ if await claimed.fetchone() is not None:
232
+ await _stages_added(connection, where, _samples_of(entries))
233
+ await _verdicts_added(connection, where, _tallied(verdicts, sign=1))
234
+ return True
235
+ row = await (await connection.execute(COUNTED, {"call": call})).fetchone()
236
+ counted = [] if row is None else VERDICTS.validate_python(row["verdicts"])
237
+ if counted == verdicts:
238
+ return True
239
+ changed = {**_tallied(counted, sign=-1)}
240
+ for key, (passes, fails) in _tallied(verdicts, sign=1).items():
241
+ before = changed.get(key, (0, 0))
242
+ changed[key] = (before[0] + passes, before[1] + fails)
243
+ await _verdicts_added(connection, where, changed)
244
+ await connection.execute(
245
+ RECOUNTED,
246
+ {"call": call, "verdicts": Jsonb(VERDICTS.dump_python(verdicts, mode="json"))},
247
+ )
248
+ return True
249
+
250
+
251
+ # Every agent's when agent is None; its days and versions are added together either way.
252
+ async def stages_of_window(
253
+ pool: Pool, scope: Scope, first: date, last: date, agent: str | None
254
+ ) -> list[InsightsStage]:
255
+ """Every stage of the scope's days first to last by vendor and model, versions added."""
256
+ params = {**asdict(scope), "first": first, "last": last, "agent": agent}
257
+ async with pool.connection() as connection:
258
+ rows = await (await connection.execute(WINDOW_STAGES, params)).fetchall()
259
+ merged = _merged_stages(rows)
260
+ return [
261
+ InsightsStage(
262
+ stage=stage,
263
+ vendor=vendor or None,
264
+ model=model or None,
265
+ **_tallied_stage(sample).model_dump(),
266
+ )
267
+ for (stage, vendor, model), sample in sorted(merged.items())
268
+ ]
269
+
270
+
271
+ # What moved most comes first: a judge by its pass rate, a stage by its median.
272
+ def compared(before: Tally, after: Tally) -> tuple[list[DriftJudge], list[DriftStage]]:
273
+ """Each judge and each stage on both sides, and how far it moved, the most moved first."""
274
+ judges = [
275
+ _judge_moved(name, before.judges.get(name), after.judges.get(name))
276
+ for name in sorted({*before.judges, *after.judges})
277
+ ]
278
+ stages = [
279
+ _stage_moved(key, before.stages.get(key), after.stages.get(key))
280
+ for key in sorted({*before.stages, *after.stages})
281
+ ]
282
+ judges.sort(key=lambda judge: -abs(judge.moved) if judge.moved is not None else 1.0)
283
+ stages.sort(key=lambda row: -abs(row.median_moved_s) if row.median_moved_s is not None else 1.0)
284
+ return judges, stages
285
+
286
+
287
+ async def tally(pool: Pool, scope: Scope, agent: str, side: Side) -> Tally:
288
+ """One side of an agent's drift in the scope: its stages, its judges, its versions."""
289
+ params = {
290
+ "org": scope.org,
291
+ "env": scope.env,
292
+ "holder": scope.holder,
293
+ "agent": agent,
294
+ "day": side.day,
295
+ "version": side.version,
296
+ }
297
+ # independent: two reads of counted days; a call counted between them moves one by a call
298
+ async with pool.connection() as connection:
299
+ stages = await (await connection.execute(SIDE_STAGES, params)).fetchall()
300
+ verdicts = await (await connection.execute(SIDE_VERDICTS, params)).fetchall()
301
+ judges: dict[str, JudgeRead] = {}
302
+ for row in verdicts:
303
+ before = judges.get(row["judge"], JudgeRead(0, 0, frozenset()))
304
+ judges[row["judge"]] = JudgeRead(
305
+ held=before.held + row["held"],
306
+ judged=before.judged + row["held"] + row["broken"],
307
+ criteria=before.criteria | {row["criteria"]},
308
+ )
309
+ ran = {int(row["config_version"]) for row in (*stages, *verdicts)}
310
+ return Tally(
311
+ stages=_merged_stages(stages),
312
+ judges=judges,
313
+ versions=frozenset(version for version in ran if version != NO_VERSION),
314
+ )
315
+
316
+
317
+ async def rebuild(pool: Pool, *, org: str | None, since: float | None) -> Rebuilt:
318
+ """Forget the drift of the calls the flags name and fold each sealed one again from its log."""
319
+ day = None if since is None else _day_of(since)
320
+ async with pool.connection() as connection, connection.transaction():
321
+ await connection.execute(FORGET, {"org": org, "since": day})
322
+ calls = folded = 0
323
+ after = ""
324
+ while names := await _sealed_after(pool, org, since, after):
325
+ for call in names:
326
+ entries = await _entries_of(pool, call)
327
+ score = _last_score(entries)
328
+ if score is not None and await fold(pool, call, entries, score):
329
+ folded += 1
330
+ calls += 1
331
+ after = names[-1]
332
+ return Rebuilt(calls=calls, folded=folded)
333
+
334
+
335
+ def _samples_of(entries: Iterable[Entry]) -> dict[StageKey, StageSample]:
336
+ """The stages a call's turns reported, by the vendor and model each turn named."""
337
+ seconds: dict[StageKey, list[float]] = {}
338
+ turns: dict[StageKey, int] = {}
339
+ confidence: dict[StageKey, list[float]] = {}
340
+ for entry in entries:
341
+ for key, value, sure in _stages_of(entry):
342
+ turns[key] = turns.get(key, 0) + 1
343
+ if value is not None:
344
+ seconds.setdefault(key, []).append(value)
345
+ if sure is not None:
346
+ confidence.setdefault(key, []).append(sure)
347
+ return {
348
+ key: StageSample(
349
+ turns=count,
350
+ buckets=tuple(_histogram.counted(seconds.get(key, ()))),
351
+ confidence_sum=sum(confidence.get(key, ())),
352
+ confidence_turns=len(confidence.get(key, ())),
353
+ )
354
+ for key, count in turns.items()
355
+ }
356
+
357
+
358
+ def _verdicts_of(score: CallScore) -> list[Verdict]:
359
+ """The settled verdicts of a score, each under the hash of its question."""
360
+ return [
361
+ Verdict(judge.name, _criteria_hash(judge.criteria), judge.verdict == "held")
362
+ for judge in score.judges
363
+ if judge.verdict in SETTLED
364
+ ]
365
+
366
+
367
+ def _criteria_hash(criteria: str) -> str:
368
+ """A judge's question as drift keys it: its hash, so a changed question is told apart."""
369
+ return sha256(criteria.encode()).hexdigest()
370
+
371
+
372
+ def _day_of(started_at: float) -> date:
373
+ """The UTC day a call that started then is counted on."""
374
+ return datetime.fromtimestamp(started_at, UTC).date()
375
+
376
+
377
+ def _stages_of(entry: Entry) -> list[tuple[StageKey, float | None, float | None]]:
378
+ if entry.type not in {"turn.user", "turn.agent"}:
379
+ return []
380
+ try:
381
+ turn = event_of(entry)
382
+ except DeclarationRefused:
383
+ logger.warning("call %s: seq %d is not a turn drift reads", entry.call, entry.seq)
384
+ return []
385
+ found: list[tuple[StageKey, float | None, float | None]] = []
386
+ if isinstance(turn, UserTurnEnded):
387
+ heard = turn.metrics
388
+ delay, sure = heard.transcription_delay, turn.transcript_confidence
389
+ if delay is not None or sure is not None:
390
+ found.append((_key("stt", heard.stt_metadata), delay, sure))
391
+ if isinstance(turn, AgentTurnEnded):
392
+ answered = turn.metrics
393
+ if answered.llm_node_ttft is not None:
394
+ found.append((_key("llm", answered.llm_metadata), answered.llm_node_ttft, None))
395
+ if answered.tts_node_ttfb is not None:
396
+ found.append((_key("tts", answered.tts_metadata), answered.tts_node_ttfb, None))
397
+ return found
398
+
399
+
400
+ def _key(stage: Stage, metadata: TurnMetadata | None) -> StageKey:
401
+ if metadata is None:
402
+ return (stage, "", "")
403
+ return (stage, metadata.model_provider or "", metadata.model_name or "")
404
+
405
+
406
+ def _where(scope: Scope, agent: str, day: date, version: int | None) -> dict[str, object]:
407
+ return {
408
+ "org": scope.org,
409
+ "env": scope.env,
410
+ "holder": scope.holder,
411
+ "agent": agent,
412
+ "day": day,
413
+ "version": NO_VERSION if version is None else version,
414
+ }
415
+
416
+
417
+ def _tallied(verdicts: Iterable[Verdict], *, sign: int) -> dict[JudgeKey, tuple[int, int]]:
418
+ tallied: dict[JudgeKey, tuple[int, int]] = {}
419
+ for item in verdicts:
420
+ passes, fails = tallied.get((item.judge, item.criteria), (0, 0))
421
+ tallied[(item.judge, item.criteria)] = (
422
+ (passes + sign, fails) if item.held else (passes, fails + sign)
423
+ )
424
+ return tallied
425
+
426
+
427
+ async def _stages_added(
428
+ connection: Connection, where: Mapping[str, object], samples: Mapping[StageKey, StageSample]
429
+ ) -> None:
430
+ if not samples:
431
+ return
432
+ rows: list[JsonObject] = [
433
+ {
434
+ "stage": stage,
435
+ "vendor": vendor,
436
+ "model": model,
437
+ "turns": sample.turns,
438
+ "buckets": list(sample.buckets),
439
+ "confidence_sum": sample.confidence_sum,
440
+ "confidence_turns": sample.confidence_turns,
441
+ }
442
+ for (stage, vendor, model), sample in samples.items()
443
+ ]
444
+ await connection.execute(STAGES_ADDED, {**where, "samples": Jsonb(rows)})
445
+
446
+
447
+ async def _verdicts_added(
448
+ connection: Connection, where: Mapping[str, object], tallied: Mapping[JudgeKey, tuple[int, int]]
449
+ ) -> None:
450
+ if not tallied:
451
+ return
452
+ rows: list[JsonObject] = [
453
+ {"judge": judge, "criteria": criteria, "held": passes, "broken": fails}
454
+ for (judge, criteria), (passes, fails) in tallied.items()
455
+ ]
456
+ await connection.execute(VERDICTS_ADDED, {**where, "verdicts": Jsonb(rows)})
457
+
458
+
459
+ def _merged_stages(rows: Iterable[DictRow]) -> dict[StageKey, StageSample]:
460
+ merged: dict[StageKey, StageSample] = {}
461
+ for row in rows:
462
+ key: StageKey = (row["stage"], row["vendor"], row["model"])
463
+ before = merged.get(key, StageSample())
464
+ merged[key] = StageSample(
465
+ turns=before.turns + row["turns"],
466
+ buckets=tuple(_histogram.added(before.buckets, row["buckets"])),
467
+ confidence_sum=before.confidence_sum + row["confidence_sum"],
468
+ confidence_turns=before.confidence_turns + row["confidence_turns"],
469
+ )
470
+ return merged
471
+
472
+
473
+ def _tallied_stage(sample: StageSample) -> StageTally:
474
+ return StageTally(
475
+ turns=sample.turns,
476
+ median_s=_histogram.rank(sample.buckets, _histogram.MEDIAN),
477
+ p95_s=_histogram.rank(sample.buckets, _histogram.P95),
478
+ confidence=sample.confidence_sum / sample.confidence_turns
479
+ if sample.confidence_turns
480
+ else None,
481
+ )
482
+
483
+
484
+ def _judge_moved(name: str, before: JudgeRead | None, after: JudgeRead | None) -> DriftJudge:
485
+ rates = (
486
+ None if before is None else before.pass_rate,
487
+ None if after is None else after.pass_rate,
488
+ )
489
+ return DriftJudge(
490
+ name=name,
491
+ before=_judge_tally(before),
492
+ after=_judge_tally(after),
493
+ moved=None if None in rates else _moved(rates[0], rates[1]),
494
+ criteria_changed=before is not None
495
+ and after is not None
496
+ and before.criteria != after.criteria,
497
+ )
498
+
499
+
500
+ def _judge_tally(read: JudgeRead | None) -> JudgeTally | None:
501
+ if read is None:
502
+ return None
503
+ return JudgeTally(held=read.held, judged=read.judged, pass_rate=read.pass_rate)
504
+
505
+
506
+ def _stage_moved(
507
+ key: StageKey, before: StageSample | None, after: StageSample | None
508
+ ) -> DriftStage:
509
+ stage, vendor, model = key
510
+ first = None if before is None else _tallied_stage(before)
511
+ second = None if after is None else _tallied_stage(after)
512
+ return DriftStage(
513
+ stage=stage,
514
+ vendor=vendor or None,
515
+ model=model or None,
516
+ before=first,
517
+ after=second,
518
+ median_moved_s=None
519
+ if first is None or second is None
520
+ else _moved(first.median_s, second.median_s),
521
+ p95_moved_s=None if first is None or second is None else _moved(first.p95_s, second.p95_s),
522
+ )
523
+
524
+
525
+ def _moved(before: float | None, after: float | None) -> float | None:
526
+ return None if before is None or after is None else after - before
527
+
528
+
529
+ async def _sealed_after(pool: Pool, org: str | None, since: float | None, after: str) -> list[str]:
530
+ params = {"org": org, "since": since, "after": after, "limit": A_PAGE}
531
+ async with pool.connection() as connection:
532
+ rows = await (await connection.execute(SEALED_PAGE, params)).fetchall()
533
+ return [str(row["log"]) for row in rows]
534
+
535
+
536
+ async def _entries_of(pool: Pool, call: str) -> list[Entry]:
537
+ async with pool.connection() as connection:
538
+ rows = await (await connection.execute(refold.ENTRIES, {"call": call})).fetchall()
539
+ return [entry_of(row) for row in rows]
540
+
541
+
542
+ def _last_score(entries: Sequence[Entry]) -> CallScore | None:
543
+ scored = next((entry for entry in reversed(entries) if entry.type == "call.score"), None)
544
+ if scored is None:
545
+ return None
546
+ try:
547
+ return CallScore.model_validate(scored.data)
548
+ except ValueError:
549
+ logger.warning("call %s: its call.score is not one drift reads", scored.call)
550
+ return None