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