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/usage.py ADDED
@@ -0,0 +1,242 @@
1
+ """What a scope used: a window of whole days in numbers, and the metered feed past a cursor."""
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import UTC, date, datetime
5
+
6
+ from psycopg import sql
7
+ from psycopg.rows import DictRow
8
+
9
+ from pinecall.domain.errors import DeclarationRefused
10
+ from pinecall.domain.names import CHANNELS
11
+ from pinecall.domain.scope import Scope
12
+ from pinecall.log.facts import STAGES
13
+ from pinecall.log.reduce import METERED_TYPES, UsageRow, usage_row
14
+ from pinecall.log.store import Store
15
+ from pinecall.postgres.pool import Pool
16
+
17
+ # Days are cut in UTC: an org has no timezone.
18
+ A_DAY_S = 24 * 60 * 60
19
+
20
+
21
+ _IN_THE_WINDOW = sql.SQL("head.started_at >= %(start)s and head.started_at < %(end)s")
22
+
23
+
24
+ # A window's rows: the scope's calls, or one agent's when the window names one.
25
+ _THE_WINDOWS_CALLS = sql.SQL("""
26
+ from call_log_head head left join call_facts f on f.call = head.log
27
+ where head.org = %(org)s and head.env = %(env)s and head.holder = %(holder)s
28
+ and head.call is not null and (%(agent)s::text is null or head.agent = %(agent)s)
29
+ """)
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class AgentWindow:
34
+ """One agent's window: its calls, the share of judgments it held, what it cost by stage."""
35
+
36
+ slug: str
37
+ calls: int
38
+ score: float | None
39
+ # Dollars by stage (llm, stt, tts, phone, platform), and the minutes of the calls that ended.
40
+ spend: dict[str, float]
41
+ minutes: float
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class WindowDay:
46
+ """One UTC day of a window: its calls by door, what they cost, and how their judges answered."""
47
+
48
+ day: date
49
+ channels: dict[str, int]
50
+ spent: float
51
+ judged: int
52
+ passed: int
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Window:
57
+ """A scope's window of whole UTC days in numbers, and the window of the same length before."""
58
+
59
+ calls: int
60
+ before: int
61
+ finished: int
62
+ unescalated: int
63
+ judged: int
64
+ passed: int
65
+ escalated: int
66
+ mean_length: float | None
67
+ median_e2e: float | None
68
+ spent: float
69
+ channels: dict[str, int]
70
+ endings: list[tuple[str, int]]
71
+ days: list[WindowDay]
72
+ agents: list[AgentWindow]
73
+ total: int
74
+ live: int
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class MeteredPage:
79
+ """One page of the usage feed: the rows kept, and the cursor past everything read."""
80
+
81
+ rows: list[UsageRow]
82
+ next: int | None
83
+
84
+
85
+ # "before" is the window of the same length right before it. A call is judged once a judge
86
+ # settled, and passed unless a judge said it did not (CallFacts.score).
87
+ WINDOW = (
88
+ sql.SQL("""
89
+ select
90
+ count(*) filter (where {window}) as calls,
91
+ count(*) filter (where head.started_at >= %(start)s - (%(end)s - %(start)s)
92
+ and head.started_at < %(start)s) as before,
93
+ count(*) filter (where {window} and f.ended_at is not null) as finished,
94
+ count(*) filter (where {window} and f.ended_at is not null and not f.escalated)
95
+ as unescalated,
96
+ count(*) filter (where {window} and f.judged > 0) as judged,
97
+ count(*) filter (where {window} and f.judged > 0 and f.passed is not false) as passed,
98
+ count(*) filter (where {window} and f.escalated) as escalated,
99
+ avg(f.ended_at - head.started_at) filter (where {window} and f.ended_at is not null)
100
+ as mean_length,
101
+ coalesce(sum(f.cost_usd) filter (where {window}), 0) as spent,
102
+ count(*) filter (where {window} and f.channel = 'phone') as phone,
103
+ count(*) filter (where {window} and f.channel = 'web') as web,
104
+ count(*) filter (where {window} and f.channel = 'whatsapp') as whatsapp,
105
+ count(*) as total,
106
+ count(*) filter (where not head.sealed) as live
107
+ """).format(window=_IN_THE_WINDOW)
108
+ + _THE_WINDOWS_CALLS
109
+ )
110
+
111
+
112
+ WINDOW_MEDIAN_E2E = sql.SQL("""
113
+ select percentile_cont(0.5) within group (order by turn.seconds) as median
114
+ from call_log_head head
115
+ join call_facts f on f.call = head.log
116
+ cross join lateral unnest(f.e2e) as turn(seconds)
117
+ where head.org = %(org)s and head.env = %(env)s and head.holder = %(holder)s
118
+ and head.call is not null and (%(agent)s::text is null or head.agent = %(agent)s)
119
+ and {window}
120
+ """).format(window=_IN_THE_WINDOW)
121
+
122
+
123
+ WINDOW_BY_AGENT = (
124
+ sql.SQL("""
125
+ select head.agent as slug, count(*) as calls,
126
+ avg(f.held::double precision / f.judged) filter (where f.judged > 0) as score,
127
+ coalesce(sum(f.cost_llm_usd), 0) as llm, coalesce(sum(f.cost_stt_usd), 0) as stt,
128
+ coalesce(sum(f.cost_tts_usd), 0) as tts, coalesce(sum(f.cost_phone_usd), 0) as phone,
129
+ coalesce(sum(f.cost_platform_usd), 0) as platform,
130
+ coalesce(sum(f.ended_at - head.started_at) filter (where f.ended_at is not null), 0) / 60
131
+ as minutes
132
+ """)
133
+ + _THE_WINDOWS_CALLS
134
+ + sql.SQL("""
135
+ and {window}
136
+ group by head.agent
137
+ order by calls desc, slug
138
+ """).format(window=_IN_THE_WINDOW)
139
+ )
140
+
141
+
142
+ WINDOW_ENDINGS = (
143
+ sql.SQL("select f.end_reason as reason, count(*) as count ")
144
+ + _THE_WINDOWS_CALLS
145
+ + sql.SQL("""
146
+ and {window} and f.end_reason is not null
147
+ group by f.end_reason
148
+ order by count desc, reason
149
+ """).format(window=_IN_THE_WINDOW)
150
+ )
151
+
152
+
153
+ # Days are cut in UTC, as everywhere: the day of a call is its start's whole days since the epoch.
154
+ WINDOW_BY_DAY = (
155
+ sql.SQL("""
156
+ select floor(head.started_at / %(a_day)s)::bigint as epoch_day,
157
+ count(*) filter (where f.channel = 'phone') as phone,
158
+ count(*) filter (where f.channel = 'web') as web,
159
+ count(*) filter (where f.channel = 'whatsapp') as whatsapp,
160
+ coalesce(sum(f.cost_usd), 0) as spent,
161
+ count(*) filter (where f.judged > 0) as judged,
162
+ count(*) filter (where f.judged > 0 and f.passed is not false) as passed
163
+ """)
164
+ + _THE_WINDOWS_CALLS
165
+ + sql.SQL("""
166
+ and {window}
167
+ group by epoch_day
168
+ """).format(window=_IN_THE_WINDOW)
169
+ )
170
+
171
+
172
+ # The cursor moves past every row read, kept or not, so a page filtered to one org still moves.
173
+ async def metered_page(store: Store, *, after: int, limit: int, org: str | None) -> MeteredPage:
174
+ """The metered rows after the cursor, of one org or of every org, folded."""
175
+ read = [usage_row(item) for item in await store.across(METERED_TYPES, after=after, limit=limit)]
176
+ kept = [row for row in read if org is None or row.org == org]
177
+ return MeteredPage(rows=kept, next=read[-1].cursor if read else None)
178
+
179
+
180
+ # One agent's window when it names one; the whole scope's when it is None.
181
+ async def counted_window(
182
+ pool: Pool, scope: Scope, start: float, end: float, agent: str | None
183
+ ) -> Window:
184
+ """Return the scope's window from start to end, both on a UTC midnight, in numbers."""
185
+ params = {**asdict(scope), "start": start, "end": end, "agent": agent, "a_day": A_DAY_S}
186
+ # independent: five counts of one window, each its own snapshot
187
+ async with pool.connection() as connection:
188
+ counted = await (await connection.execute(WINDOW, params)).fetchone()
189
+ median = await (await connection.execute(WINDOW_MEDIAN_E2E, params)).fetchone()
190
+ agents = await (await connection.execute(WINDOW_BY_AGENT, params)).fetchall()
191
+ endings = await (await connection.execute(WINDOW_ENDINGS, params)).fetchall()
192
+ by_day = await (await connection.execute(WINDOW_BY_DAY, params)).fetchall()
193
+ if counted is None:
194
+ raise DeclarationRefused("a window counts, even an empty one")
195
+ return Window(
196
+ calls=int(counted["calls"]),
197
+ before=int(counted["before"]),
198
+ finished=int(counted["finished"]),
199
+ unescalated=int(counted["unescalated"]),
200
+ judged=int(counted["judged"]),
201
+ passed=int(counted["passed"]),
202
+ escalated=int(counted["escalated"]),
203
+ mean_length=None if counted["mean_length"] is None else float(counted["mean_length"]),
204
+ median_e2e=None if median is None else median["median"],
205
+ spent=float(counted["spent"]),
206
+ channels={door: int(counted[door]) for door in CHANNELS},
207
+ endings=[(row["reason"], int(row["count"])) for row in endings],
208
+ days=_every_day(start, end, {int(row["epoch_day"]): row for row in by_day}),
209
+ agents=[
210
+ AgentWindow(
211
+ slug=row["slug"],
212
+ calls=row["calls"],
213
+ score=row["score"],
214
+ spend={stage: float(row[stage]) for stage in STAGES},
215
+ minutes=float(row["minutes"]),
216
+ )
217
+ for row in agents
218
+ ],
219
+ total=int(counted["total"]),
220
+ live=int(counted["live"]),
221
+ )
222
+
223
+
224
+ # Every day of the window has its row, a day nobody called included, so a chart has no holes.
225
+ def _every_day(start: float, end: float, counted: dict[int, DictRow]) -> list[WindowDay]:
226
+ first, last = int(start // A_DAY_S), int(end // A_DAY_S)
227
+ return [_day_of(epoch_day, counted.get(epoch_day)) for epoch_day in range(first, last)]
228
+
229
+
230
+ def _day_of(epoch_day: int, row: DictRow | None) -> WindowDay:
231
+ day = datetime.fromtimestamp(epoch_day * A_DAY_S, UTC).date()
232
+ if row is None:
233
+ return WindowDay(
234
+ day=day, channels=dict.fromkeys(CHANNELS, 0), spent=0.0, judged=0, passed=0
235
+ )
236
+ return WindowDay(
237
+ day=day,
238
+ channels={door: int(row[door]) for door in CHANNELS},
239
+ spent=float(row["spent"]),
240
+ judged=int(row["judged"]),
241
+ passed=int(row["passed"]),
242
+ )
File without changes
@@ -0,0 +1,202 @@
1
+ """The migration runner: the .sql files beside it, applied in order, never edited."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import psycopg
9
+ from psycopg import sql
10
+
11
+ from pinecall.domain.errors import MigrationsRefused
12
+ from pinecall.postgres.pool import (
13
+ DEFAULT_SCHEMA,
14
+ Connection,
15
+ Pool,
16
+ connect,
17
+ database_named,
18
+ schemas_of,
19
+ )
20
+
21
+ # Applied in name order. An applied migration is never edited: add a new one instead.
22
+ MIGRATIONS = Path(__file__).parent / "migrations"
23
+ FIRST = "0001_schema.sql"
24
+
25
+ # First-line marker that runs a file outside a transaction, required by CREATE INDEX CONCURRENTLY.
26
+ # Such a file holds ONE statement: Postgres runs a multi-statement string as one implicit
27
+ # transaction.
28
+ NO_TRANSACTION = "-- pinecall:no-transaction"
29
+
30
+ # Fails fast instead of queueing behind a reader and blocking every writer behind it.
31
+ LOCK_TIMEOUT_MS = 1_000
32
+
33
+ # Serializes concurrent runners; any constant no other code in the database uses.
34
+ ADVISORY_LOCK = 0x9E3_C411
35
+
36
+ MIGRATIONS_TABLE = """
37
+ create table if not exists schema_migrations (
38
+ name text primary key,
39
+ applied_at timestamptz not null default now(),
40
+ sha256 text
41
+ )
42
+ """
43
+ APPLIED = "select name, sha256 from schema_migrations order by name"
44
+ RECORD = "insert into schema_migrations (name, sha256) values (%s, %s)"
45
+
46
+ EDITED = (
47
+ "migration {name} changed after it was applied: this database ran a different file.\n"
48
+ " applied sha256 {was}\n"
49
+ " on disk sha256 {now}\n"
50
+ "An applied migration is never edited: every database that ran it has the OLD one. Put the "
51
+ "change in a new migration, or restore the file."
52
+ )
53
+ # The rows of every table and the sequences behind them, now and for every table the owner makes
54
+ # later (a day of call_log among them); never a table's shape, never TRUNCATE.
55
+ GRANTS = (
56
+ "grant usage on schema {schema} to {role}",
57
+ "grant select, insert, update, delete on all tables in schema {schema} to {role}",
58
+ "grant usage, select, update on all sequences in schema {schema} to {role}",
59
+ (
60
+ "alter default privileges in schema {schema} "
61
+ "grant select, insert, update, delete on tables to {role}"
62
+ ),
63
+ (
64
+ "alter default privileges in schema {schema} "
65
+ "grant usage, select, update on sequences to {role}"
66
+ ),
67
+ )
68
+
69
+ ROLE_EXISTS = "select 1 from pg_roles where rolname = %s"
70
+
71
+ # CloudNativePG makes the role from its secret a few seconds after the chart says so: waited for.
72
+ ROLE_WAITED_S = 120.0
73
+ ROLE_ASKED_EVERY_S = 2.0
74
+
75
+ NO_ROLE = (
76
+ "role {role} does not exist after {waited:g} s: its secret (charts/postgres, the managed role) "
77
+ "is not synced yet, or the role is named wrong; nothing was granted"
78
+ )
79
+
80
+ MISSING = (
81
+ "migration {name} is recorded as applied and is not in this distribution: "
82
+ "this checkout is older than the database it is pointed at"
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Applied:
88
+ """The result of a migration run: the database, the schema and what was applied."""
89
+
90
+ database: str
91
+ schema: str
92
+ applied: tuple[str, ...]
93
+
94
+
95
+ async def apply_migrations(dsn: str, *, schema: str = DEFAULT_SCHEMA) -> Applied:
96
+ """Apply every pending migration in name order under an advisory lock."""
97
+ schemas = schemas_of(schema)
98
+ async with await connect(dsn) as connection:
99
+ # Before the lock, which may wait on another runner: nothing here is cut by a timeout the
100
+ # role or PGOPTIONS set. A file waits on a table's lock LOCK_TIMEOUT_MS at most.
101
+ await connection.execute("set statement_timeout = 0")
102
+ # Session-level, released with the connection. Taken before any DDL: concurrent
103
+ # `create schema if not exists` can still fail on pg_namespace's unique index.
104
+ await connection.execute("select pg_advisory_lock(%s)", (ADVISORY_LOCK,))
105
+ if schema != DEFAULT_SCHEMA:
106
+ await connection.execute(
107
+ sql.SQL("create schema if not exists {}").format(sql.Identifier(schema))
108
+ )
109
+ await connection.execute(
110
+ sql.SQL("set search_path to {}").format(
111
+ sql.SQL(", ").join(sql.Identifier(schema) for schema in schemas)
112
+ )
113
+ )
114
+ await connection.execute(MIGRATIONS_TABLE)
115
+ done = await _applied(connection)
116
+ ran = [
117
+ await _apply_one(connection, path)
118
+ for path in migration_files()
119
+ if path.name not in done
120
+ ]
121
+ return Applied(database=database_named(dsn), schema=schema, applied=tuple(ran))
122
+
123
+
124
+ # Granted again every run: a table a migration made is granted the moment it exists, and a grant
125
+ # taken away by hand comes back. Refused, not skipped, when the role never came: a gateway that
126
+ # connects as it would find nothing it may read.
127
+ async def granted(dsn: str, role: str, *, schema: str = DEFAULT_SCHEMA) -> None:
128
+ """Grant the role every table's rows and nothing else, once it exists."""
129
+ async with await connect(dsn) as connection:
130
+ waited = 0.0
131
+ while await (await connection.execute(ROLE_EXISTS, (role,))).fetchone() is None:
132
+ if waited >= ROLE_WAITED_S:
133
+ raise MigrationsRefused(NO_ROLE.format(role=role, waited=ROLE_WAITED_S))
134
+ await asyncio.sleep(ROLE_ASKED_EVERY_S)
135
+ waited += ROLE_ASKED_EVERY_S
136
+ names = {"schema": sql.Identifier(schema), "role": sql.Identifier(role)}
137
+ async with connection.transaction():
138
+ for grant in GRANTS:
139
+ await connection.execute(sql.SQL(grant).format(**names))
140
+
141
+
142
+ async def migrations_behind(pool: Pool) -> tuple[str, ...]:
143
+ """Return the migrations the database has not applied; every one, when it has no table."""
144
+ async with pool.connection() as connection:
145
+ try:
146
+ rows = await (await connection.execute(APPLIED)).fetchall()
147
+ except psycopg.errors.UndefinedTable:
148
+ rows = []
149
+ done = {str(row["name"]) for row in rows}
150
+ return tuple(path.name for path in migration_files() if path.name not in done)
151
+
152
+
153
+ def migration_files() -> list[Path]:
154
+ """Return every shipped migration, in apply order."""
155
+ return sorted(MIGRATIONS.glob("*.sql"))
156
+
157
+
158
+ def file_hash(path: Path) -> str:
159
+ """Return the SHA-256 of the file's bytes."""
160
+ return hashlib.sha256(path.read_bytes()).hexdigest()
161
+
162
+
163
+ def in_a_transaction(statements: bytes) -> bool:
164
+ """Return False when the file's first line is the no-transaction marker."""
165
+ first = statements.decode("utf-8").lstrip().split("\n", 1)[0].strip()
166
+ return first != NO_TRANSACTION
167
+
168
+
169
+ async def _applied(connection: Connection) -> set[str]:
170
+ on_disk = {path.name: path for path in migration_files()}
171
+ rows = await (await connection.execute(APPLIED)).fetchall()
172
+ recorded = {str(row["name"]): row["sha256"] for row in rows}
173
+ # A database the previous runtime migrated: its schema is what the first file holds, so the
174
+ # first file is recorded as applied and the old bookkeeping goes.
175
+ if recorded and FIRST not in recorded:
176
+ await connection.execute("delete from schema_migrations")
177
+ await connection.execute(RECORD, (FIRST, file_hash(on_disk[FIRST])))
178
+ return {FIRST}
179
+ for name, was in recorded.items():
180
+ path = on_disk.get(name)
181
+ if path is None:
182
+ raise MigrationsRefused(MISSING.format(name=name))
183
+ now = file_hash(path)
184
+ if str(was) != now:
185
+ raise MigrationsRefused(EDITED.format(name=name, was=was, now=now))
186
+ return set(recorded)
187
+
188
+
189
+ async def _apply_one(connection: Connection, path: Path) -> str:
190
+ statements = await asyncio.to_thread(path.read_bytes)
191
+ recorded = (path.name, hashlib.sha256(statements).hexdigest())
192
+ if not in_a_transaction(statements):
193
+ await connection.execute(statements)
194
+ await connection.execute(RECORD, recorded)
195
+ return path.name
196
+ async with connection.transaction():
197
+ await connection.execute(
198
+ sql.SQL("set local lock_timeout = {}").format(sql.Literal(LOCK_TIMEOUT_MS))
199
+ )
200
+ await connection.execute(statements)
201
+ await connection.execute(RECORD, recorded)
202
+ return path.name