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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/evals/judges.py
ADDED
|
@@ -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
|
+
}
|