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,446 @@
1
+ """What an agent declares (prompt, tools, voice, turns) and what the org sets on top of it."""
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from hashlib import sha256
8
+ from typing import Literal
9
+
10
+ from pinecall.domain.errors import DeclarationRefused
11
+ from pinecall.domain.names import Json, parse_slug
12
+
13
+ # public: the caller's browser. tenant: the console (default). pii: the console, masked.
14
+ type Visibility = Literal["public", "tenant", "pii"]
15
+
16
+
17
+ # app: the tenant's backend. participant: the caller's browser.
18
+ type EventSource = Literal["app", "participant"]
19
+
20
+
21
+ # irreversible tools (payments, cancellations) require the caller's confirmation, asked by the
22
+ # platform rather than the model.
23
+ type SideEffect = Literal["read", "write", "irreversible"]
24
+
25
+
26
+ # static: before the history, provider-cached. dynamic: after it, rewritten every turn.
27
+ type PromptRegion = Literal["static", "dynamic"]
28
+
29
+
30
+ # retrieved: top chunks are injected every turn. tool: the model gets a search(query) tool.
31
+ type DocsMode = Literal["retrieved", "tool"]
32
+
33
+
34
+ # every-call: at every hang-up the org judges. simulations: only a call a persona played.
35
+ type RunsOn = Literal["every-call", "simulations"]
36
+
37
+
38
+ # The names the platform gives its verdicts, at hang-up and on a golden; a judge of the org's or an
39
+ # agent's takes another, or a call would carry two verdicts under one name.
40
+ PANEL_JUDGES: tuple[str, ...] = (
41
+ "consent",
42
+ "grounded",
43
+ "promises",
44
+ "persona",
45
+ "identified",
46
+ "disclosed",
47
+ "honoured_stop",
48
+ "heard",
49
+ )
50
+
51
+
52
+ # A name every model vendor accepts as a function name.
53
+ _A_NAME_A_MODEL_CAN_CALL = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
54
+
55
+
56
+ # Written by the platform, never the app, so a knowledge file carries the operator's authority.
57
+ KNOWLEDGE = "knowledge"
58
+
59
+
60
+ DEFAULT_CHUNKS_PER_TURN = 8
61
+
62
+
63
+ # The default ends abandoned lines and machine-to-machine loops; 0 is no limit, else 1 to 60 min.
64
+ LONGEST_VOICE_CALL_S = 600
65
+
66
+
67
+ SHORTEST_LIMIT_S = 60
68
+
69
+
70
+ LONGEST_LIMIT_S = 3600
71
+
72
+
73
+ NO_LIMIT = 0
74
+
75
+ # How long a tool may run before the model hears it timed out.
76
+ LONGEST_TOOL_S = 300
77
+
78
+
79
+ # Unset knobs are None, never "": an empty string reaches the vendor as a value (an empty voice
80
+ # once made every call silent).
81
+ BLANK = (
82
+ "a blank {field} is not a change: an empty value once silenced a whole line of calls. "
83
+ "Leave the field out to go back to what the app declared."
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Voice:
89
+ """The agent's TTS voice."""
90
+
91
+ provider: str
92
+ model: str | None = None
93
+ voice_id: str | None = None
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Model:
98
+ """An LLM or STT model and its temperature."""
99
+
100
+ provider: str
101
+ model: str
102
+ temperature: float | None = None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Turn:
107
+ """Turn detection and interruption settings."""
108
+
109
+ min_interruption_words: int | None = None
110
+ endpointing_ms: int | None = None
111
+ # Deepgram reports its default (0.7) ends up to a fifth of turns early.
112
+ eot_threshold: float | None = None
113
+ # The lower bar that starts LiveKit's speculative generation, offsetting a higher eot.
114
+ eager_eot_threshold: float | None = None
115
+ # How long the caller must speak over the agent before it stops; None is livekit's 0.5 s.
116
+ min_interruption_ms: int | None = None
117
+
118
+ # Deepgram rejects the connection when eager > eot, leaving the call without STT.
119
+ def __post_init__(self) -> None:
120
+ counts = {
121
+ "min_interruption_ms": self.min_interruption_ms,
122
+ "endpointing_ms": self.endpointing_ms,
123
+ "min_interruption_words": self.min_interruption_words,
124
+ }
125
+ for name, value in counts.items():
126
+ if value is not None and value < 0:
127
+ raise DeclarationRefused(f"{name} is a count, 0 or more")
128
+ if self.eager_eot_threshold is None or self.eot_threshold is None:
129
+ return
130
+ if self.eager_eot_threshold > self.eot_threshold:
131
+ raise DeclarationRefused(
132
+ f"eager_eot_threshold {self.eager_eot_threshold} is the bar for GUESSING the turn "
133
+ f"is over, so it cannot sit above eot_threshold {self.eot_threshold}, which is the "
134
+ "bar for ending it."
135
+ )
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Greeting:
140
+ """The opening turn: fixed words (`say`) or an instruction for the model (`reply`)."""
141
+
142
+ say: str | None = None
143
+ reply: str | None = None
144
+ allow_interruptions: bool | None = None
145
+
146
+ def __post_init__(self) -> None:
147
+ if (self.say is None) == (self.reply is None):
148
+ text = "both were declared" if self.say is not None else "neither was"
149
+ raise DeclarationRefused(
150
+ "a greeting is one of two things: `say` the words, or `reply` what the model "
151
+ f"reads before it finds its own. {text} — pick one."
152
+ )
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Hangup:
157
+ """Lets the model end the call, with instructions on when."""
158
+
159
+ when: str = ""
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class ToolSpec:
164
+ """A tool as the app declares it, validated on construction."""
165
+
166
+ name: str
167
+ description: str
168
+ parameters: Mapping[str, Json]
169
+ side_effect: SideEffect = "read"
170
+ pii: frozenset[str] = frozenset()
171
+ confirm: str | None = None
172
+ preview: int | None = None
173
+ timeout_s: float = 30.0
174
+
175
+ def __post_init__(self) -> None:
176
+ if not _A_NAME_A_MODEL_CAN_CALL.match(self.name):
177
+ raise DeclarationRefused(f"a tool name is one word a model can call, not {self.name!r}")
178
+ if not self.description.strip():
179
+ raise DeclarationRefused(
180
+ f"tool {self.name}: without a description no model can choose it"
181
+ )
182
+ if self.parameters.get("type") != "object":
183
+ raise DeclarationRefused(
184
+ f"tool {self.name}: parameters are a JSON Schema of type object"
185
+ )
186
+ if self.side_effect == "irreversible" and not self.confirm:
187
+ raise DeclarationRefused(
188
+ f"tool {self.name}: an irreversible tool needs a confirm template to read back"
189
+ )
190
+ if unknown := self.pii - self.parameter_names:
191
+ raise DeclarationRefused(
192
+ f"tool {self.name}: pii names parameters the tool has; unknown: {sorted(unknown)}"
193
+ )
194
+ if self.preview is not None and self.preview < 1:
195
+ raise DeclarationRefused(f"tool {self.name}: a preview shows at least one item")
196
+ # The caller waits in silence or on the melody while a tool runs: five minutes at most.
197
+ if not 0 < self.timeout_s <= LONGEST_TOOL_S:
198
+ raise DeclarationRefused(
199
+ f"tool {self.name}: timeout_s is seconds above 0, {LONGEST_TOOL_S} at most"
200
+ )
201
+
202
+ @property
203
+ def parameter_names(self) -> frozenset[str]:
204
+ """Return the property names of the parameter schema."""
205
+ properties = self.parameters.get("properties")
206
+ return frozenset(properties) if isinstance(properties, dict) else frozenset()
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class PromptBlock:
211
+ """A named prompt block and its region."""
212
+
213
+ name: str
214
+ region: PromptRegion
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class Docs:
219
+ """A knowledge base the agent uses, and how results reach the model."""
220
+
221
+ base: str
222
+ mode: DocsMode = "retrieved"
223
+ k: int = DEFAULT_CHUNKS_PER_TURN
224
+ min_score: float | None = None
225
+
226
+ def __post_init__(self) -> None:
227
+ if not self.base:
228
+ raise DeclarationRefused("docs name the knowledge base they were pushed to")
229
+ if self.k < 1:
230
+ raise DeclarationRefused("docs hand the model at least one chunk")
231
+ if self.min_score is not None and self.min_score < 0:
232
+ raise DeclarationRefused("a fused rank score is never negative; min_score cannot be")
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class KnowledgeFile:
237
+ """One file pushed to a knowledge base."""
238
+
239
+ path: str
240
+ text: str
241
+
242
+ def __post_init__(self) -> None:
243
+ if not self.path:
244
+ raise DeclarationRefused("a knowledge file is named by its path")
245
+
246
+
247
+ # Free-text categories; anything under `forget` is never stored, whatever the model extracts.
248
+ @dataclass(frozen=True)
249
+ class MemoryPolicy:
250
+ """What memory keeps about a contact across calls, and what it never keeps."""
251
+
252
+ remember: tuple[str, ...] = ()
253
+ forget: tuple[str, ...] = ()
254
+
255
+ def __post_init__(self) -> None:
256
+ if both := set(self.remember) & set(self.forget):
257
+ raise DeclarationRefused(f"memory cannot both remember and forget {sorted(both)}")
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class Tuning:
262
+ """One agent's settings; every knob is None until set."""
263
+
264
+ voice: str | None = None
265
+ tts: str | None = None
266
+ tts_model: str | None = None
267
+ stt: str | None = None
268
+ llm: str | None = None
269
+ # A tag (`en`, `pt-BR`); None leaves each vendor its own default.
270
+ language: str | None = None
271
+ greeting: Greeting | None = None
272
+ hangup: Hangup | None = None
273
+ turn: Turn | None = None
274
+ memory: MemoryPolicy | None = None
275
+ # Tri-state so an explicit False still overrides the layer below.
276
+ record: bool | None = None
277
+ # Voice calls only; 0 is no limit, None is the runtime default.
278
+ max_duration_s: int | None = None
279
+ # How long a turn waits for the model's first token; None waits as long as livekit retries.
280
+ llm_timeout_s: float | None = None
281
+ # Markdown placed whole in the static knowledge block; editable with the `words` scope.
282
+ knowledge: str | None = None
283
+ # An empty tuple means "none" and overrides the layer below; None means unset.
284
+ bases: tuple[Docs, ...] | None = None
285
+
286
+ def __post_init__(self) -> None:
287
+ knobs = (
288
+ ("voice", self.voice),
289
+ ("tts", self.tts),
290
+ ("tts_model", self.tts_model),
291
+ ("stt", self.stt),
292
+ ("llm", self.llm),
293
+ ("language", self.language),
294
+ ("knowledge", self.knowledge),
295
+ )
296
+ for name, value in knobs:
297
+ if value is not None and not value.strip():
298
+ raise DeclarationRefused(BLANK.format(field=name))
299
+ if self.max_duration_s is not None:
300
+ _check_call_limit(self.max_duration_s)
301
+ if self.llm_timeout_s is not None and self.llm_timeout_s <= 0:
302
+ raise DeclarationRefused("llm_timeout_s is a positive number of seconds")
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class Lexicon:
307
+ """An agent's lexicon: TTS pronunciations and STT keyterms."""
308
+
309
+ said: Mapping[str, str] = field(default_factory=dict[str, str])
310
+ heard: tuple[str, ...] = ()
311
+
312
+ def __post_init__(self) -> None:
313
+ _check_pronunciations(self.said, "the lexicon")
314
+ if any(not word.strip() for word in self.heard):
315
+ raise DeclarationRefused("the lexicon: a word the ears must know is not blank")
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class Version[T]:
320
+ """A stored version of a value, with its holder, number, author and timestamp."""
321
+
322
+ holder: str
323
+ version: int
324
+ author: str
325
+ note: str | None
326
+ set_at: datetime
327
+ value: T
328
+
329
+
330
+ # Stored on the call's head row so the exact configuration can be read back later.
331
+ @dataclass(frozen=True)
332
+ class Versions:
333
+ """Tuning and lexicon versions a call was built on; None where nothing was set."""
334
+
335
+ config: int | None = None
336
+ lexicon: int | None = None
337
+
338
+
339
+ @dataclass(frozen=True)
340
+ class AgentJudge:
341
+ """A question the org wrote about one agent's job, put to the judge model at hang-up."""
342
+
343
+ name: str
344
+ question: str
345
+ runs_on: RunsOn = "every-call"
346
+
347
+
348
+ DEFAULT_LAYOUT: tuple[PromptBlock, ...] = (
349
+ PromptBlock("identity", "static"),
350
+ PromptBlock(KNOWLEDGE, "static"),
351
+ PromptBlock("tools", "static"),
352
+ PromptBlock("view", "dynamic"),
353
+ )
354
+
355
+
356
+ # Field names match the wire's AgentConfig (tests/domain/test_names.py holds them to it, from
357
+ # TWINS in tests/domain/conftest.py).
358
+ @dataclass(frozen=True)
359
+ class AgentConfig:
360
+ """An agent's resolved declaration."""
361
+
362
+ slug: str
363
+ name: str | None = None
364
+ prompt: tuple[PromptBlock, ...] = DEFAULT_LAYOUT
365
+ greeting: Greeting | None = None
366
+ language: str | None = None
367
+ voice: Voice | None = None
368
+ llm: Model | None = None
369
+ stt: Model | None = None
370
+ turn: Turn | None = None
371
+ says: Mapping[str, str] = field(default_factory=dict[str, str])
372
+ hears: tuple[str, ...] = ()
373
+ # From Tuning.knowledge; placed whole in the static knowledge block.
374
+ knowledge: str | None = None
375
+ bases: tuple[Docs, ...] = ()
376
+ # The class calls `this.knowledge.search`; registration fails when no base is attached.
377
+ uses_knowledge: bool = False
378
+ memory: MemoryPolicy | None = None
379
+ hangup: Hangup | None = None
380
+ record: bool = True
381
+ max_duration_s: int = LONGEST_VOICE_CALL_S
382
+ # None: no deadline of ours, so livekit's own retries decide how long a turn waits.
383
+ llm_timeout_s: float | None = None
384
+ tools: tuple[ToolSpec, ...] = ()
385
+ state_fields: Mapping[str, Visibility] = field(default_factory=dict[str, Visibility])
386
+ # Side-panel title only; the console fetches its contents from the app, so tenant data
387
+ # never passes through the gateway's config.
388
+ view: str | None = None
389
+ events: Mapping[str, frozenset[EventSource]] = field(
390
+ default_factory=dict[str, frozenset[EventSource]]
391
+ )
392
+
393
+ def __post_init__(self) -> None:
394
+ parse_slug(self.slug)
395
+ names = [tool.name for tool in self.tools]
396
+ if repeated := {name for name in names if names.count(name) > 1}:
397
+ raise DeclarationRefused(f"agent {self.slug}: tool names repeat: {sorted(repeated)}")
398
+ blocks = [block.name for block in self.prompt]
399
+ if repeated := {name for name in blocks if blocks.count(name) > 1}:
400
+ raise DeclarationRefused(
401
+ f"agent {self.slug}: prompt block names repeat: {sorted(repeated)}"
402
+ )
403
+ _check_pronunciations(self.says, f"agent {self.slug}")
404
+ _check_call_limit(self.max_duration_s)
405
+ for event, sources in self.events.items():
406
+ if not event or not sources:
407
+ raise DeclarationRefused(
408
+ f"agent {self.slug}: event {event!r} names who may send it: app, participant"
409
+ )
410
+
411
+ def visibility_of(self, state_field: str) -> Visibility:
412
+ """Return a state field's visibility, tenant unless declared."""
413
+ return self.state_fields.get(state_field, "tenant")
414
+
415
+ def accepts(self, event: str, source: EventSource) -> bool:
416
+ """Return whether the event may be sent by this source."""
417
+ return source in self.events.get(event, frozenset())
418
+
419
+ @property
420
+ def tools_by_name(self) -> Mapping[str, ToolSpec]:
421
+ """Return the tools keyed by name."""
422
+ return {tool.name: tool for tool in self.tools}
423
+
424
+
425
+ # The log keeps a block's hash, never its words (prompt.changed); the org's prompts keep the words
426
+ # under the same hash, so a call's prompt is read back whole.
427
+ def block_hash(text: str) -> str:
428
+ """A block of the prompt as the log names it: the sha256 of its text."""
429
+ return sha256(text.encode()).hexdigest()
430
+
431
+
432
+ def _check_call_limit(seconds: int) -> None:
433
+ """Raise DeclarationRefused unless seconds is 0 or between 60 and 3600."""
434
+ if seconds != NO_LIMIT and not SHORTEST_LIMIT_S <= seconds <= LONGEST_LIMIT_S:
435
+ raise DeclarationRefused(
436
+ f"max_duration_s {seconds}: 0 for no limit, or {SHORTEST_LIMIT_S} to "
437
+ f"{LONGEST_LIMIT_S} seconds (1 to 60 minutes)"
438
+ )
439
+
440
+
441
+ def _check_pronunciations(says: Mapping[str, str], owner: str) -> None:
442
+ """Raise DeclarationRefused when a word or its spoken form is blank."""
443
+ if blank := {word for word, spoken in says.items() if not word or not spoken}:
444
+ raise DeclarationRefused(
445
+ f"{owner}: a pronunciation is a word and how it is said: {sorted(blank)}"
446
+ )
@@ -0,0 +1,152 @@
1
+ """What a call is: its id, direction, who is calling, the route it came in by, its day."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, date, datetime
6
+ from typing import Literal
7
+ from uuid import uuid4
8
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
9
+
10
+ from pinecall.domain.errors import DeclarationRefused
11
+ from pinecall.domain.names import (
12
+ CHANNELS_WITH_A_NUMBER,
13
+ PRODUCTION,
14
+ Channel,
15
+ Direction,
16
+ Env,
17
+ Json,
18
+ dialable,
19
+ )
20
+
21
+ # Prefix of call ids minted here (`call_` + 32 hex); phone calls are named by the media plane.
22
+ A_CALL = "call_"
23
+
24
+ # Who opened a call's log: the fleet's worker, an org's own worker (an app key), or the gateway
25
+ # for a written call. The worker doors of a fleet's call take the fleet's key alone.
26
+ type Opener = Literal["fleet", "app", "gateway"]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Contact:
31
+ """What is known about the caller; a web visitor may be anonymous."""
32
+
33
+ id: str | None = None
34
+ phone: str | None = None
35
+ name: str | None = None
36
+ email: str | None = None
37
+ external_id: str | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Route:
42
+ """Which agent answers on a channel and number, and which org owns it."""
43
+
44
+ org: str
45
+ agent: str
46
+ channel: Channel
47
+ number: str | None = None
48
+ label: str | None = None
49
+ # A number belongs to one environment; the registry refuses it in the other.
50
+ env: Env = PRODUCTION
51
+ # Bought on the box's carrier account, so it counts against the `numbers` quota; numbers
52
+ # imported from the tenant's own account do not.
53
+ managed: bool = False
54
+
55
+ def __post_init__(self) -> None:
56
+ if not self.org or not self.agent:
57
+ raise DeclarationRefused(
58
+ "a route names the org that owns it and the agent that answers"
59
+ )
60
+ if self.channel in CHANNELS_WITH_A_NUMBER:
61
+ if self.number is None or not dialable(self.number):
62
+ raise DeclarationRefused(
63
+ f"a {self.channel} route answers at a number in E.164 form, not {self.number!r}"
64
+ )
65
+ elif self.number is not None:
66
+ raise DeclarationRefused(f"the {self.channel} widget answers at no number")
67
+
68
+ @property
69
+ def door(self) -> tuple[Channel, str | None]:
70
+ """Return (channel, number): the registry's key, whatever the org or the label."""
71
+ return (self.channel, self.number)
72
+
73
+
74
+ # Immutable: whatever changes during a call is recorded in the log.
75
+ @dataclass(frozen=True)
76
+ class CallContext:
77
+ """A call as the worker and the gateway see it when it starts."""
78
+
79
+ call: str
80
+ channel: Channel
81
+ direction: Direction
82
+ caller: str
83
+ route: Route
84
+ today: date
85
+ contact: Contact | None = None
86
+ metadata: Mapping[str, Json] = field(default_factory=dict[str, Json])
87
+ # Eval run that opened the call; None for a real caller.
88
+ run: str | None = None
89
+ # Simulated caller's name, and its rules frozen at call start so judging ignores later edits.
90
+ persona: str | None = None
91
+ accepts_when: str | None = None
92
+ declines_when: str | None = None
93
+ # Sandbox developer the call belongs to; None for the org itself.
94
+ holder: str | None = None
95
+ # The state the call opens in, when a golden, a persona or `?state=` asked for one.
96
+ state: Mapping[str, Json] = field(default_factory=dict[str, Json])
97
+
98
+ def __post_init__(self) -> None:
99
+ if not self.call:
100
+ raise DeclarationRefused("a call context names its call")
101
+ if not self.caller:
102
+ raise DeclarationRefused("a call has a calling side: a number, or the visitor id")
103
+ if self.route.channel != self.channel:
104
+ raise DeclarationRefused(
105
+ f"a {self.channel} call cannot come through a {self.route.channel} route"
106
+ )
107
+
108
+ @property
109
+ def env(self) -> Env:
110
+ """Return the environment of the call's route."""
111
+ return self.route.env
112
+
113
+ # The app's contact id, else the number, so phone and WhatsApp calls share one contact.
114
+ @property
115
+ def remembered_as(self) -> str | None:
116
+ """Return the memory key for the caller, or None when the caller is anonymous."""
117
+ if self.contact is not None and self.contact.id:
118
+ return self.contact.id
119
+ return self.caller if self.channel in CHANNELS_WITH_A_NUMBER else None
120
+
121
+
122
+ # A carrier bills each leg on its own, by the number at the end of it: the box's own number when
123
+ # the call came in, the dialled one when it went out.
124
+ @dataclass(frozen=True)
125
+ class PhoneLeg:
126
+ """One leg of a call on the phone network, and how long it was up."""
127
+
128
+ # `twilio`, or `sip` for a trunk the box knows only by its address.
129
+ carrier: str
130
+ direction: Direction
131
+ number: str
132
+ seconds: float
133
+
134
+
135
+ def new_call_id() -> str:
136
+ """Mint a call id; it is also the LiveKit room name."""
137
+ return f"{A_CALL}{uuid4().hex}"
138
+
139
+
140
+ def parse_zone(zone: str) -> ZoneInfo:
141
+ """Return the IANA zone, raising DeclarationRefused when there is none by that name."""
142
+ try:
143
+ return ZoneInfo(zone)
144
+ except (ZoneInfoNotFoundError, ValueError) as unknown:
145
+ raise DeclarationRefused(
146
+ f"{zone!r} is not an IANA time zone (Europe/Madrid, America/Montevideo, UTC)"
147
+ ) from unknown
148
+
149
+
150
+ def today_in(zone: str) -> date:
151
+ """Return today's date in the zone: a call is dated where the caller is, not the box."""
152
+ return datetime.now(UTC).astimezone(parse_zone(zone)).date()
@@ -0,0 +1,32 @@
1
+ """E.164 country calling codes, the satellite ranges never dialled, the shortest national number."""
2
+
3
+ # E.164 country calling codes, matched longest first; no longer code starts with 1 or 7.
4
+ CALLING_CODES: frozenset[str] = frozenset(
5
+ {
6
+ "1", "7", "20", "27", "30", "31", "32", "33", "34", "36", "39", "40", "41", "43", "44",
7
+ "45", "46", "47", "48", "49", "51", "52", "53", "54", "55", "56", "57", "58", "60",
8
+ "61", "62", "63", "64", "65", "66", "81", "82", "84", "86", "90", "91", "92", "93",
9
+ "94", "95", "98", "211", "212", "213", "216", "218", "220", "221", "222", "223", "224",
10
+ "225", "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236",
11
+ "237", "238", "239", "240", "241", "242", "243", "244", "245", "246", "247", "248",
12
+ "249", "250", "251", "252", "253", "254", "255", "256", "257", "258", "260", "261",
13
+ "262", "263", "264", "265", "266", "267", "268", "269", "290", "291", "297", "298",
14
+ "299", "350", "351", "352", "353", "354", "355", "356", "357", "358", "359", "370",
15
+ "371", "372", "373", "374", "375", "376", "377", "378", "379", "380", "381", "382",
16
+ "383", "385", "386", "387", "389", "420", "421", "423", "500", "501", "502", "503",
17
+ "504", "505", "506", "507", "508", "509", "590", "591", "592", "593", "594", "595",
18
+ "596", "597", "598", "599", "670", "672", "673", "674", "675", "676", "677", "678",
19
+ "679", "680", "681", "682", "683", "685", "686", "687", "688", "689", "690", "691",
20
+ "692", "800", "808", "850", "852", "853", "855", "856", "870", "878", "880", "881",
21
+ "882", "883", "886", "888", "960", "961", "962", "963", "964", "965", "966", "967",
22
+ "968", "970", "971", "972", "973", "974", "975", "976", "977", "979", "992", "993",
23
+ "994", "995", "996", "998",
24
+ }
25
+ ) # fmt: skip
26
+
27
+ # Satellite and global-service ranges: where international revenue-share fraud is dialled, and
28
+ # never a number that called anybody back.
29
+ NEVER_DIALLED = ("870", "878", "881", "882", "883", "888", "979")
30
+
31
+ # Shorter national numbers are short codes, service numbers or typos.
32
+ SHORTEST_NATIONAL = 5