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,283 @@
1
+ """The org's own: judging, identity provider, mailbox, policy, erasures, reads and export."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Query, Request
7
+ from fastapi.responses import StreamingResponse
8
+
9
+ from pinecall.domain.errors import Conflict, DeclarationRefused, NotFound, UpstreamFailed
10
+ from pinecall.domain.person import parse_role
11
+ from pinecall.domain.scope import Scope
12
+ from pinecall.gateway._deps import (
13
+ CallsKey,
14
+ GatewayDep,
15
+ TeamKey,
16
+ UsageKey,
17
+ asked_by,
18
+ public_url,
19
+ )
20
+ from pinecall.providers import catalog
21
+ from pinecall.providers.catalog import judge_ceiling
22
+ from pinecall.tenancy import (
23
+ erasure,
24
+ export,
25
+ keys,
26
+ letters,
27
+ mail,
28
+ orgs,
29
+ policy,
30
+ reads,
31
+ sso,
32
+ sso_domains,
33
+ )
34
+ from pinecall.tenancy.mail import Mailbox, MailboxStatus
35
+ from pinecall.tenancy.sso import Client, OrgSso
36
+ from pinecall.wire.rest.accounts import (
37
+ OrgMailRequest,
38
+ OrgMailResponse,
39
+ OrgPolicy,
40
+ OrgPolicyRow,
41
+ OrgSsoRequest,
42
+ OrgSsoResponse,
43
+ SendTestLetterRequest,
44
+ SendTestLetterResponse,
45
+ SsoDomainProof,
46
+ )
47
+ from pinecall.wire.rest.agents import (
48
+ JudgingRequest,
49
+ JudgingSettings,
50
+ )
51
+ from pinecall.wire.rest.calls import ErasureTrail, ReadsResponse
52
+
53
+ router = APIRouter()
54
+
55
+
56
+ # A 404, not a 204: taking away what is not there must not look like it worked.
57
+ NO_SSO = "this org signs in with no identity provider"
58
+
59
+
60
+ # Required with no domain proven would lock everybody out: the provider is offered for none.
61
+ NOT_PROVEN_YET = (
62
+ "no domain of this SSO is verified yet: publish each domain's TXT record and ask "
63
+ "POST /v1/org/sso/domains/{domain}/verify, then require it"
64
+ )
65
+
66
+
67
+ NO_MAIL = "this org sends its letters through the box's own mail"
68
+
69
+
70
+ # The issuer is asked before it is kept, so a typo shows here and not at the first sign-in.
71
+ NOT_KEPT = "{said}: nothing was kept"
72
+
73
+
74
+ # A 409, not a 503: the request is fine, and there is nothing to test.
75
+ NOTHING_TO_TEST = (
76
+ "neither this org nor this box has a mail server: set one at PUT /v1/org/mail, or set "
77
+ "PINECALL_SMTP_URL on the box"
78
+ )
79
+
80
+
81
+ # Per org, not per world: judging is billed to the org across both.
82
+ @router.get("/v1/org/judging")
83
+ async def get_judging(key: CallsKey, gateway: GatewayDep) -> JudgingSettings:
84
+ """Whether hang-up judging is on, and its ceiling per call."""
85
+ pool = gateway.connections.pool
86
+ on = await orgs.judged(pool, key.org)
87
+ return JudgingSettings(on=on, ceiling_usd=judge_ceiling(await catalog.providers(pool)))
88
+
89
+
90
+ @router.put("/v1/org/judging")
91
+ async def put_judging(body: JudgingRequest, key: UsageKey, gateway: GatewayDep) -> JudgingSettings:
92
+ """Hang-up judging on or off, from the next call."""
93
+ pool = gateway.connections.pool
94
+ await orgs.set_judging(pool, key.org, on=body.on)
95
+ return JudgingSettings(on=body.on, ceiling_usd=judge_ceiling(await catalog.providers(pool)))
96
+
97
+
98
+ # `team`: the provider decides who the org's people are.
99
+ @router.get("/v1/org/sso")
100
+ async def get_sso(key: TeamKey, request: Request, gateway: GatewayDep) -> OrgSsoResponse:
101
+ """The org's provider, never its secret, and the redirect URI to register there."""
102
+ connections = gateway.connections
103
+ wired = await sso.sso_of(connections.pool, connections.vault, key.org)
104
+ proofs = await sso_domains.of_org(connections.pool, key.org)
105
+ return sso_row(wired, f"{public_url(request, gateway)}{sso.CALLBACK}", proofs)
106
+
107
+
108
+ # The role the provider seats people with counts as granted by this key: a manager makes no admin.
109
+ @router.put("/v1/org/sso")
110
+ async def put_sso(
111
+ body: OrgSsoRequest, key: TeamKey, request: Request, gateway: GatewayDep
112
+ ) -> OrgSsoResponse:
113
+ """Replace the org's provider whole, once its issuer answered as one."""
114
+ connections = gateway.connections
115
+ role = None if body.role is None else parse_role(body.role)
116
+ wanted = OrgSso(
117
+ org=key.org,
118
+ client=Client(body.issuer.strip(), body.client_id.strip(), body.client_secret),
119
+ domains=tuple(domain.strip().lower().removeprefix("@") for domain in body.domains),
120
+ role=role,
121
+ required=body.required,
122
+ )
123
+ keys.check_may_grant(key.bearer, role, production=False)
124
+ try:
125
+ await sso.discovered(connections.http, wanted.client.issuer)
126
+ except UpstreamFailed as unanswered:
127
+ raise DeclarationRefused(NOT_KEPT.format(said=unanswered)) from unanswered
128
+ if wanted.required:
129
+ proven = await sso_domains.verified_domains(connections.pool, key.org)
130
+ if not proven & frozenset(wanted.domains):
131
+ raise Conflict(NOT_PROVEN_YET)
132
+ await sso.put_sso(connections.pool, connections.vault, wanted)
133
+ proofs = await sso_domains.proofs_for(connections.pool, key.org, wanted.domains)
134
+ return sso_row(wanted, f"{public_url(request, gateway)}{sso.CALLBACK}", proofs)
135
+
136
+
137
+ # The org published the record; the box reads it now, once. A record seen stands.
138
+ @router.post("/v1/org/sso/domains/{domain}/verify")
139
+ async def verify_sso_domain(domain: str, key: TeamKey, gateway: GatewayDep) -> SsoDomainProof:
140
+ """Look for the domain's TXT record and, found, offer the provider for the domain."""
141
+ folded = domain.strip().lower().removeprefix("@")
142
+ proof = await sso_domains.verify(gateway.connections.pool, key.org, folded)
143
+ return SsoDomainProof(domain=proof.domain, txt=proof.txt, verified=proof.verified)
144
+
145
+
146
+ @router.delete("/v1/org/sso", status_code=204)
147
+ async def drop_sso(key: TeamKey, gateway: GatewayDep) -> None:
148
+ """Forget the org's provider: passwords open it again from the next attempt."""
149
+ if not await sso.drop_sso(gateway.connections.pool, key.org):
150
+ raise NotFound(NO_SSO)
151
+
152
+
153
+ # `team`: these letters carry invitations and password links.
154
+ @router.get("/v1/org/mail")
155
+ async def get_mail(key: TeamKey, gateway: GatewayDep) -> OrgMailResponse:
156
+ """The org's own mailbox, never its password, and how its last letter went."""
157
+ connections = gateway.connections
158
+ return _mail_row(await mail.mail_of(connections.pool, connections.vault, key.org))
159
+
160
+
161
+ # Sends nothing: the test door does, and a person watches it.
162
+ @router.put("/v1/org/mail")
163
+ async def put_mail(body: OrgMailRequest, key: TeamKey, gateway: GatewayDep) -> OrgMailResponse:
164
+ """Replace the org's own mailbox; its letters go through it from the next one."""
165
+ connections = gateway.connections
166
+ await mail.put_mail(connections.pool, connections.vault, key.org, mailbox_of(body))
167
+ return _mail_row(await mail.mail_of(connections.pool, connections.vault, key.org))
168
+
169
+
170
+ @router.delete("/v1/org/mail", status_code=204)
171
+ async def drop_mail(key: TeamKey, gateway: GatewayDep) -> None:
172
+ """Forget the org's own mailbox: its letters go through the box's again."""
173
+ if not await mail.drop_mail(gateway.connections.pool, key.org):
174
+ raise NotFound(NO_MAIL)
175
+
176
+
177
+ # The one mail door that waits on SMTP; how it went is kept as a real letter's is.
178
+ @router.post("/v1/org/mail/test")
179
+ async def send_test_letter(
180
+ body: SendTestLetterRequest, key: TeamKey, gateway: GatewayDep
181
+ ) -> SendTestLetterResponse:
182
+ """One test letter, waited for: whether it went, and what the server said when not."""
183
+ to = mail.address_of(body.to)
184
+ if await gateway.outbox.mailbox_for(key.org) is None:
185
+ raise Conflict(NOTHING_TO_TEST)
186
+ letter = letters.probe_letter(to, await letters.brand_of(gateway.connections.pool))
187
+ error = await gateway.outbox.sent(key.org, letter)
188
+ return SendTestLetterResponse(sent=error is None, error=error)
189
+
190
+
191
+ def mailbox_of(body: OrgMailRequest) -> Mailbox:
192
+ """The mailbox a body describes, its words trimmed; refused in the mailbox's own terms."""
193
+ return Mailbox(
194
+ host=body.host.strip(),
195
+ port=body.port,
196
+ security=mail.parse_security(body.security),
197
+ username=body.username.strip(),
198
+ password=body.password,
199
+ sender=body.sender.strip(),
200
+ )
201
+
202
+
203
+ def sso_row(
204
+ wired: OrgSso | None, redirect_uri: str, proofs: list[sso_domains.Proof]
205
+ ) -> OrgSsoResponse:
206
+ """An org's provider as the doors send it: wired or not, never the secret."""
207
+ return OrgSsoResponse(
208
+ configured=wired is not None,
209
+ issuer=None if wired is None else wired.client.issuer,
210
+ client_id=None if wired is None else wired.client.client_id,
211
+ domains=[] if wired is None else list(wired.domains),
212
+ role=None if wired is None else wired.role,
213
+ required=wired is not None and wired.required,
214
+ redirect_uri=redirect_uri,
215
+ proofs=[
216
+ SsoDomainProof(domain=proof.domain, txt=proof.txt, verified=proof.verified)
217
+ for proof in proofs
218
+ ],
219
+ )
220
+
221
+
222
+ @router.get("/v1/org/erasures")
223
+ async def erasures(key: TeamKey, gateway: GatewayDep) -> ErasureTrail:
224
+ """The org's erasures, newest first: what went, when, and who asked."""
225
+ rows = await erasure.trail(gateway.connections.pool, key.org)
226
+ return ErasureTrail(erasures=rows)
227
+
228
+
229
+ @router.get("/v1/org/reads")
230
+ async def who_read(
231
+ key: TeamKey, gateway: GatewayDep, subject: Annotated[str | None, Query()] = None
232
+ ) -> ReadsResponse:
233
+ """Who read the org's calls, recordings, seats, exports and memory; of one subject if named."""
234
+ rows = await reads.of_org(gateway.connections.pool, key.org, subject=subject)
235
+ return ReadsResponse(reads=rows)
236
+
237
+
238
+ @router.get("/v1/org/policy")
239
+ async def get_policy(key: TeamKey, gateway: GatewayDep) -> OrgPolicyRow:
240
+ """The org's compliance settings: how many days a sealed call is kept, and who set it."""
241
+ return await policy.policy_of(gateway.connections.pool, key.org)
242
+
243
+
244
+ @router.put("/v1/org/policy")
245
+ async def put_policy(body: OrgPolicy, key: TeamKey, gateway: GatewayDep) -> OrgPolicyRow:
246
+ """Replace the org's compliance settings whole, from the next nightly run."""
247
+ pool = gateway.connections.pool
248
+ await policy.put_policy(pool, key.org, body, by=asked_by(key))
249
+ return await policy.policy_of(pool, key.org)
250
+
251
+
252
+ @router.get("/v1/org/export", response_model=None)
253
+ async def export_org(key: TeamKey, gateway: GatewayDep) -> StreamingResponse:
254
+ """The org's data in the key's world as JSON Lines: calls, memories, settings, documents."""
255
+ export_read = reads.Read(key.org, "export", asked_by(key))
256
+ await reads.record(gateway.connections.pool, Scope(key.org, key.env), export_read)
257
+ filename = f"pinecall-{key.org}-{key.env}.jsonl"
258
+ return StreamingResponse(
259
+ _one_per_line(export.lines(gateway.connections.pool, key.org, key.env)),
260
+ media_type="application/x-ndjson",
261
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
262
+ )
263
+
264
+
265
+ def _mail_row(kept: MailboxStatus | None) -> OrgMailResponse:
266
+ # By its wire name: `from` is a keyword.
267
+ return OrgMailResponse.model_validate(
268
+ {
269
+ "configured": kept is not None,
270
+ "host": None if kept is None else kept.mailbox.host,
271
+ "port": None if kept is None else kept.mailbox.port,
272
+ "security": None if kept is None else kept.mailbox.security,
273
+ "username": None if kept is None else kept.mailbox.username,
274
+ "from": None if kept is None else kept.mailbox.sender,
275
+ "verified_at": None if kept is None else kept.verified_at,
276
+ "last_error": None if kept is None else kept.last_error,
277
+ }
278
+ )
279
+
280
+
281
+ async def _one_per_line(lines: AsyncIterator[str]) -> AsyncIterator[bytes]:
282
+ async for line in lines:
283
+ yield f"{line}\n".encode()
@@ -0,0 +1,157 @@
1
+ """The persona doors: an agent's synthetic callers, and what each ran."""
2
+
3
+ import re
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Depends, Query
7
+ from pydantic import BaseModel, Field
8
+
9
+ from pinecall.domain.errors import DeclarationRefused, NotFound
10
+ from pinecall.gateway import _deps
11
+ from pinecall.gateway._deps import EvalsKey, GatewayDep, ScopeDep
12
+ from pinecall.gateway._gateway import Gateway
13
+ from pinecall.log import lists
14
+ from pinecall.log.lists import PersonaRunFilters
15
+ from pinecall.providers import catalog
16
+ from pinecall.providers.declared import model_of
17
+ from pinecall.tenancy import personas
18
+ from pinecall.tenancy.personas import Persona, PersonaEdit, StoredPersona
19
+ from pinecall.wire.rest.evals import (
20
+ PersonaList,
21
+ PersonaRequest,
22
+ PersonaRow,
23
+ PersonaRunList,
24
+ PersonaRunRow,
25
+ )
26
+
27
+ router = APIRouter()
28
+
29
+
30
+ # What `--persona` takes: lower-case words joined by hyphens.
31
+ A_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
32
+
33
+
34
+ NOT_A_NAME = (
35
+ "{name!r} is not a persona's name: lower-case words joined by hyphens, like price-shopper"
36
+ )
37
+
38
+
39
+ A_SCREENFUL = 20
40
+
41
+
42
+ class PersonaRunQuery(BaseModel):
43
+ """Where a persona's runs continue, and how many."""
44
+
45
+ limit: int = Field(A_SCREENFUL, ge=1, le=_deps.LONGEST_LIST)
46
+ before: str | None = None
47
+
48
+
49
+ # One list for both worlds: a persona is test data, never heard by a customer.
50
+ @router.get("/v1/agents/{slug}/personas")
51
+ async def list_personas(slug: str, key: EvalsKey, gateway: GatewayDep) -> PersonaList:
52
+ """The agent's callers, by name."""
53
+ return await _list_of(gateway, key.org, slug)
54
+
55
+
56
+ # A model or a vendor this box lacks is refused when written, not on the call that plays it.
57
+ @router.put("/v1/agents/{slug}/personas/{name}")
58
+ async def put_persona(
59
+ slug: str, name: str, body: PersonaRequest, key: EvalsKey, gateway: GatewayDep
60
+ ) -> PersonaList:
61
+ """Write one of the agent's callers whole, or rename one from `was`; its list after it."""
62
+ if not A_NAME.match(name):
63
+ raise DeclarationRefused(NOT_A_NAME.format(name=name))
64
+ pool = gateway.connections.pool
65
+ defaults = (await catalog.providers(pool)).defaults
66
+ llm, tts = body.llm or None, body.tts or None
67
+ model_of(llm, "llm", in_use=defaults["llm"].vendor)
68
+ model_of(tts, "tts", in_use=defaults["tts"].vendor)
69
+ written = Persona(
70
+ name=name,
71
+ goal=body.goal,
72
+ style=body.style,
73
+ about=body.about,
74
+ facts=dict(body.facts),
75
+ state=dict(body.state),
76
+ llm=llm,
77
+ tts=tts,
78
+ voice=body.voice or None,
79
+ accepts_when=body.accepts_when or "",
80
+ declines_when=body.declines_when or "",
81
+ )
82
+ bearer = key.bearer.key
83
+ edit = PersonaEdit(author=bearer.subject or bearer.key_id, was=body.was)
84
+ await personas.put_persona(pool, key.org, slug, written, edit)
85
+ return await _list_of(gateway, key.org, slug)
86
+
87
+
88
+ @router.delete("/v1/agents/{slug}/personas/{name}")
89
+ async def drop_persona(slug: str, name: str, key: EvalsKey, gateway: GatewayDep) -> PersonaList:
90
+ """Forget one of the agent's callers; its list after it, 404 for a name nobody wrote."""
91
+ await personas.drop_persona(gateway.connections.pool, key.org, slug, name)
92
+ return await _list_of(gateway, key.org, slug)
93
+
94
+
95
+ # A name nobody wrote for the agent is a 404, not an empty page. The key is let through by the
96
+ # route's dependency, since the scope already names its org.
97
+ @router.get(
98
+ "/v1/agents/{slug}/personas/{name}/runs", dependencies=[Depends(_deps.opening("evals"))]
99
+ )
100
+ async def list_persona_runs(
101
+ slug: str,
102
+ name: str,
103
+ scope: ScopeDep,
104
+ gateway: GatewayDep,
105
+ query: Annotated[PersonaRunQuery, Query()],
106
+ ) -> PersonaRunList:
107
+ """The calls the persona made to the agent in the key's world and scope, newest first."""
108
+ pool = gateway.connections.pool
109
+ if await personas.persona(pool, scope.org, slug, name) is None:
110
+ raise NotFound(personas.NOBODY.format(name=name, agent=slug))
111
+ found = await lists.runs_of_persona(
112
+ pool,
113
+ scope,
114
+ PersonaRunFilters(agent=slug, persona=name, before=query.before),
115
+ limit=query.limit,
116
+ )
117
+ rows = [
118
+ PersonaRunRow.model_validate(
119
+ {
120
+ "call": run.facts.call,
121
+ "agent": run.facts.agent,
122
+ "started_at": run.started_at,
123
+ "ended_at": run.facts.ended_at,
124
+ "turns": run.turns,
125
+ "end_reason": run.facts.end_reason,
126
+ "outcome": run.facts.outcome,
127
+ "cost_usd": run.facts.cost_usd,
128
+ "score": run.facts.score,
129
+ }
130
+ )
131
+ for run in found.runs
132
+ ]
133
+ return PersonaRunList(runs=rows, total=found.total, next=found.next)
134
+
135
+
136
+ def _row_of(stored: StoredPersona) -> PersonaRow:
137
+ persona = stored.persona
138
+ return PersonaRow(
139
+ name=persona.name,
140
+ about=persona.about,
141
+ goal=persona.goal,
142
+ style=persona.style,
143
+ facts=dict(persona.facts),
144
+ state=dict(persona.state),
145
+ llm=persona.llm,
146
+ tts=persona.tts,
147
+ voice=persona.voice,
148
+ accepts_when=persona.accepts_when,
149
+ declines_when=persona.declines_when,
150
+ author=stored.author,
151
+ set_at=stored.set_at.timestamp(),
152
+ )
153
+
154
+
155
+ async def _list_of(gateway: Gateway, org: str, agent: str) -> PersonaList:
156
+ kept = await personas.personas_of(gateway.connections.pool, org, agent)
157
+ return PersonaList(personas=[_row_of(stored) for stored in kept])
@@ -0,0 +1,206 @@
1
+ """An agent's pipeline: what it hears, decides and speaks with, how fast, and its hold melody."""
2
+
3
+ import asyncio
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Query, Request, Response
7
+
8
+ from pinecall.domain.agent import AgentConfig, Greeting
9
+ from pinecall.domain.errors import DeclarationRefused, NotFound, PinecallError
10
+ from pinecall.domain.scope import Scope
11
+ from pinecall.gateway._call_setup import keys_of, tuned
12
+ from pinecall.gateway._deps import GatewayDep, PipelineKey, ScopeDep, capped_body
13
+ from pinecall.gateway._gateway import Gateway
14
+ from pinecall.gateway.api.providers import catalogue_of
15
+ from pinecall.log.reduce import medians, reduce
16
+ from pinecall.providers import catalog, credentials
17
+ from pinecall.providers.build import Modality, primary
18
+ from pinecall.providers.credentials import Keyring
19
+ from pinecall.session import hold
20
+ from pinecall.tenancy import agents
21
+ from pinecall.tenancy.agents import Chosen, Clip
22
+ from pinecall.wire.parts import GreetingConfig
23
+ from pinecall.wire.rest.agents import (
24
+ HoldAudio,
25
+ Measured,
26
+ PipelineReport,
27
+ PipelineStage,
28
+ PlayedRequest,
29
+ )
30
+ from pinecall.wire.state import Turn
31
+
32
+ router = APIRouter()
33
+
34
+
35
+ # Medians pool the turns of this many calls, so a short call weighs as much as its turns.
36
+ LAST_CALLS = 20
37
+
38
+
39
+ # The whole file in one request; a five-minute mp3 is under 10 MB.
40
+ LONGEST_UPLOAD = 20 * 1024 * 1024
41
+
42
+
43
+ NO_CLIP = "agent {slug} plays no clip of its own: the box's melody is the worker's, off or default"
44
+
45
+
46
+ OGG = "audio/ogg"
47
+
48
+
49
+ TOO_BIG = f"a hold melody is {LONGEST_UPLOAD // (1024 * 1024)} MB at most as uploaded"
50
+
51
+
52
+ # The stages are the ones the next call would be built with: the declaration under the scope's
53
+ # settings. Nothing is built: a vendor without a key is a reason, not a failure.
54
+ @router.get("/v1/agents/{slug}/pipeline")
55
+ async def pipeline_report(
56
+ slug: str, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
57
+ ) -> PipelineReport:
58
+ """The agent's three stages, the catalogue, and the latencies of its last calls."""
59
+ pool = gateway.connections.pool
60
+ registration = gateway.sockets.of(scope, slug)
61
+ declared = AgentConfig(slug=slug) if registration is None else registration.config
62
+ configured = await catalog.providers(pool)
63
+ config, _ = await tuned(pool, declared, scope, configured)
64
+ keyring = await keys_of(pool, gateway.connections.vault, scope)
65
+ language = primary(config.language)
66
+ stages = {
67
+ "hears": _stage("stt", config, configured, language=language),
68
+ "decides": _stage("llm", config, configured, language=None),
69
+ "speaks": _stage("tts", config, configured, language=language),
70
+ }
71
+ listed = catalogue_of(configured, keyring)
72
+ turns = await _recent_turns(gateway, scope.org, slug)
73
+ return PipelineReport(
74
+ agent=slug,
75
+ hears=stages["hears"],
76
+ decides=stages["decides"],
77
+ speaks=stages["speaks"],
78
+ greeting=_greeting(config.greeting),
79
+ voices=listed.voices,
80
+ providers=listed.providers,
81
+ defaults=listed.defaults,
82
+ models=listed.models,
83
+ calls=turns[0],
84
+ medians=[
85
+ Measured(name=row.name, seconds=row.seconds, turns=row.turns)
86
+ for row in medians(turns[1])
87
+ ],
88
+ unavailable_reasons=_unavailable(stages, keyring),
89
+ )
90
+
91
+
92
+ @router.get("/v1/agents/{slug}/pipeline/hold-audio")
93
+ async def hold_audio(
94
+ slug: str, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
95
+ ) -> HoldAudio:
96
+ """What the agent plays while a tool runs: the box's melody, silence, or a clip of its own."""
97
+ return _hold_audio_row(await agents.hold_of(gateway.connections.pool, _of(scope), slug))
98
+
99
+
100
+ # The body is the file, no multipart; the name rides the query. Converted here, once, so no
101
+ # worker decodes a tenant's upload in the middle of a call.
102
+ @router.put("/v1/agents/{slug}/pipeline/hold-audio")
103
+ async def upload_hold_audio(
104
+ slug: str,
105
+ request: Request,
106
+ _key: PipelineKey,
107
+ scope: ScopeDep,
108
+ gateway: GatewayDep,
109
+ name: Annotated[str | None, Query(max_length=200)] = None,
110
+ ) -> HoldAudio:
111
+ """A file of the org's as the agent's melody, from the next call on."""
112
+ data = await capped_body(request, LONGEST_UPLOAD)
113
+ if len(data) > LONGEST_UPLOAD:
114
+ raise DeclarationRefused(TOO_BIG)
115
+ melody = await asyncio.to_thread(hold.converted, data)
116
+ clip = Clip(audio=melody.audio, seconds=melody.seconds, name=(name or "").strip() or "melody")
117
+ chosen = await agents.keep_hold(gateway.connections.pool, _of(scope), slug, clip)
118
+ return _hold_audio_row(chosen)
119
+
120
+
121
+ @router.get("/v1/agents/{slug}/pipeline/hold-audio/audio")
122
+ async def hold_audio_clip(
123
+ slug: str, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
124
+ ) -> Response:
125
+ """The agent's own clip, Ogg Opus, to hear before a caller does."""
126
+ audio = await agents.hold_audio(gateway.connections.pool, _of(scope), slug)
127
+ if audio is None:
128
+ raise NotFound(NO_CLIP.format(slug=slug))
129
+ return Response(audio, media_type=OGG)
130
+
131
+
132
+ @router.put("/v1/agents/{slug}/pipeline/hold-audio/played")
133
+ async def choose_hold_audio(
134
+ slug: str, body: PlayedRequest, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
135
+ ) -> HoldAudio:
136
+ """The box's melody back, or silence; an uploaded clip is forgotten either way."""
137
+ pool = gateway.connections.pool
138
+ if body.played == "default":
139
+ await agents.forget_hold(pool, _of(scope), slug)
140
+ return HoldAudio(played="default")
141
+ await agents.silence_hold(pool, _of(scope), slug)
142
+ return HoldAudio(played="off")
143
+
144
+
145
+ def _hold_audio_row(chosen: Chosen | None) -> HoldAudio:
146
+ """The stored choice as the doors send it; no row is the box's melody."""
147
+ if chosen is None:
148
+ return HoldAudio(played="default")
149
+ return HoldAudio(
150
+ played=chosen.played, sha256=chosen.sha256, seconds=chosen.seconds, name=chosen.name
151
+ )
152
+
153
+
154
+ def _stage(
155
+ modality: Modality, config: AgentConfig, configured: catalog.Providers, *, language: str | None
156
+ ) -> PipelineStage:
157
+ default = configured.defaults[modality]
158
+ if modality == "tts":
159
+ voice = config.voice
160
+ return PipelineStage(
161
+ vendor=default.vendor if voice is None else voice.provider,
162
+ model=None if voice is None else voice.model,
163
+ voice_id=None if voice is None else voice.voice_id,
164
+ language=language,
165
+ )
166
+ declared = config.stt if modality == "stt" else config.llm
167
+ return PipelineStage(
168
+ vendor=default.vendor if declared is None else declared.provider,
169
+ model=None if declared is None else declared.model or None,
170
+ language=language,
171
+ )
172
+
173
+
174
+ def _greeting(greeting: Greeting | None) -> GreetingConfig | None:
175
+ if greeting is None:
176
+ return None
177
+ return GreetingConfig(
178
+ say=greeting.say, reply=greeting.reply, allow_interruptions=greeting.allow_interruptions
179
+ )
180
+
181
+
182
+ # A slug is one org's: another org's agent has no calls here, and says nothing of its latencies.
183
+ async def _recent_turns(gateway: Gateway, org: str, slug: str) -> tuple[int, list[list[Turn]]]:
184
+ store = gateway.logs.store
185
+ owner = await store.owner(slug)
186
+ if owner is not None and owner != org:
187
+ return 0, []
188
+ calls = await store.newest_calls(LAST_CALLS, agent=slug)
189
+ turns = [reduce(await store.whole(call)).turns for call in calls]
190
+ return len(calls), turns
191
+
192
+
193
+ # Which stage has no key to run on, and why, before a call fails for want of it.
194
+ def _unavailable(stages: dict[str, PipelineStage], keyring: Keyring) -> dict[str, str]:
195
+ missing: dict[str, str] = {}
196
+ for where, stage in stages.items():
197
+ try:
198
+ credentials.running(keyring, stage.vendor, stage.model)
199
+ except PinecallError as refused:
200
+ missing[where] = str(refused)
201
+ return missing
202
+
203
+
204
+ # The melody and the widget are the agent's in a world, whoever holds it.
205
+ def _of(scope: Scope) -> Scope:
206
+ return Scope(scope.org, scope.env)
@@ -0,0 +1,50 @@
1
+ """A call's prompt read back: each block its log names by hash, with the words the org kept."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from pinecall.domain.errors import NotFound
6
+ from pinecall.gateway import _deps
7
+ from pinecall.gateway._deps import CallsKey, GatewayDep, ScopeDep, asked_by
8
+ from pinecall.log import queries
9
+ from pinecall.tenancy import prompts, reads
10
+ from pinecall.tenancy.reads import Read
11
+ from pinecall.wire.events import PromptChanged
12
+ from pinecall.wire.rest.calls import CallPromptResponse, PromptBlockRow
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ # A key's read alone, never a page's token: the prompt is the operator's and the app's words.
18
+ # It is a read of the call, and on the record as one.
19
+ @router.get("/v1/calls/{call}/prompt")
20
+ async def call_prompt(
21
+ call: str, key: CallsKey, scope: ScopeDep, gateway: GatewayDep
22
+ ) -> CallPromptResponse:
23
+ """Every block of prompt the call was told, in order, each with its words when kept."""
24
+ reader = _deps.Reader(acting=key, scope=scope)
25
+ await _deps.check_readable(gateway, reader, call)
26
+ pool = gateway.connections.pool
27
+ kept = await queries.scope_of_call(pool, call)
28
+ entries = await gateway.logs.store.whole(call)
29
+ if kept is None or kept.scope is None or not entries:
30
+ raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
31
+ await reads.record(pool, scope, Read(call, "log", asked_by(key)))
32
+ changes = [
33
+ (entry.seq, PromptChanged.model_validate(entry.data))
34
+ for entry in entries
35
+ if entry.type == "prompt.changed"
36
+ ]
37
+ texts = await prompts.texts_of(pool, kept.scope.org, {block.hash for _, block in changes})
38
+ return CallPromptResponse(
39
+ call=call,
40
+ blocks=[
41
+ PromptBlockRow(
42
+ seq=seq,
43
+ name=block.name,
44
+ hash=block.hash,
45
+ chars=block.chars,
46
+ text="" if block.chars == 0 else texts.get(block.hash),
47
+ )
48
+ for seq, block in changes
49
+ ],
50
+ )