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,405 @@
1
+ """What an org may dial, when and how often: shape, strangers, the callee's hours, the pace."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Annotated
6
+ from zoneinfo import ZoneInfo
7
+
8
+ import phonenumbers
9
+ from phonenumbers import timezone
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from pinecall.domain.country_codes import CALLING_CODES, NEVER_DIALLED, SHORTEST_NATIONAL
13
+ from pinecall.domain.errors import DeclarationRefused, NotAllowed, QuotaExhausted
14
+ from pinecall.domain.names import parse_e164
15
+ from pinecall.domain.scope import Scope
16
+ from pinecall.log.inbox import ever_reached
17
+ from pinecall.postgres.pool import Pool
18
+ from pinecall.tenancy import consents, policy
19
+ from pinecall.tenancy.consents import Given
20
+ from pinecall.wire.rest.numbers import Consent
21
+
22
+ NOT_A_COUNTRY = "{number} starts with no country calling code E.164 assigns"
23
+
24
+
25
+ A_BILL = "{number} is +{code}, a satellite or global-service range this box never dials"
26
+
27
+
28
+ TOO_SHORT = "{number} is shorter than a number anybody calls from: {digits} digits after +{code}"
29
+
30
+
31
+ # A call-back box, not a call centre: an operator raises them per org.
32
+ DIALS_A_MINUTE = 6
33
+
34
+
35
+ DIALS_A_DAY = 200
36
+
37
+
38
+ LONGEST_CALL_S = 600
39
+
40
+
41
+ # The guard that refused, written in the ledger and said in the sentence.
42
+ SHAPE = "shape"
43
+
44
+
45
+ STRANGER = "stranger"
46
+
47
+
48
+ TOO_FAST = "too_fast"
49
+
50
+
51
+ TOO_MANY = "too_many"
52
+
53
+
54
+ QUIET_HOURS = "quiet_hours"
55
+
56
+
57
+ TOO_OFTEN = "too_often"
58
+
59
+
60
+ DO_NOT_CALL = "do_not_call"
61
+
62
+
63
+ NO_CONSENT = "no_consent"
64
+
65
+
66
+ # The Telemarketing Sales Rule (16 CFR 310.4(c)): not before 8 a.m. or after 9 p.m. at the called
67
+ # person's location. It binds a call to the US; Canada's rules sit inside the same hours.
68
+ NORTH_AMERICA = "+1"
69
+
70
+
71
+ US_HOURS = (8, 21)
72
+
73
+
74
+ # Florida's "mini-TCPA" caps three calls in 24 hours to one person on one subject: the default for
75
+ # a +1 number, which the org may change. Elsewhere nothing, unless the org sets one.
76
+ US_PER_NUMBER_DAY = 3
77
+
78
+
79
+ NO_ZONE = "Etc/Unknown"
80
+
81
+
82
+ # How many of a number's zones a refusal names before it says "…".
83
+ ZONES_SAID = 3
84
+
85
+
86
+ OUT_OF_HOURS = (
87
+ "{number} is outside the hours it may be rung ({window}) in {zones} ({guard}); "
88
+ "the sandbox and your own phone are not held to them"
89
+ )
90
+
91
+
92
+ UNPLACED = "{number} has no zone this box can place it in, so its hours cannot be kept ({guard})"
93
+
94
+
95
+ A_NUMBERS_DAY = "{number} was rung {used} times in the last day, of {limit} ({guard})"
96
+
97
+
98
+ ON_THE_LIST = (
99
+ "{number} asked not to be called: it is on this org's do-not-call list ({guard}); only a "
100
+ "consent recorded at POST /v1/org/consents lifts it, never one sent with a dial"
101
+ )
102
+
103
+
104
+ NO_CONSENT_ON_FILE = (
105
+ "{number} has no consent on file ({guard}): an AI-voice call needs the person's prior express "
106
+ "consent. Send it as `consent` with the dial, or record it at POST /v1/org/consents"
107
+ )
108
+
109
+
110
+ NOT_ONE_OF_OURS = (
111
+ "{number} never called or wrote to this org in the {env}: a call back goes back to somebody "
112
+ "({guard}); an operator lifts it with dial_anywhere"
113
+ )
114
+
115
+
116
+ A_BURST = "the org dialled {used} numbers in the last minute of its {limit} ({guard})"
117
+
118
+
119
+ A_DAYS_WORTH = "the org dialled {used} numbers today of its {limit} ({guard})"
120
+
121
+
122
+ POLICY = (
123
+ "SELECT dial_anywhere, per_minute, per_day, max_duration_s FROM dial_policy WHERE org = %(org)s"
124
+ )
125
+
126
+
127
+ PUT_POLICY = """
128
+ INSERT INTO dial_policy (org, dial_anywhere, per_minute, per_day, max_duration_s)
129
+ VALUES (%(org)s, %(dial_anywhere)s, %(per_minute)s, %(per_day)s, %(max_duration_s)s)
130
+ ON CONFLICT (org) DO UPDATE SET dial_anywhere = excluded.dial_anywhere,
131
+ per_minute = excluded.per_minute, per_day = excluded.per_day,
132
+ max_duration_s = excluded.max_duration_s, set_at = now()
133
+ """
134
+
135
+
136
+ # Two dials of one org cannot both take the last slot: the lock holds until the row is written.
137
+ LOCKED = "SELECT pg_advisory_xact_lock(hashtext('dials:' || %(org)s))"
138
+
139
+
140
+ # A number's own count is of the calls placed to it, not the refusals: a refused dial rang nobody.
141
+ PACED = """
142
+ WITH counted AS (
143
+ SELECT count(*) FILTER (WHERE at > now() - interval '1 minute') AS minute,
144
+ count(*) AS day,
145
+ count(*) FILTER (WHERE dialled = %(dialled)s AND refused IS NULL) AS number_day
146
+ FROM dials WHERE org = %(org)s AND at > now() - interval '1 day'
147
+ ), judged AS (
148
+ SELECT minute, day, number_day,
149
+ CASE WHEN minute >= %(per_minute)s THEN 'too_fast'
150
+ WHEN day >= %(per_day)s THEN 'too_many'
151
+ WHEN %(per_number_day)s::int IS NOT NULL AND number_day >= %(per_number_day)s::int
152
+ THEN 'too_often' END AS refused
153
+ FROM counted
154
+ )
155
+ INSERT INTO dials (org, env, agent, call, dialled, shown, asked_by, refused)
156
+ SELECT %(org)s, %(env)s, %(agent)s, CASE WHEN judged.refused IS NULL THEN %(call)s END,
157
+ %(dialled)s, %(shown)s, %(asked_by)s, judged.refused
158
+ FROM judged
159
+ RETURNING refused, (SELECT minute FROM judged) AS minute, (SELECT day FROM judged) AS day,
160
+ (SELECT number_day FROM judged) AS number_day
161
+ """
162
+
163
+
164
+ REFUSED = """
165
+ INSERT INTO dials (org, env, agent, dialled, shown, asked_by, refused)
166
+ VALUES (%(org)s, %(env)s, %(agent)s, %(dialled)s, %(shown)s, %(asked_by)s, %(refused)s)
167
+ """
168
+
169
+
170
+ # The first leg was judged when it was placed: its ledger row names the call.
171
+ PLACED = """
172
+ SELECT 1 FROM dials WHERE org = %(org)s AND call = %(call)s AND dialled = %(dialled)s
173
+ AND refused IS NULL
174
+ """
175
+
176
+
177
+ class Guards(BaseModel):
178
+ """The org's dial guards: what it may dial and how often."""
179
+
180
+ model_config = ConfigDict(frozen=True)
181
+
182
+ dial_anywhere: bool = False
183
+ per_minute: Annotated[int, Field(ge=0)] = DIALS_A_MINUTE
184
+ per_day: Annotated[int, Field(ge=0)] = DIALS_A_DAY
185
+ max_duration_s: Annotated[int, Field(ge=0)] = LONGEST_CALL_S
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Dial:
190
+ """One dial asked for: whose, to whom, shown as what, by whom, as which call, and when."""
191
+
192
+ scope: Scope
193
+ agent: str
194
+ to: str
195
+ shown: str | None
196
+ asked_by: str
197
+ call: str
198
+ at: datetime
199
+ # The number is a phone the person who asked verified as their own: testing, not calling out.
200
+ own_phone: bool = False
201
+ # The consent the call runs on, as the dial carried it; written down before anything rings.
202
+ consent: Consent | None = None
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class Rules:
207
+ """What the called number's destination and the org's policy hold a dial to."""
208
+
209
+ hours: tuple[int, int] | None
210
+ per_number_day: int | None
211
+ # Held to the do-not-call list at all: not in the sandbox, not the asker's own phone.
212
+ listed: bool = False
213
+ consent_required: bool = False
214
+
215
+
216
+ def destination_of(number: str) -> str:
217
+ """The number a dial may reach, or DeclarationRefused saying why not."""
218
+ e164 = parse_e164(number)
219
+ digits = e164.removeprefix("+")
220
+ code = next((digits[:length] for length in (3, 2, 1) if digits[:length] in CALLING_CODES), None)
221
+ if code is None:
222
+ raise DeclarationRefused(NOT_A_COUNTRY.format(number=e164))
223
+ if code in NEVER_DIALLED:
224
+ raise DeclarationRefused(A_BILL.format(number=e164, code=code))
225
+ national = digits[len(code) :]
226
+ if len(national) < SHORTEST_NATIONAL:
227
+ raise DeclarationRefused(TOO_SHORT.format(number=e164, digits=len(national), code=code))
228
+ return e164
229
+
230
+
231
+ async def guards_of(pool: Pool, org: str) -> Guards:
232
+ """The org's guards; a column nobody set is the default."""
233
+ async with pool.connection() as connection:
234
+ row = await (await connection.execute(POLICY, {"org": org})).fetchone()
235
+ if row is None:
236
+ return Guards()
237
+ return Guards.model_validate({name: value for name, value in row.items() if value is not None})
238
+
239
+
240
+ async def put_guards(pool: Pool, org: str, guards: Guards) -> None:
241
+ """Replace the org's guards whole."""
242
+ async with pool.connection() as connection:
243
+ await connection.execute(PUT_POLICY, {"org": org, **guards.model_dump()})
244
+
245
+
246
+ async def guard_dial(pool: Pool, dial: Dial) -> Guards:
247
+ """Every guard on a call placed cold: shape, a stranger, hours, the pace; one ledger row."""
248
+ destination = await _shaped(pool, dial)
249
+ guards = await guards_of(pool, dial.scope.org)
250
+ reached = guards.dial_anywhere or await ever_reached(
251
+ pool, dial.scope.org, dial.scope.env, destination
252
+ )
253
+ if not reached:
254
+ await _refused(pool, dial, STRANGER)
255
+ raise NotAllowed(
256
+ NOT_ONE_OF_OURS.format(number=destination, env=dial.scope.env, guard=STRANGER)
257
+ )
258
+ rules = await _rules_for(pool, dial, destination)
259
+ await _consented(pool, dial, destination, rules)
260
+ if rules.hours is not None:
261
+ await _in_hours(pool, dial, destination, rules.hours)
262
+ await _paced(pool, dial, guards, rules.per_number_day)
263
+ return guards
264
+
265
+
266
+ def zones_of(number: str) -> tuple[str, ...]:
267
+ """Every time zone the number could be in; empty when none is known."""
268
+ zones = timezone.time_zones_for_number(phonenumbers.parse(number))
269
+ return tuple(zone for zone in zones if zone != NO_ZONE)
270
+
271
+
272
+ def within(zones: tuple[str, ...], at: datetime, hours: tuple[int, int]) -> bool:
273
+ """Whether it is inside the hours in every zone: a number in two zones is held to both."""
274
+ start, end = hours
275
+ return all(start <= at.astimezone(ZoneInfo(zone)).hour < end for zone in zones)
276
+
277
+
278
+ # A transfer target need not have called: the stranger fence stays off, the pace caps a loop.
279
+ async def guard_second_leg(pool: Pool, dial: Dial) -> None:
280
+ """The shape and the pace on a leg dialled into a live call; the call's own first leg passes."""
281
+ async with pool.connection() as connection:
282
+ placed = await (
283
+ await connection.execute(
284
+ PLACED, {"org": dial.scope.org, "call": dial.call, "dialled": dial.to}
285
+ )
286
+ ).fetchone()
287
+ if placed is not None:
288
+ return
289
+ await _shaped(pool, dial)
290
+ await _paced(pool, dial, await guards_of(pool, dial.scope.org), None)
291
+
292
+
293
+ async def _shaped(pool: Pool, dial: Dial) -> str:
294
+ try:
295
+ return destination_of(dial.to)
296
+ except DeclarationRefused as malformed:
297
+ await _refused(pool, dial, SHAPE)
298
+ raise DeclarationRefused(f"{malformed} ({SHAPE})") from None
299
+
300
+
301
+ async def _refused(pool: Pool, dial: Dial, guard: str) -> None:
302
+ async with pool.connection() as connection:
303
+ await connection.execute(REFUSED, {**_ledger(dial), "refused": guard})
304
+
305
+
306
+ # The rules bind a call out to a person. The sandbox is where an org tries its agent, and a
307
+ # person's own verified phone is them testing: neither is held to a caller's hours or count.
308
+ async def _rules_for(pool: Pool, dial: Dial, destination: str) -> Rules:
309
+ """The list, consent, hours and daily count a dial is held to, by destination and policy."""
310
+ if dial.scope.env == "sandbox" or dial.own_phone:
311
+ return Rules(hours=None, per_number_day=None)
312
+ kept = (await policy.policy_of(pool, dial.scope.org)).policy
313
+ window = (
314
+ None if kept.calling_hours is None else (kept.calling_hours.from_, kept.calling_hours.until)
315
+ )
316
+ if destination.startswith(NORTH_AMERICA):
317
+ floor, top = US_HOURS
318
+ start, end = window if window is not None else US_HOURS
319
+ return Rules(
320
+ hours=(max(floor, start), min(top, end)),
321
+ per_number_day=kept.per_number_day or US_PER_NUMBER_DAY,
322
+ listed=True,
323
+ consent_required=True,
324
+ )
325
+ return Rules(
326
+ hours=window,
327
+ per_number_day=kept.per_number_day,
328
+ listed=True,
329
+ consent_required=kept.consent_everywhere,
330
+ )
331
+
332
+
333
+ # An opt-out outranks the consent a dial carries: an app may not lift the list by sending one. A
334
+ # dial the list does not bind (the sandbox, a developer's own phone) rings past it, and writes no
335
+ # consent over it either.
336
+ async def _consented(pool: Pool, dial: Dial, destination: str, rules: Rules) -> None:
337
+ current = await consents.standing_of(pool, dial.scope, destination)
338
+ if current == "opted_out" and not rules.listed:
339
+ return
340
+ if current == "opted_out":
341
+ await _refused(pool, dial, DO_NOT_CALL)
342
+ raise NotAllowed(ON_THE_LIST.format(number=destination, guard=DO_NOT_CALL))
343
+ if dial.consent is not None:
344
+ given = Given(
345
+ kind=dial.consent.kind,
346
+ source=dial.consent.source,
347
+ given_by=dial.asked_by,
348
+ text=dial.consent.text,
349
+ evidence=dial.consent.evidence,
350
+ call=dial.call,
351
+ )
352
+ await consents.give(pool, dial.scope, destination, given)
353
+ return
354
+ if rules.consent_required and current != "consented":
355
+ await _refused(pool, dial, NO_CONSENT)
356
+ raise NotAllowed(NO_CONSENT_ON_FILE.format(number=destination, guard=NO_CONSENT))
357
+
358
+
359
+ async def _in_hours(pool: Pool, dial: Dial, destination: str, hours: tuple[int, int]) -> None:
360
+ zones = zones_of(destination)
361
+ if not zones:
362
+ await _refused(pool, dial, QUIET_HOURS)
363
+ raise NotAllowed(UNPLACED.format(number=destination, guard=QUIET_HOURS))
364
+ if not within(zones, dial.at, hours):
365
+ await _refused(pool, dial, QUIET_HOURS)
366
+ window = f"{hours[0]}:00 to {hours[1]}:00"
367
+ named = ", ".join(zones[:ZONES_SAID]) + ("…" if len(zones) > ZONES_SAID else "")
368
+ raise NotAllowed(
369
+ OUT_OF_HOURS.format(number=destination, window=window, zones=named, guard=QUIET_HOURS)
370
+ )
371
+
372
+
373
+ async def _paced(pool: Pool, dial: Dial, guards: Guards, per_number_day: int | None) -> None:
374
+ params = {
375
+ **_ledger(dial),
376
+ "call": dial.call,
377
+ "per_minute": guards.per_minute,
378
+ "per_day": guards.per_day,
379
+ "per_number_day": per_number_day,
380
+ }
381
+ async with pool.connection() as connection, connection.transaction():
382
+ await connection.execute(LOCKED, {"org": dial.scope.org})
383
+ row = await (await connection.execute(PACED, params)).fetchone()
384
+ if row is None or row["refused"] is None:
385
+ return
386
+ if row["refused"] == TOO_FAST:
387
+ sentence = A_BURST.format(used=row["minute"], limit=guards.per_minute, guard=TOO_FAST)
388
+ elif row["refused"] == TOO_OFTEN:
389
+ sentence = A_NUMBERS_DAY.format(
390
+ number=dial.to, used=row["number_day"], limit=per_number_day, guard=TOO_OFTEN
391
+ )
392
+ else:
393
+ sentence = A_DAYS_WORTH.format(used=row["day"], limit=guards.per_day, guard=TOO_MANY)
394
+ raise QuotaExhausted(sentence)
395
+
396
+
397
+ def _ledger(dial: Dial) -> dict[str, str | None]:
398
+ return {
399
+ "org": dial.scope.org,
400
+ "env": dial.scope.env,
401
+ "agent": dial.agent,
402
+ "dialled": dial.to.strip(),
403
+ "shown": dial.shown,
404
+ "asked_by": dial.asked_by,
405
+ }
@@ -0,0 +1,41 @@
1
+ """What a spoken call says before its greeting: the AI disclosure and the recording notice."""
2
+
3
+ from pinecall.wire.rest.accounts import OrgPolicy
4
+
5
+ # The platform's sentences, by the first part of the agent's language; English for any other.
6
+ CALLING_FOR = {
7
+ "en": "This is an automated assistant calling on behalf of {org}.",
8
+ "es": "Le habla un asistente automático en nombre de {org}.",
9
+ "pt": "Aqui fala um assistente automático em nome de {org}.",
10
+ "fr": "Ici un assistant automatique qui appelle au nom de {org}.",
11
+ "de": "Hier spricht ein automatischer Assistent im Auftrag von {org}.",
12
+ "it": "Sono un assistente automatico che chiama per conto di {org}.",
13
+ }
14
+
15
+ MAY_BE_RECORDED = {
16
+ "en": "This call may be recorded.",
17
+ "es": "Esta llamada puede ser grabada.",
18
+ "pt": "Esta chamada pode ser gravada.",
19
+ "fr": "Cet appel peut être enregistré.",
20
+ "de": "Dieser Anruf kann aufgezeichnet werden.",
21
+ "it": "Questa chiamata può essere registrata.",
22
+ }
23
+
24
+ FALLBACK = "en"
25
+
26
+
27
+ def disclosure_of(policy: OrgPolicy, org: str, language: str | None) -> str | None:
28
+ """An outbound call's first sentence: the org's own, else the platform's, or None."""
29
+ if policy.disclosure is not None:
30
+ return policy.disclosure.strip() or None
31
+ return CALLING_FOR[_spoken(language)].format(org=org)
32
+
33
+
34
+ def notice_of(policy: OrgPolicy, language: str | None) -> str | None:
35
+ """The sentence a recorded call says, or None when the org turned the notice off."""
36
+ return MAY_BE_RECORDED[_spoken(language)] if policy.recording_notice else None
37
+
38
+
39
+ def _spoken(language: str | None) -> str:
40
+ first = (language or FALLBACK).replace("_", "-").split("-")[0].lower()
41
+ return first if first in CALLING_FOR else FALLBACK
@@ -0,0 +1,204 @@
1
+ """An erasure: a call's, a contact's or an org's rows and recordings gone, and its trail row."""
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ from psycopg import AsyncConnection
6
+ from psycopg.rows import DictRow
7
+
8
+ from pinecall.domain.errors import Conflict, StoreUnreachable
9
+ from pinecall.domain.names import Env
10
+ from pinecall.domain.scope import Scope
11
+ from pinecall.postgres.pool import Pool, unbounded
12
+ from pinecall.process.recordings import Recordings
13
+ from pinecall.wire.rest.calls import Erasure, ErasureSubject
14
+
15
+ # The trigger on call_log lets a DELETE through in this transaction alone (0011_erasure.sql). An
16
+ # erasure is unbounded: an org's logs are long to delete, and its recordings go inside the
17
+ # transaction.
18
+ ERASING = "SET LOCAL pinecall.erasing = 'on'"
19
+
20
+ CALLS_OF_CONTACT = """
21
+ SELECT head.call, head.sealed
22
+ FROM call_log_head head JOIN call_facts facts ON facts.call = head.call
23
+ WHERE head.org = %(org)s AND head.env = %(env)s AND facts.contact = %(contact)s
24
+ """
25
+
26
+ # Every log of the org: its calls, and its agents' own logs ("@<agent>"), which carry no call.
27
+ LOGS_OF_ORG = "SELECT log, call, agent FROM call_log_head WHERE org = %(org)s"
28
+
29
+ # What a call left besides its entries: the private values sealed beside them, its head, facts,
30
+ # tokens, the memories it taught, its recording's key, the verdicts its day's drift counted (the
31
+ # day's sums keep its numbers, which name nobody), the case of the org's dataset made of it
32
+ # (evals/dataset.py), the labels people gave its judges (evals/calibration.py) and what it was
33
+ # when it opened (log/openings.py). A phone call's numbers, times and end stay in call_records,
34
+ # and the dials ledger stays: they name numbers and times, never what was said, and a traceback
35
+ # asks for them (0016_call_records.sql). Every statement of the WITH reads the rows as they were
36
+ # before it, so the record reads the facts.
37
+ ERASE_LOGS = """
38
+ WITH recorded AS (
39
+ INSERT INTO call_records (call, org, env, direction, from_number, to_number, started_at,
40
+ ended_at, end_reason)
41
+ SELECT facts.call, head.org, head.env, facts.direction, facts.from_number, facts.to_number,
42
+ head.started_at, facts.ended_at, facts.end_reason
43
+ FROM call_facts facts JOIN call_log_head head ON head.call = facts.call
44
+ WHERE facts.call = ANY(%(calls)s) AND facts.channel = 'phone'
45
+ ON CONFLICT (call) DO NOTHING
46
+ ),
47
+ private AS (DELETE FROM call_private WHERE log = ANY(%(logs)s)),
48
+ keys AS (DELETE FROM recording_keys WHERE call = ANY(%(calls)s)),
49
+ heads AS (DELETE FROM call_log_head WHERE log = ANY(%(logs)s) RETURNING 1),
50
+ facts AS (DELETE FROM call_facts WHERE call = ANY(%(calls)s) RETURNING 1),
51
+ spent AS (DELETE FROM tokens WHERE call = ANY(%(calls)s) RETURNING 1),
52
+ taught AS (DELETE FROM contact_memories WHERE source_call = ANY(%(calls)s) RETURNING 1),
53
+ drifted AS (DELETE FROM drift_calls WHERE call = ANY(%(calls)s)),
54
+ promoted AS (DELETE FROM eval_cases WHERE source_call = ANY(%(calls)s)),
55
+ labels AS (DELETE FROM judge_labels WHERE call = ANY(%(calls)s)),
56
+ openings AS (DELETE FROM call_openings WHERE call = ANY(%(calls)s))
57
+ SELECT (SELECT count(*) FROM taught) AS memories
58
+ """
59
+
60
+ # Before ERASE_LOGS takes the heads: call_log's trigger reads each summary's org off its head, to
61
+ # take its usage out of the org's totals (0023_usage_totals.sql).
62
+ ERASE_ENTRIES = "DELETE FROM call_log WHERE log = ANY(%(logs)s)"
63
+
64
+ # A contact's memories no erased call taught (written by hand, or by a call erased before), and
65
+ # what each reader had read of their thread.
66
+ ERASE_CONTACT = """
67
+ WITH kept AS (
68
+ DELETE FROM contact_memories WHERE org = %(org)s AND env = %(env)s AND contact = %(contact)s
69
+ RETURNING 1
70
+ ), reads AS (
71
+ DELETE FROM thread_reads WHERE org = %(org)s AND env = %(env)s AND contact = %(contact)s
72
+ )
73
+ SELECT count(*) AS memories FROM kept
74
+ """
75
+
76
+ # What has no foreign key to the org: its eval runs (by the org they carry, or by its agents for a
77
+ # run written before runs carried one) and hold melodies, what each reader read of a thread, and
78
+ # its keys, revoked ones included. Then the org, and the quotas, carriers, settings and memories
79
+ # cascade.
80
+ ERASE_ORG = """
81
+ WITH runs AS (
82
+ DELETE FROM eval_runs
83
+ WHERE org = %(org)s OR (org IS NULL AND agent = ANY(%(agents)s))
84
+ ),
85
+ melodies AS (DELETE FROM hold_audio WHERE org = %(org)s),
86
+ reads AS (DELETE FROM thread_reads WHERE org = %(org)s),
87
+ keys AS (DELETE FROM api_keys WHERE org = %(org)s)
88
+ DELETE FROM orgs WHERE id = %(org)s
89
+ """
90
+
91
+ TRAIL = """
92
+ INSERT INTO erasures (org, env, what, subject, asked_by, calls, entries, memories, recordings)
93
+ VALUES (%(org)s, %(env)s, %(what)s, %(subject)s, %(asked_by)s, %(calls)s, %(entries)s,
94
+ %(memories)s, %(recordings)s)
95
+ RETURNING id, env, what, subject, asked_by, calls, entries, memories, recordings, at
96
+ """
97
+
98
+ TRAIL_OF_ORG = """
99
+ SELECT id, env, what, subject, asked_by, calls, entries, memories, recordings, at
100
+ FROM erasures WHERE org = %(org)s ORDER BY at DESC, id DESC LIMIT %(limit)s
101
+ """
102
+
103
+ NO_TRAIL = "the erasure of {subject} was not written down, so it was undone"
104
+
105
+ STILL_ON_A_CALL = "{contact} is on call {call} right now: erase them once it has ended"
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Erased:
110
+ """An erasure done: its trail row, and the calls it took, for a process that holds them."""
111
+
112
+ trail: Erasure
113
+ calls: tuple[str, ...]
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class _Taking:
118
+ """An erasure on its way: whose, what, who asked, and the rows gone so far."""
119
+
120
+ org: str
121
+ env: Env | None
122
+ what: ErasureSubject
123
+ subject: str
124
+ asked_by: str
125
+ calls: list[str]
126
+ entries: int
127
+ memories: int
128
+
129
+
130
+ async def call(
131
+ pool: Pool, recordings: Recordings, scope: Scope, call_id: str, *, by: str
132
+ ) -> Erased:
133
+ """Erase one call: its log, facts, tokens, the memories it taught, and its recording."""
134
+ async with unbounded(pool) as connection:
135
+ await connection.execute(ERASING)
136
+ entries, memories = await _logs(connection, [call_id], [call_id])
137
+ taking = _Taking(scope.org, scope.env, "call", call_id, by, [call_id], entries, memories)
138
+ return await _written(connection, recordings, taking)
139
+
140
+
141
+ async def contact(
142
+ pool: Pool, recordings: Recordings, scope: Scope, contact_id: str, *, by: str
143
+ ) -> Erased:
144
+ """Erase a contact in the world: every call they were on and every fact kept of them."""
145
+ params = {"org": scope.org, "env": scope.env, "contact": contact_id}
146
+ async with unbounded(pool) as connection:
147
+ await connection.execute(ERASING)
148
+ rows = await (await connection.execute(CALLS_OF_CONTACT, params)).fetchall()
149
+ live = next((str(row["call"]) for row in rows if not row["sealed"]), None)
150
+ if live is not None:
151
+ raise Conflict(STILL_ON_A_CALL.format(contact=contact_id, call=live))
152
+ calls = [str(row["call"]) for row in rows]
153
+ entries, taught = await _logs(connection, calls, calls)
154
+ kept = await (await connection.execute(ERASE_CONTACT, params)).fetchone()
155
+ memories = taught + (0 if kept is None else int(kept["memories"]))
156
+ taking = _Taking(scope.org, scope.env, "contact", contact_id, by, calls, entries, memories)
157
+ return await _written(connection, recordings, taking)
158
+
159
+
160
+ async def org(pool: Pool, recordings: Recordings, org_id: str, *, by: str) -> Erased:
161
+ """Erase an org whole: every log it owns, every recording, then the org and what cascades."""
162
+ async with unbounded(pool) as connection:
163
+ await connection.execute(ERASING)
164
+ rows = await (await connection.execute(LOGS_OF_ORG, {"org": org_id})).fetchall()
165
+ logs = [str(row["log"]) for row in rows]
166
+ calls = [str(row["call"]) for row in rows if row["call"] is not None]
167
+ agents = sorted({str(row["agent"]) for row in rows if row["agent"] is not None})
168
+ entries, memories = await _logs(connection, logs, calls)
169
+ await connection.execute(ERASE_ORG, {"org": org_id, "agents": agents})
170
+ taking = _Taking(org_id, None, "org", org_id, by, calls, entries, memories)
171
+ return await _written(connection, recordings, taking)
172
+
173
+
174
+ async def trail(pool: Pool, org_id: str, *, limit: int = 100) -> list[Erasure]:
175
+ """The org's erasures, newest first."""
176
+ params = {"org": org_id, "limit": limit}
177
+ async with pool.connection() as connection:
178
+ rows = await (await connection.execute(TRAIL_OF_ORG, params)).fetchall()
179
+ return [_trail_row(row) for row in rows]
180
+
181
+
182
+ async def _logs(
183
+ connection: AsyncConnection[DictRow], logs: list[str], calls: list[str]
184
+ ) -> tuple[int, int]:
185
+ entries = (await connection.execute(ERASE_ENTRIES, {"logs": logs})).rowcount
186
+ row = await (await connection.execute(ERASE_LOGS, {"logs": logs, "calls": calls})).fetchone()
187
+ return entries, 0 if row is None else int(row["memories"])
188
+
189
+
190
+ # The recordings go inside the transaction, before its trail is written: a disk or a bucket
191
+ # that refuses leaves the rows in place and the erasure failed, not a trail that lies.
192
+ async def _written(
193
+ connection: AsyncConnection[DictRow], recordings: Recordings, taking: _Taking
194
+ ) -> Erased:
195
+ removed = await recordings.erase(taking.org, taking.calls)
196
+ params = {**asdict(taking), "calls": len(taking.calls), "recordings": removed}
197
+ row = await (await connection.execute(TRAIL, params)).fetchone()
198
+ if row is None:
199
+ raise StoreUnreachable(NO_TRAIL.format(subject=taking.subject))
200
+ return Erased(trail=_trail_row(row), calls=tuple(taking.calls))
201
+
202
+
203
+ def _trail_row(row: DictRow) -> Erasure:
204
+ return Erasure.model_validate({**row, "at": row["at"].timestamp()})