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,223 @@
1
+ """A written call over a WebSocket: a message in, every entry of the call out."""
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
6
+ from pydantic import TypeAdapter, ValidationError
7
+ from starlette.websockets import WebSocketState
8
+
9
+ from pinecall.domain.call import CallContext, Contact, Route, new_call_id, today_in
10
+ from pinecall.domain.errors import (
11
+ Conflict,
12
+ DeclarationRefused,
13
+ NotAllowed,
14
+ NotFound,
15
+ PinecallError,
16
+ QuotaExhausted,
17
+ )
18
+ from pinecall.domain.names import THE_WIDGET, Json, JsonObject
19
+ from pinecall.domain.scope import Scope
20
+ from pinecall.gateway import _deps
21
+ from pinecall.gateway._call_setup import exhausted
22
+ from pinecall.gateway._gateway import Gateway
23
+ from pinecall.gateway._sockets import NO_AGENT, NO_UNCLAIMED, NOT_THAT_APP, Registration
24
+ from pinecall.gateway._text_calls import open_text, resume_text, tokens_of
25
+ from pinecall.log.logs import Subscription
26
+ from pinecall.session import text
27
+ from pinecall.session.session import Session
28
+ from pinecall.tenancy import admission, keys, personas
29
+
30
+ router = APIRouter()
31
+
32
+
33
+ NOT_TAKEN_UP = "call {call} cannot be taken up: it is over, or not this agent's; open a new one"
34
+
35
+ NOT_A_STATE = "?state= is the state the call opens in: a JSON object of the class's fields"
36
+
37
+ STATE_TOO_BIG = "?state= is {size} bytes: the state a call opens in is {at_most} bytes at most"
38
+
39
+ A_STATE_AT_MOST = 16 * 1024
40
+
41
+ _A_STATE: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
42
+
43
+ OVER = "the call ended: {reason}"
44
+
45
+ # What uvicorn closes with on a stop; a caller never sends it, so the call is left for the next
46
+ # process to take up.
47
+ SERVICE_RESTART = 1012
48
+
49
+
50
+ # The org's key, never a room token (`pinecall chat`, `test`, `simulate`). A refusal accepts the
51
+ # socket first: a close before the handshake is a bare 403 with no reason.
52
+ @router.websocket("/v1/chat")
53
+ async def chat(websocket: WebSocket) -> None:
54
+ """One text call: {text} frames in, every entry of the call out."""
55
+ gateway = _deps.gateway_of(websocket)
56
+ try:
57
+ found, again = await _chatting(websocket, gateway)
58
+ session = (
59
+ await _taken_up(gateway, found, again)
60
+ if again
61
+ else await open_text(
62
+ gateway.serving, found, await _chat_context(websocket, gateway, found)
63
+ )
64
+ )
65
+ except PinecallError as refused:
66
+ await websocket.accept()
67
+ await websocket.close(code=_deps.POLICY_VIOLATION, reason=_deps.close_reason(str(refused)))
68
+ return
69
+ await websocket.accept()
70
+ served = gateway.live.calls.get(session.call.context.call)
71
+ heard = await served.log.followed() if served is not None else None
72
+ sending = asyncio.create_task(_sent(websocket, heard)) if heard is not None else None
73
+ # The call can end from the desk or by the model: the socket is closed under the caller.
74
+ ending = asyncio.create_task(_hung_up(websocket, session))
75
+ try:
76
+ if not again:
77
+ await session.start()
78
+ await _turns(websocket, gateway, session)
79
+ finally:
80
+ ending.cancel()
81
+ if sending is not None:
82
+ sending.cancel()
83
+
84
+
85
+ async def _hung_up(websocket: WebSocket, session: Session) -> None:
86
+ await session.over.wait()
87
+ reason, _ = session.ended or ("error", "platform")
88
+ if websocket.application_state is WebSocketState.CONNECTED:
89
+ await websocket.close(reason=_deps.close_reason(OVER.format(reason=reason)))
90
+
91
+
92
+ async def _chatting(websocket: WebSocket, gateway: Gateway) -> tuple[Registration, str | None]:
93
+ data = _deps.bearer_of(websocket.headers)
94
+ verified = None if data is None else await gateway.keys.verify(data)
95
+ if verified is None:
96
+ raise NotAllowed(_deps.TAKES_A_KEY)
97
+ keys.check_opens(verified, "talk")
98
+ env = _deps.world_of_request(websocket, verified)
99
+ scope = keys.scope_of(verified, env)
100
+ slug = websocket.query_params.get("agent", "")
101
+ keys.check_agent(verified, slug)
102
+ app = websocket.query_params.get("app")
103
+ registration = gateway.sockets.serving(scope, slug, app)
104
+ if registration is None:
105
+ raise NotFound(_why_not(gateway, scope, slug, app))
106
+ return registration, websocket.query_params.get("call")
107
+
108
+
109
+ async def _taken_up(gateway: Gateway, registration: Registration, call: str) -> Session:
110
+ session = await resume_text(
111
+ gateway.serving, registration, call, gateway.connections.settings.timezone
112
+ )
113
+ if session is None:
114
+ raise Conflict(NOT_TAKEN_UP.format(call=call))
115
+ return session
116
+
117
+
118
+ # A web caller is a visitor id; the org's own key may name who it is (`?contact=`) and which of
119
+ # the agent's synthetic callers plays it (`?persona=`), whose rules are frozen into the call here.
120
+ async def _chat_context(
121
+ websocket: WebSocket, gateway: Gateway, registration: Registration
122
+ ) -> CallContext:
123
+ scope = registration.scope
124
+ named = websocket.query_params.get("persona") or None
125
+ persona = (
126
+ None
127
+ if named is None
128
+ else await personas.persona(gateway.connections.pool, scope.org, registration.slug, named)
129
+ )
130
+ contact = websocket.query_params.get("contact")
131
+ return CallContext(
132
+ call=new_call_id(),
133
+ channel=THE_WIDGET,
134
+ direction="inbound",
135
+ caller=websocket.query_params.get("caller") or f"web_{new_call_id()[5:17]}",
136
+ contact=Contact(id=contact) if contact else None,
137
+ persona=named,
138
+ accepts_when=None if persona is None else persona.persona.accepts_when or None,
139
+ declines_when=None if persona is None else persona.persona.declines_when or None,
140
+ route=Route(org=scope.org, agent=registration.slug, channel=THE_WIDGET, env=scope.env),
141
+ today=today_in(gateway.connections.settings.timezone),
142
+ holder=scope.holder or None,
143
+ state=_opening_state(websocket),
144
+ )
145
+
146
+
147
+ def _opening_state(websocket: WebSocket) -> JsonObject:
148
+ query = websocket.query_params.get("state")
149
+ if not query:
150
+ return {}
151
+ size = len(query.encode())
152
+ if size > A_STATE_AT_MOST:
153
+ raise DeclarationRefused(STATE_TOO_BIG.format(size=size, at_most=A_STATE_AT_MOST))
154
+ try:
155
+ return _A_STATE.validate_json(query)
156
+ except ValidationError:
157
+ raise DeclarationRefused(NOT_A_STATE) from None
158
+
159
+
160
+ # A failed send flips the application state; a receive after it raises RuntimeError, so the
161
+ # state is asked before each receive.
162
+ async def _turns(websocket: WebSocket, gateway: Gateway, session: Session) -> None:
163
+ scope = session.call.context.route
164
+ try:
165
+ while websocket.application_state is WebSocketState.CONNECTED:
166
+ frame = await websocket.receive_json()
167
+ if _hangs_up(frame):
168
+ # Ended before the socket closes, so the caller hears the call is over before
169
+ # it stops whatever served it.
170
+ await text.end(session, "caller_hung_up", "caller")
171
+ over = OVER.format(reason="caller_hung_up")
172
+ await websocket.close(reason=_deps.close_reason(over))
173
+ return
174
+ data = _text_of(frame)
175
+ if not data:
176
+ continue
177
+ try:
178
+ await admission.admit_turn(
179
+ gateway.connections.pool,
180
+ Scope(scope.org, scope.env),
181
+ turns=session.call.turns,
182
+ tokens=tokens_of(session.usage),
183
+ at=gateway.logs.store.clock(),
184
+ )
185
+ except QuotaExhausted as refused:
186
+ await exhausted(gateway.logs, scope.org, session.call.config.slug, refused)
187
+ await text.end(session, "timeout", "platform")
188
+ await websocket.close(
189
+ code=_deps.POLICY_VIOLATION, reason=_deps.close_reason(str(refused))
190
+ )
191
+ return
192
+ await text.hears(session, data)
193
+ except WebSocketDisconnect as gone:
194
+ if gone.code != SERVICE_RESTART and not session.closed:
195
+ await text.end(session, "caller_hung_up", "caller")
196
+ return
197
+ # A send that found the caller gone closed the socket under the loop, with no close to hear.
198
+ if not session.closed:
199
+ await text.end(session, "caller_hung_up", "caller")
200
+
201
+
202
+ async def _sent(websocket: WebSocket, heard: Subscription) -> None:
203
+ async for entry in heard:
204
+ await websocket.send_json(entry.written())
205
+
206
+
207
+ def _hangs_up(data: Json) -> bool:
208
+ return isinstance(data, dict) and data.get("hangup") is True
209
+
210
+
211
+ def _text_of(data: Json) -> str:
212
+ if not isinstance(data, dict):
213
+ return ""
214
+ text_said = data.get("text")
215
+ return text_said if isinstance(text_said, str) else ""
216
+
217
+
218
+ def _why_not(gateway: Gateway, scope: Scope, slug: str, app: str | None) -> str:
219
+ if app is not None:
220
+ return NOT_THAT_APP.format(app=app, slug=slug)
221
+ if gateway.sockets.of(scope, slug) is not None:
222
+ return NO_UNCLAIMED.format(slug=slug)
223
+ return f"{NO_AGENT.format(slug=slug)}: start the app that serves it, then chat again"
@@ -0,0 +1,163 @@
1
+ """The dataset doors: a call kept as a case or read as a golden, the inbox, a case decided."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Path, Query
6
+ from pydantic import BaseModel, Field
7
+
8
+ from pinecall.domain.errors import Conflict, NotFound
9
+ from pinecall.domain.names import Env
10
+ from pinecall.domain.scope import Scope
11
+ from pinecall.evals import calibration, dataset
12
+ from pinecall.evals.calibration import Label, Where
13
+ from pinecall.evals.dataset import Promoted
14
+ from pinecall.gateway import _deps
15
+ from pinecall.gateway._deps import Acting, EvalsKey, GatewayDep, ScopeDep
16
+ from pinecall.gateway._gateway import Gateway
17
+ from pinecall.log import queries
18
+ from pinecall.tenancy.keys import check_agent
19
+ from pinecall.wire.frames import Entry
20
+ from pinecall.wire.rest.evals import (
21
+ CaseDecision,
22
+ CaseStatus,
23
+ EvalCase,
24
+ EvalCaseList,
25
+ Expect,
26
+ Golden,
27
+ PromoteCaseRequest,
28
+ )
29
+ from pinecall.wire.scores import CallScore
30
+
31
+ router = APIRouter()
32
+
33
+
34
+ STILL_GOING = "call {call} is still going: a case is made of a call that ended"
35
+
36
+
37
+ class GoldenQuery(BaseModel):
38
+ """What a call read as a golden is named, and the seq it is cut at."""
39
+
40
+ name: str | None = Field(default=None, min_length=1)
41
+ from_seq: int = Field(default=0, ge=0)
42
+
43
+
44
+ # The case is the org's, played in the sandbox whichever world its call ran in; the key must be
45
+ # one that reads the call, as a replay's does. An expect left empty is the one the call's broken
46
+ # verdicts give.
47
+ @router.post("/v1/evals/cases", response_model_exclude_unset=True)
48
+ async def promote_case(
49
+ body: PromoteCaseRequest, key: EvalsKey, scope: ScopeDep, gateway: GatewayDep
50
+ ) -> EvalCase:
51
+ """A finished call's caller lines kept as a case of the org's dataset."""
52
+ entries, agent, env, version = await _finished(gateway, key, scope, body.call)
53
+ score = _score_in(entries)
54
+ expect = body.expect
55
+ if expect == Expect() and score is not None:
56
+ expect = dataset.expect_of(score.judges, entries)
57
+ golden = dataset.golden_of(entries, body.name, expect)
58
+ promoting = Promoted(
59
+ org=key.org,
60
+ env=env,
61
+ name=body.name,
62
+ author=key.bearer.key.subject or key.bearer.key.key_id,
63
+ held_out=body.held_out,
64
+ broke=[] if score is None else dataset.broke_of(score),
65
+ source_version=version,
66
+ )
67
+ return await dataset.promoted(gateway.connections.pool, golden, agent, promoting)
68
+
69
+
70
+ # What `pinecall runs promote` writes to test/candidates: the same golden a case would hold,
71
+ # derived here once, kept nowhere.
72
+ @router.get("/v1/calls/{call}/golden", response_model_exclude_unset=True)
73
+ async def golden_of_call(
74
+ call: Annotated[str, Path()],
75
+ key: EvalsKey,
76
+ scope: ScopeDep,
77
+ gateway: GatewayDep,
78
+ query: Annotated[GoldenQuery, Query()],
79
+ ) -> Golden:
80
+ """The golden a finished call makes from a seq on, its expect from its broken verdicts."""
81
+ entries, _, _, _ = await _finished(gateway, key, scope, call)
82
+ score = _score_in(entries)
83
+ expect = Expect() if score is None else dataset.expect_of(score.judges, entries)
84
+ cut = dataset.cut_at(entries, query.from_seq)
85
+ return dataset.golden_of(cut, query.name or call, expect)
86
+
87
+
88
+ @router.get("/v1/evals/cases", response_model_exclude_unset=True)
89
+ async def list_cases(
90
+ key: EvalsKey,
91
+ gateway: GatewayDep,
92
+ agent: Annotated[str | None, Query()] = None,
93
+ status: Annotated[CaseStatus | None, Query()] = None,
94
+ ) -> EvalCaseList:
95
+ """The org's cases, one agent's or every one, the pending first; how many wait, of how many."""
96
+ if agent is not None:
97
+ check_agent(key.bearer, agent)
98
+ pool = gateway.connections.pool
99
+ return EvalCaseList(
100
+ cases=await dataset.listed(pool, key.org, agent, status),
101
+ pending=await dataset.waiting(pool, key.org, agent),
102
+ pending_at_most=dataset.PENDING_AT_MOST,
103
+ )
104
+
105
+
106
+ # The judge called wrong is a calibration label in the call's own world, so the key must read the
107
+ # call as the calibration door's does.
108
+ @router.patch("/v1/evals/cases/{id}", response_model_exclude_unset=True)
109
+ async def decide_case(
110
+ case: Annotated[str, Path(alias="id")],
111
+ body: CaseDecision,
112
+ key: EvalsKey,
113
+ scope: ScopeDep,
114
+ gateway: GatewayDep,
115
+ ) -> EvalCase:
116
+ """Approve, dismiss, hold out or mark a case as kept in the repository."""
117
+ pool = gateway.connections.pool
118
+ kept = await dataset.found(pool, key.org, case)
119
+ check_agent(key.bearer, kept.agent)
120
+ dataset.check_decision(kept, body)
121
+ author = key.bearer.key.subject or key.bearer.key.key_id
122
+ if body.judge_was_wrong is not None:
123
+ reader = _deps.Reader(acting=key, scope=scope)
124
+ await _deps.check_readable(gateway, reader, kept.source_call)
125
+ where = Where(org=key.org, env=kept.source_env, agent=kept.agent)
126
+ label = Label(
127
+ call=kept.source_call,
128
+ judge=body.judge_was_wrong,
129
+ held=True,
130
+ author=author,
131
+ note=body.note,
132
+ )
133
+ await calibration.labelled(pool, where, label)
134
+ return await dataset.decided(pool, key.org, case, body, author)
135
+
136
+
137
+ @router.delete("/v1/evals/cases/{id}", status_code=204)
138
+ async def forget_case(
139
+ case: Annotated[str, Path(alias="id")], key: EvalsKey, gateway: GatewayDep
140
+ ) -> None:
141
+ """Forget one of the org's cases; another org's, or nobody's, is the same 404."""
142
+ await dataset.forgotten(gateway.connections.pool, key.org, case)
143
+
144
+
145
+ async def _finished(
146
+ gateway: Gateway, key: Acting, scope: Scope, call: str
147
+ ) -> tuple[list[Entry], str, Env, int | None]:
148
+ """A call the key reads and that ended: its log, its agent, its world, its settings version."""
149
+ await _deps.check_readable(gateway, _deps.Reader(acting=key, scope=scope), call)
150
+ kept = await queries.scope_of_call(gateway.connections.pool, call)
151
+ entries = await gateway.logs.store.whole(call)
152
+ if kept is None or kept.scope is None or not entries:
153
+ raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
154
+ check_agent(key.bearer, kept.agent)
155
+ if not any(entry.type == "call.ended" for entry in entries):
156
+ raise Conflict(STILL_GOING.format(call=call))
157
+ return list(entries), kept.agent, kept.scope.env, kept.versions.config
158
+
159
+
160
+ def _score_in(entries: list[Entry]) -> CallScore | None:
161
+ """The call's last call.score, the verdict a judge may write again after the seal."""
162
+ found = [entry for entry in entries if entry.type == "call.score"]
163
+ return None if not found else CallScore.model_validate(found[-1].data)
@@ -0,0 +1,113 @@
1
+ """The supervisor's desk: listening in, taking a seat, and the verbs sent to a live call."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from pinecall.domain.errors import (
6
+ Conflict,
7
+ NotAllowed,
8
+ NotFound,
9
+ )
10
+ from pinecall.domain.person import RoomScope
11
+ from pinecall.gateway import _deps
12
+ from pinecall.gateway._deps import Acting, GatewayDep, Reader, ReaderDep
13
+ from pinecall.gateway._gateway import Gateway
14
+ from pinecall.gateway.calls.commands import SUPERVISOR_VERB, commanded
15
+ from pinecall.log import queries
16
+ from pinecall.tenancy import keys, reads, tokens
17
+ from pinecall.tenancy.reads import Read
18
+ from pinecall.wire.commands import SupervisorVerb, Verb
19
+ from pinecall.wire.frames import Command
20
+ from pinecall.wire.parts import Supervisor
21
+ from pinecall.wire.rest.calls import (
22
+ ReadKind,
23
+ SeatResponse,
24
+ VerbResponse,
25
+ )
26
+
27
+ router = APIRouter()
28
+
29
+
30
+ IS_OVER = "call {call} is over: read its log or its recording instead"
31
+
32
+ READS_ONLY = "this token reads the call and sends no verb: steering it takes a supervise token"
33
+
34
+ A_KEY = "key:{org}"
35
+
36
+
37
+ @router.post("/v1/calls/{call}/listen")
38
+ async def listen(call: str, key: _deps.SuperviseKey, gateway: GatewayDep) -> SeatResponse:
39
+ """A hidden seat that hears one live call."""
40
+ return await _seated(gateway, key, call, "observe", "listen")
41
+
42
+
43
+ # Not hidden: livekit delivers no track of a hidden seat, and a takeover would be silent.
44
+ @router.post("/v1/calls/{call}/supervise")
45
+ async def supervise(call: str, key: _deps.SuperviseKey, gateway: GatewayDep) -> SeatResponse:
46
+ """A seat that speaks in one live call; its token also sends the verbs."""
47
+ return await _seated(gateway, key, call, "supervise", "supervise")
48
+
49
+
50
+ # The body never says who sent it: the credential does.
51
+ @router.post("/v1/calls/{call}/verbs", status_code=202)
52
+ async def queue_verb(
53
+ call: str, verb: Verb, reading: ReaderDep, gateway: GatewayDep
54
+ ) -> VerbResponse:
55
+ """One supervise verb on a live call; the call's log says what it did."""
56
+ if reading.visit is not None and reading.visit.scope != "supervise":
57
+ raise NotAllowed(READS_ONLY)
58
+ if reading.acting is not None:
59
+ keys.check_opens(reading.acting.bearer, "supervise")
60
+ await _deps.check_readable(gateway, reading, call)
61
+ state = await gateway.logs.reading(call).snapshot()
62
+ if state.seq == 0:
63
+ raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
64
+ if await gateway.logs.store.sealed(call):
65
+ raise Conflict(IS_OVER.format(call=call))
66
+ wanted = SupervisorVerb(by=_who(reading), verb=verb)
67
+ served = gateway.live.calls.get(call)
68
+ if served is not None and served.session is not None:
69
+ await served.session.supervise(wanted)
70
+ return VerbResponse(call=call, verb=verb.verb, seq=None)
71
+ # Whichever gateway runs the call's worker stream or its session takes it.
72
+ sent = Command(type=SUPERVISOR_VERB, agent=state.agent, call=call, data=wanted.written())
73
+ if not await commanded(gateway.connections.signal, sent):
74
+ raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
75
+ return VerbResponse(call=call, verb=verb.verb, seq=None)
76
+
77
+
78
+ # A seat hears the call live: it is recorded as a read of the call, once it is handed out.
79
+ async def _seated(
80
+ gateway: Gateway, key: Acting, call: str, scope: RoomScope, what: ReadKind
81
+ ) -> SeatResponse:
82
+ kept = await queries.scope_of_call(gateway.connections.pool, call)
83
+ if kept is None or kept.scope is None or kept.scope.org != key.org:
84
+ raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
85
+ keys.check_agent(key.bearer, kept.agent)
86
+ if await gateway.logs.store.sealed(call):
87
+ raise Conflict(IS_OVER.format(call=call))
88
+ seat = tokens.seat(gateway.signer, call, scope, key.bearer)
89
+ read = Read(call, what, _deps.asked_by(key))
90
+ await reads.record(gateway.connections.pool, kept.scope, read)
91
+ return SeatResponse(
92
+ server_url=gateway.connections.settings.browser_livekit_url(kept.scope.env),
93
+ participant_token=seat.token,
94
+ call=call,
95
+ identity=seat.identity,
96
+ org=key.org,
97
+ subject=seat.subject,
98
+ name=seat.name,
99
+ )
100
+
101
+
102
+ def _who(reading: Reader) -> Supervisor:
103
+ if reading.visit is not None:
104
+ if reading.visit.subject is not None:
105
+ return Supervisor(id=reading.visit.subject, name=reading.visit.name)
106
+ return Supervisor(id=reading.visit.identity or "")
107
+ if reading.acting is None:
108
+ return Supervisor(id="")
109
+ bearer = reading.acting.bearer
110
+ if bearer.key.subject is not None:
111
+ name = bearer.member.name if bearer.member is not None else bearer.key.name
112
+ return Supervisor(id=bearer.key.subject, name=name)
113
+ return Supervisor(id=A_KEY.format(org=bearer.key.org))
@@ -0,0 +1,77 @@
1
+ """A worker's batches of a call on one socket for its life, each answered with the seqs it got."""
2
+
3
+ import logging
4
+ import time
5
+
6
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
7
+
8
+ from pinecall.domain.errors import NotSignedIn, PinecallError
9
+ from pinecall.gateway import _deps
10
+ from pinecall.gateway._deps import Acting
11
+ from pinecall.gateway._gateway import Gateway
12
+ from pinecall.gateway._served import Served
13
+ from pinecall.gateway.api.calls import check_kind, counted, known
14
+ from pinecall.tenancy import keys
15
+ from pinecall.wire.rest.calls import (
16
+ AppendEntriesRefused,
17
+ AppendEntriesRequest,
18
+ AppendEntriesResponse,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ router = APIRouter()
24
+
25
+
26
+ # A request per batch cost the gateway its routing, its headers and its key each time; on the
27
+ # socket a batch is one frame in and one out, and the log's answer to a retry is the same as the
28
+ # batch door's. A refusal is a frame too, and the socket stays: a refused batch never ends a call.
29
+ @router.websocket("/v1/calls/{call}/entries")
30
+ async def entries_socket(websocket: WebSocket, call: str) -> None:
31
+ """A worker's batches of a call, on one socket for its life, each answered with its seqs."""
32
+ gateway = _deps.gateway_of(websocket)
33
+ await websocket.accept()
34
+ try:
35
+ served = await _served(websocket, gateway, call)
36
+ except PinecallError as refused:
37
+ await _refused(websocket, refused)
38
+ await websocket.close(code=_deps.POLICY_VIOLATION, reason=_deps.close_reason(str(refused)))
39
+ return
40
+ try:
41
+ while True:
42
+ raw = await websocket.receive_text()
43
+ await _answered(websocket, gateway, served, call, raw)
44
+ except WebSocketDisconnect:
45
+ logger.debug("call %s: its worker's socket closed", call)
46
+
47
+
48
+ async def _served(websocket: WebSocket, gateway: Gateway, call: str) -> Served:
49
+ data = _deps.bearer_of(websocket.headers)
50
+ verified = None if data is None else await gateway.keys.verify(data)
51
+ if verified is None:
52
+ raise NotSignedIn(_deps.TAKES_A_KEY)
53
+ keys.check_opens(verified, "app", "fleet")
54
+ key = Acting(bearer=verified, env=_deps.world_of_request(websocket, verified))
55
+ return await known(gateway, key, call)
56
+
57
+
58
+ async def _answered(
59
+ websocket: WebSocket, gateway: Gateway, served: Served, call: str, raw: str
60
+ ) -> None:
61
+ try:
62
+ body = AppendEntriesRequest.read_json(raw, "entries")
63
+ for item in body.entries:
64
+ check_kind(item.type)
65
+ gateway.live.in_use(call, time.monotonic())
66
+ began = time.perf_counter()
67
+ entries = await served.log.append_many(body.entries, after=body.after)
68
+ counted(gateway, time.perf_counter() - began, entries)
69
+ except PinecallError as refused:
70
+ await _refused(websocket, refused)
71
+ return
72
+ await websocket.send_text(AppendEntriesResponse(entries=entries).written_json())
73
+
74
+
75
+ async def _refused(websocket: WebSocket, refused: PinecallError) -> None:
76
+ frame = AppendEntriesRefused(refused=str(refused), status=refused.status)
77
+ await websocket.send_json(frame.written())