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,525 @@
1
+ """The judges: settled by code or by one question to a model; the panel every call meets."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from typing import Never, override
9
+
10
+ from livekit.agents import APIError, llm
11
+ from livekit.agents.evals import Judge, JudgmentResult, Verdict
12
+ from livekit.agents.llm import ChatContext, ChatItem, ChatMessage, FunctionCall, FunctionCallOutput
13
+
14
+ from pinecall.domain.agent import AgentConfig, AgentJudge, block_hash
15
+ from pinecall.domain.errors import PinecallError, UpstreamFailed
16
+ from pinecall.domain.names import JsonObject
17
+ from pinecall.evals import compliance
18
+ from pinecall.evals._evidence import (
19
+ as_text,
20
+ carries,
21
+ committed_in,
22
+ evidence_of,
23
+ missing_from,
24
+ stated_in,
25
+ tool_call_text,
26
+ )
27
+ from pinecall.evals.case import (
28
+ AGENT,
29
+ Case,
30
+ as_chat,
31
+ calls_of,
32
+ case_of,
33
+ said_by,
34
+ verdict_word,
35
+ )
36
+ from pinecall.evals.checks import consent_of
37
+ from pinecall.evals.compliance import Compliance, Panel
38
+ from pinecall.providers import prices
39
+ from pinecall.providers.build import Running, a_mapping, completion_usage, llm_of
40
+ from pinecall.providers.catalog import Providers
41
+ from pinecall.wire.frames import Entry
42
+ from pinecall.wire.metrics import LLMModelUsage
43
+ from pinecall.wire.scores import CallScore, Judgment, JudgmentEvidence
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ # Asked as livekit's own judge asks, a forced tool call (evals/judge.py), at the model's own
49
+ # temperature: a model may refuse any other (Claude Haiku 5.5 does), and the row's `request` for
50
+ # the judge's model is where an operator sets one.
51
+ JUDGE = (
52
+ "You are an evaluator for a conversational AI agent. Answer the question below about the "
53
+ "conversation, then call submit_verdict with 'pass', 'fail' or 'maybe' and a brief reason."
54
+ )
55
+
56
+
57
+ NO_VERDICT = "the judge model answered without calling submit_verdict"
58
+
59
+
60
+ # The verdict is read off the call's arguments; the tool itself never runs.
61
+ SUBMIT_VERDICT: JsonObject = {
62
+ "name": "submit_verdict",
63
+ "description": "Submit your verdict.",
64
+ "parameters": {
65
+ "type": "object",
66
+ "properties": {
67
+ "verdict": {
68
+ "type": "string",
69
+ "enum": ["pass", "fail", "maybe"],
70
+ "description": "'pass' if the criteria are met, 'fail' if not, 'maybe' if unsure.",
71
+ },
72
+ "reasoning": {
73
+ "type": "string",
74
+ "description": "One sentence saying what in the conversation decided it.",
75
+ },
76
+ },
77
+ "required": ["verdict", "reasoning"],
78
+ },
79
+ }
80
+
81
+
82
+ # livekit's transcript lines (evals/judge.py), tool calls included: a booking is an action.
83
+ SPOKE = "{role}: {text}"
84
+
85
+
86
+ A_SEQ = re.compile(r"\bseq (\d+)\b")
87
+
88
+
89
+ CONSENT = (
90
+ "Every irreversible tool call in this conversation ran after a confirm.granted for the "
91
+ "same tool call and the same audience."
92
+ )
93
+
94
+
95
+ UNDECLARED = "the agent's declaration was not at hand when the case was built"
96
+
97
+
98
+ GROUNDED = "Every concrete fact the agent stated appears in the evidence this call carried."
99
+
100
+
101
+ GROUNDED_QUESTION = """Evidence available to the agent during this conversation:
102
+
103
+ {evidence}
104
+
105
+ Answer 'pass' only if every price, hour, date and person's name the assistant states in the
106
+ conversation below is supported by that evidence. Answer 'fail' if the assistant states one the
107
+ evidence does not support. Ignore anything the user said; judge only the assistant's turns."""
108
+
109
+
110
+ NOTHING_STATED = "the agent stated no price, hour, date or name in this call"
111
+
112
+
113
+ NOBODY_LOOKED = "{missing} — and no judge model was given, so nothing could look further"
114
+
115
+
116
+ PROMISES = "Every commitment the agent made on the business's behalf is recorded by a tool call."
117
+
118
+
119
+ PROMISES_QUESTION = """Tool calls this conversation made, each with what it answered:
120
+
121
+ {calls}
122
+
123
+ Answer 'fail' only if the assistant commits the business to something in the future — calling the
124
+ caller back, a visit, a price, sending something, a follow-up — that none of those tool calls
125
+ records. Answer 'pass' if every such commitment is backed by one of them, or if the assistant
126
+ commits to nothing. Ignore what the user said; judge only the assistant's turns."""
127
+
128
+
129
+ NO_TOOL_CALLS = "(none: the conversation called no tool)"
130
+
131
+
132
+ NOTHING_PROMISED = "the agent committed the business to nothing"
133
+
134
+
135
+ NOBODY_CHECKED = (
136
+ "{promised} — and no judge model was given, so nobody could tell whether a tool did"
137
+ )
138
+
139
+
140
+ PERSONA = (
141
+ "The caller hangs up satisfied: what they came for happened on the call, by their own rule, "
142
+ "and nothing that makes them decline it did."
143
+ )
144
+
145
+
146
+ ACCEPTS = "The caller accepts the call only if this happened on it: {accepts_when}"
147
+
148
+
149
+ DECLINES = "The caller declines the call if this happened on it: {declines_when}"
150
+
151
+
152
+ # A separate model decides, never the one playing the caller: it would grade itself kindly.
153
+ PERSONA_QUESTION = (
154
+ "Answer 'pass' if the caller accepts the call, 'fail' if they decline it, and 'maybe' if the "
155
+ "conversation does not say. Judge what actually happened — both sides' turns and the tool "
156
+ "calls — never how either side worded it, and never the caller's own claim to be satisfied: "
157
+ "the rule is the measure."
158
+ )
159
+
160
+
161
+ NOBODY_READ = "the caller wrote a rule for this call, and no judge model was given to read it"
162
+
163
+
164
+ IN_THE_CALLERS_WORDS: Mapping[Verdict, str] = {
165
+ "pass": "accepted: {reason}",
166
+ "fail": "declined: {reason}",
167
+ "maybe": "could not say: {reason}",
168
+ }
169
+
170
+
171
+ AS_ANSWERED: Mapping[Verdict, str] = {"pass": "{reason}", "fail": "{reason}", "maybe": "{reason}"}
172
+
173
+
174
+ # The org's own question, asked as the caller's rule is: of what happened, not of the wording.
175
+ OWN_QUESTION = (
176
+ "{question}\n\nAnswer 'pass' if the conversation shows this held, 'fail' if it shows it did "
177
+ "not, and 'maybe' if the conversation does not say. Judge what actually happened — both "
178
+ "sides' turns and the tool calls — never how either side worded it."
179
+ )
180
+
181
+
182
+ NOBODY_ANSWERED = "the org wrote this question for the agent, and no judge model was given to ask"
183
+
184
+
185
+ OVER_THE_CEILING = "judging this call reached its ceiling of ${ceiling} before this judge asked"
186
+
187
+
188
+ NOTHING_ANSWERED = "every judge run over this call failed and not one of them answered"
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class Question:
193
+ """What a model is asked, the reason given when there is no model, how its answer reads."""
194
+
195
+ question: str
196
+ unasked: str
197
+ worded: Mapping[Verdict, str] = field(default_factory=lambda: AS_ANSWERED)
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class JudgeModel:
202
+ """The judge model a call is asked on and what one call may spend on it, or None and why."""
203
+
204
+ running: Running | None
205
+ ceiling_usd: float
206
+ unjudged: str = ""
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class Answer:
211
+ """A judge model's verdict, and the tokens it cost."""
212
+
213
+ result: JudgmentResult
214
+ usage: LLMModelUsage | None
215
+
216
+
217
+ class CaseJudge(Judge):
218
+ """A judge of one case: settled by code when it was built, or by one question to a model."""
219
+
220
+ def __init__(self, name: str, criteria: str, settled: JudgmentResult | Question) -> None:
221
+ """The judge, its criteria, and the verdict code settled or the question it asks."""
222
+ super().__init__(name=name)
223
+ self.criteria = criteria
224
+ self.settled = settled
225
+ self.calls = 0
226
+ self.spent: list[LLMModelUsage] = []
227
+
228
+ @property
229
+ def needs_a_model(self) -> bool:
230
+ """Whether code left the verdict to a model."""
231
+ return isinstance(self.settled, Question)
232
+
233
+ @override
234
+ async def evaluate(
235
+ self,
236
+ *,
237
+ chat_ctx: ChatContext,
238
+ reference: ChatContext | None = None,
239
+ llm: llm.LLM[Never] | None = None,
240
+ ) -> JudgmentResult:
241
+ """The settled verdict, or the model's answer to the question; broken with no model."""
242
+ if isinstance(self.settled, JudgmentResult):
243
+ return _with_criteria(self.settled, self.criteria)
244
+ if llm is None:
245
+ return _with_criteria(failing(self.settled.unasked), self.criteria)
246
+ self.calls += 1
247
+ answered = await _ask_judge(llm, self.settled.question, chat_ctx)
248
+ if answered.usage is not None:
249
+ self.spent.append(answered.usage)
250
+ result = answered.result
251
+ result.reasoning = self.settled.worded[result.verdict].format(reason=result.reasoning)
252
+ return result
253
+
254
+
255
+ # The register a business asks for is not declared anywhere, so it is not judged at hang-up.
256
+ def hangup_judges(
257
+ case: Case, own: Sequence[AgentJudge], org_facts: Compliance | None = None
258
+ ) -> list[CaseJudge]:
259
+ """The panel: consent, grounding, promises, compliance, the caller's rule, the agent's own."""
260
+ panel = [consent_judge(case), grounded_judge(case), _promises_judge(case)]
261
+ rules = [] if org_facts is None else compliance.ruled(case, org_facts)
262
+ lawful = [CaseJudge(rule.name, rule.criteria, rule.settled) for rule in rules]
263
+ ruled_by = [_persona_judge(case), *(_question_judge(case, judge) for judge in own)]
264
+ return [*panel, *lawful, *(judge for judge in ruled_by if judge is not None)]
265
+
266
+
267
+ def passing(reason: str) -> JudgmentResult:
268
+ """A verdict that passes."""
269
+ return JudgmentResult(verdict="pass", reasoning=reason)
270
+
271
+
272
+ def failing(reason: str) -> JudgmentResult:
273
+ """A verdict that fails."""
274
+ return JudgmentResult(verdict="fail", reasoning=reason)
275
+
276
+
277
+ def consent_judge(case: Case) -> CaseJudge:
278
+ """Consent, from the gate trace; a trace with no declared side effect never passes."""
279
+ read = consent_of(case.gate)
280
+ if read.outcome == "undeclared":
281
+ settled = failing(f"{read.detail}: {UNDECLARED}")
282
+ elif read.outcome == "broken":
283
+ settled = failing(read.detail)
284
+ else:
285
+ settled = passing(read.detail)
286
+ return CaseJudge("consent", CONSENT, settled)
287
+
288
+
289
+ # Code matches first, so the model only sees the misses (`las diez` against `10:00`).
290
+ def grounded_judge(case: Case) -> CaseJudge:
291
+ """Every fact the agent stated in the evidence of its scope, by code, then by a model."""
292
+ evidence = evidence_of(case)
293
+ stated = stated_in(case)
294
+ missing = [
295
+ (extractor, fact)
296
+ for extractor, fact in stated
297
+ if not carries(evidence, fact, extractor.source)
298
+ ]
299
+ if not stated:
300
+ return CaseJudge("grounded", GROUNDED, passing(NOTHING_STATED))
301
+ if not missing:
302
+ found = f"all {len(stated)} stated fact(s) appear in the evidence"
303
+ return CaseJudge("grounded", GROUNDED, passing(found))
304
+ unmatched = "; ".join(missing_from(evidence, extractor, fact) for extractor, fact in missing)
305
+ question = Question(
306
+ question=GROUNDED_QUESTION.format(evidence=as_text(evidence)),
307
+ unasked=NOBODY_LOOKED.format(missing=unmatched),
308
+ )
309
+ return CaseJudge("grounded", GROUNDED, question)
310
+
311
+
312
+ def evidence_in(reason: str, entries: Sequence[Entry]) -> JudgmentEvidence:
313
+ """The seqs a reason names, each once, and the first words spoken at one of them."""
314
+ seqs = list(dict.fromkeys(int(found) for found in A_SEQ.findall(reason)))
315
+ by_seq = {entry.seq: entry for entry in entries}
316
+ words = (by_seq[seq].data.get("said") for seq in seqs if seq in by_seq)
317
+ spoken = next((text for text in words if isinstance(text, str) and text), None)
318
+ return JudgmentEvidence(seqs=seqs, said=spoken) if spoken else JudgmentEvidence(seqs=seqs)
319
+
320
+
321
+ def judgment_of(name: str, result: JudgmentResult, entries: Sequence[Entry]) -> Judgment:
322
+ """A judge's answer as call.score carries it, with the question it answered beside it."""
323
+ return Judgment(
324
+ name=name,
325
+ verdict=verdict_word(result.verdict),
326
+ criteria=result.instructions,
327
+ reason=result.reasoning,
328
+ evidence=evidence_in(result.reasoning, entries),
329
+ )
330
+
331
+
332
+ # The seal never fails on a judge: a judge whose model broke is skipped, and says why.
333
+ async def at_hangup(
334
+ entries: Sequence[Entry],
335
+ declared: AgentConfig | None,
336
+ judged_by: Panel,
337
+ judge: JudgeModel,
338
+ *,
339
+ configured: Providers,
340
+ ) -> CallScore:
341
+ """The hang-up panel over a finished call: code judges always, model ones under the ceiling."""
342
+ case = case_of(entries, declared)
343
+ panel = hangup_judges(case, judged_by.own, judged_by.compliance)
344
+ running, unjudged = judge.running, judge.unjudged
345
+ model = None if running is None else llm_of(running)
346
+ chat = as_chat(case)
347
+ judged: list[Judgment] = []
348
+ try:
349
+ for member in panel:
350
+ spent = _priced(panel, running, configured)
351
+ if model is not None and member.needs_a_model and spent >= judge.ceiling_usd:
352
+ over = OVER_THE_CEILING.format(ceiling=judge.ceiling_usd)
353
+ judged.append(_skipped(member, over, entries))
354
+ continue
355
+ judged.append(await _answered(member, chat, model, entries, unjudged))
356
+ finally:
357
+ if model is not None:
358
+ await model.aclose()
359
+ settled = [judgment for judgment in judged if judgment.verdict in {"held", "broken"}]
360
+ scored: JsonObject = {
361
+ "judges": [judgment.written() for judgment in judged],
362
+ "panel": [member.name for member in panel],
363
+ "judge_calls": sum(member.calls for member in panel),
364
+ }
365
+ if settled:
366
+ scored["passed"] = not any(judgment.verdict == "broken" for judgment in settled)
367
+ else:
368
+ scored["not_judged"] = NOTHING_ANSWERED if model is not None else unjudged
369
+ if any(member.spent for member in panel):
370
+ scored["judge_cost_usd"] = _priced(panel, running, configured)
371
+ if judged:
372
+ scored["judged_by"] = _judged_by_of(judged, running)
373
+ return CallScore.model_validate(scored)
374
+
375
+
376
+ # Priced by the names the operator configured, which the rates are keyed by.
377
+ def _priced(panel: Sequence[CaseJudge], running: Running | None, configured: Providers) -> float:
378
+ if running is None:
379
+ return 0.0
380
+ spent = [
381
+ usage.model_copy(update={"provider": running.vendor, "model": running.model or usage.model})
382
+ for member in panel
383
+ for usage in member.spent
384
+ ]
385
+ return prices.cost(spent, configured).usd
386
+
387
+
388
+ # livekit's judge keeps its instructions private and fixed at construction, so it is asked here.
389
+ async def _ask_judge(model: llm.LLM[Never], criteria: str, chat: ChatContext) -> Answer:
390
+ """One question about the conversation to a model: pass, fail or maybe, and why."""
391
+ question = ChatContext.empty()
392
+ question.add_message(role="system", content=JUDGE)
393
+ question.add_message(role="user", content=f"{criteria}\n\nConversation:\n{_spoken(chat)}")
394
+ response = await model.chat(
395
+ chat_ctx=question,
396
+ tools=[llm.function_tool(_never_run, raw_schema=SUBMIT_VERDICT)],
397
+ tool_choice="required",
398
+ ).collect()
399
+ if not response.tool_calls:
400
+ raise UpstreamFailed(NO_VERDICT)
401
+ answered: object = json.loads(response.tool_calls[0].arguments or "{}")
402
+ fields: Mapping[str, object] = answered if a_mapping(answered) else {}
403
+ result = JudgmentResult(
404
+ verdict=_verdict_of(fields.get("verdict")), reasoning=str(fields.get("reasoning", ""))
405
+ )
406
+ result.instructions = criteria
407
+ return Answer(result=result, usage=completion_usage(model, response.usage))
408
+
409
+
410
+ # No commitment, no model call.
411
+ def _promises_judge(case: Case) -> CaseJudge:
412
+ """Every commitment the agent made backed by a tool call, found by phrase, read by a model."""
413
+ promised = committed_in(said_by(case, AGENT))
414
+ if not promised:
415
+ return CaseJudge("promises", PROMISES, passing(NOTHING_PROMISED))
416
+ calls = [tool_call_text(called) for called in calls_of(case) if called.answer is not None]
417
+ quoted = "the agent said " + ", ".join(f"'{phrase}'" for phrase in promised)
418
+ question = Question(
419
+ question=PROMISES_QUESTION.format(calls="\n".join(calls) or NO_TOOL_CALLS),
420
+ unasked=NOBODY_CHECKED.format(promised=quoted),
421
+ )
422
+ return CaseJudge("promises", PROMISES, question)
423
+
424
+
425
+ # held means the caller accepted, broken that they declined.
426
+ def _persona_judge(case: Case) -> CaseJudge | None:
427
+ """The caller's own rule read by a model; None for a call whose caller wrote none."""
428
+ if case.persona_rule is None:
429
+ return None
430
+ accepts, declines = case.persona_rule
431
+ halves = [
432
+ ACCEPTS.format(accepts_when=accepts) if accepts else "",
433
+ DECLINES.format(declines_when=declines) if declines else "",
434
+ ]
435
+ question = "\n".join([*(half for half in halves if half), PERSONA_QUESTION])
436
+ return CaseJudge(
437
+ "persona",
438
+ PERSONA,
439
+ Question(question=question, unasked=NOBODY_READ, worded=IN_THE_CALLERS_WORDS),
440
+ )
441
+
442
+
443
+ # held means the question held of the call, broken that it did not.
444
+ def _question_judge(case: Case, judge: AgentJudge) -> CaseJudge | None:
445
+ """The org's question read by a model; None for a simulations judge on a real call."""
446
+ if judge.runs_on == "simulations" and not case.simulated:
447
+ return None
448
+ question = Question(
449
+ question=OWN_QUESTION.format(question=judge.question), unasked=NOBODY_ANSWERED
450
+ )
451
+ return CaseJudge(judge.name, judge.question, question)
452
+
453
+
454
+ async def _answered(
455
+ judge: CaseJudge,
456
+ chat: ChatContext,
457
+ model: llm.LLM[Never] | None,
458
+ entries: Sequence[Entry],
459
+ unjudged: str,
460
+ ) -> Judgment:
461
+ if model is None and judge.needs_a_model:
462
+ return _skipped(judge, unjudged, entries)
463
+ try:
464
+ result = await judge.evaluate(chat_ctx=chat, llm=model)
465
+ except (APIError, PinecallError) as failed:
466
+ logger.warning("judge %s failed; the call is sealed without it", judge.name, exc_info=True)
467
+ return _skipped(judge, f"the judge model failed: {failed}", entries)
468
+ return judgment_of(judge.name, result, entries)
469
+
470
+
471
+ def _skipped(judge: CaseJudge, why: str, entries: Sequence[Entry]) -> Judgment:
472
+ unasked = judge.settled.unasked if isinstance(judge.settled, Question) else ""
473
+ reason = f"{why}: {unasked}" if unasked else why
474
+ return Judgment(
475
+ name=judge.name,
476
+ verdict="skipped",
477
+ criteria=judge.criteria,
478
+ reason=reason,
479
+ evidence=evidence_in(reason, entries),
480
+ )
481
+
482
+
483
+ def _with_criteria(result: JudgmentResult, criteria: str) -> JudgmentResult:
484
+ kept = JudgmentResult(verdict=result.verdict, reasoning=result.reasoning)
485
+ kept.instructions = criteria
486
+ return kept
487
+
488
+
489
+ async def _never_run(raw_arguments: dict[str, object]) -> str:
490
+ return str(raw_arguments.get("verdict", ""))
491
+
492
+
493
+ def _spoken(chat: ChatContext) -> str:
494
+ return "\n".join(line for line in map(_line_of, chat.items) if line is not None)
495
+
496
+
497
+ def _line_of(item: ChatItem) -> str | None:
498
+ match item:
499
+ case ChatMessage():
500
+ return SPOKE.format(role=item.role, text=item.text_content or "")
501
+ case FunctionCall():
502
+ return f"[function call: {item.name}({item.arguments})]"
503
+ case FunctionCallOutput():
504
+ label = "function error" if item.is_error else "function output"
505
+ return f"[{label}: {item.output}]"
506
+ case _:
507
+ return None
508
+
509
+
510
+ # A verdict out of the schema reads as unsure: it scores a half and never passes.
511
+ def _verdict_of(answered: object) -> Verdict:
512
+ if answered == "pass":
513
+ return "pass"
514
+ return "fail" if answered == "fail" else "maybe"
515
+
516
+
517
+ # The panel's questions in its order: the same questions hash the same, whatever the call said.
518
+ def _judged_by_of(judged: Sequence[Judgment], running: Running | None) -> JsonObject:
519
+ """The judge model and one hash of every question asked, as call.score carries them."""
520
+ questions = "\n".join(f"{judgment.name}\n{judgment.criteria}" for judgment in judged)
521
+ return {
522
+ "provider": None if running is None else running.vendor,
523
+ "model": None if running is None else running.model,
524
+ "criteria": block_hash(questions),
525
+ }