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/domain/agent.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""What an agent declares (prompt, tools, voice, turns) and what the org sets on top of it."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
11
|
+
from pinecall.domain.names import Json, parse_slug
|
|
12
|
+
|
|
13
|
+
# public: the caller's browser. tenant: the console (default). pii: the console, masked.
|
|
14
|
+
type Visibility = Literal["public", "tenant", "pii"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# app: the tenant's backend. participant: the caller's browser.
|
|
18
|
+
type EventSource = Literal["app", "participant"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# irreversible tools (payments, cancellations) require the caller's confirmation, asked by the
|
|
22
|
+
# platform rather than the model.
|
|
23
|
+
type SideEffect = Literal["read", "write", "irreversible"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# static: before the history, provider-cached. dynamic: after it, rewritten every turn.
|
|
27
|
+
type PromptRegion = Literal["static", "dynamic"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# retrieved: top chunks are injected every turn. tool: the model gets a search(query) tool.
|
|
31
|
+
type DocsMode = Literal["retrieved", "tool"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# every-call: at every hang-up the org judges. simulations: only a call a persona played.
|
|
35
|
+
type RunsOn = Literal["every-call", "simulations"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# The names the platform gives its verdicts, at hang-up and on a golden; a judge of the org's or an
|
|
39
|
+
# agent's takes another, or a call would carry two verdicts under one name.
|
|
40
|
+
PANEL_JUDGES: tuple[str, ...] = (
|
|
41
|
+
"consent",
|
|
42
|
+
"grounded",
|
|
43
|
+
"promises",
|
|
44
|
+
"persona",
|
|
45
|
+
"identified",
|
|
46
|
+
"disclosed",
|
|
47
|
+
"honoured_stop",
|
|
48
|
+
"heard",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# A name every model vendor accepts as a function name.
|
|
53
|
+
_A_NAME_A_MODEL_CAN_CALL = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Written by the platform, never the app, so a knowledge file carries the operator's authority.
|
|
57
|
+
KNOWLEDGE = "knowledge"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DEFAULT_CHUNKS_PER_TURN = 8
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# The default ends abandoned lines and machine-to-machine loops; 0 is no limit, else 1 to 60 min.
|
|
64
|
+
LONGEST_VOICE_CALL_S = 600
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
SHORTEST_LIMIT_S = 60
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
LONGEST_LIMIT_S = 3600
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
NO_LIMIT = 0
|
|
74
|
+
|
|
75
|
+
# How long a tool may run before the model hears it timed out.
|
|
76
|
+
LONGEST_TOOL_S = 300
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Unset knobs are None, never "": an empty string reaches the vendor as a value (an empty voice
|
|
80
|
+
# once made every call silent).
|
|
81
|
+
BLANK = (
|
|
82
|
+
"a blank {field} is not a change: an empty value once silenced a whole line of calls. "
|
|
83
|
+
"Leave the field out to go back to what the app declared."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Voice:
|
|
89
|
+
"""The agent's TTS voice."""
|
|
90
|
+
|
|
91
|
+
provider: str
|
|
92
|
+
model: str | None = None
|
|
93
|
+
voice_id: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Model:
|
|
98
|
+
"""An LLM or STT model and its temperature."""
|
|
99
|
+
|
|
100
|
+
provider: str
|
|
101
|
+
model: str
|
|
102
|
+
temperature: float | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Turn:
|
|
107
|
+
"""Turn detection and interruption settings."""
|
|
108
|
+
|
|
109
|
+
min_interruption_words: int | None = None
|
|
110
|
+
endpointing_ms: int | None = None
|
|
111
|
+
# Deepgram reports its default (0.7) ends up to a fifth of turns early.
|
|
112
|
+
eot_threshold: float | None = None
|
|
113
|
+
# The lower bar that starts LiveKit's speculative generation, offsetting a higher eot.
|
|
114
|
+
eager_eot_threshold: float | None = None
|
|
115
|
+
# How long the caller must speak over the agent before it stops; None is livekit's 0.5 s.
|
|
116
|
+
min_interruption_ms: int | None = None
|
|
117
|
+
|
|
118
|
+
# Deepgram rejects the connection when eager > eot, leaving the call without STT.
|
|
119
|
+
def __post_init__(self) -> None:
|
|
120
|
+
counts = {
|
|
121
|
+
"min_interruption_ms": self.min_interruption_ms,
|
|
122
|
+
"endpointing_ms": self.endpointing_ms,
|
|
123
|
+
"min_interruption_words": self.min_interruption_words,
|
|
124
|
+
}
|
|
125
|
+
for name, value in counts.items():
|
|
126
|
+
if value is not None and value < 0:
|
|
127
|
+
raise DeclarationRefused(f"{name} is a count, 0 or more")
|
|
128
|
+
if self.eager_eot_threshold is None or self.eot_threshold is None:
|
|
129
|
+
return
|
|
130
|
+
if self.eager_eot_threshold > self.eot_threshold:
|
|
131
|
+
raise DeclarationRefused(
|
|
132
|
+
f"eager_eot_threshold {self.eager_eot_threshold} is the bar for GUESSING the turn "
|
|
133
|
+
f"is over, so it cannot sit above eot_threshold {self.eot_threshold}, which is the "
|
|
134
|
+
"bar for ending it."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class Greeting:
|
|
140
|
+
"""The opening turn: fixed words (`say`) or an instruction for the model (`reply`)."""
|
|
141
|
+
|
|
142
|
+
say: str | None = None
|
|
143
|
+
reply: str | None = None
|
|
144
|
+
allow_interruptions: bool | None = None
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
if (self.say is None) == (self.reply is None):
|
|
148
|
+
text = "both were declared" if self.say is not None else "neither was"
|
|
149
|
+
raise DeclarationRefused(
|
|
150
|
+
"a greeting is one of two things: `say` the words, or `reply` what the model "
|
|
151
|
+
f"reads before it finds its own. {text} — pick one."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class Hangup:
|
|
157
|
+
"""Lets the model end the call, with instructions on when."""
|
|
158
|
+
|
|
159
|
+
when: str = ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class ToolSpec:
|
|
164
|
+
"""A tool as the app declares it, validated on construction."""
|
|
165
|
+
|
|
166
|
+
name: str
|
|
167
|
+
description: str
|
|
168
|
+
parameters: Mapping[str, Json]
|
|
169
|
+
side_effect: SideEffect = "read"
|
|
170
|
+
pii: frozenset[str] = frozenset()
|
|
171
|
+
confirm: str | None = None
|
|
172
|
+
preview: int | None = None
|
|
173
|
+
timeout_s: float = 30.0
|
|
174
|
+
|
|
175
|
+
def __post_init__(self) -> None:
|
|
176
|
+
if not _A_NAME_A_MODEL_CAN_CALL.match(self.name):
|
|
177
|
+
raise DeclarationRefused(f"a tool name is one word a model can call, not {self.name!r}")
|
|
178
|
+
if not self.description.strip():
|
|
179
|
+
raise DeclarationRefused(
|
|
180
|
+
f"tool {self.name}: without a description no model can choose it"
|
|
181
|
+
)
|
|
182
|
+
if self.parameters.get("type") != "object":
|
|
183
|
+
raise DeclarationRefused(
|
|
184
|
+
f"tool {self.name}: parameters are a JSON Schema of type object"
|
|
185
|
+
)
|
|
186
|
+
if self.side_effect == "irreversible" and not self.confirm:
|
|
187
|
+
raise DeclarationRefused(
|
|
188
|
+
f"tool {self.name}: an irreversible tool needs a confirm template to read back"
|
|
189
|
+
)
|
|
190
|
+
if unknown := self.pii - self.parameter_names:
|
|
191
|
+
raise DeclarationRefused(
|
|
192
|
+
f"tool {self.name}: pii names parameters the tool has; unknown: {sorted(unknown)}"
|
|
193
|
+
)
|
|
194
|
+
if self.preview is not None and self.preview < 1:
|
|
195
|
+
raise DeclarationRefused(f"tool {self.name}: a preview shows at least one item")
|
|
196
|
+
# The caller waits in silence or on the melody while a tool runs: five minutes at most.
|
|
197
|
+
if not 0 < self.timeout_s <= LONGEST_TOOL_S:
|
|
198
|
+
raise DeclarationRefused(
|
|
199
|
+
f"tool {self.name}: timeout_s is seconds above 0, {LONGEST_TOOL_S} at most"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def parameter_names(self) -> frozenset[str]:
|
|
204
|
+
"""Return the property names of the parameter schema."""
|
|
205
|
+
properties = self.parameters.get("properties")
|
|
206
|
+
return frozenset(properties) if isinstance(properties, dict) else frozenset()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True)
|
|
210
|
+
class PromptBlock:
|
|
211
|
+
"""A named prompt block and its region."""
|
|
212
|
+
|
|
213
|
+
name: str
|
|
214
|
+
region: PromptRegion
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class Docs:
|
|
219
|
+
"""A knowledge base the agent uses, and how results reach the model."""
|
|
220
|
+
|
|
221
|
+
base: str
|
|
222
|
+
mode: DocsMode = "retrieved"
|
|
223
|
+
k: int = DEFAULT_CHUNKS_PER_TURN
|
|
224
|
+
min_score: float | None = None
|
|
225
|
+
|
|
226
|
+
def __post_init__(self) -> None:
|
|
227
|
+
if not self.base:
|
|
228
|
+
raise DeclarationRefused("docs name the knowledge base they were pushed to")
|
|
229
|
+
if self.k < 1:
|
|
230
|
+
raise DeclarationRefused("docs hand the model at least one chunk")
|
|
231
|
+
if self.min_score is not None and self.min_score < 0:
|
|
232
|
+
raise DeclarationRefused("a fused rank score is never negative; min_score cannot be")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True)
|
|
236
|
+
class KnowledgeFile:
|
|
237
|
+
"""One file pushed to a knowledge base."""
|
|
238
|
+
|
|
239
|
+
path: str
|
|
240
|
+
text: str
|
|
241
|
+
|
|
242
|
+
def __post_init__(self) -> None:
|
|
243
|
+
if not self.path:
|
|
244
|
+
raise DeclarationRefused("a knowledge file is named by its path")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Free-text categories; anything under `forget` is never stored, whatever the model extracts.
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class MemoryPolicy:
|
|
250
|
+
"""What memory keeps about a contact across calls, and what it never keeps."""
|
|
251
|
+
|
|
252
|
+
remember: tuple[str, ...] = ()
|
|
253
|
+
forget: tuple[str, ...] = ()
|
|
254
|
+
|
|
255
|
+
def __post_init__(self) -> None:
|
|
256
|
+
if both := set(self.remember) & set(self.forget):
|
|
257
|
+
raise DeclarationRefused(f"memory cannot both remember and forget {sorted(both)}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True)
|
|
261
|
+
class Tuning:
|
|
262
|
+
"""One agent's settings; every knob is None until set."""
|
|
263
|
+
|
|
264
|
+
voice: str | None = None
|
|
265
|
+
tts: str | None = None
|
|
266
|
+
tts_model: str | None = None
|
|
267
|
+
stt: str | None = None
|
|
268
|
+
llm: str | None = None
|
|
269
|
+
# A tag (`en`, `pt-BR`); None leaves each vendor its own default.
|
|
270
|
+
language: str | None = None
|
|
271
|
+
greeting: Greeting | None = None
|
|
272
|
+
hangup: Hangup | None = None
|
|
273
|
+
turn: Turn | None = None
|
|
274
|
+
memory: MemoryPolicy | None = None
|
|
275
|
+
# Tri-state so an explicit False still overrides the layer below.
|
|
276
|
+
record: bool | None = None
|
|
277
|
+
# Voice calls only; 0 is no limit, None is the runtime default.
|
|
278
|
+
max_duration_s: int | None = None
|
|
279
|
+
# How long a turn waits for the model's first token; None waits as long as livekit retries.
|
|
280
|
+
llm_timeout_s: float | None = None
|
|
281
|
+
# Markdown placed whole in the static knowledge block; editable with the `words` scope.
|
|
282
|
+
knowledge: str | None = None
|
|
283
|
+
# An empty tuple means "none" and overrides the layer below; None means unset.
|
|
284
|
+
bases: tuple[Docs, ...] | None = None
|
|
285
|
+
|
|
286
|
+
def __post_init__(self) -> None:
|
|
287
|
+
knobs = (
|
|
288
|
+
("voice", self.voice),
|
|
289
|
+
("tts", self.tts),
|
|
290
|
+
("tts_model", self.tts_model),
|
|
291
|
+
("stt", self.stt),
|
|
292
|
+
("llm", self.llm),
|
|
293
|
+
("language", self.language),
|
|
294
|
+
("knowledge", self.knowledge),
|
|
295
|
+
)
|
|
296
|
+
for name, value in knobs:
|
|
297
|
+
if value is not None and not value.strip():
|
|
298
|
+
raise DeclarationRefused(BLANK.format(field=name))
|
|
299
|
+
if self.max_duration_s is not None:
|
|
300
|
+
_check_call_limit(self.max_duration_s)
|
|
301
|
+
if self.llm_timeout_s is not None and self.llm_timeout_s <= 0:
|
|
302
|
+
raise DeclarationRefused("llm_timeout_s is a positive number of seconds")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True)
|
|
306
|
+
class Lexicon:
|
|
307
|
+
"""An agent's lexicon: TTS pronunciations and STT keyterms."""
|
|
308
|
+
|
|
309
|
+
said: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
310
|
+
heard: tuple[str, ...] = ()
|
|
311
|
+
|
|
312
|
+
def __post_init__(self) -> None:
|
|
313
|
+
_check_pronunciations(self.said, "the lexicon")
|
|
314
|
+
if any(not word.strip() for word in self.heard):
|
|
315
|
+
raise DeclarationRefused("the lexicon: a word the ears must know is not blank")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True)
|
|
319
|
+
class Version[T]:
|
|
320
|
+
"""A stored version of a value, with its holder, number, author and timestamp."""
|
|
321
|
+
|
|
322
|
+
holder: str
|
|
323
|
+
version: int
|
|
324
|
+
author: str
|
|
325
|
+
note: str | None
|
|
326
|
+
set_at: datetime
|
|
327
|
+
value: T
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# Stored on the call's head row so the exact configuration can be read back later.
|
|
331
|
+
@dataclass(frozen=True)
|
|
332
|
+
class Versions:
|
|
333
|
+
"""Tuning and lexicon versions a call was built on; None where nothing was set."""
|
|
334
|
+
|
|
335
|
+
config: int | None = None
|
|
336
|
+
lexicon: int | None = None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass(frozen=True)
|
|
340
|
+
class AgentJudge:
|
|
341
|
+
"""A question the org wrote about one agent's job, put to the judge model at hang-up."""
|
|
342
|
+
|
|
343
|
+
name: str
|
|
344
|
+
question: str
|
|
345
|
+
runs_on: RunsOn = "every-call"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
DEFAULT_LAYOUT: tuple[PromptBlock, ...] = (
|
|
349
|
+
PromptBlock("identity", "static"),
|
|
350
|
+
PromptBlock(KNOWLEDGE, "static"),
|
|
351
|
+
PromptBlock("tools", "static"),
|
|
352
|
+
PromptBlock("view", "dynamic"),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# Field names match the wire's AgentConfig (tests/domain/test_names.py holds them to it, from
|
|
357
|
+
# TWINS in tests/domain/conftest.py).
|
|
358
|
+
@dataclass(frozen=True)
|
|
359
|
+
class AgentConfig:
|
|
360
|
+
"""An agent's resolved declaration."""
|
|
361
|
+
|
|
362
|
+
slug: str
|
|
363
|
+
name: str | None = None
|
|
364
|
+
prompt: tuple[PromptBlock, ...] = DEFAULT_LAYOUT
|
|
365
|
+
greeting: Greeting | None = None
|
|
366
|
+
language: str | None = None
|
|
367
|
+
voice: Voice | None = None
|
|
368
|
+
llm: Model | None = None
|
|
369
|
+
stt: Model | None = None
|
|
370
|
+
turn: Turn | None = None
|
|
371
|
+
says: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
372
|
+
hears: tuple[str, ...] = ()
|
|
373
|
+
# From Tuning.knowledge; placed whole in the static knowledge block.
|
|
374
|
+
knowledge: str | None = None
|
|
375
|
+
bases: tuple[Docs, ...] = ()
|
|
376
|
+
# The class calls `this.knowledge.search`; registration fails when no base is attached.
|
|
377
|
+
uses_knowledge: bool = False
|
|
378
|
+
memory: MemoryPolicy | None = None
|
|
379
|
+
hangup: Hangup | None = None
|
|
380
|
+
record: bool = True
|
|
381
|
+
max_duration_s: int = LONGEST_VOICE_CALL_S
|
|
382
|
+
# None: no deadline of ours, so livekit's own retries decide how long a turn waits.
|
|
383
|
+
llm_timeout_s: float | None = None
|
|
384
|
+
tools: tuple[ToolSpec, ...] = ()
|
|
385
|
+
state_fields: Mapping[str, Visibility] = field(default_factory=dict[str, Visibility])
|
|
386
|
+
# Side-panel title only; the console fetches its contents from the app, so tenant data
|
|
387
|
+
# never passes through the gateway's config.
|
|
388
|
+
view: str | None = None
|
|
389
|
+
events: Mapping[str, frozenset[EventSource]] = field(
|
|
390
|
+
default_factory=dict[str, frozenset[EventSource]]
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def __post_init__(self) -> None:
|
|
394
|
+
parse_slug(self.slug)
|
|
395
|
+
names = [tool.name for tool in self.tools]
|
|
396
|
+
if repeated := {name for name in names if names.count(name) > 1}:
|
|
397
|
+
raise DeclarationRefused(f"agent {self.slug}: tool names repeat: {sorted(repeated)}")
|
|
398
|
+
blocks = [block.name for block in self.prompt]
|
|
399
|
+
if repeated := {name for name in blocks if blocks.count(name) > 1}:
|
|
400
|
+
raise DeclarationRefused(
|
|
401
|
+
f"agent {self.slug}: prompt block names repeat: {sorted(repeated)}"
|
|
402
|
+
)
|
|
403
|
+
_check_pronunciations(self.says, f"agent {self.slug}")
|
|
404
|
+
_check_call_limit(self.max_duration_s)
|
|
405
|
+
for event, sources in self.events.items():
|
|
406
|
+
if not event or not sources:
|
|
407
|
+
raise DeclarationRefused(
|
|
408
|
+
f"agent {self.slug}: event {event!r} names who may send it: app, participant"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def visibility_of(self, state_field: str) -> Visibility:
|
|
412
|
+
"""Return a state field's visibility, tenant unless declared."""
|
|
413
|
+
return self.state_fields.get(state_field, "tenant")
|
|
414
|
+
|
|
415
|
+
def accepts(self, event: str, source: EventSource) -> bool:
|
|
416
|
+
"""Return whether the event may be sent by this source."""
|
|
417
|
+
return source in self.events.get(event, frozenset())
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def tools_by_name(self) -> Mapping[str, ToolSpec]:
|
|
421
|
+
"""Return the tools keyed by name."""
|
|
422
|
+
return {tool.name: tool for tool in self.tools}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# The log keeps a block's hash, never its words (prompt.changed); the org's prompts keep the words
|
|
426
|
+
# under the same hash, so a call's prompt is read back whole.
|
|
427
|
+
def block_hash(text: str) -> str:
|
|
428
|
+
"""A block of the prompt as the log names it: the sha256 of its text."""
|
|
429
|
+
return sha256(text.encode()).hexdigest()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _check_call_limit(seconds: int) -> None:
|
|
433
|
+
"""Raise DeclarationRefused unless seconds is 0 or between 60 and 3600."""
|
|
434
|
+
if seconds != NO_LIMIT and not SHORTEST_LIMIT_S <= seconds <= LONGEST_LIMIT_S:
|
|
435
|
+
raise DeclarationRefused(
|
|
436
|
+
f"max_duration_s {seconds}: 0 for no limit, or {SHORTEST_LIMIT_S} to "
|
|
437
|
+
f"{LONGEST_LIMIT_S} seconds (1 to 60 minutes)"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _check_pronunciations(says: Mapping[str, str], owner: str) -> None:
|
|
442
|
+
"""Raise DeclarationRefused when a word or its spoken form is blank."""
|
|
443
|
+
if blank := {word for word, spoken in says.items() if not word or not spoken}:
|
|
444
|
+
raise DeclarationRefused(
|
|
445
|
+
f"{owner}: a pronunciation is a word and how it is said: {sorted(blank)}"
|
|
446
|
+
)
|
pinecall/domain/call.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""What a call is: its id, direction, who is calling, the route it came in by, its day."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, date, datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
11
|
+
from pinecall.domain.names import (
|
|
12
|
+
CHANNELS_WITH_A_NUMBER,
|
|
13
|
+
PRODUCTION,
|
|
14
|
+
Channel,
|
|
15
|
+
Direction,
|
|
16
|
+
Env,
|
|
17
|
+
Json,
|
|
18
|
+
dialable,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Prefix of call ids minted here (`call_` + 32 hex); phone calls are named by the media plane.
|
|
22
|
+
A_CALL = "call_"
|
|
23
|
+
|
|
24
|
+
# Who opened a call's log: the fleet's worker, an org's own worker (an app key), or the gateway
|
|
25
|
+
# for a written call. The worker doors of a fleet's call take the fleet's key alone.
|
|
26
|
+
type Opener = Literal["fleet", "app", "gateway"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Contact:
|
|
31
|
+
"""What is known about the caller; a web visitor may be anonymous."""
|
|
32
|
+
|
|
33
|
+
id: str | None = None
|
|
34
|
+
phone: str | None = None
|
|
35
|
+
name: str | None = None
|
|
36
|
+
email: str | None = None
|
|
37
|
+
external_id: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Route:
|
|
42
|
+
"""Which agent answers on a channel and number, and which org owns it."""
|
|
43
|
+
|
|
44
|
+
org: str
|
|
45
|
+
agent: str
|
|
46
|
+
channel: Channel
|
|
47
|
+
number: str | None = None
|
|
48
|
+
label: str | None = None
|
|
49
|
+
# A number belongs to one environment; the registry refuses it in the other.
|
|
50
|
+
env: Env = PRODUCTION
|
|
51
|
+
# Bought on the box's carrier account, so it counts against the `numbers` quota; numbers
|
|
52
|
+
# imported from the tenant's own account do not.
|
|
53
|
+
managed: bool = False
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if not self.org or not self.agent:
|
|
57
|
+
raise DeclarationRefused(
|
|
58
|
+
"a route names the org that owns it and the agent that answers"
|
|
59
|
+
)
|
|
60
|
+
if self.channel in CHANNELS_WITH_A_NUMBER:
|
|
61
|
+
if self.number is None or not dialable(self.number):
|
|
62
|
+
raise DeclarationRefused(
|
|
63
|
+
f"a {self.channel} route answers at a number in E.164 form, not {self.number!r}"
|
|
64
|
+
)
|
|
65
|
+
elif self.number is not None:
|
|
66
|
+
raise DeclarationRefused(f"the {self.channel} widget answers at no number")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def door(self) -> tuple[Channel, str | None]:
|
|
70
|
+
"""Return (channel, number): the registry's key, whatever the org or the label."""
|
|
71
|
+
return (self.channel, self.number)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Immutable: whatever changes during a call is recorded in the log.
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class CallContext:
|
|
77
|
+
"""A call as the worker and the gateway see it when it starts."""
|
|
78
|
+
|
|
79
|
+
call: str
|
|
80
|
+
channel: Channel
|
|
81
|
+
direction: Direction
|
|
82
|
+
caller: str
|
|
83
|
+
route: Route
|
|
84
|
+
today: date
|
|
85
|
+
contact: Contact | None = None
|
|
86
|
+
metadata: Mapping[str, Json] = field(default_factory=dict[str, Json])
|
|
87
|
+
# Eval run that opened the call; None for a real caller.
|
|
88
|
+
run: str | None = None
|
|
89
|
+
# Simulated caller's name, and its rules frozen at call start so judging ignores later edits.
|
|
90
|
+
persona: str | None = None
|
|
91
|
+
accepts_when: str | None = None
|
|
92
|
+
declines_when: str | None = None
|
|
93
|
+
# Sandbox developer the call belongs to; None for the org itself.
|
|
94
|
+
holder: str | None = None
|
|
95
|
+
# The state the call opens in, when a golden, a persona or `?state=` asked for one.
|
|
96
|
+
state: Mapping[str, Json] = field(default_factory=dict[str, Json])
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
if not self.call:
|
|
100
|
+
raise DeclarationRefused("a call context names its call")
|
|
101
|
+
if not self.caller:
|
|
102
|
+
raise DeclarationRefused("a call has a calling side: a number, or the visitor id")
|
|
103
|
+
if self.route.channel != self.channel:
|
|
104
|
+
raise DeclarationRefused(
|
|
105
|
+
f"a {self.channel} call cannot come through a {self.route.channel} route"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def env(self) -> Env:
|
|
110
|
+
"""Return the environment of the call's route."""
|
|
111
|
+
return self.route.env
|
|
112
|
+
|
|
113
|
+
# The app's contact id, else the number, so phone and WhatsApp calls share one contact.
|
|
114
|
+
@property
|
|
115
|
+
def remembered_as(self) -> str | None:
|
|
116
|
+
"""Return the memory key for the caller, or None when the caller is anonymous."""
|
|
117
|
+
if self.contact is not None and self.contact.id:
|
|
118
|
+
return self.contact.id
|
|
119
|
+
return self.caller if self.channel in CHANNELS_WITH_A_NUMBER else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# A carrier bills each leg on its own, by the number at the end of it: the box's own number when
|
|
123
|
+
# the call came in, the dialled one when it went out.
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class PhoneLeg:
|
|
126
|
+
"""One leg of a call on the phone network, and how long it was up."""
|
|
127
|
+
|
|
128
|
+
# `twilio`, or `sip` for a trunk the box knows only by its address.
|
|
129
|
+
carrier: str
|
|
130
|
+
direction: Direction
|
|
131
|
+
number: str
|
|
132
|
+
seconds: float
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def new_call_id() -> str:
|
|
136
|
+
"""Mint a call id; it is also the LiveKit room name."""
|
|
137
|
+
return f"{A_CALL}{uuid4().hex}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def parse_zone(zone: str) -> ZoneInfo:
|
|
141
|
+
"""Return the IANA zone, raising DeclarationRefused when there is none by that name."""
|
|
142
|
+
try:
|
|
143
|
+
return ZoneInfo(zone)
|
|
144
|
+
except (ZoneInfoNotFoundError, ValueError) as unknown:
|
|
145
|
+
raise DeclarationRefused(
|
|
146
|
+
f"{zone!r} is not an IANA time zone (Europe/Madrid, America/Montevideo, UTC)"
|
|
147
|
+
) from unknown
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def today_in(zone: str) -> date:
|
|
151
|
+
"""Return today's date in the zone: a call is dated where the caller is, not the box."""
|
|
152
|
+
return datetime.now(UTC).astimezone(parse_zone(zone)).date()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""E.164 country calling codes, the satellite ranges never dialled, the shortest national number."""
|
|
2
|
+
|
|
3
|
+
# E.164 country calling codes, matched longest first; no longer code starts with 1 or 7.
|
|
4
|
+
CALLING_CODES: frozenset[str] = frozenset(
|
|
5
|
+
{
|
|
6
|
+
"1", "7", "20", "27", "30", "31", "32", "33", "34", "36", "39", "40", "41", "43", "44",
|
|
7
|
+
"45", "46", "47", "48", "49", "51", "52", "53", "54", "55", "56", "57", "58", "60",
|
|
8
|
+
"61", "62", "63", "64", "65", "66", "81", "82", "84", "86", "90", "91", "92", "93",
|
|
9
|
+
"94", "95", "98", "211", "212", "213", "216", "218", "220", "221", "222", "223", "224",
|
|
10
|
+
"225", "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236",
|
|
11
|
+
"237", "238", "239", "240", "241", "242", "243", "244", "245", "246", "247", "248",
|
|
12
|
+
"249", "250", "251", "252", "253", "254", "255", "256", "257", "258", "260", "261",
|
|
13
|
+
"262", "263", "264", "265", "266", "267", "268", "269", "290", "291", "297", "298",
|
|
14
|
+
"299", "350", "351", "352", "353", "354", "355", "356", "357", "358", "359", "370",
|
|
15
|
+
"371", "372", "373", "374", "375", "376", "377", "378", "379", "380", "381", "382",
|
|
16
|
+
"383", "385", "386", "387", "389", "420", "421", "423", "500", "501", "502", "503",
|
|
17
|
+
"504", "505", "506", "507", "508", "509", "590", "591", "592", "593", "594", "595",
|
|
18
|
+
"596", "597", "598", "599", "670", "672", "673", "674", "675", "676", "677", "678",
|
|
19
|
+
"679", "680", "681", "682", "683", "685", "686", "687", "688", "689", "690", "691",
|
|
20
|
+
"692", "800", "808", "850", "852", "853", "855", "856", "870", "878", "880", "881",
|
|
21
|
+
"882", "883", "886", "888", "960", "961", "962", "963", "964", "965", "966", "967",
|
|
22
|
+
"968", "970", "971", "972", "973", "974", "975", "976", "977", "979", "992", "993",
|
|
23
|
+
"994", "995", "996", "998",
|
|
24
|
+
}
|
|
25
|
+
) # fmt: skip
|
|
26
|
+
|
|
27
|
+
# Satellite and global-service ranges: where international revenue-share fraud is dialled, and
|
|
28
|
+
# never a number that called anybody back.
|
|
29
|
+
NEVER_DIALLED = ("870", "878", "881", "882", "883", "888", "979")
|
|
30
|
+
|
|
31
|
+
# Shorter national numbers are short codes, service numbers or typos.
|
|
32
|
+
SHORTEST_NATIONAL = 5
|