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,437 @@
1
+ """Ring 3: a finished call rebuilt from its log and checked by code alone, no model."""
2
+
3
+ import dataclasses
4
+ from collections.abc import Collection, Iterable, Mapping, Sequence
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal
7
+
8
+ from pinecall.evals.case import (
9
+ AGENT,
10
+ CALLER,
11
+ PUNCTUATION,
12
+ GateKind,
13
+ GateLine,
14
+ Role,
15
+ gate_line,
16
+ words_of,
17
+ )
18
+ from pinecall.log.reduce import interruption_delays, reduce, samples
19
+ from pinecall.wire.events import (
20
+ AgentTurnEnded,
21
+ CallEnded,
22
+ ConfirmDeclined,
23
+ ConfirmGranted,
24
+ ConfirmRequest,
25
+ ErrorEvent,
26
+ ToolCall,
27
+ UserTurnEnded,
28
+ event_of,
29
+ )
30
+ from pinecall.wire.frames import Entry
31
+ from pinecall.wire.parts import EndedBy, ScoreVerdict
32
+ from pinecall.wire.rest.evals import CheckVerdict
33
+
34
+ # ungated and undeclared are apart from kept, so neither is ever read as a pass.
35
+ type ConsentOutcome = Literal["kept", "broken", "ungated", "undeclared"]
36
+
37
+
38
+ CONFIRMATIONS: frozenset[str] = frozenset(
39
+ {"confirm.request", "confirm.granted", "confirm.declined"}
40
+ )
41
+
42
+
43
+ # The entries the checks read, but the confirmations.
44
+ READ: frozenset[str] = frozenset({"turn.agent", "turn.user", "tool.call", "error", "call.ended"})
45
+
46
+
47
+ # This runtime writes no confirm.* yet: a call it wrote is reported, not failed.
48
+ UNGATED = (
49
+ "the confirmation gate is not built yet: {count} irreversible tool call(s) ran and the log "
50
+ "carries no confirm.* at all"
51
+ )
52
+
53
+
54
+ NOTHING_DECLARED = (
55
+ "{count} tool call(s) and not one declared side effect, "
56
+ "so nothing here says which of them are irreversible"
57
+ )
58
+
59
+
60
+ NO_DECLARATION = (
61
+ "agent {agent} is not registered on this gateway, so no tool's side effect is known: "
62
+ "run this while the app that declares the tools is connected"
63
+ )
64
+
65
+
66
+ NO_WORDS = (
67
+ "no words were declared for this call: send them as `banned` with the request, or point "
68
+ "`pinecall eval` at a policy file"
69
+ )
70
+
71
+
72
+ NOTHING_MEASURED = "no turn of this call carried any of {names}"
73
+
74
+
75
+ # A budget may name it like any latency: the worst barge-in of the call, in seconds.
76
+ INTERRUPTION_DELAY = "interruption_delay"
77
+
78
+
79
+ # A share of the talking time, 0 to 1, judged by its own check and never as seconds.
80
+ TALK_SHARE = "talk_share"
81
+
82
+
83
+ NO_TALK_BUDGET = (
84
+ "no talk_share in the budget: send one, the most of the talking the agent may do (0 to 1)"
85
+ )
86
+
87
+
88
+ NOTHING_TIMED = "no turn of this call said when anybody started and stopped speaking"
89
+
90
+
91
+ NOTHING_CUT_OFF = "no reply of the agent's was cut off in this call"
92
+
93
+
94
+ # A cut-off reply the caller said no words over (a cough, a false start) is livekit's to resume,
95
+ # and one the caller hung up after has nothing left to answer: neither is judged.
96
+ NOTHING_TO_ANSWER = (
97
+ "the caller cut the agent off {count} time(s) and said nothing it could answer, or hung up"
98
+ )
99
+
100
+
101
+ # The agent that starts its cut-off reply over, rather than answering, opens with the same
102
+ # words: four in a row is not a coincidence of Spanish or English.
103
+ STARTED_OVER_WORDS = 4
104
+
105
+
106
+ # Seconds, under livekit's own metric names; a request may bring its own.
107
+ DEFAULT_BUDGET: Mapping[str, float] = {
108
+ "e2e_latency": 2.0,
109
+ "llm_node_ttft": 1.0,
110
+ "tts_node_ttfb": 0.6,
111
+ }
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class ConsentRead:
116
+ """What the consent rule found, and the sentence that says it."""
117
+
118
+ outcome: ConsentOutcome
119
+ detail: str
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class Failure:
124
+ """One error entry, and whether the session recovered from it."""
125
+
126
+ seq: int
127
+ code: str
128
+ message: str
129
+ recoverable: bool
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Spoken:
134
+ """One finished turn in log order: whose, what, and whether the caller cut it off."""
135
+
136
+ seq: int
137
+ role: Role
138
+ text: str
139
+ interrupted: bool = False
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class Replayed:
144
+ """A call as the code checks read it."""
145
+
146
+ call: str
147
+ agent: str
148
+ said: tuple[str, ...] = ()
149
+ # Tool calls and confirmations in seq order: consent is about their order.
150
+ gate: tuple[GateLine, ...] = ()
151
+ failures: tuple[Failure, ...] = ()
152
+ turns: tuple[Spoken, ...] = ()
153
+ ended_by: EndedBy | None = None
154
+ # Each measure's values (reduce.MEASURES), per turn; talk_share is one value for the call, and
155
+ # interruption_delay one per reply the caller cut off.
156
+ latencies: Mapping[str, tuple[float, ...]] = field(default_factory=dict[str, tuple[float, ...]])
157
+
158
+
159
+ # ungated is deferred, not broken: the gate is missing from the runtime, not from the agent.
160
+ AS_A_STATUS: Mapping[ConsentOutcome, ScoreVerdict] = {
161
+ "kept": "held",
162
+ "broken": "broken",
163
+ "ungated": "deferred",
164
+ "undeclared": "skipped",
165
+ }
166
+
167
+
168
+ def rebuild(entries: Sequence[Entry]) -> Replayed:
169
+ """The call the checks read, folded from its entries."""
170
+ spoken: list[str] = []
171
+ turns: list[Spoken] = []
172
+ gate: list[GateLine] = []
173
+ failures: list[Failure] = []
174
+ ended_by: EndedBy | None = None
175
+ for entry in entries:
176
+ if entry.type not in {*READ, *CONFIRMATIONS}:
177
+ continue
178
+ match event_of(entry):
179
+ case AgentTurnEnded() as turn:
180
+ spoken.append(turn.text)
181
+ turns.append(Spoken(entry.seq, AGENT, turn.text, turn.interrupted))
182
+ case UserTurnEnded() as heard:
183
+ turns.append(Spoken(entry.seq, CALLER, heard.text))
184
+ case CallEnded() as over:
185
+ ended_by = over.ended_by
186
+ case ToolCall() as tool:
187
+ gate.append(GateLine(entry.seq, "tool.call", tool.call_id, tool.name))
188
+ case ConfirmRequest() | ConfirmGranted() | ConfirmDeclined() as confirmation:
189
+ gate.append(gate_line(entry, confirmation))
190
+ case ErrorEvent() as failed:
191
+ failures.append(Failure(entry.seq, failed.code, failed.message, failed.recoverable))
192
+ case _:
193
+ continue
194
+ measured = samples(reduce(entries).turns)
195
+ barged = interruption_delays(entries)
196
+ if barged:
197
+ measured[INTERRUPTION_DELAY] = barged
198
+ return Replayed(
199
+ call=next((entry.call for entry in entries if entry.call is not None), ""),
200
+ agent=entries[0].agent if entries else "",
201
+ said=tuple(spoken),
202
+ gate=tuple(gate),
203
+ failures=tuple(failures),
204
+ turns=tuple(turns),
205
+ ended_by=ended_by,
206
+ latencies={name: tuple(values) for name, values in measured.items()},
207
+ )
208
+
209
+
210
+ # Grants match by call id, never by tool name: two calls of one tool each need their own yes.
211
+ def consent_of(gate: Iterable[GateLine]) -> ConsentRead:
212
+ """Whether every irreversible tool call ran after its own granted confirmation, by seq."""
213
+ lines = tuple(gate)
214
+ calls = tuple(line for line in lines if line.kind == "tool.call")
215
+ if calls and all(line.side_effect is None for line in calls):
216
+ return ConsentRead("undeclared", NOTHING_DECLARED.format(count=len(calls)))
217
+ ran = tuple(line for line in calls if line.side_effect == "irreversible")
218
+ if not ran:
219
+ return ConsentRead(
220
+ "kept", f"no irreversible tool ran in this call; {len(calls)} tool call(s) did"
221
+ )
222
+ confirmations = tuple(line for line in lines if line.kind in CONFIRMATIONS)
223
+ if not confirmations:
224
+ return ConsentRead("ungated", UNGATED.format(count=len(ran)))
225
+ faults = [
226
+ fault
227
+ for tool in ran
228
+ if (fault := _ungranted(tool, [c for c in confirmations if c.call_id == tool.call_id]))
229
+ is not None
230
+ ]
231
+ if faults:
232
+ return ConsentRead("broken", "; ".join(faults))
233
+ return ConsentRead(
234
+ "kept", f"{len(ran)} irreversible tool call(s), each after a granted confirmation"
235
+ )
236
+
237
+
238
+ # The log carries no side effect: the irreversible tools are the agent's declaration.
239
+ def consent(call: Replayed, irreversible: Collection[str] | None) -> CheckVerdict:
240
+ """Consent, from the gate trace and the tools the agent declares irreversible."""
241
+ if irreversible is None:
242
+ return CheckVerdict(
243
+ check="consent", status="skipped", detail=NO_DECLARATION.format(agent=call.agent)
244
+ )
245
+ declared = [
246
+ dataclasses.replace(
247
+ line, side_effect="irreversible" if line.tool in irreversible else "read"
248
+ )
249
+ if line.kind == "tool.call"
250
+ else line
251
+ for line in call.gate
252
+ ]
253
+ read = consent_of(declared)
254
+ return CheckVerdict(check="consent", status=AS_A_STATUS[read.outcome], detail=read.detail)
255
+
256
+
257
+ # A lone word matches whole words (`tú` is not `tútem`); a phrase matches anywhere.
258
+ def register(call: Replayed, banned: Sequence[str]) -> CheckVerdict:
259
+ """Whether the agent said none of the banned words, case-insensitively."""
260
+ if not banned:
261
+ return CheckVerdict(check="register", status="skipped", detail=NO_WORDS)
262
+ found = [
263
+ (turn, word)
264
+ for turn, text in enumerate(call.said, 1)
265
+ for word in banned
266
+ if (
267
+ word.casefold() in text.casefold() if " " in word else word.casefold() in words_of(text)
268
+ )
269
+ ]
270
+ if found:
271
+ spoken = "; ".join(f"{word!r} in turn {turn}" for turn, word in found)
272
+ return CheckVerdict(check="register", status="broken", detail=f"the agent said {spoken}")
273
+ kept = f"none of the {len(banned)} declared word(s) was said in {len(call.said)} agent turns"
274
+ return CheckVerdict(check="register", status="held", detail=kept)
275
+
276
+
277
+ def errors(call: Replayed) -> CheckVerdict:
278
+ """Broken on an error the session did not recover from; the recovered ones named."""
279
+ fatal = [failure for failure in call.failures if not failure.recoverable]
280
+ if fatal:
281
+ return CheckVerdict(
282
+ check="errors", status="broken", detail="; ".join(_failed(failure) for failure in fatal)
283
+ )
284
+ if call.failures:
285
+ recovered = "; ".join(_failed(failure) for failure in call.failures)
286
+ return CheckVerdict(
287
+ check="errors",
288
+ status="held",
289
+ detail=f"recovered from {len(call.failures)} error(s): {recovered}",
290
+ )
291
+ return CheckVerdict(check="errors", status="held", detail="the call logged no error")
292
+
293
+
294
+ # The worst turn, not the median: the caller who waited four seconds once heard four seconds.
295
+ def latency(call: Replayed, budget: Mapping[str, float] = DEFAULT_BUDGET) -> CheckVerdict:
296
+ """Each budgeted measure's worst turn, against its limit."""
297
+ measured = {
298
+ name: max(values)
299
+ for name, values in call.latencies.items()
300
+ if name in budget and name != TALK_SHARE and values
301
+ }
302
+ if not measured:
303
+ return CheckVerdict(
304
+ check="latency",
305
+ status="skipped",
306
+ detail=NOTHING_MEASURED.format(names=", ".join(budget)),
307
+ )
308
+ over = any(seconds > budget[name] for name, seconds in measured.items())
309
+ detail = "; ".join(
310
+ f"{name} {seconds:.3f}s {'>' if seconds > budget[name] else '<='} {budget[name]:.3f}s "
311
+ f"at its worst of {len(call.latencies[name])} turns"
312
+ for name, seconds in measured.items()
313
+ )
314
+ return CheckVerdict(check="latency", status="broken" if over else "held", detail=detail)
315
+
316
+
317
+ def talk(call: Replayed, budget: Mapping[str, float]) -> CheckVerdict:
318
+ """Broken when the agent took more of the talking than the budget's talk_share allows."""
319
+ limit = budget.get(TALK_SHARE)
320
+ if limit is None:
321
+ return CheckVerdict(check="talk", status="skipped", detail=NO_TALK_BUDGET)
322
+ shares = call.latencies.get(TALK_SHARE)
323
+ if not shares:
324
+ return CheckVerdict(check="talk", status="skipped", detail=NOTHING_TIMED)
325
+ share = shares[0]
326
+ over = share > limit
327
+ detail = f"the agent spoke {share:.0%} of the talking time, {'>' if over else '<='} {limit:.0%}"
328
+ return CheckVerdict(check="talk", status="broken" if over else "held", detail=detail)
329
+
330
+
331
+ # Only the replies the caller cut off with words are judged: those words want an answer.
332
+ def interruptions(call: Replayed) -> CheckVerdict:
333
+ """Each reply the caller cut off, followed by the agent answering them, not starting over."""
334
+ cut = [index for index, line in enumerate(call.turns) if line.interrupted]
335
+ if not cut:
336
+ return CheckVerdict(check="interruptions", status="skipped", detail=NOTHING_CUT_OFF)
337
+ faults: list[str] = []
338
+ answered = unanswerable = 0
339
+ for index in cut:
340
+ found = _answer_to(call, index)
341
+ if found is None:
342
+ unanswerable += 1
343
+ continue
344
+ heard, answer = found
345
+ if answer is None:
346
+ faults.append(f"the caller cut in at seq {heard.seq} and the agent never answered")
347
+ elif _started_over(call.turns[index].text, answer.text):
348
+ faults.append(
349
+ f"the reply at seq {answer.seq} started over the one cut off at seq "
350
+ f"{call.turns[index].seq} instead of answering the caller at seq {heard.seq}"
351
+ )
352
+ else:
353
+ answered += 1
354
+ if faults:
355
+ return CheckVerdict(check="interruptions", status="broken", detail="; ".join(faults))
356
+ if not answered:
357
+ detail = NOTHING_TO_ANSWER.format(count=unanswerable)
358
+ return CheckVerdict(check="interruptions", status="skipped", detail=detail)
359
+ detail = (
360
+ f"the agent stopped and answered the caller each of the {answered} time(s) it was cut off"
361
+ )
362
+ return CheckVerdict(check="interruptions", status="held", detail=detail)
363
+
364
+
365
+ def replay(
366
+ entries: Sequence[Entry],
367
+ *,
368
+ banned: Sequence[str],
369
+ budget: Mapping[str, float],
370
+ irreversible: Collection[str] | None,
371
+ ) -> list[CheckVerdict]:
372
+ """The six checks over a finished call: consent, register, errors, latency, talk, barge-ins."""
373
+ call = rebuild(entries)
374
+ return [
375
+ consent(call, irreversible),
376
+ register(call, banned),
377
+ errors(call),
378
+ latency(call, budget or DEFAULT_BUDGET),
379
+ talk(call, budget),
380
+ interruptions(call),
381
+ ]
382
+
383
+
384
+ def _ungranted(tool: GateLine, about: Sequence[GateLine]) -> str | None:
385
+ granted = _first(about, "confirm.granted")
386
+ if granted is None:
387
+ declined = _first(about, "confirm.declined")
388
+ if declined is not None:
389
+ after = f"after its confirm.declined at seq {declined.seq}"
390
+ return f"{tool.tool} ran at seq {tool.seq}, {after}"
391
+ return f"{tool.tool} ran at seq {tool.seq} with no confirm.granted before it"
392
+ if granted.seq > tool.seq:
393
+ return f"{tool.tool} ran at seq {tool.seq}, before its confirm.granted at seq {granted.seq}"
394
+ request = _first(about, "confirm.request")
395
+ if (
396
+ request is not None
397
+ and request.audience is not None
398
+ and request.audience != granted.audience
399
+ ):
400
+ return (
401
+ f"{tool.tool} at seq {tool.seq} was confirmed by {granted.audience} "
402
+ f"and asked of {request.audience} at seq {request.seq}"
403
+ )
404
+ return None
405
+
406
+
407
+ def _first(about: Sequence[GateLine], kind: GateKind) -> GateLine | None:
408
+ return next((line for line in about if line.kind == kind), None)
409
+
410
+
411
+ def _failed(failure: Failure) -> str:
412
+ return f"seq {failure.seq} {failure.code}: {failure.message}"
413
+
414
+
415
+ # The caller's next words after the cut, and the agent's next reply after them: None when the
416
+ # agent spoke again before the caller said anything, or the caller hung up with no reply owed.
417
+ def _answer_to(call: Replayed, index: int) -> tuple[Spoken, Spoken | None] | None:
418
+ after = call.turns[index + 1 :]
419
+ heard = next(iter(after), None)
420
+ if heard is None or heard.role != CALLER:
421
+ return None
422
+ answer = next((line for line in after if line.role == AGENT), None)
423
+ if answer is None and call.ended_by == "caller":
424
+ return None
425
+ return heard, answer
426
+
427
+
428
+ def _started_over(cut_off: str, answer: str) -> bool:
429
+ opening = _words_in_order(cut_off)[:STARTED_OVER_WORDS]
430
+ return (
431
+ len(opening) == STARTED_OVER_WORDS
432
+ and _words_in_order(answer)[:STARTED_OVER_WORDS] == opening
433
+ )
434
+
435
+
436
+ def _words_in_order(text: str) -> list[str]:
437
+ return [word.strip(PUNCTUATION).casefold() for word in text.split() if word.strip(PUNCTUATION)]
@@ -0,0 +1,197 @@
1
+ """The compliance judges, settled by code: the org named, automation disclosed, a stop honoured."""
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+
7
+ from livekit.agents.evals import JudgmentResult
8
+
9
+ from pinecall.domain.agent import AgentJudge
10
+ from pinecall.evals.case import AGENT, CALLER, Case, Said
11
+
12
+ IDENTIFIED = "An outbound call's first words name the organisation it calls for."
13
+
14
+ DISCLOSED = (
15
+ "The agent says it is an automated assistant before the caller's second turn, or answers so "
16
+ "the moment the caller asks."
17
+ )
18
+
19
+ HONOURED_STOP = (
20
+ "A caller who asks not to be called again is put on the do-not-call list on the same call."
21
+ )
22
+
23
+ # The caller's turn by which the agent must have said it is automated.
24
+ SAID_BY_TURN = 2
25
+
26
+ # A phone's keyboard types a curly apostrophe; the phrases are written with a straight one.
27
+ CURLY_APOSTROPHE = "\u2019"
28
+
29
+ # Lowercase, whole words inside a turn. First what the platform's own sentence says in every
30
+ # language it speaks (tenancy/disclosure.py; tests/evals/test_compliance.py holds the two
31
+ # together), then what an agent's own greeting says in English and Spanish.
32
+ AUTOMATED = (
33
+ "automated assistant",
34
+ "asistente automático",
35
+ "assistente automático",
36
+ "assistant automatique",
37
+ "automatischer assistent",
38
+ "assistente automatico",
39
+ "automated",
40
+ "virtual assistant",
41
+ "ai assistant",
42
+ "artificial intelligence",
43
+ "an ai",
44
+ "a bot",
45
+ "asistente automatico",
46
+ "asistente virtual",
47
+ "inteligencia artificial",
48
+ "un bot",
49
+ )
50
+
51
+ ASKS_IF_HUMAN = (
52
+ "are you a robot",
53
+ "are you a bot",
54
+ "are you real",
55
+ "are you human",
56
+ "a real person",
57
+ "is this a machine",
58
+ "am i talking to a",
59
+ "eres un robot",
60
+ "es un robot",
61
+ "eres una persona",
62
+ "es una persona",
63
+ "persona real",
64
+ "eres humano",
65
+ "es una máquina",
66
+ "hablo con una máquina",
67
+ )
68
+
69
+ ASKS_TO_STOP = (
70
+ "stop calling",
71
+ "don't call me",
72
+ "do not call me",
73
+ "take me off",
74
+ "remove me from",
75
+ "remove my number",
76
+ "put me on your do not call",
77
+ "no me llamen",
78
+ "no me llames",
79
+ "no vuelvan a llamar",
80
+ "dejen de llamar",
81
+ "deja de llamar",
82
+ "quítenme de",
83
+ "borren mi número",
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Compliance:
89
+ """What the seal knows of a call beyond its log: the org, its opening sentence, an opt-out."""
90
+
91
+ org: str
92
+ # The sentence outbound calls open with (the org's or the platform's); None when it set none.
93
+ disclosure: str | None
94
+ # A do-not-call fact was written with this call's id (call.opt_out).
95
+ opted_out: bool
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Panel:
100
+ """What a finished call is judged by beyond the fixed panel: the agent's own, and compliance."""
101
+
102
+ own: Sequence[AgentJudge] = ()
103
+ # None where there is no org behind the call to judge it for (a golden run, a test).
104
+ compliance: Compliance | None = None
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Ruled:
109
+ """One compliance judge's name, what it holds the call to, and the verdict code settled."""
110
+
111
+ name: str
112
+ criteria: str
113
+ settled: JudgmentResult
114
+
115
+
116
+ def ruled(case: Case, compliance: Compliance) -> list[Ruled]:
117
+ """The compliance judges this call meets: identified on an outbound call, then the other two."""
118
+ judged = [
119
+ Ruled("disclosed", DISCLOSED, _disclosed(case)),
120
+ Ruled("honoured_stop", HONOURED_STOP, _honoured_stop(case, compliance)),
121
+ ]
122
+ if case.direction == "outbound":
123
+ judged.insert(0, Ruled("identified", IDENTIFIED, _identified(case, compliance)))
124
+ return judged
125
+
126
+
127
+ def _identified(case: Case, compliance: Compliance) -> JudgmentResult:
128
+ first = next((turn for turn in case.turns if turn.role == AGENT), None)
129
+ if first is None:
130
+ return _passing("the agent never spoke on this call")
131
+ first_words = first.text.casefold()
132
+ opening = (compliance.disclosure or "").casefold()
133
+ if compliance.org.casefold() in first_words or (opening and opening in first_words):
134
+ return _passing(f"the first words named {compliance.org} (seq {first.seq})")
135
+ return _failing(f"the first words (seq {first.seq}) named no organisation")
136
+
137
+
138
+ def _disclosed(case: Case) -> JudgmentResult:
139
+ early = _before_the_callers_second_turn(case.turns)
140
+ disclosing = next(
141
+ (turn for turn in early if turn.role == AGENT and _says(turn, AUTOMATED)), None
142
+ )
143
+ if disclosing is not None:
144
+ return _passing(f"the agent said it is automated at seq {disclosing.seq}")
145
+ question = next(
146
+ (turn for turn in case.turns if turn.role == CALLER and _says(turn, ASKS_IF_HUMAN)), None
147
+ )
148
+ if question is None:
149
+ return _passing("the caller never asked whether a person was speaking")
150
+ answer = next(
151
+ (turn for turn in case.turns if turn.role == AGENT and turn.seq > question.seq), None
152
+ )
153
+ if answer is not None and _says(answer, AUTOMATED):
154
+ return _passing(f"asked at seq {question.seq}, it said it is automated at seq {answer.seq}")
155
+ return _failing(
156
+ f"the caller asked at seq {question.seq} and the agent did not say it is automated"
157
+ )
158
+
159
+
160
+ def _honoured_stop(case: Case, compliance: Compliance) -> JudgmentResult:
161
+ question = next(
162
+ (turn for turn in case.turns if turn.role == CALLER and _says(turn, ASKS_TO_STOP)), None
163
+ )
164
+ if question is None:
165
+ return _passing("nobody asked not to be called again")
166
+ if compliance.opted_out:
167
+ return _passing(f"asked at seq {question.seq}, the number went on the do-not-call list")
168
+ return _failing(
169
+ f"the caller asked not to be called again at seq {question.seq} and the number is not "
170
+ "on the do-not-call list: call.optOut() puts it there"
171
+ )
172
+
173
+
174
+ def _before_the_callers_second_turn(turns: Sequence[Said]) -> list[Said]:
175
+ heard = 0
176
+ kept: list[Said] = []
177
+ for turn in turns:
178
+ if turn.role == CALLER:
179
+ heard += 1
180
+ if heard == SAID_BY_TURN:
181
+ break
182
+ kept.append(turn)
183
+ return kept
184
+
185
+
186
+ # Whole words: "a bot" is not "a bottle".
187
+ def _says(turn: Said, phrases: Sequence[str]) -> bool:
188
+ words = turn.text.casefold().replace(CURLY_APOSTROPHE, "'")
189
+ return any(re.search(rf"\b{re.escape(phrase)}\b", words) for phrase in phrases)
190
+
191
+
192
+ def _passing(reason: str) -> JudgmentResult:
193
+ return JudgmentResult(verdict="pass", reasoning=reason)
194
+
195
+
196
+ def _failing(reason: str) -> JudgmentResult:
197
+ return JudgmentResult(verdict="fail", reasoning=reason)