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
@@ -0,0 +1,406 @@
1
+ """An agent's tuning and lexicon, versioned per scope, and what is current."""
2
+
3
+ from collections.abc import Collection, Sequence
4
+ from dataclasses import dataclass, replace
5
+ from datetime import datetime
6
+ from typing import Literal, LiteralString
7
+
8
+ from psycopg import sql
9
+ from psycopg.rows import DictRow
10
+ from psycopg.types.json import Jsonb
11
+ from pydantic import TypeAdapter
12
+
13
+ from pinecall.domain.agent import Lexicon, Tuning, Version, Versions
14
+ from pinecall.domain.errors import Conflict
15
+ from pinecall.domain.names import PRODUCTION, Json, JsonObject
16
+ from pinecall.domain.scope import THE_ORGS_OWN, Scope
17
+ from pinecall.postgres.pool import Connection, Pool
18
+ from pinecall.tenancy.canary import bucket_of
19
+
20
+ type VersionedTable = Literal["agent_config", "lexicon"]
21
+
22
+
23
+ MOVED = "this corner is at v{newest} now, not the version you read: read it again, then set again"
24
+
25
+
26
+ HISTORY_LIMIT = 20
27
+
28
+
29
+ TUNING: TypeAdapter[Tuning] = TypeAdapter(Tuning)
30
+
31
+
32
+ HISTORY = sql.SQL("""
33
+ SELECT holder, version, {value} AS value, author, note, set_at FROM {table}
34
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND agent = %(agent)s
35
+ ORDER BY version DESC LIMIT %(limit)s
36
+ """)
37
+
38
+
39
+ # The version a call ran on is the holder's or, when it fell through, the org's own.
40
+ AT = sql.SQL("""
41
+ SELECT holder, version, {value} AS value, author, note, set_at FROM {table}
42
+ WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND agent = %(agent)s
43
+ AND version = %(version)s
44
+ ORDER BY holder DESC LIMIT 1
45
+ """)
46
+
47
+
48
+ NEWEST = sql.SQL("""
49
+ SELECT coalesce(max(version), 0) AS newest FROM {table}
50
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND agent = %(agent)s
51
+ """)
52
+
53
+
54
+ # The next version, unless the scope moved since the writer read it: then no row.
55
+ PUT_TUNING = """
56
+ INSERT INTO agent_config (org, env, holder, agent, version, config, author, note)
57
+ SELECT %(org)s, %(env)s, %(holder)s, %(agent)s, coalesce(max(version), 0) + 1, %(value)s,
58
+ %(author)s, %(note)s
59
+ FROM agent_config
60
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND agent = %(agent)s
61
+ HAVING %(if_version)s::integer IS NULL OR coalesce(max(version), 0) = %(if_version)s
62
+ ON CONFLICT (org, env, holder, agent, version) DO NOTHING
63
+ RETURNING version
64
+ """
65
+
66
+
67
+ PUT_LEXICON = """
68
+ INSERT INTO lexicon (org, env, holder, agent, version, said, heard, author, note)
69
+ SELECT %(org)s, %(env)s, %(holder)s, %(agent)s, coalesce(max(version), 0) + 1, %(said)s,
70
+ %(heard)s, %(author)s, %(note)s
71
+ FROM lexicon
72
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND agent = %(agent)s
73
+ HAVING %(if_version)s::integer IS NULL OR coalesce(max(version), 0) = %(if_version)s
74
+ ON CONFLICT (org, env, holder, agent, version) DO NOTHING
75
+ RETURNING version
76
+ """
77
+
78
+
79
+ # Both chains in one statement, so the tuning and the lexicon a call is built on are read from
80
+ # the same moment: the holder's newest row, then the org's own, of each. Where a level stands on a
81
+ # canary (tenancy/canary.py), a call whose bucket is under its share runs the canary's version and
82
+ # every other call, or a read for no call, the newest version but that one. A version named (an
83
+ # eval run's candidate) is the scope's own level's, whatever canary stands there.
84
+ STANDING = """
85
+ SELECT * FROM (
86
+ SELECT DISTINCT ON (config.holder) 'tuning' AS kind, config.holder, config.version,
87
+ config.config AS value, config.author, config.note, config.set_at
88
+ FROM agent_config config
89
+ LEFT JOIN LATERAL (
90
+ SELECT canary.version, canary.share FROM agent_canaries canary
91
+ WHERE canary.org = config.org AND canary.env = config.env
92
+ AND canary.holder = config.holder AND canary.agent = config.agent
93
+ ORDER BY canary.set_at DESC, canary.id DESC
94
+ LIMIT 1
95
+ ) standing ON true
96
+ WHERE config.org = %(org)s AND config.env = %(env)s AND config.holder IN (%(holder)s, '')
97
+ AND config.agent = %(agent)s
98
+ AND CASE WHEN %(version)s::integer IS NOT NULL AND config.holder = %(holder)s
99
+ THEN config.version = %(version)s
100
+ ELSE standing.version IS NULL
101
+ OR (config.version = standing.version)
102
+ = coalesce(%(bucket)s::integer < standing.share, false)
103
+ END
104
+ ORDER BY config.holder DESC, config.version DESC
105
+ ) tuning
106
+ UNION ALL
107
+ SELECT * FROM (
108
+ SELECT DISTINCT ON (holder) 'lexicon', holder, version,
109
+ jsonb_build_object('said', said, 'heard', heard), author, note, set_at
110
+ FROM lexicon
111
+ WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND agent = %(agent)s
112
+ ORDER BY holder DESC, version DESC
113
+ ) lexicon
114
+ ORDER BY kind, holder DESC
115
+ """
116
+
117
+
118
+ EVERY_AGENT = """
119
+ SELECT DISTINCT ON (agent, holder) agent, holder, version, config AS value, author, note, set_at
120
+ FROM agent_config
121
+ WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '')
122
+ ORDER BY agent, holder DESC, version DESC
123
+ """
124
+
125
+
126
+ # The versions a drift names: the ones its calls ran and the ones set in its window. The same
127
+ # number at both levels is the holder's, as the version a call ran on is read (AT).
128
+ NOTED = """
129
+ SELECT DISTINCT ON (version) holder, version, author, note, set_at FROM agent_config
130
+ WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND agent = %(agent)s
131
+ AND (version = ANY(%(versions)s)
132
+ OR (set_at >= to_timestamp(%(since)s) AND set_at < to_timestamp(%(until)s)))
133
+ ORDER BY version, holder DESC
134
+ """
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class Noted:
139
+ """A version of the agent's tuning without its value: whose, which, who wrote it, why, when."""
140
+
141
+ holder: str
142
+ version: int
143
+ author: str
144
+ note: str | None
145
+ set_at: datetime
146
+
147
+
148
+ # The call picks between a canary's version and the rest by its id; neither is the rest.
149
+ @dataclass(frozen=True)
150
+ class Picked:
151
+ """Which of a scope's versions a read is for: a call's, a version named, or the rest's."""
152
+
153
+ call: str | None = None
154
+ version: int | None = None
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class Written:
159
+ """Who writes a version, why, and the version they read, when they want no other."""
160
+
161
+ author: str
162
+ note: str | None = None
163
+ if_version: int | None = None
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class SideBySide[T]:
168
+ """What a settings screen shows side by side: yours, the team's and production's newest."""
169
+
170
+ yours: Version[T] | None
171
+ team: Version[T] | None
172
+ production: Version[T] | None
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class Current:
177
+ """The tuning and the lexicon a call in the scope is built on, and their versions."""
178
+
179
+ tuning: Tuning
180
+ lexicon: Lexicon
181
+ versions: Versions
182
+
183
+
184
+ # What each table keeps as its value, read as one JSON object so both are read the same way.
185
+ VALUE: dict[VersionedTable, sql.Composable] = {
186
+ "agent_config": sql.SQL("config"),
187
+ "lexicon": sql.SQL("jsonb_build_object('said', said, 'heard', heard)"),
188
+ }
189
+
190
+
191
+ async def tuning_history(
192
+ pool: Pool, scope: Scope, agent: str, *, limit: int = HISTORY_LIMIT
193
+ ) -> list[Version[Tuning]]:
194
+ """The scope's own versions of the agent's tuning, newest first."""
195
+ rows = await _history(pool, "agent_config", scope, agent, limit)
196
+ return [_tuning(row) for row in rows]
197
+
198
+
199
+ async def lexicon_history(
200
+ pool: Pool, scope: Scope, agent: str, *, limit: int = HISTORY_LIMIT
201
+ ) -> list[Version[Lexicon]]:
202
+ """The scope's own versions of the agent's lexicon, newest first."""
203
+ return [_lexicon(row) for row in await _history(pool, "lexicon", scope, agent, limit)]
204
+
205
+
206
+ async def tuning_at(pool: Pool, scope: Scope, agent: str, version: int) -> Version[Tuning] | None:
207
+ """One version of the agent's tuning, the scope's own or the org's it fell through to."""
208
+ row = await _at(pool, "agent_config", _where(scope, agent), version)
209
+ return None if row is None else _tuning(row)
210
+
211
+
212
+ async def lexicon_at(pool: Pool, scope: Scope, agent: str, version: int) -> Version[Lexicon] | None:
213
+ """One version of the agent's lexicon, the scope's own or the org's it fell through to."""
214
+ row = await _at(pool, "lexicon", _where(scope, agent), version)
215
+ return None if row is None else _lexicon(row)
216
+
217
+
218
+ async def put_tuning(pool: Pool, scope: Scope, agent: str, tuning: Tuning, written: Written) -> int:
219
+ """Write the agent's next tuning in the scope; a scope moved since it was read refuses."""
220
+ values: dict[str, object] = {
221
+ **_where(scope, agent),
222
+ "value": Jsonb(_stored(tuning)),
223
+ "author": written.author,
224
+ "note": written.note,
225
+ "if_version": written.if_version,
226
+ }
227
+ return await _put(pool, "agent_config", PUT_TUNING, values)
228
+
229
+
230
+ async def put_lexicon(
231
+ pool: Pool, scope: Scope, agent: str, lexicon: Lexicon, written: Written
232
+ ) -> int:
233
+ """Write the agent's next lexicon in the scope; a scope moved since it was read refuses."""
234
+ values: dict[str, object] = {
235
+ **_where(scope, agent),
236
+ "said": Jsonb(dict(lexicon.said)),
237
+ "heard": Jsonb(list(lexicon.heard)),
238
+ "author": written.author,
239
+ "note": written.note,
240
+ "if_version": written.if_version,
241
+ }
242
+ return await _put(pool, "lexicon", PUT_LEXICON, values)
243
+
244
+
245
+ async def current(pool: Pool, scope: Scope, agent: str, picked: Picked | None = None) -> Current:
246
+ """What a call in the scope is built on: each knob from the nearest scope that sets it."""
247
+ wanted = picked or Picked()
248
+ params = {
249
+ **_where(scope, agent),
250
+ "bucket": None if wanted.call is None else bucket_of(wanted.call),
251
+ "version": wanted.version,
252
+ }
253
+ async with pool.connection() as connection:
254
+ rows = await (await connection.execute(STANDING, params)).fetchall()
255
+ tuned = resolve([_tuning(row) for row in rows if row["kind"] == "tuning"])
256
+ words = next((_lexicon(row) for row in rows if row["kind"] == "lexicon"), None)
257
+ return Current(
258
+ tuning=Tuning() if tuned is None else tuned.value,
259
+ lexicon=Lexicon() if words is None else words.value,
260
+ versions=Versions(
261
+ config=None if tuned is None else tuned.version,
262
+ lexicon=None if words is None else words.version,
263
+ ),
264
+ )
265
+
266
+
267
+ async def versions_noted(
268
+ pool: Pool, scope: Scope, agent: str, versions: Collection[int], between: tuple[float, float]
269
+ ) -> list[Noted]:
270
+ """The agent's versions named, and those set in [since, until), oldest first, without values."""
271
+ since, until = between
272
+ params = {**_where(scope, agent), "versions": list(versions), "since": since, "until": until}
273
+ async with pool.connection() as connection:
274
+ rows = await (await connection.execute(NOTED, params)).fetchall()
275
+ return [
276
+ Noted(row["holder"], row["version"], row["author"], row["note"], row["set_at"])
277
+ for row in rows
278
+ ]
279
+
280
+
281
+ async def every_tuning(pool: Pool, scope: Scope) -> dict[str, Tuning]:
282
+ """Every agent's tuning as it stands in the scope."""
283
+ async with pool.connection() as connection:
284
+ rows = await (await connection.execute(EVERY_AGENT, _where(scope, None))).fetchall()
285
+ chains: dict[str, list[Version[Tuning]]] = {}
286
+ for row in rows:
287
+ chains.setdefault(row["agent"], []).append(_tuning(row))
288
+ resolved = {agent: resolve(chain) for agent, chain in chains.items()}
289
+ return {agent: kept.value for agent, kept in resolved.items() if kept is not None}
290
+
291
+
292
+ async def tuning_side_by_side(pool: Pool, scope: Scope, agent: str) -> SideBySide[Tuning]:
293
+ """The agent's tuning as yours, the team's and production's, none falling through."""
294
+ return SideBySide(
295
+ yours=await _newest_tuning(pool, scope, agent) if scope.holder else None,
296
+ team=await _newest_tuning(pool, replace(scope, holder=THE_ORGS_OWN), agent),
297
+ production=await _newest_tuning(pool, Scope(scope.org, PRODUCTION), agent),
298
+ )
299
+
300
+
301
+ async def lexicon_side_by_side(pool: Pool, scope: Scope, agent: str) -> SideBySide[Lexicon]:
302
+ """The agent's lexicon as yours, the team's and production's, none falling through."""
303
+ return SideBySide(
304
+ yours=await _newest_lexicon(pool, scope, agent) if scope.holder else None,
305
+ team=await _newest_lexicon(pool, replace(scope, holder=THE_ORGS_OWN), agent),
306
+ production=await _newest_lexicon(pool, Scope(scope.org, PRODUCTION), agent),
307
+ )
308
+
309
+
310
+ # A knob is set when it is not None, so a falsy value is set and does not fall through:
311
+ # `bases: []` is "no bases", not "the team's". The version is the nearest row that set any.
312
+ def resolve(chain: Sequence[Version[Tuning]]) -> Version[Tuning] | None:
313
+ """Merge a nearest-first chain knob by knob; None when no row sets any knob."""
314
+ supplying = [(row, stored) for row in chain if (stored := _stored(row.value))]
315
+ if not supplying:
316
+ return None
317
+ merged: JsonObject = {}
318
+ for _row, stored in reversed(supplying):
319
+ merged.update(stored)
320
+ nearest, _ = supplying[0]
321
+ return replace(nearest, value=TUNING.validate_python(merged))
322
+
323
+
324
+ async def _newest_tuning(pool: Pool, scope: Scope, agent: str) -> Version[Tuning] | None:
325
+ rows = await _history(pool, "agent_config", scope, agent, 1)
326
+ return _tuning(rows[0]) if rows else None
327
+
328
+
329
+ async def _newest_lexicon(pool: Pool, scope: Scope, agent: str) -> Version[Lexicon] | None:
330
+ rows = await _history(pool, "lexicon", scope, agent, 1)
331
+ return _lexicon(rows[0]) if rows else None
332
+
333
+
334
+ async def _history(
335
+ pool: Pool, table: VersionedTable, scope: Scope, agent: str, limit: int
336
+ ) -> list[DictRow]:
337
+ query = HISTORY.format(**_parts(table))
338
+ async with pool.connection() as connection:
339
+ values = {**_where(scope, agent), "limit": limit}
340
+ return await (await connection.execute(query, values)).fetchall()
341
+
342
+
343
+ async def _at(
344
+ pool: Pool, table: VersionedTable, where: dict[str, object], version: int
345
+ ) -> DictRow | None:
346
+ query = AT.format(**_parts(table))
347
+ async with pool.connection() as connection:
348
+ values = {**where, "version": version}
349
+ return await (await connection.execute(query, values)).fetchone()
350
+
351
+
352
+ async def _put(
353
+ pool: Pool, table: VersionedTable, statement: LiteralString, values: dict[str, object]
354
+ ) -> int:
355
+ # independent: a write the version refused, then the newest version read to say so
356
+ async with pool.connection() as connection:
357
+ row = await (await connection.execute(statement, values)).fetchone()
358
+ if row is not None:
359
+ return int(row["version"])
360
+ raise Conflict(MOVED.format(newest=await _newest(connection, table, values)))
361
+
362
+
363
+ async def _newest(connection: Connection, table: VersionedTable, values: dict[str, object]) -> int:
364
+ row = await (await connection.execute(NEWEST.format(**_parts(table)), values)).fetchone()
365
+ return 0 if row is None else int(row["newest"])
366
+
367
+
368
+ def _parts(table: VersionedTable) -> dict[str, sql.Composable]:
369
+ return {"table": sql.Identifier(table), "value": VALUE[table]}
370
+
371
+
372
+ def _where(scope: Scope, agent: str | None) -> dict[str, object]:
373
+ return {"org": scope.org, "env": scope.env, "holder": scope.holder, "agent": agent}
374
+
375
+
376
+ def _stored(tuning: Tuning) -> JsonObject:
377
+ dumped: JsonObject = TUNING.dump_python(tuning, mode="json", exclude_none=True)
378
+ return dumped
379
+
380
+
381
+ def _tuning(row: DictRow) -> Version[Tuning]:
382
+ return _kept(row, TUNING.validate_python(row["value"]))
383
+
384
+
385
+ def _lexicon(row: DictRow) -> Version[Lexicon]:
386
+ value: dict[str, Json] = row["value"]
387
+ answer = value.get("said") or {}
388
+ heard = value.get("heard") or []
389
+ lexicon = Lexicon(
390
+ said={str(word): str(spoken) for word, spoken in dict(answer).items()}
391
+ if isinstance(answer, dict)
392
+ else {},
393
+ heard=tuple(str(word) for word in heard) if isinstance(heard, list) else (),
394
+ )
395
+ return _kept(row, lexicon)
396
+
397
+
398
+ def _kept[T](row: DictRow, value: T) -> Version[T]:
399
+ return Version(
400
+ holder=row["holder"],
401
+ version=row["version"],
402
+ author=row["author"],
403
+ note=row["note"],
404
+ set_at=row["set_at"],
405
+ value=value,
406
+ )