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/runner/main.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""The runner: a world's hosted apps kept running as the gateway wants them, one pod each."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import uvicorn
|
|
14
|
+
from starlette.applications import Starlette
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
from starlette.responses import Response
|
|
17
|
+
from starlette.routing import Route
|
|
18
|
+
|
|
19
|
+
from pinecall.domain.errors import GatewayRefused, SettingsRefused, UpstreamFailed
|
|
20
|
+
from pinecall.domain.names import PRODUCTION, Env
|
|
21
|
+
from pinecall.process.settings import Settings
|
|
22
|
+
from pinecall.runner._kube import Cluster, Container, Engine, Launch, in_cluster
|
|
23
|
+
from pinecall.runner._plan import (
|
|
24
|
+
Failed,
|
|
25
|
+
Revive,
|
|
26
|
+
Start,
|
|
27
|
+
Step,
|
|
28
|
+
Stop,
|
|
29
|
+
WentLive,
|
|
30
|
+
crashes_within,
|
|
31
|
+
label_of,
|
|
32
|
+
planned,
|
|
33
|
+
)
|
|
34
|
+
from pinecall.wire.rest.hosting import (
|
|
35
|
+
AppEnvironment,
|
|
36
|
+
AppLogs,
|
|
37
|
+
RunnerHeartbeatRequest,
|
|
38
|
+
RunnerHeartbeatResponse,
|
|
39
|
+
RunnerReport,
|
|
40
|
+
WantedApp,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
BEAT_S = 5.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# What a failure says of the process's own output: its last lines.
|
|
50
|
+
LAST_LINES = 20
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# What a person asking for an app's logs is sent: its process's last lines.
|
|
54
|
+
SENT_LINES = 300
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
LONGEST_WHY = 2000
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
START = ("./node_modules/.bin/pinecall", "start")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
NO_KEY = "PINECALL_RUNNER_KEY: a runner knocks its gateway with its world's runner key"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
NOT_THE_RELEASE = "release {release}'s sources are not the ones uploaded: sha256 differs"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
START_FAILED = "the cluster could not start the release:\n{output}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# A release is (org, name, release): what the gateway numbers and what a pod carries.
|
|
73
|
+
type Release = tuple[str, str, int]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Runner:
|
|
78
|
+
"""A world's runner: what it holds between two beats, and the steps it has in hand."""
|
|
79
|
+
|
|
80
|
+
cluster: Cluster
|
|
81
|
+
gateway: httpx.AsyncClient
|
|
82
|
+
reports: list[RunnerReport] = field(default_factory=list[RunnerReport])
|
|
83
|
+
logs: list[AppLogs] = field(default_factory=list[AppLogs])
|
|
84
|
+
# When each host's process exited and was run again, so one that keeps exiting is given up.
|
|
85
|
+
crashes: dict[str, list[float]] = field(default_factory=dict[str, list[float]])
|
|
86
|
+
# One task per app with steps under way; an app with one is left alone this beat.
|
|
87
|
+
working: dict[str, asyncio.Task[None]] = field(default_factory=dict[str, asyncio.Task[None]])
|
|
88
|
+
# The releases a pod of this world may fetch, by their digest: read off the gateway, checked.
|
|
89
|
+
sources: dict[str, bytes] = field(default_factory=dict[str, bytes])
|
|
90
|
+
digests: dict[Release, str] = field(default_factory=dict[Release, str])
|
|
91
|
+
|
|
92
|
+
async def beat(self, name: str) -> RunnerHeartbeatResponse:
|
|
93
|
+
"""Send what happened since the last beat; the gateway answers what should be running."""
|
|
94
|
+
reports, self.reports = self.reports, []
|
|
95
|
+
logs, self.logs = self.logs, []
|
|
96
|
+
body = RunnerHeartbeatRequest(runner=name, reports=reports, logs=logs)
|
|
97
|
+
try:
|
|
98
|
+
answer = await self.gateway.post("/v1/runner/heartbeat", json=body.written())
|
|
99
|
+
except httpx.HTTPError:
|
|
100
|
+
self.reports, self.logs = reports + self.reports, logs + self.logs
|
|
101
|
+
raise
|
|
102
|
+
if not answer.is_success:
|
|
103
|
+
self.reports, self.logs = reports + self.reports, logs + self.logs
|
|
104
|
+
raise GatewayRefused(answer.text, answered=answer.status_code)
|
|
105
|
+
return RunnerHeartbeatResponse.model_validate(answer.json())
|
|
106
|
+
|
|
107
|
+
async def reconcile(self, wanted: RunnerHeartbeatResponse, now: float) -> None:
|
|
108
|
+
"""Make the pods what the gateway wants; the long steps go on past this beat."""
|
|
109
|
+
# Finished first: a step that ends after the listing would be planned again on a stale one.
|
|
110
|
+
self._finished()
|
|
111
|
+
containers = await self.cluster.pods(wanted.world)
|
|
112
|
+
for label, steps in planned(wanted.apps, containers, self.crashes, now).items():
|
|
113
|
+
if label not in self.working:
|
|
114
|
+
self.working[label] = asyncio.create_task(self._done(wanted.world, steps, now))
|
|
115
|
+
await self._logs_asked_for(wanted.apps, containers)
|
|
116
|
+
self._swept(wanted.apps, containers)
|
|
117
|
+
|
|
118
|
+
async def close(self) -> None:
|
|
119
|
+
"""Leave: every step under way is cut short; the pods go on without the runner."""
|
|
120
|
+
for task in self.working.values():
|
|
121
|
+
task.cancel()
|
|
122
|
+
await asyncio.gather(*self.working.values(), return_exceptions=True)
|
|
123
|
+
self.working.clear()
|
|
124
|
+
|
|
125
|
+
def _finished(self) -> None:
|
|
126
|
+
for label, task in list(self.working.items()):
|
|
127
|
+
if task.done():
|
|
128
|
+
del self.working[label]
|
|
129
|
+
if not task.cancelled() and task.exception() is not None:
|
|
130
|
+
logger.error("%s: its steps failed", label, exc_info=task.exception())
|
|
131
|
+
|
|
132
|
+
async def _done(self, world: Env, steps: Sequence[Step], now: float) -> None:
|
|
133
|
+
for step in steps:
|
|
134
|
+
match step:
|
|
135
|
+
case Start(app):
|
|
136
|
+
await self._started(world, app, app.host, app.release)
|
|
137
|
+
case Revive(app, container):
|
|
138
|
+
await self._revived(world, app, container, now)
|
|
139
|
+
case Stop(container):
|
|
140
|
+
await self.cluster.stop(container.name)
|
|
141
|
+
logger.info("stopped %s", container.name)
|
|
142
|
+
case WentLive(app):
|
|
143
|
+
self._report(app, "live", "")
|
|
144
|
+
case Failed(app, why, container):
|
|
145
|
+
output = await self.cluster.logs(container.name, LAST_LINES)
|
|
146
|
+
self._report(app, "failed", why.format(output=_tail(output)))
|
|
147
|
+
|
|
148
|
+
async def _started(self, world: Env, app: WantedApp, host: str, release: int) -> None:
|
|
149
|
+
try:
|
|
150
|
+
digest = await self._fetched(app, release)
|
|
151
|
+
environment = await self._environment(app)
|
|
152
|
+
except UpstreamFailed as failure:
|
|
153
|
+
self._report(app, "failed", str(failure))
|
|
154
|
+
return
|
|
155
|
+
launch = Launch(
|
|
156
|
+
world=world,
|
|
157
|
+
app=label_of(app),
|
|
158
|
+
host=host,
|
|
159
|
+
release=release,
|
|
160
|
+
sha256=digest,
|
|
161
|
+
command=[*START, "--prod"] if world == PRODUCTION else list(START),
|
|
162
|
+
)
|
|
163
|
+
try:
|
|
164
|
+
await self.cluster.start(launch, environment)
|
|
165
|
+
except UpstreamFailed as failure:
|
|
166
|
+
self._report(app, "failed", START_FAILED.format(output=str(failure)))
|
|
167
|
+
return
|
|
168
|
+
logger.info("started %s release %d as %s", label_of(app), release, host)
|
|
169
|
+
|
|
170
|
+
# Its last lines are kept as the app's logs before the pod goes, so the exit is read.
|
|
171
|
+
async def _revived(self, world: Env, app: WantedApp, container: Container, now: float) -> None:
|
|
172
|
+
output = await self.cluster.logs(container.name, SENT_LINES)
|
|
173
|
+
self.logs.append(AppLogs(org=app.org, name=app.name, host=container.name, lines=output))
|
|
174
|
+
logger.warning(
|
|
175
|
+
"%s: %s exited, run again:\n%s", label_of(app), container.name, _tail(output)
|
|
176
|
+
)
|
|
177
|
+
self.crashes[container.name] = [
|
|
178
|
+
*crashes_within(self.crashes.get(container.name, []), now),
|
|
179
|
+
now,
|
|
180
|
+
]
|
|
181
|
+
await self.cluster.stop(container.name, grace_s=0)
|
|
182
|
+
await self._started(world, app, container.name, container.release)
|
|
183
|
+
|
|
184
|
+
# The wanted release is checked against the digest the gateway kept when it was uploaded; an
|
|
185
|
+
# older one, revived, is the one the gateway still holds under its number.
|
|
186
|
+
async def _fetched(self, app: WantedApp, release: int) -> str:
|
|
187
|
+
known = self.digests.get((app.org, app.name, release))
|
|
188
|
+
if known is not None and known in self.sources:
|
|
189
|
+
return known
|
|
190
|
+
answer = await self.gateway.get(
|
|
191
|
+
f"/v1/runner/apps/{app.org}/{app.name}/releases/{release}/source"
|
|
192
|
+
)
|
|
193
|
+
if not answer.is_success:
|
|
194
|
+
raise UpstreamFailed(f"the release's sources: {answer.status_code} {answer.text}")
|
|
195
|
+
digest = hashlib.sha256(answer.content).hexdigest()
|
|
196
|
+
if release == app.release and digest != app.sha256:
|
|
197
|
+
raise UpstreamFailed(NOT_THE_RELEASE.format(release=release))
|
|
198
|
+
self.sources[digest] = answer.content
|
|
199
|
+
self.digests[(app.org, app.name, release)] = digest
|
|
200
|
+
return digest
|
|
201
|
+
|
|
202
|
+
async def _environment(self, app: WantedApp) -> Mapping[str, str]:
|
|
203
|
+
answer = await self.gateway.get(f"/v1/runner/apps/{app.org}/{app.name}/environment")
|
|
204
|
+
if not answer.is_success:
|
|
205
|
+
raise UpstreamFailed(f"the app's environment: {answer.status_code} {answer.text}")
|
|
206
|
+
return AppEnvironment.model_validate(answer.json()).environment
|
|
207
|
+
|
|
208
|
+
# The host asked for, else the one still serving while it installs.
|
|
209
|
+
async def _logs_asked_for(
|
|
210
|
+
self, apps: Sequence[WantedApp], containers: Sequence[Container]
|
|
211
|
+
) -> None:
|
|
212
|
+
for app in apps:
|
|
213
|
+
ours = [each.name for each in containers if each.app == label_of(app)]
|
|
214
|
+
host = app.host if app.host in ours else next(iter(ours), None)
|
|
215
|
+
if not app.logs_wanted or host is None:
|
|
216
|
+
continue
|
|
217
|
+
output = await self.cluster.logs(host, SENT_LINES)
|
|
218
|
+
self.logs.append(AppLogs(org=app.org, name=app.name, host=host, lines=output))
|
|
219
|
+
|
|
220
|
+
# A release no pod runs and nobody wants is no longer anybody's to fetch.
|
|
221
|
+
def _swept(self, apps: Sequence[WantedApp], containers: Sequence[Container]) -> None:
|
|
222
|
+
in_use: set[Release] = {(app.org, app.name, app.release) for app in apps}
|
|
223
|
+
for each in containers:
|
|
224
|
+
org, _, name = each.app.partition("/")
|
|
225
|
+
in_use.add((org, name, each.release))
|
|
226
|
+
for release in [each for each in self.digests if each not in in_use]:
|
|
227
|
+
del self.digests[release]
|
|
228
|
+
kept = set(self.digests.values())
|
|
229
|
+
for digest in [each for each in self.sources if each not in kept]:
|
|
230
|
+
del self.sources[digest]
|
|
231
|
+
|
|
232
|
+
def _report(self, app: WantedApp, state: str, why: str) -> None:
|
|
233
|
+
if state == "failed":
|
|
234
|
+
logger.warning("%s release %d failed: %s", label_of(app), app.release, why)
|
|
235
|
+
self.reports.append(
|
|
236
|
+
RunnerReport.model_validate(
|
|
237
|
+
{
|
|
238
|
+
"org": app.org,
|
|
239
|
+
"name": app.name,
|
|
240
|
+
"host": app.host,
|
|
241
|
+
"state": state,
|
|
242
|
+
"why": why[-LONGEST_WHY:],
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def sources_app(runner: Runner) -> Starlette:
|
|
249
|
+
"""The one door a pod of this world knocks: its release's sources, by their digest."""
|
|
250
|
+
|
|
251
|
+
async def served(request: Request) -> Response:
|
|
252
|
+
found = runner.sources.get(str(request.path_params["sha256"]))
|
|
253
|
+
if found is None:
|
|
254
|
+
return Response(status_code=404)
|
|
255
|
+
return Response(found, media_type="application/gzip")
|
|
256
|
+
|
|
257
|
+
return Starlette(routes=[Route("/sources/{sha256}", served)])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def run(settings: Settings) -> int:
|
|
261
|
+
"""Keep the world's hosted apps running until told to stop; the apps go on without it."""
|
|
262
|
+
if settings.runner_key is None:
|
|
263
|
+
raise SettingsRefused(NO_KEY)
|
|
264
|
+
stop = asyncio.Event()
|
|
265
|
+
loop = asyncio.get_running_loop()
|
|
266
|
+
for each in (signal.SIGTERM, signal.SIGINT):
|
|
267
|
+
loop.add_signal_handler(each, stop.set)
|
|
268
|
+
engine = Engine(
|
|
269
|
+
image=settings.runner_image,
|
|
270
|
+
runtime_class=settings.runner_runtime_class,
|
|
271
|
+
namespace=settings.runner_namespace,
|
|
272
|
+
sources_url=settings.runner_sources_url,
|
|
273
|
+
)
|
|
274
|
+
headers = {"Authorization": f"Bearer {settings.runner_key}"}
|
|
275
|
+
async with (
|
|
276
|
+
httpx.AsyncClient(base_url=settings.gateway_url, headers=headers, timeout=30.0) as gateway,
|
|
277
|
+
in_cluster(engine).http as kube,
|
|
278
|
+
):
|
|
279
|
+
runner = Runner(cluster=Cluster(http=kube, engine=engine), gateway=gateway)
|
|
280
|
+
server = uvicorn.Server(
|
|
281
|
+
uvicorn.Config(
|
|
282
|
+
sources_app(runner),
|
|
283
|
+
host=settings.runner_listen,
|
|
284
|
+
port=settings.runner_port,
|
|
285
|
+
log_level="warning",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
serving = asyncio.create_task(server.serve())
|
|
289
|
+
name = socket.gethostname().split(".")[0]
|
|
290
|
+
while not stop.is_set():
|
|
291
|
+
try:
|
|
292
|
+
await runner.reconcile(await runner.beat(name), time.time())
|
|
293
|
+
except (UpstreamFailed, httpx.HTTPError):
|
|
294
|
+
logger.warning("runner: this beat did nothing", exc_info=True)
|
|
295
|
+
try:
|
|
296
|
+
await asyncio.wait_for(stop.wait(), BEAT_S)
|
|
297
|
+
except TimeoutError:
|
|
298
|
+
continue
|
|
299
|
+
await runner.close()
|
|
300
|
+
server.should_exit = True
|
|
301
|
+
await serving
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _tail(output: str) -> str:
|
|
306
|
+
return "\n".join(output.strip().splitlines()[-LAST_LINES:])
|
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The livekit Agent the session runs: the prompt, the ears' filter, and the hooks around a turn."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from collections.abc import AsyncIterable, AsyncIterator, Callable
|
|
6
|
+
from contextlib import AbstractAsyncContextManager
|
|
7
|
+
from typing import override
|
|
8
|
+
|
|
9
|
+
from livekit import rtc
|
|
10
|
+
from livekit.agents import (
|
|
11
|
+
llm,
|
|
12
|
+
stt,
|
|
13
|
+
)
|
|
14
|
+
from livekit.agents.types import TimedString
|
|
15
|
+
from livekit.agents.voice import Agent, AgentSession, ModelSettings
|
|
16
|
+
|
|
17
|
+
from pinecall.session import _prompt, tools
|
|
18
|
+
from pinecall.session._livekit import transcript_of
|
|
19
|
+
from pinecall.session._prompt import Blocks
|
|
20
|
+
from pinecall.wire.events import ErrorEvent
|
|
21
|
+
|
|
22
|
+
type Heard = AsyncIterator[stt.SpeechEvent]
|
|
23
|
+
type Declared = list[llm.Tool | llm.Toolset]
|
|
24
|
+
type Thought = AsyncIterator[llm.ChatChunk | str]
|
|
25
|
+
type Words = AsyncIterator[str | TimedString]
|
|
26
|
+
# What plays while the caller waits: the hold melody after its grace, or nothing.
|
|
27
|
+
type Waiting = Callable[[], AbstractAsyncContextManager[None]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
LLM_TIMEOUT = "llm_timeout"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
MODEL_SILENT = "the model said nothing within {seconds:g}s, so the caller was asked to say it again"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# How long a turn waits for the model's first word when the agent sets no deadline: past it the
|
|
37
|
+
# caller hears MODEL_LATE instead of a silence a model stuck for 20 s would leave them in.
|
|
38
|
+
LLM_TIMEOUT_S = 12.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# What the caller hears when the model is past the deadline, by the agent's language; English
|
|
42
|
+
# where the agent's language has no sentence here.
|
|
43
|
+
MODEL_LATE = {
|
|
44
|
+
"es": "Disculpe, me demoré. ¿Me lo repite, por favor?",
|
|
45
|
+
"en": "Sorry, that took me too long. Could you say that again?",
|
|
46
|
+
"pt": "Desculpe, demorei. Pode repetir, por favor?",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CallAgent(Agent):
|
|
51
|
+
"""livekit's Agent reading the call's prompt blocks, lookups and tools."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
live: AgentSession[None],
|
|
56
|
+
blocks: Blocks,
|
|
57
|
+
lookups: tools.Lookups,
|
|
58
|
+
declared: Declared,
|
|
59
|
+
waiting: Waiting,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""The agent with the static blocks as its instructions and every tool of the call."""
|
|
62
|
+
super().__init__(instructions=blocks.instructions, tools=declared) # pyright: ignore[reportUnknownMemberType]
|
|
63
|
+
self.live = live
|
|
64
|
+
self.call = lookups.call
|
|
65
|
+
self.blocks = blocks
|
|
66
|
+
self.lookups = lookups
|
|
67
|
+
self.waiting = waiting
|
|
68
|
+
|
|
69
|
+
# Runs before the request and livekit times it, so the lookups here are the ones already
|
|
70
|
+
# running (started on an interim) or quick ones. No speech handle exists yet.
|
|
71
|
+
@override
|
|
72
|
+
async def on_user_turn_completed(
|
|
73
|
+
self, turn_ctx: llm.ChatContext, new_message: llm.ChatMessage
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Collect this turn's lookups and write the ones that did not run."""
|
|
76
|
+
for skipped in await self.lookups.turn_ended(new_message.text_content or ""):
|
|
77
|
+
self.call.writing.write("error", skipped)
|
|
78
|
+
|
|
79
|
+
# Lookups and dynamic blocks go into this request only, so the cached prefix (instructions
|
|
80
|
+
# and history) stays the same bytes from turn to turn. The wait for the first token plays the
|
|
81
|
+
# melody as a tool's does, and the agent's deadline cuts it, livekit's retries with it.
|
|
82
|
+
@override
|
|
83
|
+
async def llm_node(
|
|
84
|
+
self, chat_ctx: llm.ChatContext, tools: list[llm.Tool], model_settings: ModelSettings
|
|
85
|
+
) -> Thought:
|
|
86
|
+
"""The model run on the history, this turn's lookups, and the dynamic blocks."""
|
|
87
|
+
params = _prompt.request(chat_ctx, self.blocks, self.lookups.items)
|
|
88
|
+
if self.call.context.run is not None:
|
|
89
|
+
self.call.requests.append(_prompt.as_asked(params, tools))
|
|
90
|
+
deadline = self.call.config.llm_timeout_s or LLM_TIMEOUT_S
|
|
91
|
+
thought = Agent.default.llm_node(self, params, tools, model_settings)
|
|
92
|
+
waited = asyncio.timeout(deadline)
|
|
93
|
+
async with contextlib.aclosing(thought):
|
|
94
|
+
try:
|
|
95
|
+
async with self.waiting(), waited:
|
|
96
|
+
first = await anext(thought, None)
|
|
97
|
+
except TimeoutError:
|
|
98
|
+
# A vendor's own TimeoutError is livekit's to handle, as it was before.
|
|
99
|
+
if not waited.expired():
|
|
100
|
+
raise
|
|
101
|
+
why = MODEL_SILENT.format(seconds=deadline)
|
|
102
|
+
self.call.writing.write(
|
|
103
|
+
"error", ErrorEvent(code=LLM_TIMEOUT, message=why, recoverable=True)
|
|
104
|
+
)
|
|
105
|
+
yield _late_in(self.call.config.language)
|
|
106
|
+
return
|
|
107
|
+
if isinstance(first, llm.ChatChunk | str):
|
|
108
|
+
yield first
|
|
109
|
+
async for chunk in thought:
|
|
110
|
+
if isinstance(chunk, llm.ChatChunk | str):
|
|
111
|
+
yield chunk
|
|
112
|
+
|
|
113
|
+
# Runs on what was played, so an interrupted reply's transcript stops where its audio did; a
|
|
114
|
+
# written call plays everything. Aligned speech yields one timed string per word.
|
|
115
|
+
@override
|
|
116
|
+
async def transcription_node(
|
|
117
|
+
self, text: AsyncIterable[str | TimedString], model_settings: ModelSettings
|
|
118
|
+
) -> Words:
|
|
119
|
+
"""Each played piece of the reply, written as it plays, then passed on."""
|
|
120
|
+
async for delta in Agent.default.transcription_node(self, text, model_settings):
|
|
121
|
+
if str(delta):
|
|
122
|
+
self.call.writing.write("agent.transcript", transcript_of(self.live, delta))
|
|
123
|
+
yield delta
|
|
124
|
+
|
|
125
|
+
# Before the turn detector: the last point to drop a backchannel at no cost of a model.
|
|
126
|
+
@override
|
|
127
|
+
async def stt_node(
|
|
128
|
+
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
|
|
129
|
+
) -> Heard:
|
|
130
|
+
"""What the ears heard, but agreement said over the agent's voice."""
|
|
131
|
+
async for event in Agent.default.stt_node(self, audio, model_settings):
|
|
132
|
+
if not self._agreement(event):
|
|
133
|
+
yield event
|
|
134
|
+
|
|
135
|
+
def _agreement(self, event: stt.SpeechEvent) -> bool:
|
|
136
|
+
text = event.alternatives[0].text if event.alternatives else ""
|
|
137
|
+
return self.call.agent_speaking and self.call.turn_policy.is_a_backchannel(text)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _late_in(language: str | None) -> str:
|
|
141
|
+
return MODEL_LATE.get((language or "en")[:2].lower(), MODEL_LATE["en"])
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""What the ears are told: the words that only agree, the pause before a barge-in, the keyterms."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from pinecall.domain.agent import AgentConfig, Turn
|
|
7
|
+
from pinecall.domain.names import JsonObject
|
|
8
|
+
|
|
9
|
+
# livekit counts no words by default; one word is as often a cough or an echo.
|
|
10
|
+
MIN_WORDS = 2
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Silence before an interruption is judged false: livekit's 2 s sound like a dropped call.
|
|
14
|
+
FALSE_INTERRUPTION_TIMEOUT_S = 1.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# A turn made of these alone is somebody agreeing, and does not take the floor. livekit's own
|
|
18
|
+
# backchannel detector is hosted only. Per language: "vale" takes the floor in English.
|
|
19
|
+
BACKCHANNELS: dict[str, frozenset[str]] = {
|
|
20
|
+
"es": frozenset(
|
|
21
|
+
{
|
|
22
|
+
"aha", "ajá", "ah", "ajam", "bien", "bueno", "claro", "dale", "eh", "em", "exacto",
|
|
23
|
+
"hm", "hmm", "mm", "mmm", "ok", "okay", "perfecto", "sí", "si", "vale", "ya", "uh",
|
|
24
|
+
"uhu", "uhum",
|
|
25
|
+
}
|
|
26
|
+
),
|
|
27
|
+
"en": frozenset(
|
|
28
|
+
{
|
|
29
|
+
"aha", "ah", "hm", "hmm", "mm", "mmm", "mhm", "ok", "okay", "yeah", "yes", "yep",
|
|
30
|
+
"right", "sure", "uh", "uh-huh", "gotcha", "exactly",
|
|
31
|
+
}
|
|
32
|
+
),
|
|
33
|
+
"pt": frozenset({"aham", "ah", "sim", "tá", "certo", "claro", "ok", "beleza", "hum", "uhum"}),
|
|
34
|
+
"ca": frozenset({"sí", "vale", "d'acord", "clar", "entesos", "ok", "mm", "aha", "eh"}),
|
|
35
|
+
} # fmt: skip
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_A_WORD = re.compile(r"[^\W_]+(?:['\u2019-][^\W_]+)*")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Keyterms are names, not sentences: longer text dilutes them. The cap is ours, since the ears'
|
|
42
|
+
# vendors either document none or count the terms against the model's budget.
|
|
43
|
+
LONGEST_TERM = 40
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
MOST_WORDS = 4
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
MOST_TERMS = 50
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class TurnPolicy:
|
|
54
|
+
"""What the caller must say to take the floor, in the agent's language."""
|
|
55
|
+
|
|
56
|
+
backchannels: frozenset[str]
|
|
57
|
+
min_words: int = MIN_WORDS
|
|
58
|
+
false_interruption_s: float = FALSE_INTERRUPTION_TIMEOUT_S
|
|
59
|
+
# How long the caller speaks over the agent before it stops; None leaves livekit's own.
|
|
60
|
+
min_speech_s: float | None = None
|
|
61
|
+
|
|
62
|
+
def is_a_backchannel(self, text: str) -> bool:
|
|
63
|
+
"""Whether every word said is somebody agreeing."""
|
|
64
|
+
words: list[str] = [str(found.group(0)).lower() for found in _A_WORD.finditer(text)]
|
|
65
|
+
return bool(words) and all(word in self.backchannels for word in words)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def policy_for(language: str | None, turn: Turn | None = None) -> TurnPolicy:
|
|
69
|
+
"""The policy of the agent's language (`es-ES` is `es`) and its turn knobs; else every list."""
|
|
70
|
+
tag = (language or "").split("-", 1)[0].lower()
|
|
71
|
+
words = BACKCHANNELS.get(tag) or frozenset[str]().union(*BACKCHANNELS.values())
|
|
72
|
+
declared = turn or Turn()
|
|
73
|
+
return TurnPolicy(
|
|
74
|
+
backchannels=words,
|
|
75
|
+
min_words=MIN_WORDS
|
|
76
|
+
if declared.min_interruption_words is None
|
|
77
|
+
else declared.min_interruption_words,
|
|
78
|
+
min_speech_s=None
|
|
79
|
+
if declared.min_interruption_ms is None
|
|
80
|
+
else declared.min_interruption_ms / 1000,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# The declared words first, so the cap drops the names found in the state before them. One
|
|
85
|
+
# level deep catches `patient = {name, phone}`; a term needs a letter, which leaves out numbers.
|
|
86
|
+
def keyterms(config: AgentConfig, state: JsonObject) -> list[str]:
|
|
87
|
+
"""The words the ears are told to expect: the agent's own, then names the state holds."""
|
|
88
|
+
found: list[object] = []
|
|
89
|
+
for value in state.values():
|
|
90
|
+
found += list(value.values()) if isinstance(value, dict) else [value]
|
|
91
|
+
names = [name for name in (_a_name(item) for item in found) if name]
|
|
92
|
+
return list(dict.fromkeys(term for term in (*config.hears, *names) if term))[:MOST_TERMS]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _a_name(value: object) -> str:
|
|
96
|
+
if not isinstance(value, str):
|
|
97
|
+
return ""
|
|
98
|
+
name = value.strip()
|
|
99
|
+
if not name or len(name) > LONGEST_TERM or len(name.split()) > MOST_WORDS:
|
|
100
|
+
return ""
|
|
101
|
+
return name if any(letter.isalpha() for letter in name) else ""
|