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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""A session opened for a spoken call: the pipeline, the turn options, the ears' keyterms."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from functools import cache
|
|
5
|
+
|
|
6
|
+
from livekit.agents import (
|
|
7
|
+
NOT_GIVEN,
|
|
8
|
+
NotGivenOr,
|
|
9
|
+
inference,
|
|
10
|
+
)
|
|
11
|
+
from livekit.agents.voice import AgentSession, STTContextOptions
|
|
12
|
+
from livekit.agents.voice import text_transforms as transforms
|
|
13
|
+
from livekit.agents.voice.agent_session import DEFAULT_TTS_TEXT_TRANSFORMS
|
|
14
|
+
from livekit.agents.voice.turn import InterruptionOptions, TurnDetectionMode, TurnHandlingOptions
|
|
15
|
+
from smart_turn_livekit import SmartTurnDetector # pyright: ignore[reportMissingTypeStubs]
|
|
16
|
+
|
|
17
|
+
from pinecall.domain.agent import AgentConfig
|
|
18
|
+
from pinecall.providers.build import (
|
|
19
|
+
Ears,
|
|
20
|
+
Running,
|
|
21
|
+
TurnModel,
|
|
22
|
+
ears_of,
|
|
23
|
+
speaking_of,
|
|
24
|
+
thinking_of,
|
|
25
|
+
)
|
|
26
|
+
from pinecall.providers.credentials import Pipeline
|
|
27
|
+
from pinecall.session import tools
|
|
28
|
+
from pinecall.session._hearing import keyterms, policy_for
|
|
29
|
+
from pinecall.session._livekit import switches_written
|
|
30
|
+
from pinecall.session.call import Call
|
|
31
|
+
from pinecall.session.session import ONE_ANSWER_PER_TOOL, Session
|
|
32
|
+
from pinecall.session.tools import VOICE_LOOKUP_MS
|
|
33
|
+
|
|
34
|
+
# The local end-of-turn model: left unset, livekit may pick the hosted one and send the
|
|
35
|
+
# caller's words to a cloud.
|
|
36
|
+
LOCAL_TURN_VERSION: inference.TurnDetectorVersions = "v1-mini"
|
|
37
|
+
|
|
38
|
+
# The checkpoint in smart-turn-livekit's registry: upstream's, benchmarked on 23 languages.
|
|
39
|
+
SMART_TURN: TurnModel = "smart-turn-v3"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def voice_session(call: Call, stages: Pipeline) -> Session:
|
|
43
|
+
"""A voice call: the three stages built for it, the turn taken as a phone line needs."""
|
|
44
|
+
thinking, ears, voice = (
|
|
45
|
+
thinking_of(stages.llm),
|
|
46
|
+
ears_of(stages.stt, call.config.turn),
|
|
47
|
+
speaking_of(stages.tts),
|
|
48
|
+
)
|
|
49
|
+
switches_written(call.writing, (thinking, ears, voice))
|
|
50
|
+
live: AgentSession[None] = AgentSession(
|
|
51
|
+
llm=thinking,
|
|
52
|
+
stt=ears,
|
|
53
|
+
tts=voice,
|
|
54
|
+
turn_handling=_spoken_turns(call.config, stages.stt),
|
|
55
|
+
# Word timings reach transcription_node only from an aligned voice.
|
|
56
|
+
use_tts_aligned_transcript=True,
|
|
57
|
+
tts_text_transforms=_text_transforms_of(call.config),
|
|
58
|
+
stt_context_options=context_of(call.config, ears),
|
|
59
|
+
max_tool_steps=ONE_ANSWER_PER_TOOL,
|
|
60
|
+
)
|
|
61
|
+
return Session(
|
|
62
|
+
live, (thinking, ears, voice), tools.Lookups(call, call.platform.lookup, VOICE_LOOKUP_MS)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def context_of(config: AgentConfig, ears: Ears) -> NotGivenOr[STTContextOptions]:
|
|
67
|
+
"""The keyterms the ears are told at start, when they take any."""
|
|
68
|
+
if not config.hears or not ears.capabilities.keyterms:
|
|
69
|
+
return NOT_GIVEN
|
|
70
|
+
return {"keyterms": keyterms(config, {})}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# One Smart Turn per process: its weights load once and its first inference runs when it is made,
|
|
74
|
+
# so only the first call that asks for it pays. Each session streams to it on its own.
|
|
75
|
+
@cache
|
|
76
|
+
def smart_turn() -> SmartTurnDetector:
|
|
77
|
+
"""Smart Turn v3, the row's alternative to livekit's own detector."""
|
|
78
|
+
return SmartTurnDetector(SMART_TURN)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _end_of_turn(model: TurnModel) -> TurnDetectionMode:
|
|
82
|
+
"""The local model that reads the end of the caller's turn off the audio, no transcript."""
|
|
83
|
+
if model == SMART_TURN:
|
|
84
|
+
return smart_turn()
|
|
85
|
+
return inference.TurnDetector(version=LOCAL_TURN_VERSION)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# The parameter replaces livekit's defaults, so they come first and the tenant's words after.
|
|
89
|
+
def _text_transforms_of(config: AgentConfig) -> NotGivenOr[Sequence[transforms.TextTransforms]]:
|
|
90
|
+
"""Livekit's own transforms, then the tenant's pronunciations."""
|
|
91
|
+
if not config.says:
|
|
92
|
+
return NOT_GIVEN
|
|
93
|
+
return [*DEFAULT_TTS_TEXT_TRANSFORMS, transforms.replace(dict(config.says))]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Interruptions are judged by the local VAD: livekit's adaptive detector streams the caller's
|
|
97
|
+
# audio to its cloud. A false interruption is not resumed: livekit replays the whole sentence.
|
|
98
|
+
# No endpointing here: the agent's own already reaches the ears, and both would wait twice.
|
|
99
|
+
def _spoken_turns(config: AgentConfig, ears: Running) -> TurnHandlingOptions:
|
|
100
|
+
r"""How the caller takes the floor, from the agent\'s language and its own turn knobs."""
|
|
101
|
+
policy = policy_for(config.language, config.turn)
|
|
102
|
+
interruption: InterruptionOptions = {
|
|
103
|
+
"min_words": policy.min_words,
|
|
104
|
+
"mode": "vad",
|
|
105
|
+
"false_interruption_timeout": policy.false_interruption_s,
|
|
106
|
+
"resume_false_interruption": False,
|
|
107
|
+
}
|
|
108
|
+
if policy.min_speech_s is not None:
|
|
109
|
+
interruption["min_duration"] = policy.min_speech_s
|
|
110
|
+
return {
|
|
111
|
+
# Ears that end the turn themselves decide it; the local detector stacked on them waits
|
|
112
|
+
# its whole delay after a pause in the middle of a sentence.
|
|
113
|
+
"turn_detection": "stt" if ears.ends_the_turn else _end_of_turn(ears.turn_model),
|
|
114
|
+
# A reply started inside a tool's window, on a context without its result, answers its
|
|
115
|
+
# own question.
|
|
116
|
+
"preemptive_generation": {"enabled": False},
|
|
117
|
+
"interruption": interruption,
|
|
118
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""The DataChannel to a browser widget: the public log of its call, and the events it sends."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from livekit import rtc
|
|
11
|
+
from pydantic import TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from pinecall.domain.errors import PinecallError
|
|
14
|
+
from pinecall.domain.names import JsonObject
|
|
15
|
+
from pinecall.domain.person import READS_ITS_OWN_CALL
|
|
16
|
+
from pinecall.domain.scope import SCOPE_ATTRIBUTE
|
|
17
|
+
from pinecall.log.readers import project_entry, project_state
|
|
18
|
+
from pinecall.session.call import Call
|
|
19
|
+
from pinecall.wire.events import EventReceived
|
|
20
|
+
from pinecall.wire.frames import Entry
|
|
21
|
+
from pinecall.wire.state import State
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# The topics of docs/protocol/projections.md; pinecall.ui is room.send's.
|
|
26
|
+
LOG = "pinecall.log"
|
|
27
|
+
SNAPSHOT = "pinecall.snapshot"
|
|
28
|
+
REPLAY = "pinecall.replay"
|
|
29
|
+
EVENT = "pinecall.event"
|
|
30
|
+
_A_JSON_OBJECT: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
|
|
31
|
+
|
|
32
|
+
# What a widget's greeting waits for the log to hold its arrival.
|
|
33
|
+
FLUSH_S = 5.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Only the gateway numbers a log's entries, so what reaches a browser is read back from it,
|
|
37
|
+
# never taken from what the session wrote.
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Reading:
|
|
40
|
+
"""The call's log as the gateway holds it: the state now, a stretch of it, and its tail."""
|
|
41
|
+
|
|
42
|
+
state: Callable[[], Awaitable[tuple[State, int]]]
|
|
43
|
+
since: Callable[[int], AsyncIterator[Entry]]
|
|
44
|
+
tail: Callable[[int], AsyncIterator[Entry]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Widget:
|
|
48
|
+
"""One call's channel to its widgets: one tail of the log, projected for each of them."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, call: Call, room: rtc.Room, reading: Reading) -> None:
|
|
51
|
+
"""A channel nobody listens to yet."""
|
|
52
|
+
self.call = call
|
|
53
|
+
self.room = room
|
|
54
|
+
self.reading = reading
|
|
55
|
+
# What each widget in the room has been sent, by its seat.
|
|
56
|
+
self.audience: dict[str, int] = {}
|
|
57
|
+
self.cursor = 0
|
|
58
|
+
self.tailing: asyncio.Task[None] | None = None
|
|
59
|
+
self.answering: set[asyncio.Task[None]] = set()
|
|
60
|
+
|
|
61
|
+
def watch(self) -> None:
|
|
62
|
+
"""Greet each widget that is or comes into the room, and hear what it sends."""
|
|
63
|
+
for name, listener in self._listeners().items():
|
|
64
|
+
self.room.on(name, listener) # pyright: ignore[reportUnknownMemberType]
|
|
65
|
+
|
|
66
|
+
def stop(self) -> None:
|
|
67
|
+
"""Let go of the room and of the tail."""
|
|
68
|
+
for name, listener in self._listeners().items():
|
|
69
|
+
self.room.off(name, listener) # pyright: ignore[reportUnknownMemberType]
|
|
70
|
+
for task in (self.tailing, *self.answering):
|
|
71
|
+
if task is not None:
|
|
72
|
+
task.cancel()
|
|
73
|
+
self.tailing = None
|
|
74
|
+
self.answering.clear()
|
|
75
|
+
|
|
76
|
+
def _listeners(self) -> dict[rtc.room.EventTypes, Callable[..., None]]:
|
|
77
|
+
return {
|
|
78
|
+
"connection_state_changed": self._connection,
|
|
79
|
+
"participant_connected": self._arrived,
|
|
80
|
+
"participant_disconnected": self._left,
|
|
81
|
+
"data_received": self._received,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def _connection(self, state: int) -> None:
|
|
85
|
+
if state == rtc.ConnectionState.CONN_CONNECTED:
|
|
86
|
+
for seat in self.room.remote_participants.values():
|
|
87
|
+
self._arrived(seat)
|
|
88
|
+
|
|
89
|
+
def _arrived(self, seat: rtc.RemoteParticipant) -> None:
|
|
90
|
+
if _a_widget(seat):
|
|
91
|
+
self._answer(self._greet(seat.identity))
|
|
92
|
+
|
|
93
|
+
def _left(self, seat: rtc.RemoteParticipant) -> None:
|
|
94
|
+
self.audience.pop(seat.identity, None)
|
|
95
|
+
|
|
96
|
+
# Flushed first, so the snapshot holds the widget's own arrival. What the tail sent past the
|
|
97
|
+
# snapshot is sent again to this widget alone: nothing skipped, nothing twice.
|
|
98
|
+
async def _greet(self, identity: str) -> None:
|
|
99
|
+
await self.call.writing.flushed(FLUSH_S)
|
|
100
|
+
state, last = await self.reading.state()
|
|
101
|
+
public = project_state(state, "public", self.call.config, identity)
|
|
102
|
+
await self._publish(SNAPSHOT, {"state": public, "last_seq": last}, identity)
|
|
103
|
+
if self.tailing is None:
|
|
104
|
+
self.cursor = last
|
|
105
|
+
self.tailing = asyncio.create_task(self._tail(last))
|
|
106
|
+
sent = last
|
|
107
|
+
while sent < self.cursor:
|
|
108
|
+
sent = await self._resend(identity, sent, self.cursor)
|
|
109
|
+
self.audience[identity] = sent
|
|
110
|
+
|
|
111
|
+
async def _tail(self, after: int) -> None:
|
|
112
|
+
async for entry in self.reading.tail(after):
|
|
113
|
+
self.cursor = entry.seq
|
|
114
|
+
for identity, sent in sorted(self.audience.items()):
|
|
115
|
+
if entry.seq > sent:
|
|
116
|
+
await self._send(entry, identity)
|
|
117
|
+
# A widget that left while this was sent is not added back.
|
|
118
|
+
if identity in self.audience:
|
|
119
|
+
self.audience[identity] = max(sent, entry.seq)
|
|
120
|
+
|
|
121
|
+
async def _resend(self, identity: str, after: int, until: int) -> int:
|
|
122
|
+
sent = after
|
|
123
|
+
async for entry in self.reading.since(after):
|
|
124
|
+
if entry.seq > until:
|
|
125
|
+
break
|
|
126
|
+
sent = entry.seq
|
|
127
|
+
await self._send(entry, identity)
|
|
128
|
+
return max(sent, until)
|
|
129
|
+
|
|
130
|
+
async def _send(self, entry: Entry, identity: str) -> None:
|
|
131
|
+
public = project_entry(entry, "public", self.call.config, identity)
|
|
132
|
+
if public is not None:
|
|
133
|
+
await self._publish(LOG, public, identity)
|
|
134
|
+
|
|
135
|
+
async def _publish(self, topic: str, payload: JsonObject, identity: str) -> None:
|
|
136
|
+
packed = json.dumps(payload, separators=(",", ":")).encode()
|
|
137
|
+
await self.room.local_participant.publish_data(
|
|
138
|
+
packed, topic=topic, destination_identities=[identity]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Only a widget's seat speaks here. A replay ends with log.caught_up, as the SSE door's does;
|
|
142
|
+
# an event must be one the agent declared a participant may send.
|
|
143
|
+
def _received(self, packet: rtc.DataPacket) -> None:
|
|
144
|
+
sender = packet.participant
|
|
145
|
+
if sender is None or not _a_widget(sender):
|
|
146
|
+
return
|
|
147
|
+
data = _decoded(packet.data)
|
|
148
|
+
if packet.topic == REPLAY and isinstance(data.get("after"), int):
|
|
149
|
+
after = data["after"]
|
|
150
|
+
if isinstance(after, int):
|
|
151
|
+
self._answer(self._replay(sender.identity, after))
|
|
152
|
+
elif packet.topic == EVENT:
|
|
153
|
+
self._event(sender.identity, data)
|
|
154
|
+
|
|
155
|
+
async def _replay(self, identity: str, after: int) -> None:
|
|
156
|
+
sent = await self._resend(identity, after, self.cursor)
|
|
157
|
+
caught_up = Entry(
|
|
158
|
+
seq=sent,
|
|
159
|
+
ts=time.time(),
|
|
160
|
+
call=self.call.context.call,
|
|
161
|
+
agent=self.call.config.slug,
|
|
162
|
+
type="log.caught_up",
|
|
163
|
+
ephemeral=True,
|
|
164
|
+
data={"seq": sent},
|
|
165
|
+
)
|
|
166
|
+
await self._send(caught_up, identity)
|
|
167
|
+
|
|
168
|
+
def _event(self, identity: str, payload: JsonObject) -> None:
|
|
169
|
+
name, data = payload.get("name"), payload.get("data")
|
|
170
|
+
if not isinstance(name, str) or not isinstance(data, dict):
|
|
171
|
+
logger.warning("%s from a widget is not {name, data}; dropped", EVENT)
|
|
172
|
+
return
|
|
173
|
+
if not self.call.config.accepts(name, "participant"):
|
|
174
|
+
logger.warning("%s %r is not one a participant may send; dropped", EVENT, name)
|
|
175
|
+
return
|
|
176
|
+
received = EventReceived(name=name, data=data, source="participant", identity=identity)
|
|
177
|
+
self.call.writing.write("event.received", received)
|
|
178
|
+
|
|
179
|
+
# Runs off the room's synchronous listener: the gateway's refusal is a warning, never a
|
|
180
|
+
# task's exception nobody reads.
|
|
181
|
+
def _answer(self, answering: Coroutine[object, object, None]) -> None:
|
|
182
|
+
async def answered() -> None:
|
|
183
|
+
try:
|
|
184
|
+
await answering
|
|
185
|
+
except PinecallError:
|
|
186
|
+
logger.warning("the widget's channel could not read the log", exc_info=True)
|
|
187
|
+
|
|
188
|
+
task = asyncio.create_task(answered())
|
|
189
|
+
self.answering.add(task)
|
|
190
|
+
task.add_done_callback(self.answering.discard)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _a_widget(seat: rtc.Participant) -> bool:
|
|
194
|
+
return seat.attributes.get(SCOPE_ATTRIBUTE, "") in READS_ITS_OWN_CALL
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _decoded(data: bytes) -> JsonObject:
|
|
198
|
+
try:
|
|
199
|
+
return _A_JSON_OBJECT.validate_json(data)
|
|
200
|
+
except ValidationError:
|
|
201
|
+
return {}
|
|
File without changes
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Admission: an org's limits in a world against what it used there, before it uses more."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.errors import QuotaExhausted
|
|
8
|
+
from pinecall.domain.names import Env
|
|
9
|
+
from pinecall.domain.org import QuotaName, Quotas
|
|
10
|
+
from pinecall.domain.scope import Scope
|
|
11
|
+
from pinecall.postgres.pool import Connection, Pool
|
|
12
|
+
from pinecall.process import box_settings
|
|
13
|
+
from pinecall.tenancy import usage
|
|
14
|
+
|
|
15
|
+
REFUSED = "the org has used {used} of its {limit} {quota} in the {env}"
|
|
16
|
+
|
|
17
|
+
# Dollars are the same in both worlds: the month's spend in both is held to the one budget.
|
|
18
|
+
BUDGET_REFUSED = "the org has spent {used} of its {limit} USD budget this month"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ADMISSION = "admission"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
QUOTAS = """
|
|
25
|
+
SELECT minutes, messages, agents, concurrent_calls, memory_facts, knowledge_chunks, numbers, seats,
|
|
26
|
+
llm_tokens, hosted_apps, budget_usd, lends
|
|
27
|
+
FROM quotas WHERE org = %(org)s AND env = %(env)s
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Replaced whole: a limit left out stops being one.
|
|
32
|
+
SET_QUOTAS = """
|
|
33
|
+
INSERT INTO quotas (org, env, minutes, messages, agents, concurrent_calls, memory_facts,
|
|
34
|
+
knowledge_chunks, numbers, seats, llm_tokens, hosted_apps, budget_usd, lends)
|
|
35
|
+
VALUES (%(org)s, %(env)s, %(minutes)s, %(messages)s, %(agents)s, %(concurrent_calls)s,
|
|
36
|
+
%(memory_facts)s, %(knowledge_chunks)s, %(numbers)s, %(seats)s, %(llm_tokens)s,
|
|
37
|
+
%(hosted_apps)s, %(budget_usd)s, %(lends)s)
|
|
38
|
+
ON CONFLICT (org, env) DO UPDATE SET
|
|
39
|
+
minutes = excluded.minutes, messages = excluded.messages, agents = excluded.agents,
|
|
40
|
+
concurrent_calls = excluded.concurrent_calls, memory_facts = excluded.memory_facts,
|
|
41
|
+
knowledge_chunks = excluded.knowledge_chunks, numbers = excluded.numbers,
|
|
42
|
+
seats = excluded.seats, llm_tokens = excluded.llm_tokens,
|
|
43
|
+
hosted_apps = excluded.hosted_apps, budget_usd = excluded.budget_usd, lends = excluded.lends,
|
|
44
|
+
set_at = now()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Ceiling:
|
|
50
|
+
"""How long a call admitted may last: what is left of the org's minutes."""
|
|
51
|
+
|
|
52
|
+
seconds: int
|
|
53
|
+
minutes: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Admission(BaseModel):
|
|
57
|
+
"""What a newborn org may use in each world: a person's first org, and any later one."""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
60
|
+
|
|
61
|
+
# A world the row does not name has no limits.
|
|
62
|
+
first: dict[Env, Quotas] = Field(default_factory=dict[Env, Quotas])
|
|
63
|
+
# None gives a later org what the first got; set, one trial per person.
|
|
64
|
+
later: dict[Env, Quotas] | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# `at` is the log's clock, so a call is held to the month its summary will be counted in.
|
|
68
|
+
async def admit_call(pool: Pool, org: str, env: Env, *, running: int, at: float) -> Ceiling | None:
|
|
69
|
+
"""Admit one more call, with its ceiling; None when the org's minutes have no limit."""
|
|
70
|
+
quotas = await quotas_of(pool, org, env)
|
|
71
|
+
_refuse_past(quotas, env, "concurrent_calls", running)
|
|
72
|
+
month = usage.month_of(at)
|
|
73
|
+
if quotas.budget_usd is not None:
|
|
74
|
+
_refuse_over_budget(quotas, await usage.spent_in(pool, org, month))
|
|
75
|
+
if quotas.minutes is None and quotas.messages is None and quotas.llm_tokens is None:
|
|
76
|
+
return None
|
|
77
|
+
spent = await usage.used(pool, org, env, month)
|
|
78
|
+
_refuse_past(quotas, env, "minutes", spent.minutes)
|
|
79
|
+
_refuse_past(quotas, env, "messages", spent.messages)
|
|
80
|
+
_refuse_past(quotas, env, "llm_tokens", spent.input_tokens + spent.output_tokens)
|
|
81
|
+
if quotas.minutes is None:
|
|
82
|
+
return None
|
|
83
|
+
# Never zero: the worker reads a zero ceiling as none.
|
|
84
|
+
seconds = max(1, int((quotas.minutes - spent.minutes) * 60))
|
|
85
|
+
return Ceiling(seconds=seconds, minutes=quotas.minutes)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# A call is counted at its summary, so a long written call adds what it has used so far.
|
|
89
|
+
async def admit_turn(pool: Pool, scope: Scope, *, turns: int, tokens: int, at: float) -> None:
|
|
90
|
+
"""Admit one more turn of a written call already this long."""
|
|
91
|
+
org, env = scope.org, scope.env
|
|
92
|
+
quotas = await quotas_of(pool, org, env)
|
|
93
|
+
if quotas.messages is None and quotas.llm_tokens is None:
|
|
94
|
+
return
|
|
95
|
+
spent = await usage.used(pool, org, env, usage.month_of(at))
|
|
96
|
+
_refuse_past(quotas, env, "messages", spent.messages + turns)
|
|
97
|
+
_refuse_past(quotas, env, "llm_tokens", spent.input_tokens + spent.output_tokens + tokens)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def admit_agent(pool: Pool, org: str, env: Env, *, holding: int) -> None:
|
|
101
|
+
"""Admit one more agent held by the org's apps."""
|
|
102
|
+
_refuse_past(await quotas_of(pool, org, env), env, "agents", holding)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def admit_number(pool: Pool, org: str, env: Env, *, bought: int) -> None:
|
|
106
|
+
"""Admit one more number bought on the box's carrier account for the org."""
|
|
107
|
+
_refuse_past(await quotas_of(pool, org, env), env, "numbers", bought)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def admit_memory(pool: Pool, org: str, env: Env, *, kept: int) -> None:
|
|
111
|
+
"""Admit one more fact remembered about a contact."""
|
|
112
|
+
_refuse_past(await quotas_of(pool, org, env), env, "memory_facts", kept)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def admit_seat(pool: Pool, org: str, env: Env, *, seated: int) -> None:
|
|
116
|
+
"""Admit one more person seated in the org."""
|
|
117
|
+
_refuse_past(await quotas_of(pool, org, env), env, "seats", seated)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def admit_hosted_app(pool: Pool, org: str, env: Env, *, hosting: int) -> None:
|
|
121
|
+
"""Admit one more app the box hosts for the org."""
|
|
122
|
+
_refuse_past(await quotas_of(pool, org, env), env, "hosted_apps", hosting)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Sized up front: a push that would pass the limit is refused whole.
|
|
126
|
+
async def admit_push(pool: Pool, org: str, env: Env, *, keeping: int) -> None:
|
|
127
|
+
"""Admit a push that leaves the org keeping this many knowledge chunks."""
|
|
128
|
+
quotas = await quotas_of(pool, org, env)
|
|
129
|
+
limit = quotas.exceeded("knowledge_chunks", keeping)
|
|
130
|
+
if limit is not None:
|
|
131
|
+
_refused(env, "knowledge_chunks", keeping, limit)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def quotas_of(pool: Pool, org: str, env: Env) -> Quotas:
|
|
135
|
+
"""The org's limits in the world; none when nobody set them."""
|
|
136
|
+
async with pool.connection() as connection:
|
|
137
|
+
row = await (await connection.execute(QUOTAS, {"org": org, "env": env})).fetchone()
|
|
138
|
+
if row is None:
|
|
139
|
+
return Quotas()
|
|
140
|
+
lends = row.pop("lends")
|
|
141
|
+
return Quotas(**row, lends=None if lends is None else frozenset(lends))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def set_quotas(pool: Pool, org: str, env: Env, quotas: Quotas) -> None:
|
|
145
|
+
"""Replace the org's limits in the world, whole."""
|
|
146
|
+
async with pool.connection() as connection:
|
|
147
|
+
await _set_quotas(connection, org, env, quotas)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def admission(pool: Pool) -> Admission:
|
|
151
|
+
"""What a newborn org is given; nothing limited on a box that never said."""
|
|
152
|
+
async with pool.connection() as connection:
|
|
153
|
+
return await _admission(connection)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def set_admission(pool: Pool, allowed: Admission) -> None:
|
|
157
|
+
"""Write what a newborn org is given, whole, as the console's box screen sends it."""
|
|
158
|
+
async with pool.connection() as connection:
|
|
159
|
+
await box_settings.write(connection, ADMISSION, allowed.model_dump(mode="json"))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def give_first_quotas(connection: Connection, org: str, *, already: int) -> None:
|
|
163
|
+
"""Set a newborn org's quotas in each world, as the box's admission says for its person."""
|
|
164
|
+
allowed = await _admission(connection)
|
|
165
|
+
worlds = allowed.first if allowed.later is None or already == 0 else allowed.later
|
|
166
|
+
for env, quotas in worlds.items():
|
|
167
|
+
await _set_quotas(connection, org, env, quotas)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _refuse_past(quotas: Quotas, env: Env, quota: QuotaName, spent: float) -> None:
|
|
171
|
+
limit = quotas.reached(quota, spent)
|
|
172
|
+
if limit is not None:
|
|
173
|
+
_refused(env, quota, spent, limit)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _refused(env: Env, quota: QuotaName, spent: float, limit: int) -> None:
|
|
177
|
+
sentence = REFUSED.format(
|
|
178
|
+
used=_shown(spent), limit=limit, quota=quota.replace("_", " "), env=env
|
|
179
|
+
)
|
|
180
|
+
raise QuotaExhausted(sentence, quota=quota, used=spent, limit=limit)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _refuse_over_budget(quotas: Quotas, spent: float) -> None:
|
|
184
|
+
limit = quotas.reached("budget_usd", spent)
|
|
185
|
+
if limit is not None:
|
|
186
|
+
sentence = BUDGET_REFUSED.format(used=_shown(spent), limit=limit)
|
|
187
|
+
raise QuotaExhausted(sentence, quota="budget_usd", used=spent, limit=limit)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _shown(spent: float) -> int | float:
|
|
191
|
+
return int(spent) if float(spent).is_integer() else round(spent, 2)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _admission(connection: Connection) -> Admission:
|
|
195
|
+
value = await box_settings.read(connection, ADMISSION)
|
|
196
|
+
return Admission() if value is None else Admission.model_validate(value)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _set_quotas(connection: Connection, org: str, env: Env, quotas: Quotas) -> None:
|
|
200
|
+
lends = None if quotas.lends is None else sorted(quotas.lends)
|
|
201
|
+
await connection.execute(
|
|
202
|
+
SET_QUOTAS,
|
|
203
|
+
{"org": org, "env": env, **quotas.limits, "lends": lends},
|
|
204
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""What an org sets per agent: how its widget looks, and its hold melody."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
9
|
+
from pinecall.domain.scope import Scope
|
|
10
|
+
from pinecall.postgres.pool import Pool
|
|
11
|
+
from pinecall.wire.parts import WidgetTheme
|
|
12
|
+
|
|
13
|
+
# No row is the box's own melody; `off` is silence; `custom` an uploaded clip, converted once.
|
|
14
|
+
type Played = Literal["off", "custom"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
LONGEST = {"title": 80, "tagline": 160, "greeting": 500, "accent": 40}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# The widget sets the accent as a CSS variable, so nothing that could end the declaration and
|
|
21
|
+
# open another passes.
|
|
22
|
+
A_COLOUR = re.compile(
|
|
23
|
+
r"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|(rgb|rgba|hsl|hsla|oklch)\([0-9.,%\s/a-z-]+\))$"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
LOOK = """
|
|
28
|
+
SELECT title, tagline, greeting, accent, autostart, theme FROM agent_widgets
|
|
29
|
+
WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PUT_LOOK = """
|
|
34
|
+
INSERT INTO agent_widgets (org, env, agent, title, tagline, greeting, accent, autostart, theme)
|
|
35
|
+
VALUES (%(org)s, %(env)s, %(agent)s, %(title)s, %(tagline)s, %(greeting)s, %(accent)s,
|
|
36
|
+
%(autostart)s, %(theme)s)
|
|
37
|
+
ON CONFLICT (org, env, agent) DO UPDATE SET
|
|
38
|
+
title = excluded.title, tagline = excluded.tagline, greeting = excluded.greeting,
|
|
39
|
+
accent = excluded.accent, autostart = excluded.autostart, theme = excluded.theme,
|
|
40
|
+
set_at = now()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
HOLD = """
|
|
45
|
+
SELECT played, sha256, seconds, name FROM hold_audio
|
|
46
|
+
WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
HOLD_AUDIO = (
|
|
51
|
+
"SELECT audio FROM hold_audio WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
KEEP_HOLD = """
|
|
56
|
+
INSERT INTO hold_audio (org, env, agent, played, audio, sha256, seconds, name)
|
|
57
|
+
VALUES (%(org)s, %(env)s, %(agent)s, %(played)s, %(audio)s, %(sha256)s, %(seconds)s, %(name)s)
|
|
58
|
+
ON CONFLICT (org, env, agent) DO UPDATE SET
|
|
59
|
+
played = excluded.played, audio = excluded.audio, sha256 = excluded.sha256,
|
|
60
|
+
seconds = excluded.seconds, name = excluded.name, set_at = now()
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
FORGET_HOLD = "DELETE FROM hold_audio WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Look:
|
|
69
|
+
"""How the widget presents the agent; None is the widget's own default."""
|
|
70
|
+
|
|
71
|
+
title: str | None = None
|
|
72
|
+
tagline: str | None = None
|
|
73
|
+
greeting: str | None = None
|
|
74
|
+
accent: str | None = None
|
|
75
|
+
autostart: bool = False
|
|
76
|
+
theme: WidgetTheme | None = None
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
text = {
|
|
80
|
+
"title": self.title,
|
|
81
|
+
"tagline": self.tagline,
|
|
82
|
+
"greeting": self.greeting,
|
|
83
|
+
"accent": self.accent,
|
|
84
|
+
}
|
|
85
|
+
for name, value in text.items():
|
|
86
|
+
longest = LONGEST[name]
|
|
87
|
+
if value is not None and len(value) > longest:
|
|
88
|
+
raise DeclarationRefused(f"a widget's {name} is {longest} characters at most")
|
|
89
|
+
if self.accent is not None and not A_COLOUR.match(self.accent):
|
|
90
|
+
raise DeclarationRefused(f"an accent is a CSS colour, not {self.accent!r}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Chosen:
|
|
95
|
+
"""What the agent plays while a tool runs: nothing, or a clip of its own."""
|
|
96
|
+
|
|
97
|
+
played: Played
|
|
98
|
+
sha256: str | None = None
|
|
99
|
+
seconds: float | None = None
|
|
100
|
+
name: str | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Clip:
|
|
105
|
+
"""An uploaded melody, already converted: its bytes, how long it lasts, the file's name."""
|
|
106
|
+
|
|
107
|
+
audio: bytes
|
|
108
|
+
seconds: float
|
|
109
|
+
name: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def look_of(pool: Pool, scope: Scope, agent: str) -> Look:
|
|
113
|
+
"""How the agent's widget looks in the world; the widget's defaults when nobody said."""
|
|
114
|
+
async with pool.connection() as connection:
|
|
115
|
+
row = await (await connection.execute(LOOK, _of(scope, agent))).fetchone()
|
|
116
|
+
return Look() if row is None else Look(**row)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def put_look(pool: Pool, scope: Scope, agent: str, look: Look) -> None:
|
|
120
|
+
"""Replace how the agent's widget looks in the world."""
|
|
121
|
+
values = {**_of(scope, agent), **asdict(look)}
|
|
122
|
+
async with pool.connection() as connection:
|
|
123
|
+
await connection.execute(PUT_LOOK, values)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def hold_of(pool: Pool, scope: Scope, agent: str) -> Chosen | None:
|
|
127
|
+
"""What the agent plays while a tool runs in the world; None is the box's melody."""
|
|
128
|
+
async with pool.connection() as connection:
|
|
129
|
+
row = await (await connection.execute(HOLD, _of(scope, agent))).fetchone()
|
|
130
|
+
return None if row is None else Chosen(**row)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def hold_audio(pool: Pool, scope: Scope, agent: str) -> bytes | None:
|
|
134
|
+
"""The agent's own clip, when it plays one."""
|
|
135
|
+
async with pool.connection() as connection:
|
|
136
|
+
row = await (await connection.execute(HOLD_AUDIO, _of(scope, agent))).fetchone()
|
|
137
|
+
return None if row is None or row["audio"] is None else bytes(row["audio"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def keep_hold(pool: Pool, scope: Scope, agent: str, clip: Clip) -> Chosen:
|
|
141
|
+
"""Play this clip while the agent's tools run in the world."""
|
|
142
|
+
chosen = Chosen("custom", hashlib.sha256(clip.audio).hexdigest(), clip.seconds, clip.name)
|
|
143
|
+
await _kept_hold(pool, {**_of(scope, agent), **asdict(chosen), "audio": clip.audio})
|
|
144
|
+
return chosen
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def silence_hold(pool: Pool, scope: Scope, agent: str) -> None:
|
|
148
|
+
"""Play nothing while the agent's tools run; any clip is forgotten."""
|
|
149
|
+
await _kept_hold(pool, {**_of(scope, agent), **asdict(Chosen("off")), "audio": None})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def forget_hold(pool: Pool, scope: Scope, agent: str) -> None:
|
|
153
|
+
"""Play the box's own melody again; any clip is forgotten."""
|
|
154
|
+
async with pool.connection() as connection:
|
|
155
|
+
await connection.execute(FORGET_HOLD, _of(scope, agent))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _kept_hold(pool: Pool, values: dict[str, object]) -> None:
|
|
159
|
+
async with pool.connection() as connection:
|
|
160
|
+
await connection.execute(KEEP_HOLD, values)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _of(scope: Scope, agent: str) -> dict[str, object]:
|
|
164
|
+
return {"org": scope.org, "env": scope.env, "agent": agent}
|