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,405 @@
|
|
|
1
|
+
"""What an org may dial, when and how often: shape, strangers, the callee's hours, the pace."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
import phonenumbers
|
|
9
|
+
from phonenumbers import timezone
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from pinecall.domain.country_codes import CALLING_CODES, NEVER_DIALLED, SHORTEST_NATIONAL
|
|
13
|
+
from pinecall.domain.errors import DeclarationRefused, NotAllowed, QuotaExhausted
|
|
14
|
+
from pinecall.domain.names import parse_e164
|
|
15
|
+
from pinecall.domain.scope import Scope
|
|
16
|
+
from pinecall.log.inbox import ever_reached
|
|
17
|
+
from pinecall.postgres.pool import Pool
|
|
18
|
+
from pinecall.tenancy import consents, policy
|
|
19
|
+
from pinecall.tenancy.consents import Given
|
|
20
|
+
from pinecall.wire.rest.numbers import Consent
|
|
21
|
+
|
|
22
|
+
NOT_A_COUNTRY = "{number} starts with no country calling code E.164 assigns"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
A_BILL = "{number} is +{code}, a satellite or global-service range this box never dials"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
TOO_SHORT = "{number} is shorter than a number anybody calls from: {digits} digits after +{code}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# A call-back box, not a call centre: an operator raises them per org.
|
|
32
|
+
DIALS_A_MINUTE = 6
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DIALS_A_DAY = 200
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
LONGEST_CALL_S = 600
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# The guard that refused, written in the ledger and said in the sentence.
|
|
42
|
+
SHAPE = "shape"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
STRANGER = "stranger"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
TOO_FAST = "too_fast"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TOO_MANY = "too_many"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
QUIET_HOURS = "quiet_hours"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
TOO_OFTEN = "too_often"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DO_NOT_CALL = "do_not_call"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
NO_CONSENT = "no_consent"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# The Telemarketing Sales Rule (16 CFR 310.4(c)): not before 8 a.m. or after 9 p.m. at the called
|
|
67
|
+
# person's location. It binds a call to the US; Canada's rules sit inside the same hours.
|
|
68
|
+
NORTH_AMERICA = "+1"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
US_HOURS = (8, 21)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Florida's "mini-TCPA" caps three calls in 24 hours to one person on one subject: the default for
|
|
75
|
+
# a +1 number, which the org may change. Elsewhere nothing, unless the org sets one.
|
|
76
|
+
US_PER_NUMBER_DAY = 3
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
NO_ZONE = "Etc/Unknown"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# How many of a number's zones a refusal names before it says "…".
|
|
83
|
+
ZONES_SAID = 3
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
OUT_OF_HOURS = (
|
|
87
|
+
"{number} is outside the hours it may be rung ({window}) in {zones} ({guard}); "
|
|
88
|
+
"the sandbox and your own phone are not held to them"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
UNPLACED = "{number} has no zone this box can place it in, so its hours cannot be kept ({guard})"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
A_NUMBERS_DAY = "{number} was rung {used} times in the last day, of {limit} ({guard})"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
ON_THE_LIST = (
|
|
99
|
+
"{number} asked not to be called: it is on this org's do-not-call list ({guard}); only a "
|
|
100
|
+
"consent recorded at POST /v1/org/consents lifts it, never one sent with a dial"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
NO_CONSENT_ON_FILE = (
|
|
105
|
+
"{number} has no consent on file ({guard}): an AI-voice call needs the person's prior express "
|
|
106
|
+
"consent. Send it as `consent` with the dial, or record it at POST /v1/org/consents"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
NOT_ONE_OF_OURS = (
|
|
111
|
+
"{number} never called or wrote to this org in the {env}: a call back goes back to somebody "
|
|
112
|
+
"({guard}); an operator lifts it with dial_anywhere"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
A_BURST = "the org dialled {used} numbers in the last minute of its {limit} ({guard})"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
A_DAYS_WORTH = "the org dialled {used} numbers today of its {limit} ({guard})"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
POLICY = (
|
|
123
|
+
"SELECT dial_anywhere, per_minute, per_day, max_duration_s FROM dial_policy WHERE org = %(org)s"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
PUT_POLICY = """
|
|
128
|
+
INSERT INTO dial_policy (org, dial_anywhere, per_minute, per_day, max_duration_s)
|
|
129
|
+
VALUES (%(org)s, %(dial_anywhere)s, %(per_minute)s, %(per_day)s, %(max_duration_s)s)
|
|
130
|
+
ON CONFLICT (org) DO UPDATE SET dial_anywhere = excluded.dial_anywhere,
|
|
131
|
+
per_minute = excluded.per_minute, per_day = excluded.per_day,
|
|
132
|
+
max_duration_s = excluded.max_duration_s, set_at = now()
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Two dials of one org cannot both take the last slot: the lock holds until the row is written.
|
|
137
|
+
LOCKED = "SELECT pg_advisory_xact_lock(hashtext('dials:' || %(org)s))"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# A number's own count is of the calls placed to it, not the refusals: a refused dial rang nobody.
|
|
141
|
+
PACED = """
|
|
142
|
+
WITH counted AS (
|
|
143
|
+
SELECT count(*) FILTER (WHERE at > now() - interval '1 minute') AS minute,
|
|
144
|
+
count(*) AS day,
|
|
145
|
+
count(*) FILTER (WHERE dialled = %(dialled)s AND refused IS NULL) AS number_day
|
|
146
|
+
FROM dials WHERE org = %(org)s AND at > now() - interval '1 day'
|
|
147
|
+
), judged AS (
|
|
148
|
+
SELECT minute, day, number_day,
|
|
149
|
+
CASE WHEN minute >= %(per_minute)s THEN 'too_fast'
|
|
150
|
+
WHEN day >= %(per_day)s THEN 'too_many'
|
|
151
|
+
WHEN %(per_number_day)s::int IS NOT NULL AND number_day >= %(per_number_day)s::int
|
|
152
|
+
THEN 'too_often' END AS refused
|
|
153
|
+
FROM counted
|
|
154
|
+
)
|
|
155
|
+
INSERT INTO dials (org, env, agent, call, dialled, shown, asked_by, refused)
|
|
156
|
+
SELECT %(org)s, %(env)s, %(agent)s, CASE WHEN judged.refused IS NULL THEN %(call)s END,
|
|
157
|
+
%(dialled)s, %(shown)s, %(asked_by)s, judged.refused
|
|
158
|
+
FROM judged
|
|
159
|
+
RETURNING refused, (SELECT minute FROM judged) AS minute, (SELECT day FROM judged) AS day,
|
|
160
|
+
(SELECT number_day FROM judged) AS number_day
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
REFUSED = """
|
|
165
|
+
INSERT INTO dials (org, env, agent, dialled, shown, asked_by, refused)
|
|
166
|
+
VALUES (%(org)s, %(env)s, %(agent)s, %(dialled)s, %(shown)s, %(asked_by)s, %(refused)s)
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# The first leg was judged when it was placed: its ledger row names the call.
|
|
171
|
+
PLACED = """
|
|
172
|
+
SELECT 1 FROM dials WHERE org = %(org)s AND call = %(call)s AND dialled = %(dialled)s
|
|
173
|
+
AND refused IS NULL
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Guards(BaseModel):
|
|
178
|
+
"""The org's dial guards: what it may dial and how often."""
|
|
179
|
+
|
|
180
|
+
model_config = ConfigDict(frozen=True)
|
|
181
|
+
|
|
182
|
+
dial_anywhere: bool = False
|
|
183
|
+
per_minute: Annotated[int, Field(ge=0)] = DIALS_A_MINUTE
|
|
184
|
+
per_day: Annotated[int, Field(ge=0)] = DIALS_A_DAY
|
|
185
|
+
max_duration_s: Annotated[int, Field(ge=0)] = LONGEST_CALL_S
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class Dial:
|
|
190
|
+
"""One dial asked for: whose, to whom, shown as what, by whom, as which call, and when."""
|
|
191
|
+
|
|
192
|
+
scope: Scope
|
|
193
|
+
agent: str
|
|
194
|
+
to: str
|
|
195
|
+
shown: str | None
|
|
196
|
+
asked_by: str
|
|
197
|
+
call: str
|
|
198
|
+
at: datetime
|
|
199
|
+
# The number is a phone the person who asked verified as their own: testing, not calling out.
|
|
200
|
+
own_phone: bool = False
|
|
201
|
+
# The consent the call runs on, as the dial carried it; written down before anything rings.
|
|
202
|
+
consent: Consent | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class Rules:
|
|
207
|
+
"""What the called number's destination and the org's policy hold a dial to."""
|
|
208
|
+
|
|
209
|
+
hours: tuple[int, int] | None
|
|
210
|
+
per_number_day: int | None
|
|
211
|
+
# Held to the do-not-call list at all: not in the sandbox, not the asker's own phone.
|
|
212
|
+
listed: bool = False
|
|
213
|
+
consent_required: bool = False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def destination_of(number: str) -> str:
|
|
217
|
+
"""The number a dial may reach, or DeclarationRefused saying why not."""
|
|
218
|
+
e164 = parse_e164(number)
|
|
219
|
+
digits = e164.removeprefix("+")
|
|
220
|
+
code = next((digits[:length] for length in (3, 2, 1) if digits[:length] in CALLING_CODES), None)
|
|
221
|
+
if code is None:
|
|
222
|
+
raise DeclarationRefused(NOT_A_COUNTRY.format(number=e164))
|
|
223
|
+
if code in NEVER_DIALLED:
|
|
224
|
+
raise DeclarationRefused(A_BILL.format(number=e164, code=code))
|
|
225
|
+
national = digits[len(code) :]
|
|
226
|
+
if len(national) < SHORTEST_NATIONAL:
|
|
227
|
+
raise DeclarationRefused(TOO_SHORT.format(number=e164, digits=len(national), code=code))
|
|
228
|
+
return e164
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def guards_of(pool: Pool, org: str) -> Guards:
|
|
232
|
+
"""The org's guards; a column nobody set is the default."""
|
|
233
|
+
async with pool.connection() as connection:
|
|
234
|
+
row = await (await connection.execute(POLICY, {"org": org})).fetchone()
|
|
235
|
+
if row is None:
|
|
236
|
+
return Guards()
|
|
237
|
+
return Guards.model_validate({name: value for name, value in row.items() if value is not None})
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def put_guards(pool: Pool, org: str, guards: Guards) -> None:
|
|
241
|
+
"""Replace the org's guards whole."""
|
|
242
|
+
async with pool.connection() as connection:
|
|
243
|
+
await connection.execute(PUT_POLICY, {"org": org, **guards.model_dump()})
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def guard_dial(pool: Pool, dial: Dial) -> Guards:
|
|
247
|
+
"""Every guard on a call placed cold: shape, a stranger, hours, the pace; one ledger row."""
|
|
248
|
+
destination = await _shaped(pool, dial)
|
|
249
|
+
guards = await guards_of(pool, dial.scope.org)
|
|
250
|
+
reached = guards.dial_anywhere or await ever_reached(
|
|
251
|
+
pool, dial.scope.org, dial.scope.env, destination
|
|
252
|
+
)
|
|
253
|
+
if not reached:
|
|
254
|
+
await _refused(pool, dial, STRANGER)
|
|
255
|
+
raise NotAllowed(
|
|
256
|
+
NOT_ONE_OF_OURS.format(number=destination, env=dial.scope.env, guard=STRANGER)
|
|
257
|
+
)
|
|
258
|
+
rules = await _rules_for(pool, dial, destination)
|
|
259
|
+
await _consented(pool, dial, destination, rules)
|
|
260
|
+
if rules.hours is not None:
|
|
261
|
+
await _in_hours(pool, dial, destination, rules.hours)
|
|
262
|
+
await _paced(pool, dial, guards, rules.per_number_day)
|
|
263
|
+
return guards
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def zones_of(number: str) -> tuple[str, ...]:
|
|
267
|
+
"""Every time zone the number could be in; empty when none is known."""
|
|
268
|
+
zones = timezone.time_zones_for_number(phonenumbers.parse(number))
|
|
269
|
+
return tuple(zone for zone in zones if zone != NO_ZONE)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def within(zones: tuple[str, ...], at: datetime, hours: tuple[int, int]) -> bool:
|
|
273
|
+
"""Whether it is inside the hours in every zone: a number in two zones is held to both."""
|
|
274
|
+
start, end = hours
|
|
275
|
+
return all(start <= at.astimezone(ZoneInfo(zone)).hour < end for zone in zones)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# A transfer target need not have called: the stranger fence stays off, the pace caps a loop.
|
|
279
|
+
async def guard_second_leg(pool: Pool, dial: Dial) -> None:
|
|
280
|
+
"""The shape and the pace on a leg dialled into a live call; the call's own first leg passes."""
|
|
281
|
+
async with pool.connection() as connection:
|
|
282
|
+
placed = await (
|
|
283
|
+
await connection.execute(
|
|
284
|
+
PLACED, {"org": dial.scope.org, "call": dial.call, "dialled": dial.to}
|
|
285
|
+
)
|
|
286
|
+
).fetchone()
|
|
287
|
+
if placed is not None:
|
|
288
|
+
return
|
|
289
|
+
await _shaped(pool, dial)
|
|
290
|
+
await _paced(pool, dial, await guards_of(pool, dial.scope.org), None)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _shaped(pool: Pool, dial: Dial) -> str:
|
|
294
|
+
try:
|
|
295
|
+
return destination_of(dial.to)
|
|
296
|
+
except DeclarationRefused as malformed:
|
|
297
|
+
await _refused(pool, dial, SHAPE)
|
|
298
|
+
raise DeclarationRefused(f"{malformed} ({SHAPE})") from None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def _refused(pool: Pool, dial: Dial, guard: str) -> None:
|
|
302
|
+
async with pool.connection() as connection:
|
|
303
|
+
await connection.execute(REFUSED, {**_ledger(dial), "refused": guard})
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# The rules bind a call out to a person. The sandbox is where an org tries its agent, and a
|
|
307
|
+
# person's own verified phone is them testing: neither is held to a caller's hours or count.
|
|
308
|
+
async def _rules_for(pool: Pool, dial: Dial, destination: str) -> Rules:
|
|
309
|
+
"""The list, consent, hours and daily count a dial is held to, by destination and policy."""
|
|
310
|
+
if dial.scope.env == "sandbox" or dial.own_phone:
|
|
311
|
+
return Rules(hours=None, per_number_day=None)
|
|
312
|
+
kept = (await policy.policy_of(pool, dial.scope.org)).policy
|
|
313
|
+
window = (
|
|
314
|
+
None if kept.calling_hours is None else (kept.calling_hours.from_, kept.calling_hours.until)
|
|
315
|
+
)
|
|
316
|
+
if destination.startswith(NORTH_AMERICA):
|
|
317
|
+
floor, top = US_HOURS
|
|
318
|
+
start, end = window if window is not None else US_HOURS
|
|
319
|
+
return Rules(
|
|
320
|
+
hours=(max(floor, start), min(top, end)),
|
|
321
|
+
per_number_day=kept.per_number_day or US_PER_NUMBER_DAY,
|
|
322
|
+
listed=True,
|
|
323
|
+
consent_required=True,
|
|
324
|
+
)
|
|
325
|
+
return Rules(
|
|
326
|
+
hours=window,
|
|
327
|
+
per_number_day=kept.per_number_day,
|
|
328
|
+
listed=True,
|
|
329
|
+
consent_required=kept.consent_everywhere,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# An opt-out outranks the consent a dial carries: an app may not lift the list by sending one. A
|
|
334
|
+
# dial the list does not bind (the sandbox, a developer's own phone) rings past it, and writes no
|
|
335
|
+
# consent over it either.
|
|
336
|
+
async def _consented(pool: Pool, dial: Dial, destination: str, rules: Rules) -> None:
|
|
337
|
+
current = await consents.standing_of(pool, dial.scope, destination)
|
|
338
|
+
if current == "opted_out" and not rules.listed:
|
|
339
|
+
return
|
|
340
|
+
if current == "opted_out":
|
|
341
|
+
await _refused(pool, dial, DO_NOT_CALL)
|
|
342
|
+
raise NotAllowed(ON_THE_LIST.format(number=destination, guard=DO_NOT_CALL))
|
|
343
|
+
if dial.consent is not None:
|
|
344
|
+
given = Given(
|
|
345
|
+
kind=dial.consent.kind,
|
|
346
|
+
source=dial.consent.source,
|
|
347
|
+
given_by=dial.asked_by,
|
|
348
|
+
text=dial.consent.text,
|
|
349
|
+
evidence=dial.consent.evidence,
|
|
350
|
+
call=dial.call,
|
|
351
|
+
)
|
|
352
|
+
await consents.give(pool, dial.scope, destination, given)
|
|
353
|
+
return
|
|
354
|
+
if rules.consent_required and current != "consented":
|
|
355
|
+
await _refused(pool, dial, NO_CONSENT)
|
|
356
|
+
raise NotAllowed(NO_CONSENT_ON_FILE.format(number=destination, guard=NO_CONSENT))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def _in_hours(pool: Pool, dial: Dial, destination: str, hours: tuple[int, int]) -> None:
|
|
360
|
+
zones = zones_of(destination)
|
|
361
|
+
if not zones:
|
|
362
|
+
await _refused(pool, dial, QUIET_HOURS)
|
|
363
|
+
raise NotAllowed(UNPLACED.format(number=destination, guard=QUIET_HOURS))
|
|
364
|
+
if not within(zones, dial.at, hours):
|
|
365
|
+
await _refused(pool, dial, QUIET_HOURS)
|
|
366
|
+
window = f"{hours[0]}:00 to {hours[1]}:00"
|
|
367
|
+
named = ", ".join(zones[:ZONES_SAID]) + ("…" if len(zones) > ZONES_SAID else "")
|
|
368
|
+
raise NotAllowed(
|
|
369
|
+
OUT_OF_HOURS.format(number=destination, window=window, zones=named, guard=QUIET_HOURS)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def _paced(pool: Pool, dial: Dial, guards: Guards, per_number_day: int | None) -> None:
|
|
374
|
+
params = {
|
|
375
|
+
**_ledger(dial),
|
|
376
|
+
"call": dial.call,
|
|
377
|
+
"per_minute": guards.per_minute,
|
|
378
|
+
"per_day": guards.per_day,
|
|
379
|
+
"per_number_day": per_number_day,
|
|
380
|
+
}
|
|
381
|
+
async with pool.connection() as connection, connection.transaction():
|
|
382
|
+
await connection.execute(LOCKED, {"org": dial.scope.org})
|
|
383
|
+
row = await (await connection.execute(PACED, params)).fetchone()
|
|
384
|
+
if row is None or row["refused"] is None:
|
|
385
|
+
return
|
|
386
|
+
if row["refused"] == TOO_FAST:
|
|
387
|
+
sentence = A_BURST.format(used=row["minute"], limit=guards.per_minute, guard=TOO_FAST)
|
|
388
|
+
elif row["refused"] == TOO_OFTEN:
|
|
389
|
+
sentence = A_NUMBERS_DAY.format(
|
|
390
|
+
number=dial.to, used=row["number_day"], limit=per_number_day, guard=TOO_OFTEN
|
|
391
|
+
)
|
|
392
|
+
else:
|
|
393
|
+
sentence = A_DAYS_WORTH.format(used=row["day"], limit=guards.per_day, guard=TOO_MANY)
|
|
394
|
+
raise QuotaExhausted(sentence)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _ledger(dial: Dial) -> dict[str, str | None]:
|
|
398
|
+
return {
|
|
399
|
+
"org": dial.scope.org,
|
|
400
|
+
"env": dial.scope.env,
|
|
401
|
+
"agent": dial.agent,
|
|
402
|
+
"dialled": dial.to.strip(),
|
|
403
|
+
"shown": dial.shown,
|
|
404
|
+
"asked_by": dial.asked_by,
|
|
405
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""What a spoken call says before its greeting: the AI disclosure and the recording notice."""
|
|
2
|
+
|
|
3
|
+
from pinecall.wire.rest.accounts import OrgPolicy
|
|
4
|
+
|
|
5
|
+
# The platform's sentences, by the first part of the agent's language; English for any other.
|
|
6
|
+
CALLING_FOR = {
|
|
7
|
+
"en": "This is an automated assistant calling on behalf of {org}.",
|
|
8
|
+
"es": "Le habla un asistente automático en nombre de {org}.",
|
|
9
|
+
"pt": "Aqui fala um assistente automático em nome de {org}.",
|
|
10
|
+
"fr": "Ici un assistant automatique qui appelle au nom de {org}.",
|
|
11
|
+
"de": "Hier spricht ein automatischer Assistent im Auftrag von {org}.",
|
|
12
|
+
"it": "Sono un assistente automatico che chiama per conto di {org}.",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
MAY_BE_RECORDED = {
|
|
16
|
+
"en": "This call may be recorded.",
|
|
17
|
+
"es": "Esta llamada puede ser grabada.",
|
|
18
|
+
"pt": "Esta chamada pode ser gravada.",
|
|
19
|
+
"fr": "Cet appel peut être enregistré.",
|
|
20
|
+
"de": "Dieser Anruf kann aufgezeichnet werden.",
|
|
21
|
+
"it": "Questa chiamata può essere registrata.",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
FALLBACK = "en"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def disclosure_of(policy: OrgPolicy, org: str, language: str | None) -> str | None:
|
|
28
|
+
"""An outbound call's first sentence: the org's own, else the platform's, or None."""
|
|
29
|
+
if policy.disclosure is not None:
|
|
30
|
+
return policy.disclosure.strip() or None
|
|
31
|
+
return CALLING_FOR[_spoken(language)].format(org=org)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def notice_of(policy: OrgPolicy, language: str | None) -> str | None:
|
|
35
|
+
"""The sentence a recorded call says, or None when the org turned the notice off."""
|
|
36
|
+
return MAY_BE_RECORDED[_spoken(language)] if policy.recording_notice else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _spoken(language: str | None) -> str:
|
|
40
|
+
first = (language or FALLBACK).replace("_", "-").split("-")[0].lower()
|
|
41
|
+
return first if first in CALLING_FOR else FALLBACK
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""An erasure: a call's, a contact's or an org's rows and recordings gone, and its trail row."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
|
|
5
|
+
from psycopg import AsyncConnection
|
|
6
|
+
from psycopg.rows import DictRow
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.errors import Conflict, StoreUnreachable
|
|
9
|
+
from pinecall.domain.names import Env
|
|
10
|
+
from pinecall.domain.scope import Scope
|
|
11
|
+
from pinecall.postgres.pool import Pool, unbounded
|
|
12
|
+
from pinecall.process.recordings import Recordings
|
|
13
|
+
from pinecall.wire.rest.calls import Erasure, ErasureSubject
|
|
14
|
+
|
|
15
|
+
# The trigger on call_log lets a DELETE through in this transaction alone (0011_erasure.sql). An
|
|
16
|
+
# erasure is unbounded: an org's logs are long to delete, and its recordings go inside the
|
|
17
|
+
# transaction.
|
|
18
|
+
ERASING = "SET LOCAL pinecall.erasing = 'on'"
|
|
19
|
+
|
|
20
|
+
CALLS_OF_CONTACT = """
|
|
21
|
+
SELECT head.call, head.sealed
|
|
22
|
+
FROM call_log_head head JOIN call_facts facts ON facts.call = head.call
|
|
23
|
+
WHERE head.org = %(org)s AND head.env = %(env)s AND facts.contact = %(contact)s
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Every log of the org: its calls, and its agents' own logs ("@<agent>"), which carry no call.
|
|
27
|
+
LOGS_OF_ORG = "SELECT log, call, agent FROM call_log_head WHERE org = %(org)s"
|
|
28
|
+
|
|
29
|
+
# What a call left besides its entries: the private values sealed beside them, its head, facts,
|
|
30
|
+
# tokens, the memories it taught, its recording's key, the verdicts its day's drift counted (the
|
|
31
|
+
# day's sums keep its numbers, which name nobody), the case of the org's dataset made of it
|
|
32
|
+
# (evals/dataset.py), the labels people gave its judges (evals/calibration.py) and what it was
|
|
33
|
+
# when it opened (log/openings.py). A phone call's numbers, times and end stay in call_records,
|
|
34
|
+
# and the dials ledger stays: they name numbers and times, never what was said, and a traceback
|
|
35
|
+
# asks for them (0016_call_records.sql). Every statement of the WITH reads the rows as they were
|
|
36
|
+
# before it, so the record reads the facts.
|
|
37
|
+
ERASE_LOGS = """
|
|
38
|
+
WITH recorded AS (
|
|
39
|
+
INSERT INTO call_records (call, org, env, direction, from_number, to_number, started_at,
|
|
40
|
+
ended_at, end_reason)
|
|
41
|
+
SELECT facts.call, head.org, head.env, facts.direction, facts.from_number, facts.to_number,
|
|
42
|
+
head.started_at, facts.ended_at, facts.end_reason
|
|
43
|
+
FROM call_facts facts JOIN call_log_head head ON head.call = facts.call
|
|
44
|
+
WHERE facts.call = ANY(%(calls)s) AND facts.channel = 'phone'
|
|
45
|
+
ON CONFLICT (call) DO NOTHING
|
|
46
|
+
),
|
|
47
|
+
private AS (DELETE FROM call_private WHERE log = ANY(%(logs)s)),
|
|
48
|
+
keys AS (DELETE FROM recording_keys WHERE call = ANY(%(calls)s)),
|
|
49
|
+
heads AS (DELETE FROM call_log_head WHERE log = ANY(%(logs)s) RETURNING 1),
|
|
50
|
+
facts AS (DELETE FROM call_facts WHERE call = ANY(%(calls)s) RETURNING 1),
|
|
51
|
+
spent AS (DELETE FROM tokens WHERE call = ANY(%(calls)s) RETURNING 1),
|
|
52
|
+
taught AS (DELETE FROM contact_memories WHERE source_call = ANY(%(calls)s) RETURNING 1),
|
|
53
|
+
drifted AS (DELETE FROM drift_calls WHERE call = ANY(%(calls)s)),
|
|
54
|
+
promoted AS (DELETE FROM eval_cases WHERE source_call = ANY(%(calls)s)),
|
|
55
|
+
labels AS (DELETE FROM judge_labels WHERE call = ANY(%(calls)s)),
|
|
56
|
+
openings AS (DELETE FROM call_openings WHERE call = ANY(%(calls)s))
|
|
57
|
+
SELECT (SELECT count(*) FROM taught) AS memories
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Before ERASE_LOGS takes the heads: call_log's trigger reads each summary's org off its head, to
|
|
61
|
+
# take its usage out of the org's totals (0023_usage_totals.sql).
|
|
62
|
+
ERASE_ENTRIES = "DELETE FROM call_log WHERE log = ANY(%(logs)s)"
|
|
63
|
+
|
|
64
|
+
# A contact's memories no erased call taught (written by hand, or by a call erased before), and
|
|
65
|
+
# what each reader had read of their thread.
|
|
66
|
+
ERASE_CONTACT = """
|
|
67
|
+
WITH kept AS (
|
|
68
|
+
DELETE FROM contact_memories WHERE org = %(org)s AND env = %(env)s AND contact = %(contact)s
|
|
69
|
+
RETURNING 1
|
|
70
|
+
), reads AS (
|
|
71
|
+
DELETE FROM thread_reads WHERE org = %(org)s AND env = %(env)s AND contact = %(contact)s
|
|
72
|
+
)
|
|
73
|
+
SELECT count(*) AS memories FROM kept
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# What has no foreign key to the org: its eval runs (by the org they carry, or by its agents for a
|
|
77
|
+
# run written before runs carried one) and hold melodies, what each reader read of a thread, and
|
|
78
|
+
# its keys, revoked ones included. Then the org, and the quotas, carriers, settings and memories
|
|
79
|
+
# cascade.
|
|
80
|
+
ERASE_ORG = """
|
|
81
|
+
WITH runs AS (
|
|
82
|
+
DELETE FROM eval_runs
|
|
83
|
+
WHERE org = %(org)s OR (org IS NULL AND agent = ANY(%(agents)s))
|
|
84
|
+
),
|
|
85
|
+
melodies AS (DELETE FROM hold_audio WHERE org = %(org)s),
|
|
86
|
+
reads AS (DELETE FROM thread_reads WHERE org = %(org)s),
|
|
87
|
+
keys AS (DELETE FROM api_keys WHERE org = %(org)s)
|
|
88
|
+
DELETE FROM orgs WHERE id = %(org)s
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
TRAIL = """
|
|
92
|
+
INSERT INTO erasures (org, env, what, subject, asked_by, calls, entries, memories, recordings)
|
|
93
|
+
VALUES (%(org)s, %(env)s, %(what)s, %(subject)s, %(asked_by)s, %(calls)s, %(entries)s,
|
|
94
|
+
%(memories)s, %(recordings)s)
|
|
95
|
+
RETURNING id, env, what, subject, asked_by, calls, entries, memories, recordings, at
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
TRAIL_OF_ORG = """
|
|
99
|
+
SELECT id, env, what, subject, asked_by, calls, entries, memories, recordings, at
|
|
100
|
+
FROM erasures WHERE org = %(org)s ORDER BY at DESC, id DESC LIMIT %(limit)s
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
NO_TRAIL = "the erasure of {subject} was not written down, so it was undone"
|
|
104
|
+
|
|
105
|
+
STILL_ON_A_CALL = "{contact} is on call {call} right now: erase them once it has ended"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Erased:
|
|
110
|
+
"""An erasure done: its trail row, and the calls it took, for a process that holds them."""
|
|
111
|
+
|
|
112
|
+
trail: Erasure
|
|
113
|
+
calls: tuple[str, ...]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class _Taking:
|
|
118
|
+
"""An erasure on its way: whose, what, who asked, and the rows gone so far."""
|
|
119
|
+
|
|
120
|
+
org: str
|
|
121
|
+
env: Env | None
|
|
122
|
+
what: ErasureSubject
|
|
123
|
+
subject: str
|
|
124
|
+
asked_by: str
|
|
125
|
+
calls: list[str]
|
|
126
|
+
entries: int
|
|
127
|
+
memories: int
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def call(
|
|
131
|
+
pool: Pool, recordings: Recordings, scope: Scope, call_id: str, *, by: str
|
|
132
|
+
) -> Erased:
|
|
133
|
+
"""Erase one call: its log, facts, tokens, the memories it taught, and its recording."""
|
|
134
|
+
async with unbounded(pool) as connection:
|
|
135
|
+
await connection.execute(ERASING)
|
|
136
|
+
entries, memories = await _logs(connection, [call_id], [call_id])
|
|
137
|
+
taking = _Taking(scope.org, scope.env, "call", call_id, by, [call_id], entries, memories)
|
|
138
|
+
return await _written(connection, recordings, taking)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def contact(
|
|
142
|
+
pool: Pool, recordings: Recordings, scope: Scope, contact_id: str, *, by: str
|
|
143
|
+
) -> Erased:
|
|
144
|
+
"""Erase a contact in the world: every call they were on and every fact kept of them."""
|
|
145
|
+
params = {"org": scope.org, "env": scope.env, "contact": contact_id}
|
|
146
|
+
async with unbounded(pool) as connection:
|
|
147
|
+
await connection.execute(ERASING)
|
|
148
|
+
rows = await (await connection.execute(CALLS_OF_CONTACT, params)).fetchall()
|
|
149
|
+
live = next((str(row["call"]) for row in rows if not row["sealed"]), None)
|
|
150
|
+
if live is not None:
|
|
151
|
+
raise Conflict(STILL_ON_A_CALL.format(contact=contact_id, call=live))
|
|
152
|
+
calls = [str(row["call"]) for row in rows]
|
|
153
|
+
entries, taught = await _logs(connection, calls, calls)
|
|
154
|
+
kept = await (await connection.execute(ERASE_CONTACT, params)).fetchone()
|
|
155
|
+
memories = taught + (0 if kept is None else int(kept["memories"]))
|
|
156
|
+
taking = _Taking(scope.org, scope.env, "contact", contact_id, by, calls, entries, memories)
|
|
157
|
+
return await _written(connection, recordings, taking)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def org(pool: Pool, recordings: Recordings, org_id: str, *, by: str) -> Erased:
|
|
161
|
+
"""Erase an org whole: every log it owns, every recording, then the org and what cascades."""
|
|
162
|
+
async with unbounded(pool) as connection:
|
|
163
|
+
await connection.execute(ERASING)
|
|
164
|
+
rows = await (await connection.execute(LOGS_OF_ORG, {"org": org_id})).fetchall()
|
|
165
|
+
logs = [str(row["log"]) for row in rows]
|
|
166
|
+
calls = [str(row["call"]) for row in rows if row["call"] is not None]
|
|
167
|
+
agents = sorted({str(row["agent"]) for row in rows if row["agent"] is not None})
|
|
168
|
+
entries, memories = await _logs(connection, logs, calls)
|
|
169
|
+
await connection.execute(ERASE_ORG, {"org": org_id, "agents": agents})
|
|
170
|
+
taking = _Taking(org_id, None, "org", org_id, by, calls, entries, memories)
|
|
171
|
+
return await _written(connection, recordings, taking)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def trail(pool: Pool, org_id: str, *, limit: int = 100) -> list[Erasure]:
|
|
175
|
+
"""The org's erasures, newest first."""
|
|
176
|
+
params = {"org": org_id, "limit": limit}
|
|
177
|
+
async with pool.connection() as connection:
|
|
178
|
+
rows = await (await connection.execute(TRAIL_OF_ORG, params)).fetchall()
|
|
179
|
+
return [_trail_row(row) for row in rows]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def _logs(
|
|
183
|
+
connection: AsyncConnection[DictRow], logs: list[str], calls: list[str]
|
|
184
|
+
) -> tuple[int, int]:
|
|
185
|
+
entries = (await connection.execute(ERASE_ENTRIES, {"logs": logs})).rowcount
|
|
186
|
+
row = await (await connection.execute(ERASE_LOGS, {"logs": logs, "calls": calls})).fetchone()
|
|
187
|
+
return entries, 0 if row is None else int(row["memories"])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# The recordings go inside the transaction, before its trail is written: a disk or a bucket
|
|
191
|
+
# that refuses leaves the rows in place and the erasure failed, not a trail that lies.
|
|
192
|
+
async def _written(
|
|
193
|
+
connection: AsyncConnection[DictRow], recordings: Recordings, taking: _Taking
|
|
194
|
+
) -> Erased:
|
|
195
|
+
removed = await recordings.erase(taking.org, taking.calls)
|
|
196
|
+
params = {**asdict(taking), "calls": len(taking.calls), "recordings": removed}
|
|
197
|
+
row = await (await connection.execute(TRAIL, params)).fetchone()
|
|
198
|
+
if row is None:
|
|
199
|
+
raise StoreUnreachable(NO_TRAIL.format(subject=taking.subject))
|
|
200
|
+
return Erased(trail=_trail_row(row), calls=tuple(taking.calls))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _trail_row(row: DictRow) -> Erasure:
|
|
204
|
+
return Erasure.model_validate({**row, "at": row["at"].timestamp()})
|