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/checks.py
ADDED
|
@@ -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)
|