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
pinecall/evals/runs.py ADDED
@@ -0,0 +1,252 @@
1
+ """An eval run: one agent, every golden under every model, judged into a matrix as it goes."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import AsyncGenerator, Iterable, Sequence
6
+ from contextlib import asynccontextmanager
7
+ from typing import Never
8
+ from uuid import uuid4
9
+
10
+ from livekit.agents import llm
11
+ from livekit.agents.evals import EvaluationResult
12
+ from psycopg.types.json import Jsonb
13
+
14
+ from pinecall.domain.errors import Conflict
15
+ from pinecall.domain.names import JsonObject
16
+ from pinecall.domain.scope import Scope
17
+ from pinecall.evals.case import Case, as_chat
18
+ from pinecall.evals.judges import CaseJudge
19
+ from pinecall.postgres.pool import Pool
20
+ from pinecall.wire.parts import ModelConfig
21
+ from pinecall.wire.rest.evals import (
22
+ EvalRunResponse,
23
+ Failure,
24
+ JudgeScore,
25
+ OpenedCall,
26
+ ScoreMatrix,
27
+ ScoreRow,
28
+ )
29
+
30
+ # A run is coroutines of the gateway, so its deadline is a timeout; its finished calls stay.
31
+ A_RUN_MAY_TAKE_S = 15 * 60
32
+
33
+
34
+ A_RUN = "run_"
35
+
36
+
37
+ # The column of the agent's own model, when the run named none.
38
+ DECLARED = "declared"
39
+
40
+
41
+ ALREADY_RUNNING = (
42
+ "eval run {id} is running on agent {agent}: a run drives the app that also answers that "
43
+ "agent's real calls, so there is one per agent at a time"
44
+ )
45
+
46
+
47
+ TOOK_TOO_LONG = "the run passed its {minutes} minute deadline and was stopped"
48
+
49
+
50
+ # Written whole at every change, so a run is followed while it goes.
51
+ PUT = """
52
+ INSERT INTO eval_runs (id, org, env, agent, started_at, finished_at, status, document)
53
+ VALUES (%(id)s, %(org)s, %(env)s, %(agent)s, %(started_at)s, %(finished_at)s, %(status)s,
54
+ %(document)s)
55
+ ON CONFLICT (id) DO UPDATE SET
56
+ finished_at = excluded.finished_at, status = excluded.status, document = excluded.document
57
+ """
58
+
59
+
60
+ OF = "SELECT document FROM eval_runs WHERE id = %(id)s AND org = %(org)s AND env = %(env)s"
61
+
62
+ # A run holds its agent for so long and renews it at a third of that: a gateway that died
63
+ # mid-run leaves a lease another run takes once it ran out.
64
+ LEASED_S = 60.0
65
+
66
+ # Taken when nobody holds the agent or the holder's lease ran out; else the holder is named.
67
+ LEASED = """
68
+ insert into run_leases as lease (agent, run, until)
69
+ values (%(agent)s, %(run)s, now() + make_interval(secs => %(seconds)s))
70
+ on conflict (agent) do update set run = excluded.run, until = excluded.until
71
+ where lease.until < now() or lease.run = excluded.run
72
+ returning run
73
+ """
74
+
75
+ HOLDER = "select run from run_leases where agent = %(agent)s"
76
+
77
+ RELEASED = "delete from run_leases where agent = %(agent)s and run = %(run)s"
78
+
79
+
80
+ # The agent is filtered in SQL, so one agent's runs are never paged out by another's.
81
+ LISTED = """
82
+ SELECT document FROM eval_runs
83
+ WHERE org = %(org)s AND env = %(env)s AND started_at > %(since)s
84
+ AND (%(agent)s::text IS NULL OR agent = %(agent)s)
85
+ ORDER BY started_at DESC
86
+ LIMIT %(limit)s
87
+ """
88
+
89
+
90
+ # Not durable on purpose: a run is coroutines of this process, gone with it.
91
+ # The lease is in Postgres, so a run on one gateway holds the agent on every one.
92
+ class Runner:
93
+ """The run each agent is under, on any gateway of the box, and this process's own."""
94
+
95
+ def __init__(self, pool: Pool) -> None:
96
+ """No agent under a run of this process."""
97
+ self.pool = pool
98
+ self.running: dict[str, str] = {}
99
+
100
+ # Refused, not queued: the refusal names the run to follow instead.
101
+ @asynccontextmanager
102
+ async def alone(self, run: str, agent: str) -> AsyncGenerator[None]:
103
+ """Hold the agent for this run; Conflict while another run holds it."""
104
+ if not await self._leased(run, agent):
105
+ async with self.pool.connection() as connection:
106
+ row = await (await connection.execute(HOLDER, {"agent": agent})).fetchone()
107
+ holding = "another run" if row is None else str(row["run"])
108
+ raise Conflict(ALREADY_RUNNING.format(id=holding, agent=agent))
109
+ self.running[agent] = run
110
+ renewing = asyncio.create_task(self._renewed(run, agent))
111
+ try:
112
+ yield
113
+ finally:
114
+ renewing.cancel()
115
+ await asyncio.gather(renewing, return_exceptions=True)
116
+ del self.running[agent]
117
+ async with self.pool.connection() as connection:
118
+ await connection.execute(RELEASED, {"agent": agent, "run": run})
119
+
120
+ async def _leased(self, run: str, agent: str) -> bool:
121
+ wanted = {"agent": agent, "run": run, "seconds": LEASED_S}
122
+ async with self.pool.connection() as connection:
123
+ return await (await connection.execute(LEASED, wanted)).fetchone() is not None
124
+
125
+ async def _renewed(self, run: str, agent: str) -> None:
126
+ while True:
127
+ await asyncio.sleep(LEASED_S / 3)
128
+ await self._leased(run, agent)
129
+
130
+
131
+ def new_run(agent: str) -> EvalRunResponse:
132
+ """A run of the agent that starts now and opened nothing yet."""
133
+ return EvalRunResponse(
134
+ id=f"{A_RUN}{uuid4().hex[:12]}",
135
+ agent=agent,
136
+ started_at=time.time(),
137
+ finished_at=None,
138
+ status="running",
139
+ calls=[],
140
+ matrix=None,
141
+ error=None,
142
+ )
143
+
144
+
145
+ def column_of(model: ModelConfig | None) -> str:
146
+ """The matrix column a model's cells are under."""
147
+ return DECLARED if model is None else f"{model.provider}/{model.model}"
148
+
149
+
150
+ def opening(run: EvalRunResponse, opened: OpenedCall) -> EvalRunResponse:
151
+ """The run with one more call opened."""
152
+ return run.model_copy(update={"calls": [*run.calls, opened]})
153
+
154
+
155
+ def judged(run: EvalRunResponse, cells: Sequence[ScoreRow]) -> EvalRunResponse:
156
+ """The run with the matrix of the cells judged so far."""
157
+ return run.model_copy(update={"matrix": matrix_of(cells)})
158
+
159
+
160
+ def finished(run: EvalRunResponse) -> EvalRunResponse:
161
+ """The run done: every golden under every model was judged."""
162
+ return run.model_copy(update={"status": "done", "finished_at": time.time()})
163
+
164
+
165
+ # A failing golden is a score; this is the run itself stopping early, its cells kept.
166
+ def stopped(run: EvalRunResponse, why: str) -> EvalRunResponse:
167
+ """The run failed, and why."""
168
+ return run.model_copy(update={"status": "failed", "finished_at": time.time(), "error": why})
169
+
170
+
171
+ async def put(pool: Pool, scope: Scope, run: EvalRunResponse) -> None:
172
+ """Write the run whole, in its org and world."""
173
+ params = {
174
+ "id": run.id,
175
+ "org": scope.org,
176
+ "env": scope.env,
177
+ "agent": run.agent,
178
+ "started_at": run.started_at,
179
+ "finished_at": run.finished_at,
180
+ "status": run.status,
181
+ "document": Jsonb(run.written()),
182
+ }
183
+ async with pool.connection() as connection:
184
+ await connection.execute(PUT, params)
185
+
186
+
187
+ async def of(pool: Pool, scope: Scope, run: str) -> EvalRunResponse | None:
188
+ """One run of the org in the world; None for any other's."""
189
+ async with pool.connection() as connection:
190
+ found = await connection.execute(OF, {"id": run, "org": scope.org, "env": scope.env})
191
+ row = await found.fetchone()
192
+ return None if row is None else EvalRunResponse.model_validate(row["document"])
193
+
194
+
195
+ async def listed(
196
+ pool: Pool, scope: Scope, *, agent: str | None, since: float, limit: int
197
+ ) -> list[EvalRunResponse]:
198
+ """The org's runs in the world started after `since`, newest first, one agent's or all."""
199
+ params = {"org": scope.org, "env": scope.env, "agent": agent, "since": since, "limit": limit}
200
+ async with pool.connection() as connection:
201
+ rows = await (await connection.execute(LISTED, params)).fetchall()
202
+ return [EvalRunResponse.model_validate(row["document"]) for row in rows]
203
+
204
+
205
+ async def score(
206
+ judges: Sequence[CaseJudge], case: Case, model: llm.LLM[Never] | None
207
+ ) -> list[JudgeScore]:
208
+ """Every judge over one case, in order: its verdict as a score, and the model calls it made."""
209
+ chat = as_chat(case)
210
+ scores: list[JudgeScore] = []
211
+ for judge in judges:
212
+ result = await judge.evaluate(chat_ctx=chat, llm=model)
213
+ scores.append(
214
+ JudgeScore(
215
+ metric=judge.name,
216
+ score=EvaluationResult(judgments={judge.name: result}).score,
217
+ passed=result.passed,
218
+ reason=result.reasoning,
219
+ criteria=result.instructions,
220
+ judge_calls=judge.calls,
221
+ )
222
+ )
223
+ return scores
224
+
225
+
226
+ # Every cell carries the call's own summary, never a number of its own.
227
+ def cell_of(
228
+ opened: OpenedCall, case: Case, scores: list[JudgeScore], requests: Sequence[JsonObject] | None
229
+ ) -> ScoreRow:
230
+ """One golden under one model; a cell that did not hold carries the requests it was sent."""
231
+ cell = ScoreRow(model=opened.model, golden=opened.golden, scores=scores, summary=case.summary)
232
+ if all(cell_score.passed for cell_score in scores):
233
+ return cell
234
+ return cell.model_copy(update={"asked": None if requests is None else list(requests)})
235
+
236
+
237
+ def matrix_of(cells: Iterable[ScoreRow]) -> ScoreMatrix:
238
+ """The matrix of these cells: the axes in the order they came, and every judge that broke."""
239
+ rows = list(cells)
240
+ return ScoreMatrix(
241
+ models=list(dict.fromkeys(row.model for row in rows)),
242
+ goldens=list(dict.fromkeys(row.golden for row in rows)),
243
+ metrics=list(dict.fromkeys(score.metric for row in rows for score in row.scores)),
244
+ judge_calls=sum(score.judge_calls for row in rows for score in row.scores),
245
+ runs=rows,
246
+ failures=[
247
+ Failure(model=row.model, golden=row.golden, metric=score.metric)
248
+ for row in rows
249
+ for score in row.scores
250
+ if not score.passed
251
+ ],
252
+ )
@@ -0,0 +1,356 @@
1
+ """A persona on a real line: the caller's leg in the room, its lines spoken, the noise on it."""
2
+
3
+ import asyncio
4
+ import random as randomness
5
+ import time
6
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from typing import Never
9
+
10
+ import numpy as np
11
+ from livekit import rtc
12
+ from livekit.agents import tts
13
+ from livekit.agents.utils import http_context
14
+
15
+ from pinecall.log.logs import Logs, Subscription
16
+ from pinecall.providers.build import primary
17
+ from pinecall.wire.events import AgentStateChanged
18
+ from pinecall.wire.frames import Entry
19
+
20
+ # The caller's next line and whether it hangs up after it, given the turns left.
21
+ type NextLine = Callable[[int], Awaitable[tuple[str, bool]]]
22
+
23
+
24
+ # 10 ms of 16-bit mono per frame, the cadence livekit's examples publish at.
25
+ FRAME_MS = 10
26
+
27
+
28
+ # A sum past 16 bits is clipped, never wrapped: a wrap is a click the ears would hear.
29
+ LOUDEST = 32767
30
+
31
+
32
+ QUIETEST = -32768
33
+
34
+
35
+ # A lost packet is the silence a jitter buffer plays in its place.
36
+ LOST = b"\x00\x00"
37
+
38
+
39
+ # A television 15 dB under the caller was already transcribed as the caller.
40
+ UNDER_THE_CALLER_DB = 15.0
41
+
42
+
43
+ # Every line is resampled to this rate, so the caller's track opens before any audio exists.
44
+ SAMPLE_RATE = 48_000
45
+
46
+
47
+ CHANNELS = 1
48
+
49
+
50
+ A_SIMULATED_CALLER = "simulated_caller"
51
+
52
+
53
+ # A cold worker has been measured at 15 s before it publishes its audio.
54
+ THE_AGENT_MAY_TAKE_S = 40.0
55
+
56
+
57
+ # A turn with a tool speaks twice: two rounds of speech measured about 13 s.
58
+ AN_ANSWER_MAY_TAKE_S = 30.0
59
+
60
+
61
+ # Longer than the lag between a state and its entry, shorter than a caller's pause.
62
+ A_BEAT_S = 0.75
63
+
64
+
65
+ # An agent that only listens this long opens with nothing.
66
+ A_SILENT_OPENING_S = 3.0
67
+
68
+
69
+ AN_OPENING_MAY_TAKE_S = 15.0
70
+
71
+
72
+ NOBODY_ANSWERED = "no agent joined room {call} in {seconds:.0f}s: is the world's fleet up?"
73
+
74
+
75
+ # The background voice a noisy line carries.
76
+ A_TELEVISION = (
77
+ "A continuación, el resumen deportivo de la jornada. El equipo local venció por dos goles a "
78
+ "uno en un partido disputado hasta el último minuto, y el entrenador destacó el esfuerzo de "
79
+ "sus jugadores en la rueda de prensa posterior al encuentro."
80
+ )
81
+
82
+
83
+ LISTENING = "listening"
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Line:
88
+ """What the line does to the caller's voice: a voice under it, and packets lost."""
89
+
90
+ # None: nothing is mixed in.
91
+ interferer_db: float | None = None
92
+ packet_loss: float = 0.0
93
+ dice: randomness.Random = field(default_factory=randomness.Random, compare=False)
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SpokenLine:
98
+ """A spoken call as the caller joins it: the room's address, its token, the call, its turns."""
99
+
100
+ url: str
101
+ token: str
102
+ call: str
103
+ turns: int
104
+ line: Line
105
+
106
+
107
+ class CallerLeg:
108
+ """The caller's leg of a spoken call: its track in the room, its voice, the line's noise."""
109
+
110
+ def __init__(
111
+ self, spoken: SpokenLine, voice: tts.TTS[Never], logs: Logs, interferer: bytes
112
+ ) -> None:
113
+ """A leg with its track made and nothing said yet."""
114
+ self.spoken = spoken
115
+ self.voice = voice
116
+ self.logs = logs
117
+ self.interferer = interferer
118
+ self.source = rtc.AudioSource(SAMPLE_RATE, CHANNELS)
119
+ self.track = rtc.LocalAudioTrack.create_audio_track(A_SIMULATED_CALLER, self.source)
120
+
121
+ async def say(self, line: str) -> None:
122
+ """Speak one line into the room, through the line's noise."""
123
+ pcm = await _synthesized(self.voice, line)
124
+ if self.spoken.line.interferer_db is not None:
125
+ pcm = mix_interferer(pcm, self.interferer, self.spoken.line.interferer_db)
126
+ for frame in drop_packets(frames_of(pcm, SAMPLE_RATE), self.spoken.line):
127
+ await self.source.capture_frame(
128
+ rtc.AudioFrame(frame, SAMPLE_RATE, CHANNELS, len(frame) // 2)
129
+ )
130
+
131
+ async def line_answered(self, lines: int) -> None:
132
+ """Wait until the agent answered this many lines, or the call ended."""
133
+ await answered(self.logs, self.spoken.call, lines)
134
+
135
+
136
+ def described(line: Line) -> str:
137
+ """The line in the words a report prints."""
138
+ if line.interferer_db is None and line.packet_loss <= 0:
139
+ return "a clean line"
140
+ noise = f"interferer {line.interferer_db or 0.0:.0f} dB under the caller"
141
+ return noise if line.packet_loss <= 0 else f"{noise}, {line.packet_loss * 100:.0f}% packet loss"
142
+
143
+
144
+ def frames_of(pcm: bytes, rate: int) -> list[bytes]:
145
+ """The audio cut in 10 ms frames, the last padded with silence: a room refuses a short one."""
146
+ size = (rate * FRAME_MS // 1000) * 2
147
+ frames = [pcm[start : start + size] for start in range(0, len(pcm), size)]
148
+ if frames and len(frames[-1]) < size:
149
+ frames[-1] += LOST * ((size - len(frames[-1])) // 2)
150
+ return frames
151
+
152
+
153
+ def rms_of(pcm: bytes) -> float:
154
+ """The level of 16-bit audio."""
155
+ samples = np.frombuffer(pcm, dtype=np.int16).astype(np.float64)
156
+ if not samples.size:
157
+ return 0.0
158
+ return float(np.sqrt(np.mean(samples * samples)))
159
+
160
+
161
+ # The interferer loops, so a short one covers a long line.
162
+ def mix_interferer(
163
+ caller: bytes, interferer: bytes, db_under: float = UNDER_THE_CALLER_DB
164
+ ) -> bytes:
165
+ """The interferer mixed under the caller's voice, `db_under` quieter."""
166
+ if not interferer or not caller:
167
+ return caller
168
+ loudness = rms_of(interferer)
169
+ gain = 0.0 if loudness == 0 else rms_of(caller) / loudness * 10 ** (-db_under / 20)
170
+ voice = np.frombuffer(caller, dtype=np.int16).astype(np.int64)
171
+ noise = np.resize(np.frombuffer(interferer, dtype=np.int16), voice.size)
172
+ mixed = voice + (noise * gain).astype(np.int64)
173
+ return np.clip(mixed, QUIETEST, LOUDEST).astype(np.int16).tobytes()
174
+
175
+
176
+ def drop_packets(frames: Sequence[bytes], line: Line) -> list[bytes]:
177
+ """The frames with the line's share of them lost."""
178
+ if line.packet_loss <= 0:
179
+ return list(frames)
180
+ return [
181
+ LOST * (len(frame) // 2) if line.dice.random() < line.packet_loss else frame
182
+ for frame in frames
183
+ ]
184
+
185
+
186
+ def resample_to_room(pcm: bytes, rate: int) -> bytes:
187
+ """The audio at the room's rate, as it was when it already is."""
188
+ if rate == SAMPLE_RATE:
189
+ return pcm
190
+ resampler = rtc.AudioResampler(rate, SAMPLE_RATE, num_channels=CHANNELS)
191
+ frame = rtc.AudioFrame(
192
+ data=pcm, sample_rate=rate, num_channels=CHANNELS, samples_per_channel=len(pcm) // 2
193
+ )
194
+ resampled = [*resampler.push(frame), *resampler.flush()]
195
+ return b"".join(bytes(piece.data) for piece in resampled)
196
+
197
+
198
+ # The operator's voices per language are the ones an agent speaks with when it names none; the
199
+ # caller takes one the agent is not using, in its language, else in English, else the plugin's.
200
+ def pick_caller_voice(
201
+ voices: Mapping[str, str], vendor: str, agents_voice: str | None, language: str | None
202
+ ) -> str | None:
203
+ """A voice of the vendor the caller speaks with, never the agent's."""
204
+ own = f"{vendor}/{primary(language) or 'en'}"
205
+ ordered = [voices.get(own), voices.get(f"{vendor}/en")]
206
+ ordered += [voice for key, voice in voices.items() if key.startswith(f"{vendor}/")]
207
+ return next((voice for voice in ordered if voice and voice != agents_voice), None)
208
+
209
+
210
+ def is_call_over(entries: Sequence[Entry]) -> bool:
211
+ """Whether somebody hung up: every end lands as call.ended."""
212
+ return any(entry.type == "call.ended" for entry in entries)
213
+
214
+
215
+ def is_line_open(entries: Sequence[Entry], now: float) -> bool:
216
+ """Whether the agent finished its opening, or has none, and listens."""
217
+ states = [(index, entry) for index, entry in enumerate(entries) if entry.type == "agent.state"]
218
+ if not states:
219
+ return False
220
+ spoke = [index for index, entry in enumerate(entries) if entry.type == "turn.agent"]
221
+ index, last = states[-1]
222
+ if spoke:
223
+ return _state_of(last) == LISTENING and index > spoke[-1]
224
+ only_listened = all(_state_of(entry) == LISTENING for _, entry in states)
225
+ return only_listened and now - states[0][1].ts >= A_SILENT_OPENING_S
226
+
227
+
228
+ # Turn counts lie: livekit speaks a tool's preamble as a turn of its own, and some ears end a
229
+ # turn per sentence. The agent back to listening after it took the line is the signal.
230
+ def has_answer_landed(entries: Sequence[Entry], lines: int, *, since: float) -> bool:
231
+ """Whether every line reached the agent, and it is listening again after taking the last."""
232
+ heard = [index for index, entry in enumerate(entries) if entry.type == "turn.user"]
233
+ # A snapshot older than the caller's silence would answer with the turn before.
234
+ if not heard or entries[heard[-1]].ts < since or len(heard) < lines:
235
+ return False
236
+ if _is_a_tool_running(entries):
237
+ return False
238
+ states = [(index, entry) for index, entry in enumerate(entries) if entry.type == "agent.state"]
239
+ took_it = any(index > heard[-1] and _state_of(entry) != LISTENING for index, entry in states)
240
+ if not took_it:
241
+ return False
242
+ index, last = states[-1]
243
+ return index > heard[-1] and _state_of(last) == LISTENING
244
+
245
+
246
+ # Read off the log as it grows, never on a timer: a fixed wait talked over tool answers.
247
+ async def answered(logs: Logs, call: str, lines: int) -> None:
248
+ """Wait until the agent answered `lines` lines (its opening when 0), or the call ended."""
249
+ since = time.time()
250
+ subscription = await logs.reading(call).followed()
251
+ loop = asyncio.get_running_loop()
252
+ deadline = loop.time() + (AN_OPENING_MAY_TAKE_S if lines == 0 else AN_ANSWER_MAY_TAKE_S)
253
+ try:
254
+ while (left := deadline - loop.time()) > 0:
255
+ entries = await logs.store.whole(call)
256
+ done = (
257
+ is_line_open(entries, time.time())
258
+ if lines == 0
259
+ else has_answer_landed(entries, lines, since=since)
260
+ )
261
+ if is_call_over(entries):
262
+ return
263
+ # A state reaches the log late: an answer holds only if a beat passes with nothing.
264
+ if not await _moved(subscription, min(A_BEAT_S, left)) and done:
265
+ return
266
+ finally:
267
+ subscription.close()
268
+
269
+
270
+ async def speak_turns(
271
+ turns: int,
272
+ next_line: NextLine,
273
+ say: Callable[[str], Awaitable[None]],
274
+ wait: Callable[[int], Awaitable[None]],
275
+ ) -> int:
276
+ """Say each line once the agent answered the last, the first after its opening; the count."""
277
+ spoken = 0
278
+ await wait(0)
279
+ for turn in range(turns):
280
+ line, hanging_up = await next_line(turns - turn)
281
+ if not line:
282
+ break
283
+ await say(line)
284
+ spoken += 1
285
+ if hanging_up:
286
+ break
287
+ await wait(spoken)
288
+ return spoken
289
+
290
+
291
+ # Deleting the room is the door's: it ends the agent's job, which seals the log.
292
+ async def run_spoken(
293
+ spoken: SpokenLine, voice: tts.TTS[Never], logs: Logs, next_line: NextLine
294
+ ) -> int:
295
+ """Join the room as the caller, say the lines as the agent answers, and hang up."""
296
+ room = rtc.Room()
297
+ async with http_context.open():
298
+ interferer = b""
299
+ if spoken.line.interferer_db is not None:
300
+ interferer = await _synthesized(voice, A_TELEVISION)
301
+ await room.connect(spoken.url, spoken.token)
302
+ try:
303
+ leg = CallerLeg(spoken, voice, logs, interferer)
304
+ # Published before the agent arrives: audio pushed before a subscription is lost.
305
+ await room.local_participant.publish_track(
306
+ leg.track, rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
307
+ )
308
+ await _until_the_agent_is_here(room, spoken.call)
309
+ said_count = await speak_turns(spoken.turns, next_line, leg.say, leg.line_answered)
310
+ # Waited again: hanging up at once would cut the agent's last answer.
311
+ await leg.line_answered(said_count)
312
+ return said_count
313
+ finally:
314
+ await room.disconnect()
315
+
316
+
317
+ def _state_of(entry: Entry) -> str:
318
+ return AgentStateChanged.model_validate(entry.data).state
319
+
320
+
321
+ # livekit publishes `listening` while a tool runs, so a call with no result yet is mid-turn.
322
+ def _is_a_tool_running(entries: Sequence[Entry]) -> bool:
323
+ called = {str(entry.data.get("call_id")) for entry in entries if entry.type == "tool.call"}
324
+ answered_ids = {
325
+ str(entry.data.get("call_id")) for entry in entries if entry.type == "tool.result"
326
+ }
327
+ return bool(called - answered_ids)
328
+
329
+
330
+ async def _moved(subscription: Subscription, within_s: float) -> bool:
331
+ try:
332
+ await asyncio.wait_for(anext(subscription), within_s)
333
+ except (TimeoutError, StopAsyncIteration):
334
+ return False
335
+ return True
336
+
337
+
338
+ async def _synthesized(voice: tts.TTS[Never], text: str) -> bytes:
339
+ frame = await voice.synthesize(text).collect()
340
+ return resample_to_room(bytes(frame.data), frame.sample_rate)
341
+
342
+
343
+ # The agent joins before its session is built; its published audio means it listens.
344
+ async def _until_the_agent_is_here(room: rtc.Room, call: str) -> None:
345
+ def listening() -> bool:
346
+ return any(
347
+ publication.kind == rtc.TrackKind.KIND_AUDIO
348
+ for participant in room.remote_participants.values()
349
+ for publication in participant.track_publications.values()
350
+ )
351
+
352
+ for _ in range(int(THE_AGENT_MAY_TAKE_S / A_BEAT_S)):
353
+ if listening():
354
+ return
355
+ await asyncio.sleep(A_BEAT_S)
356
+ raise TimeoutError(NOBODY_ANSWERED.format(call=call, seconds=THE_AGENT_MAY_TAKE_S))
File without changes