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/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Which worker of a fleet a call is offered to: one heard lately, with a seat free, the freest."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Collection, Mapping
|
|
4
|
+
|
|
5
|
+
from pinecall.fleet.roster import REFUSED_AT, accepting_of
|
|
6
|
+
from pinecall.wire.rest.fleet import WorkerStatus
|
|
7
|
+
|
|
8
|
+
# Two heartbeats and a margin: a worker unheard this long may be gone, and LiveKit would keep a
|
|
9
|
+
# dead one for 15 minutes; it is not offered a call until it is heard again.
|
|
10
|
+
HEARD_WITHIN_S = 12.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def chosen(
|
|
14
|
+
seats: Collection[WorkerStatus],
|
|
15
|
+
in_flight: Mapping[str, int],
|
|
16
|
+
now: float,
|
|
17
|
+
not_these: Collection[str] = (),
|
|
18
|
+
) -> WorkerStatus | None:
|
|
19
|
+
"""The worker to offer a call to, or None when no worker of the fleet has a seat."""
|
|
20
|
+
candidates = [
|
|
21
|
+
seat
|
|
22
|
+
for seat in accepting_of(seats, now)
|
|
23
|
+
if seat.agent_name is not None
|
|
24
|
+
and seat.agent_name not in not_these
|
|
25
|
+
and now - seat.seen_at <= HEARD_WITHIN_S
|
|
26
|
+
and free_seats(seat, in_flight) > 0
|
|
27
|
+
]
|
|
28
|
+
return max(candidates, key=lambda seat: (free_seats(seat, in_flight), -seat.load), default=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# A gateway that just started rebuilds its roster from the next heartbeats, five seconds: until a
|
|
32
|
+
# worker of the fleet is heard, nobody can say the fleet is full.
|
|
33
|
+
def any_heard(seats: Collection[WorkerStatus], now: float) -> bool:
|
|
34
|
+
"""Whether any worker of the fleet was heard lately, whatever it is doing."""
|
|
35
|
+
return any(now - seat.seen_at <= HEARD_WITHIN_S for seat in seats)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# A worker gated on its CPU has no count of seats: one call at a time is offered to it, under
|
|
39
|
+
# LiveKit's line.
|
|
40
|
+
def free_seats(seat: WorkerStatus, in_flight: Mapping[str, int]) -> int:
|
|
41
|
+
"""The seats a worker has free, its calls and the ones offered to it not yet opened taken."""
|
|
42
|
+
offered = in_flight.get(seat.agent_name or "", 0)
|
|
43
|
+
if seat.max_jobs is None:
|
|
44
|
+
return 1 if seat.load < REFUSED_AT and offered == 0 else 0
|
|
45
|
+
return seat.max_jobs - seat.active - offered
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Who takes a call: offered to a worker the gateway chose, then another, then the overflow."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from livekit import api
|
|
9
|
+
|
|
10
|
+
from pinecall.channels import rooms
|
|
11
|
+
from pinecall.channels._chooser import HEARD_WITHIN_S, any_heard, chosen, free_seats
|
|
12
|
+
from pinecall.channels.rooms import Dispatch
|
|
13
|
+
from pinecall.domain.names import Env
|
|
14
|
+
from pinecall.fleet import worlds
|
|
15
|
+
from pinecall.fleet.roster import Roster, overflow_name
|
|
16
|
+
from pinecall.postgres.pool import Pool
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# LiveKit waits 10 s on a worker that does not answer before it gives a job up; a live worker opens
|
|
21
|
+
# its call in about 2 s. A room no worker opened 12 s after its offer is offered to another.
|
|
22
|
+
OFFERED_AGAIN_AFTER_S = 12.0
|
|
23
|
+
|
|
24
|
+
# Three offers, then the overflow's sentence: 36 s is the longest a caller waits on our account.
|
|
25
|
+
OFFERS = 3
|
|
26
|
+
|
|
27
|
+
# A fleet none of whose workers was heard is one the gateway knows nothing of yet (it just
|
|
28
|
+
# started): the room waits for the next sweep, up to as long as a worker takes to be heard.
|
|
29
|
+
WAITS_FOR_A_WORKER_S = HEARD_WITHIN_S
|
|
30
|
+
|
|
31
|
+
# The rooms counted as waiting for a worker: the ones a caller joined in the last ten minutes.
|
|
32
|
+
WAITING_COUNTED_S = 600.0
|
|
33
|
+
|
|
34
|
+
# Every room is done with by its third offer and a sweep after it: one kept a minute is a room no
|
|
35
|
+
# gateway is sweeping.
|
|
36
|
+
UNSWEPT_AFTER_S = 60.0
|
|
37
|
+
|
|
38
|
+
UNSWEPT = "{n} rooms a caller joined over a minute ago were never let go: no gateway sweeps them"
|
|
39
|
+
|
|
40
|
+
# The first sight stands: a second webhook for the same room changes nothing.
|
|
41
|
+
OPENED = """
|
|
42
|
+
INSERT INTO offers (room, fleet, dispatch, seen_at)
|
|
43
|
+
VALUES (%(room)s, %(fleet)s, %(dispatch)s, %(now)s)
|
|
44
|
+
ON CONFLICT (room) DO NOTHING
|
|
45
|
+
RETURNING room
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# Taken only by the gateway that read this count: two sweeping the same room offer it once.
|
|
49
|
+
CLAIMED = """
|
|
50
|
+
UPDATE offers SET worker = %(worker)s, offers = offers + 1, offered_at = %(now)s
|
|
51
|
+
WHERE room = %(room)s AND offers = %(offers)s
|
|
52
|
+
RETURNING room
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
DUE = """
|
|
56
|
+
SELECT room, fleet, dispatch, seen_at, worker, offers FROM offers
|
|
57
|
+
WHERE offered_at IS NULL OR offered_at < %(before)s
|
|
58
|
+
ORDER BY seen_at LIMIT %(limit)s
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
IN_FLIGHT = """
|
|
62
|
+
SELECT worker, count(*) AS offered FROM offers
|
|
63
|
+
WHERE worker IS NOT NULL AND offered_at >= %(since)s
|
|
64
|
+
GROUP BY worker
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
WAITING = """
|
|
68
|
+
SELECT fleet, count(*) AS waiting FROM offers WHERE seen_at >= %(since)s GROUP BY fleet
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
FORGOTTEN = "DELETE FROM offers WHERE room = %(room)s"
|
|
72
|
+
|
|
73
|
+
AGED = "DELETE FROM offers WHERE seen_at < %(before)s"
|
|
74
|
+
|
|
75
|
+
KEPT_SINCE = "SELECT count(*) AS kept FROM offers WHERE seen_at < %(before)s"
|
|
76
|
+
|
|
77
|
+
OFFERED = "room %s: offered to %s: %s"
|
|
78
|
+
|
|
79
|
+
NOT_YET = "room %s: %s; offered at the next sweep"
|
|
80
|
+
|
|
81
|
+
OFFERED_AGAIN = "room %s: no worker opened it in %.0f s; offered to %s (offer %d): %s"
|
|
82
|
+
|
|
83
|
+
TO_THE_OVERFLOW = "room %s: %s; the overflow says the sentence"
|
|
84
|
+
|
|
85
|
+
NO_WORLD = "room %s: fleet %s is no world's, so no LiveKit holds its room; not offered"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Offer:
|
|
90
|
+
"""A room waiting for a worker: its fleet, its call's dispatch, and whom it was offered to."""
|
|
91
|
+
|
|
92
|
+
room: str
|
|
93
|
+
fleet: str
|
|
94
|
+
dispatch: str
|
|
95
|
+
seen_at: float
|
|
96
|
+
worker: str | None
|
|
97
|
+
offers: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# What a process holds to send a call to a worker: the offers' table, each world's LiveKit, and
|
|
101
|
+
# the fleet's workers as their heartbeats say. Every door that sends a call into a room goes
|
|
102
|
+
# through it. A room lives on the LiveKit of its fleet's world, which the fleets row says.
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Offering:
|
|
105
|
+
"""The gateway's dispatcher: a room offered to the worker it chose, by the worker's name."""
|
|
106
|
+
|
|
107
|
+
pool: Pool
|
|
108
|
+
servers: Mapping[Env, api.LiveKitAPI]
|
|
109
|
+
roster: Roster
|
|
110
|
+
|
|
111
|
+
async def offer(self, room: str, fleet: str, dispatch: Dispatch) -> str | None:
|
|
112
|
+
"""Keep the room and offer it; the name it went to, or None if kept already or not yet."""
|
|
113
|
+
written = rooms.written(dispatch)
|
|
114
|
+
now = time.time()
|
|
115
|
+
if not await opened(self.pool, room, fleet, written, now):
|
|
116
|
+
return None
|
|
117
|
+
return await self.offered(
|
|
118
|
+
Offer(room=room, fleet=fleet, dispatch=written, seen_at=now, worker=None, offers=0)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def offered(self, offer: Offer) -> str | None:
|
|
122
|
+
"""Offer the room to the worker it should go to now; the name, or None if not offered."""
|
|
123
|
+
now = time.time()
|
|
124
|
+
world = worlds.world_of(await worlds.fleets(self.pool), offer.fleet)
|
|
125
|
+
if world is None:
|
|
126
|
+
logger.warning(NO_WORLD, offer.room, offer.fleet)
|
|
127
|
+
return None
|
|
128
|
+
target, why = await _target(self.pool, self.roster, offer, now)
|
|
129
|
+
if target is None:
|
|
130
|
+
logger.info(NOT_YET, offer.room, why)
|
|
131
|
+
return None
|
|
132
|
+
if not await claimed(self.pool, offer, target, now):
|
|
133
|
+
return None
|
|
134
|
+
dispatch = rooms.read_dispatch(offer.dispatch)
|
|
135
|
+
await rooms.dispatched(self.servers[world], offer.room, target, dispatch)
|
|
136
|
+
if target == overflow_name(offer.fleet):
|
|
137
|
+
await forgotten(self.pool, offer.room)
|
|
138
|
+
logger.warning(TO_THE_OVERFLOW, offer.room, why)
|
|
139
|
+
elif offer.offers > 0:
|
|
140
|
+
logger.warning(
|
|
141
|
+
OFFERED_AGAIN, offer.room, OFFERED_AGAIN_AFTER_S, target, offer.offers + 1, why
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
logger.info(OFFERED, offer.room, target, why)
|
|
145
|
+
return target
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def opened(pool: Pool, room: str, fleet: str, dispatch: str, now: float) -> bool:
|
|
149
|
+
"""Keep a room a caller joined; False when it was kept already."""
|
|
150
|
+
values = {"room": room, "fleet": fleet, "dispatch": dispatch, "now": now}
|
|
151
|
+
async with pool.connection() as connection:
|
|
152
|
+
return await (await connection.execute(OPENED, values)).fetchone() is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def claimed(pool: Pool, offer: Offer, worker: str, now: float) -> bool:
|
|
156
|
+
"""Say the room is offered to this worker; False when another gateway offered it first."""
|
|
157
|
+
values = {"room": offer.room, "worker": worker, "offers": offer.offers, "now": now}
|
|
158
|
+
async with pool.connection() as connection:
|
|
159
|
+
return await (await connection.execute(CLAIMED, values)).fetchone() is not None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def due(pool: Pool, before: float, limit: int) -> list[Offer]:
|
|
163
|
+
"""The rooms no worker opened and nobody offered since `before`, oldest first."""
|
|
164
|
+
async with pool.connection() as connection:
|
|
165
|
+
rows = await (await connection.execute(DUE, {"before": before, "limit": limit})).fetchall()
|
|
166
|
+
return [Offer(**row) for row in rows]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def in_flight(pool: Pool, since: float) -> dict[str, int]:
|
|
170
|
+
"""Each worker's rooms offered since `since` that it has not opened."""
|
|
171
|
+
async with pool.connection() as connection:
|
|
172
|
+
rows = await (await connection.execute(IN_FLIGHT, {"since": since})).fetchall()
|
|
173
|
+
return {row["worker"]: row["offered"] for row in rows}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def waiting(pool: Pool, since: float) -> dict[str, int]:
|
|
177
|
+
"""Each fleet's rooms with a caller seen since `since` that no worker opened."""
|
|
178
|
+
async with pool.connection() as connection:
|
|
179
|
+
rows = await (await connection.execute(WAITING, {"since": since})).fetchall()
|
|
180
|
+
return {row["fleet"]: row["waiting"] for row in rows}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def forgotten(pool: Pool, room: str) -> None:
|
|
184
|
+
"""The room is done with: its call opened, the overflow sent, or the room gone."""
|
|
185
|
+
async with pool.connection() as connection:
|
|
186
|
+
await connection.execute(FORGOTTEN, {"room": room})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def aged_out(pool: Pool, before: float) -> None:
|
|
190
|
+
"""Rooms first seen before `before` are past any offer: their callers are gone."""
|
|
191
|
+
async with pool.connection() as connection:
|
|
192
|
+
await connection.execute(AGED, {"before": before})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def examined(pool: Pool, now: float) -> str | None:
|
|
196
|
+
"""What doctor says of the offers: the rooms kept past every offer, or None."""
|
|
197
|
+
async with pool.connection() as connection:
|
|
198
|
+
row = await (
|
|
199
|
+
await connection.execute(KEPT_SINCE, {"before": now - UNSWEPT_AFTER_S})
|
|
200
|
+
).fetchone()
|
|
201
|
+
kept = 0 if row is None else row["kept"]
|
|
202
|
+
return UNSWEPT.format(n=kept) if kept else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# The worker chosen; the overflow when the offers ran out or the workers heard have no seat; None
|
|
206
|
+
# while no worker of the fleet was heard yet. And why, so the log says it.
|
|
207
|
+
async def _target(pool: Pool, roster: Roster, offer: Offer, now: float) -> tuple[str | None, str]:
|
|
208
|
+
if offer.offers >= OFFERS:
|
|
209
|
+
return overflow_name(offer.fleet), f"no worker opened it in {OFFERS} offers"
|
|
210
|
+
flying = await in_flight(pool, now - OFFERED_AGAIN_AFTER_S)
|
|
211
|
+
tried = () if offer.worker is None else (offer.worker,)
|
|
212
|
+
seats = roster.of(offer.fleet, now)
|
|
213
|
+
seat = chosen(seats, flying, now, not_these=tried)
|
|
214
|
+
if seat is not None and seat.agent_name is not None:
|
|
215
|
+
free = free_seats(seat, flying)
|
|
216
|
+
return seat.agent_name, f"{free} seats free, heard {now - seat.seen_at:.0f} s ago"
|
|
217
|
+
if not any_heard(seats, now) and now - offer.seen_at < WAITS_FOR_A_WORKER_S:
|
|
218
|
+
return None, f"no worker of the fleet heard in {HEARD_WITHIN_S:.0f} s"
|
|
219
|
+
return overflow_name(offer.fleet), "no worker of the fleet has a seat"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The dispatch to a world's fleet, the rooms an agent is in, and LiveKit's signed events."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Collection, Mapping
|
|
5
|
+
|
|
6
|
+
from google.protobuf.json_format import ParseDict, ParseError
|
|
7
|
+
from livekit import api
|
|
8
|
+
from livekit.protocol.agent_dispatch import RoomAgentDispatch
|
|
9
|
+
from livekit.protocol.room import RoomConfiguration
|
|
10
|
+
from livekit.protocol.webhook import WebhookEvent
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
12
|
+
|
|
13
|
+
from pinecall.domain.errors import DeclarationRefused, NotAllowed
|
|
14
|
+
from pinecall.domain.names import Direction, Env, Json, JsonObject
|
|
15
|
+
|
|
16
|
+
NOT_A_ROOM_CONFIG = "room_config is not a LiveKit RoomConfiguration: {reason}"
|
|
17
|
+
|
|
18
|
+
# Room names ride one request's query, so they are params for in batches.
|
|
19
|
+
ROOMS_A_REQUEST = 100
|
|
20
|
+
|
|
21
|
+
# livekit's code for a room that is not there.
|
|
22
|
+
ROOM_GONE = "not_found"
|
|
23
|
+
|
|
24
|
+
UNSIGNED = "the event carries no signature of this box's LiveKit key: {reason}"
|
|
25
|
+
|
|
26
|
+
# The seats a person sits in: a browser's, and a phone's leg.
|
|
27
|
+
A_PERSON = frozenset({api.ParticipantInfo.Kind.STANDARD, api.ParticipantInfo.Kind.SIP})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# `trunk` names the carrier account the leg is dialled through; the worker asks the gateway for
|
|
31
|
+
# its inline configuration, so no secret rides a dispatch.
|
|
32
|
+
class Dialling(BaseModel):
|
|
33
|
+
"""The leg an outbound job places: trunk, far end, the number shown, the media's ceiling."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
36
|
+
|
|
37
|
+
trunk: str
|
|
38
|
+
to: str
|
|
39
|
+
shown: str
|
|
40
|
+
max_duration_s: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Written by the gateway alone, signed into a token or sent by the server API, so the worker
|
|
44
|
+
# trusts it: the scope a call runs in, and on an outbound call the ceiling of its leg.
|
|
45
|
+
class Dispatch(BaseModel):
|
|
46
|
+
"""What a dispatch tells the job it starts: whose call, which agent, and how it arrived."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
|
|
49
|
+
|
|
50
|
+
agent: str | None = None
|
|
51
|
+
org: str | None = None
|
|
52
|
+
env: Env | None = None
|
|
53
|
+
holder: str | None = None
|
|
54
|
+
direction: Direction = "inbound"
|
|
55
|
+
caller: str | None = None
|
|
56
|
+
# An opaque id the tenant's backend knows the visitor by; never a number or a name.
|
|
57
|
+
contact: str | None = None
|
|
58
|
+
# The token's scope: a dispatch that carries one spends its token once.
|
|
59
|
+
scope: str | None = None
|
|
60
|
+
# What the tenant's backend sealed into the token.
|
|
61
|
+
metadata: JsonObject = Field(default_factory=dict[str, Json])
|
|
62
|
+
dial: Dialling | None = None
|
|
63
|
+
# The app socket that must serve the call (a spoken golden run).
|
|
64
|
+
app: str | None = None
|
|
65
|
+
# A simulated caller and its rules, frozen at dispatch so a later edit does not move them.
|
|
66
|
+
run: str | None = None
|
|
67
|
+
persona: str | None = None
|
|
68
|
+
accepts_when: str | None = None
|
|
69
|
+
declines_when: str | None = None
|
|
70
|
+
diverted_from: Env | None = None
|
|
71
|
+
# The state the call opens in, when a golden or a persona asked for one.
|
|
72
|
+
state: JsonObject = Field(default_factory=dict[str, Json])
|
|
73
|
+
# The call's worker went away mid-call: the job tells the caller once and closes the room.
|
|
74
|
+
worker_gone: bool = False
|
|
75
|
+
# How many entries the log took from the worker that went: where the told job's writer follows.
|
|
76
|
+
entries_written: int = 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def written(dispatch: Dispatch) -> str:
|
|
80
|
+
"""The dispatch as a job's metadata: compact JSON, absent fields absent."""
|
|
81
|
+
data = dispatch.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
|
|
82
|
+
return json.dumps(data, separators=(",", ":"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# A job with no metadata or with somebody else's (a room nobody dispatched) reads as empty.
|
|
86
|
+
def read_dispatch(metadata: str) -> Dispatch:
|
|
87
|
+
"""The dispatch a job was started with."""
|
|
88
|
+
if not metadata:
|
|
89
|
+
return Dispatch()
|
|
90
|
+
try:
|
|
91
|
+
return Dispatch.model_validate_json(metadata)
|
|
92
|
+
except ValidationError:
|
|
93
|
+
return Dispatch()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# livekit creates the dispatch from the token when the join creates the room, so the browser
|
|
97
|
+
# cannot change the agent, the org or the world it reaches.
|
|
98
|
+
def room_dispatch(fleet: str, dispatch: Dispatch) -> RoomConfiguration:
|
|
99
|
+
"""The room configuration a visitor's token carries: one dispatch, to the world's fleet."""
|
|
100
|
+
return RoomConfiguration(
|
|
101
|
+
agents=[RoomAgentDispatch(agent_name=fleet, metadata=written(dispatch))]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# livekit creates the room if it does not exist; its name is the call id the worker opens.
|
|
106
|
+
async def dispatched(server: api.LiveKitAPI, room: str, agent: str, dispatch: Dispatch) -> None:
|
|
107
|
+
"""Send the workers registered under this name into a room, with the call's dispatch."""
|
|
108
|
+
await server.agent_dispatch.create_dispatch(
|
|
109
|
+
api.CreateAgentDispatchRequest(room=room, agent_name=agent, metadata=written(dispatch))
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# A stock livekit client names the agent as room_config.agents[0].agent_name, in either spelling.
|
|
114
|
+
def client_named_agent(room_config: Mapping[str, object] | None) -> str | None:
|
|
115
|
+
"""The agent a livekit client put in the room configuration, or None."""
|
|
116
|
+
if not room_config:
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
parsed = ParseDict(dict(room_config), RoomConfiguration(), ignore_unknown_fields=True)
|
|
120
|
+
except ParseError as malformed:
|
|
121
|
+
raise DeclarationRefused(NOT_A_ROOM_CONFIG.format(reason=malformed)) from malformed
|
|
122
|
+
agents = list(parsed.agents)
|
|
123
|
+
return (agents[0].agent_name or None) if agents else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# A room is a live call only while an agent is in it: a browser tab or a supervisor keeps a
|
|
127
|
+
# room open long after its job is gone.
|
|
128
|
+
async def rooms_with_an_agent(server: api.LiveKitAPI, names: Collection[str]) -> set[str]:
|
|
129
|
+
"""The rooms of these that exist with an agent in them."""
|
|
130
|
+
wanted = list(names)
|
|
131
|
+
served: set[str] = set()
|
|
132
|
+
for start in range(0, len(wanted), ROOMS_A_REQUEST):
|
|
133
|
+
batch = wanted[start : start + ROOMS_A_REQUEST]
|
|
134
|
+
rooms = await server.room.list_rooms(api.ListRoomsRequest(names=batch))
|
|
135
|
+
for room in rooms.rooms:
|
|
136
|
+
seats = await server.room.list_participants(api.ListParticipantsRequest(room=room.name))
|
|
137
|
+
if any(seat.kind == api.ParticipantInfo.Kind.AGENT for seat in seats.participants):
|
|
138
|
+
served.add(room.name)
|
|
139
|
+
return served
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def room_closed(server: api.LiveKitAPI, name: str) -> None:
|
|
143
|
+
"""Delete the room, which hangs up everyone still in it; a room already gone is fine."""
|
|
144
|
+
try:
|
|
145
|
+
await server.room.delete_room(api.DeleteRoomRequest(room=name))
|
|
146
|
+
except api.TwirpError as refused:
|
|
147
|
+
if refused.code != ROOM_GONE:
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# A room gone is nobody left in it.
|
|
152
|
+
async def seats_in(server: api.LiveKitAPI, name: str) -> list[api.ParticipantInfo]:
|
|
153
|
+
"""Who is in the room: nobody when it is gone."""
|
|
154
|
+
try:
|
|
155
|
+
seats = await server.room.list_participants(api.ListParticipantsRequest(room=name))
|
|
156
|
+
except api.TwirpError as refused:
|
|
157
|
+
if refused.code != ROOM_GONE:
|
|
158
|
+
raise
|
|
159
|
+
return []
|
|
160
|
+
return list(seats.participants)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def left_alone(server: api.LiveKitAPI, name: str) -> bool:
|
|
164
|
+
"""Whether a person is still in the room and no agent is."""
|
|
165
|
+
kinds = {seat.kind for seat in await seats_in(server, name)}
|
|
166
|
+
return api.ParticipantInfo.Kind.AGENT not in kinds and bool(kinds & A_PERSON)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# livekit signs each event with a key of the server's: a token whose sha256 claim is the body's.
|
|
170
|
+
# The library raises a bare Exception for a body that does not match its token.
|
|
171
|
+
def livekit_event(body: str, token: str, key: str, secret: str) -> WebhookEvent:
|
|
172
|
+
"""The event of a body LiveKit signed with this key, or NotAllowed."""
|
|
173
|
+
try:
|
|
174
|
+
return api.WebhookReceiver(api.TokenVerifier(key, secret)).receive(body, token)
|
|
175
|
+
except Exception as refused:
|
|
176
|
+
raise NotAllowed(UNSIGNED.format(reason=refused)) from refused
|