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/log/usage.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""What a scope used: a window of whole days in numbers, and the metered feed past a cursor."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from datetime import UTC, date, datetime
|
|
5
|
+
|
|
6
|
+
from psycopg import sql
|
|
7
|
+
from psycopg.rows import DictRow
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
10
|
+
from pinecall.domain.names import CHANNELS
|
|
11
|
+
from pinecall.domain.scope import Scope
|
|
12
|
+
from pinecall.log.facts import STAGES
|
|
13
|
+
from pinecall.log.reduce import METERED_TYPES, UsageRow, usage_row
|
|
14
|
+
from pinecall.log.store import Store
|
|
15
|
+
from pinecall.postgres.pool import Pool
|
|
16
|
+
|
|
17
|
+
# Days are cut in UTC: an org has no timezone.
|
|
18
|
+
A_DAY_S = 24 * 60 * 60
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_IN_THE_WINDOW = sql.SQL("head.started_at >= %(start)s and head.started_at < %(end)s")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# A window's rows: the scope's calls, or one agent's when the window names one.
|
|
25
|
+
_THE_WINDOWS_CALLS = sql.SQL("""
|
|
26
|
+
from call_log_head head left join call_facts f on f.call = head.log
|
|
27
|
+
where head.org = %(org)s and head.env = %(env)s and head.holder = %(holder)s
|
|
28
|
+
and head.call is not null and (%(agent)s::text is null or head.agent = %(agent)s)
|
|
29
|
+
""")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class AgentWindow:
|
|
34
|
+
"""One agent's window: its calls, the share of judgments it held, what it cost by stage."""
|
|
35
|
+
|
|
36
|
+
slug: str
|
|
37
|
+
calls: int
|
|
38
|
+
score: float | None
|
|
39
|
+
# Dollars by stage (llm, stt, tts, phone, platform), and the minutes of the calls that ended.
|
|
40
|
+
spend: dict[str, float]
|
|
41
|
+
minutes: float
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class WindowDay:
|
|
46
|
+
"""One UTC day of a window: its calls by door, what they cost, and how their judges answered."""
|
|
47
|
+
|
|
48
|
+
day: date
|
|
49
|
+
channels: dict[str, int]
|
|
50
|
+
spent: float
|
|
51
|
+
judged: int
|
|
52
|
+
passed: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class Window:
|
|
57
|
+
"""A scope's window of whole UTC days in numbers, and the window of the same length before."""
|
|
58
|
+
|
|
59
|
+
calls: int
|
|
60
|
+
before: int
|
|
61
|
+
finished: int
|
|
62
|
+
unescalated: int
|
|
63
|
+
judged: int
|
|
64
|
+
passed: int
|
|
65
|
+
escalated: int
|
|
66
|
+
mean_length: float | None
|
|
67
|
+
median_e2e: float | None
|
|
68
|
+
spent: float
|
|
69
|
+
channels: dict[str, int]
|
|
70
|
+
endings: list[tuple[str, int]]
|
|
71
|
+
days: list[WindowDay]
|
|
72
|
+
agents: list[AgentWindow]
|
|
73
|
+
total: int
|
|
74
|
+
live: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class MeteredPage:
|
|
79
|
+
"""One page of the usage feed: the rows kept, and the cursor past everything read."""
|
|
80
|
+
|
|
81
|
+
rows: list[UsageRow]
|
|
82
|
+
next: int | None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# "before" is the window of the same length right before it. A call is judged once a judge
|
|
86
|
+
# settled, and passed unless a judge said it did not (CallFacts.score).
|
|
87
|
+
WINDOW = (
|
|
88
|
+
sql.SQL("""
|
|
89
|
+
select
|
|
90
|
+
count(*) filter (where {window}) as calls,
|
|
91
|
+
count(*) filter (where head.started_at >= %(start)s - (%(end)s - %(start)s)
|
|
92
|
+
and head.started_at < %(start)s) as before,
|
|
93
|
+
count(*) filter (where {window} and f.ended_at is not null) as finished,
|
|
94
|
+
count(*) filter (where {window} and f.ended_at is not null and not f.escalated)
|
|
95
|
+
as unescalated,
|
|
96
|
+
count(*) filter (where {window} and f.judged > 0) as judged,
|
|
97
|
+
count(*) filter (where {window} and f.judged > 0 and f.passed is not false) as passed,
|
|
98
|
+
count(*) filter (where {window} and f.escalated) as escalated,
|
|
99
|
+
avg(f.ended_at - head.started_at) filter (where {window} and f.ended_at is not null)
|
|
100
|
+
as mean_length,
|
|
101
|
+
coalesce(sum(f.cost_usd) filter (where {window}), 0) as spent,
|
|
102
|
+
count(*) filter (where {window} and f.channel = 'phone') as phone,
|
|
103
|
+
count(*) filter (where {window} and f.channel = 'web') as web,
|
|
104
|
+
count(*) filter (where {window} and f.channel = 'whatsapp') as whatsapp,
|
|
105
|
+
count(*) as total,
|
|
106
|
+
count(*) filter (where not head.sealed) as live
|
|
107
|
+
""").format(window=_IN_THE_WINDOW)
|
|
108
|
+
+ _THE_WINDOWS_CALLS
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
WINDOW_MEDIAN_E2E = sql.SQL("""
|
|
113
|
+
select percentile_cont(0.5) within group (order by turn.seconds) as median
|
|
114
|
+
from call_log_head head
|
|
115
|
+
join call_facts f on f.call = head.log
|
|
116
|
+
cross join lateral unnest(f.e2e) as turn(seconds)
|
|
117
|
+
where head.org = %(org)s and head.env = %(env)s and head.holder = %(holder)s
|
|
118
|
+
and head.call is not null and (%(agent)s::text is null or head.agent = %(agent)s)
|
|
119
|
+
and {window}
|
|
120
|
+
""").format(window=_IN_THE_WINDOW)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
WINDOW_BY_AGENT = (
|
|
124
|
+
sql.SQL("""
|
|
125
|
+
select head.agent as slug, count(*) as calls,
|
|
126
|
+
avg(f.held::double precision / f.judged) filter (where f.judged > 0) as score,
|
|
127
|
+
coalesce(sum(f.cost_llm_usd), 0) as llm, coalesce(sum(f.cost_stt_usd), 0) as stt,
|
|
128
|
+
coalesce(sum(f.cost_tts_usd), 0) as tts, coalesce(sum(f.cost_phone_usd), 0) as phone,
|
|
129
|
+
coalesce(sum(f.cost_platform_usd), 0) as platform,
|
|
130
|
+
coalesce(sum(f.ended_at - head.started_at) filter (where f.ended_at is not null), 0) / 60
|
|
131
|
+
as minutes
|
|
132
|
+
""")
|
|
133
|
+
+ _THE_WINDOWS_CALLS
|
|
134
|
+
+ sql.SQL("""
|
|
135
|
+
and {window}
|
|
136
|
+
group by head.agent
|
|
137
|
+
order by calls desc, slug
|
|
138
|
+
""").format(window=_IN_THE_WINDOW)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
WINDOW_ENDINGS = (
|
|
143
|
+
sql.SQL("select f.end_reason as reason, count(*) as count ")
|
|
144
|
+
+ _THE_WINDOWS_CALLS
|
|
145
|
+
+ sql.SQL("""
|
|
146
|
+
and {window} and f.end_reason is not null
|
|
147
|
+
group by f.end_reason
|
|
148
|
+
order by count desc, reason
|
|
149
|
+
""").format(window=_IN_THE_WINDOW)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Days are cut in UTC, as everywhere: the day of a call is its start's whole days since the epoch.
|
|
154
|
+
WINDOW_BY_DAY = (
|
|
155
|
+
sql.SQL("""
|
|
156
|
+
select floor(head.started_at / %(a_day)s)::bigint as epoch_day,
|
|
157
|
+
count(*) filter (where f.channel = 'phone') as phone,
|
|
158
|
+
count(*) filter (where f.channel = 'web') as web,
|
|
159
|
+
count(*) filter (where f.channel = 'whatsapp') as whatsapp,
|
|
160
|
+
coalesce(sum(f.cost_usd), 0) as spent,
|
|
161
|
+
count(*) filter (where f.judged > 0) as judged,
|
|
162
|
+
count(*) filter (where f.judged > 0 and f.passed is not false) as passed
|
|
163
|
+
""")
|
|
164
|
+
+ _THE_WINDOWS_CALLS
|
|
165
|
+
+ sql.SQL("""
|
|
166
|
+
and {window}
|
|
167
|
+
group by epoch_day
|
|
168
|
+
""").format(window=_IN_THE_WINDOW)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# The cursor moves past every row read, kept or not, so a page filtered to one org still moves.
|
|
173
|
+
async def metered_page(store: Store, *, after: int, limit: int, org: str | None) -> MeteredPage:
|
|
174
|
+
"""The metered rows after the cursor, of one org or of every org, folded."""
|
|
175
|
+
read = [usage_row(item) for item in await store.across(METERED_TYPES, after=after, limit=limit)]
|
|
176
|
+
kept = [row for row in read if org is None or row.org == org]
|
|
177
|
+
return MeteredPage(rows=kept, next=read[-1].cursor if read else None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# One agent's window when it names one; the whole scope's when it is None.
|
|
181
|
+
async def counted_window(
|
|
182
|
+
pool: Pool, scope: Scope, start: float, end: float, agent: str | None
|
|
183
|
+
) -> Window:
|
|
184
|
+
"""Return the scope's window from start to end, both on a UTC midnight, in numbers."""
|
|
185
|
+
params = {**asdict(scope), "start": start, "end": end, "agent": agent, "a_day": A_DAY_S}
|
|
186
|
+
# independent: five counts of one window, each its own snapshot
|
|
187
|
+
async with pool.connection() as connection:
|
|
188
|
+
counted = await (await connection.execute(WINDOW, params)).fetchone()
|
|
189
|
+
median = await (await connection.execute(WINDOW_MEDIAN_E2E, params)).fetchone()
|
|
190
|
+
agents = await (await connection.execute(WINDOW_BY_AGENT, params)).fetchall()
|
|
191
|
+
endings = await (await connection.execute(WINDOW_ENDINGS, params)).fetchall()
|
|
192
|
+
by_day = await (await connection.execute(WINDOW_BY_DAY, params)).fetchall()
|
|
193
|
+
if counted is None:
|
|
194
|
+
raise DeclarationRefused("a window counts, even an empty one")
|
|
195
|
+
return Window(
|
|
196
|
+
calls=int(counted["calls"]),
|
|
197
|
+
before=int(counted["before"]),
|
|
198
|
+
finished=int(counted["finished"]),
|
|
199
|
+
unescalated=int(counted["unescalated"]),
|
|
200
|
+
judged=int(counted["judged"]),
|
|
201
|
+
passed=int(counted["passed"]),
|
|
202
|
+
escalated=int(counted["escalated"]),
|
|
203
|
+
mean_length=None if counted["mean_length"] is None else float(counted["mean_length"]),
|
|
204
|
+
median_e2e=None if median is None else median["median"],
|
|
205
|
+
spent=float(counted["spent"]),
|
|
206
|
+
channels={door: int(counted[door]) for door in CHANNELS},
|
|
207
|
+
endings=[(row["reason"], int(row["count"])) for row in endings],
|
|
208
|
+
days=_every_day(start, end, {int(row["epoch_day"]): row for row in by_day}),
|
|
209
|
+
agents=[
|
|
210
|
+
AgentWindow(
|
|
211
|
+
slug=row["slug"],
|
|
212
|
+
calls=row["calls"],
|
|
213
|
+
score=row["score"],
|
|
214
|
+
spend={stage: float(row[stage]) for stage in STAGES},
|
|
215
|
+
minutes=float(row["minutes"]),
|
|
216
|
+
)
|
|
217
|
+
for row in agents
|
|
218
|
+
],
|
|
219
|
+
total=int(counted["total"]),
|
|
220
|
+
live=int(counted["live"]),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# Every day of the window has its row, a day nobody called included, so a chart has no holes.
|
|
225
|
+
def _every_day(start: float, end: float, counted: dict[int, DictRow]) -> list[WindowDay]:
|
|
226
|
+
first, last = int(start // A_DAY_S), int(end // A_DAY_S)
|
|
227
|
+
return [_day_of(epoch_day, counted.get(epoch_day)) for epoch_day in range(first, last)]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _day_of(epoch_day: int, row: DictRow | None) -> WindowDay:
|
|
231
|
+
day = datetime.fromtimestamp(epoch_day * A_DAY_S, UTC).date()
|
|
232
|
+
if row is None:
|
|
233
|
+
return WindowDay(
|
|
234
|
+
day=day, channels=dict.fromkeys(CHANNELS, 0), spent=0.0, judged=0, passed=0
|
|
235
|
+
)
|
|
236
|
+
return WindowDay(
|
|
237
|
+
day=day,
|
|
238
|
+
channels={door: int(row[door]) for door in CHANNELS},
|
|
239
|
+
spent=float(row["spent"]),
|
|
240
|
+
judged=int(row["judged"]),
|
|
241
|
+
passed=int(row["passed"]),
|
|
242
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The migration runner: the .sql files beside it, applied in order, never edited."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
|
|
11
|
+
from pinecall.domain.errors import MigrationsRefused
|
|
12
|
+
from pinecall.postgres.pool import (
|
|
13
|
+
DEFAULT_SCHEMA,
|
|
14
|
+
Connection,
|
|
15
|
+
Pool,
|
|
16
|
+
connect,
|
|
17
|
+
database_named,
|
|
18
|
+
schemas_of,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Applied in name order. An applied migration is never edited: add a new one instead.
|
|
22
|
+
MIGRATIONS = Path(__file__).parent / "migrations"
|
|
23
|
+
FIRST = "0001_schema.sql"
|
|
24
|
+
|
|
25
|
+
# First-line marker that runs a file outside a transaction, required by CREATE INDEX CONCURRENTLY.
|
|
26
|
+
# Such a file holds ONE statement: Postgres runs a multi-statement string as one implicit
|
|
27
|
+
# transaction.
|
|
28
|
+
NO_TRANSACTION = "-- pinecall:no-transaction"
|
|
29
|
+
|
|
30
|
+
# Fails fast instead of queueing behind a reader and blocking every writer behind it.
|
|
31
|
+
LOCK_TIMEOUT_MS = 1_000
|
|
32
|
+
|
|
33
|
+
# Serializes concurrent runners; any constant no other code in the database uses.
|
|
34
|
+
ADVISORY_LOCK = 0x9E3_C411
|
|
35
|
+
|
|
36
|
+
MIGRATIONS_TABLE = """
|
|
37
|
+
create table if not exists schema_migrations (
|
|
38
|
+
name text primary key,
|
|
39
|
+
applied_at timestamptz not null default now(),
|
|
40
|
+
sha256 text
|
|
41
|
+
)
|
|
42
|
+
"""
|
|
43
|
+
APPLIED = "select name, sha256 from schema_migrations order by name"
|
|
44
|
+
RECORD = "insert into schema_migrations (name, sha256) values (%s, %s)"
|
|
45
|
+
|
|
46
|
+
EDITED = (
|
|
47
|
+
"migration {name} changed after it was applied: this database ran a different file.\n"
|
|
48
|
+
" applied sha256 {was}\n"
|
|
49
|
+
" on disk sha256 {now}\n"
|
|
50
|
+
"An applied migration is never edited: every database that ran it has the OLD one. Put the "
|
|
51
|
+
"change in a new migration, or restore the file."
|
|
52
|
+
)
|
|
53
|
+
# The rows of every table and the sequences behind them, now and for every table the owner makes
|
|
54
|
+
# later (a day of call_log among them); never a table's shape, never TRUNCATE.
|
|
55
|
+
GRANTS = (
|
|
56
|
+
"grant usage on schema {schema} to {role}",
|
|
57
|
+
"grant select, insert, update, delete on all tables in schema {schema} to {role}",
|
|
58
|
+
"grant usage, select, update on all sequences in schema {schema} to {role}",
|
|
59
|
+
(
|
|
60
|
+
"alter default privileges in schema {schema} "
|
|
61
|
+
"grant select, insert, update, delete on tables to {role}"
|
|
62
|
+
),
|
|
63
|
+
(
|
|
64
|
+
"alter default privileges in schema {schema} "
|
|
65
|
+
"grant usage, select, update on sequences to {role}"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
ROLE_EXISTS = "select 1 from pg_roles where rolname = %s"
|
|
70
|
+
|
|
71
|
+
# CloudNativePG makes the role from its secret a few seconds after the chart says so: waited for.
|
|
72
|
+
ROLE_WAITED_S = 120.0
|
|
73
|
+
ROLE_ASKED_EVERY_S = 2.0
|
|
74
|
+
|
|
75
|
+
NO_ROLE = (
|
|
76
|
+
"role {role} does not exist after {waited:g} s: its secret (charts/postgres, the managed role) "
|
|
77
|
+
"is not synced yet, or the role is named wrong; nothing was granted"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
MISSING = (
|
|
81
|
+
"migration {name} is recorded as applied and is not in this distribution: "
|
|
82
|
+
"this checkout is older than the database it is pointed at"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Applied:
|
|
88
|
+
"""The result of a migration run: the database, the schema and what was applied."""
|
|
89
|
+
|
|
90
|
+
database: str
|
|
91
|
+
schema: str
|
|
92
|
+
applied: tuple[str, ...]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def apply_migrations(dsn: str, *, schema: str = DEFAULT_SCHEMA) -> Applied:
|
|
96
|
+
"""Apply every pending migration in name order under an advisory lock."""
|
|
97
|
+
schemas = schemas_of(schema)
|
|
98
|
+
async with await connect(dsn) as connection:
|
|
99
|
+
# Before the lock, which may wait on another runner: nothing here is cut by a timeout the
|
|
100
|
+
# role or PGOPTIONS set. A file waits on a table's lock LOCK_TIMEOUT_MS at most.
|
|
101
|
+
await connection.execute("set statement_timeout = 0")
|
|
102
|
+
# Session-level, released with the connection. Taken before any DDL: concurrent
|
|
103
|
+
# `create schema if not exists` can still fail on pg_namespace's unique index.
|
|
104
|
+
await connection.execute("select pg_advisory_lock(%s)", (ADVISORY_LOCK,))
|
|
105
|
+
if schema != DEFAULT_SCHEMA:
|
|
106
|
+
await connection.execute(
|
|
107
|
+
sql.SQL("create schema if not exists {}").format(sql.Identifier(schema))
|
|
108
|
+
)
|
|
109
|
+
await connection.execute(
|
|
110
|
+
sql.SQL("set search_path to {}").format(
|
|
111
|
+
sql.SQL(", ").join(sql.Identifier(schema) for schema in schemas)
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
await connection.execute(MIGRATIONS_TABLE)
|
|
115
|
+
done = await _applied(connection)
|
|
116
|
+
ran = [
|
|
117
|
+
await _apply_one(connection, path)
|
|
118
|
+
for path in migration_files()
|
|
119
|
+
if path.name not in done
|
|
120
|
+
]
|
|
121
|
+
return Applied(database=database_named(dsn), schema=schema, applied=tuple(ran))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Granted again every run: a table a migration made is granted the moment it exists, and a grant
|
|
125
|
+
# taken away by hand comes back. Refused, not skipped, when the role never came: a gateway that
|
|
126
|
+
# connects as it would find nothing it may read.
|
|
127
|
+
async def granted(dsn: str, role: str, *, schema: str = DEFAULT_SCHEMA) -> None:
|
|
128
|
+
"""Grant the role every table's rows and nothing else, once it exists."""
|
|
129
|
+
async with await connect(dsn) as connection:
|
|
130
|
+
waited = 0.0
|
|
131
|
+
while await (await connection.execute(ROLE_EXISTS, (role,))).fetchone() is None:
|
|
132
|
+
if waited >= ROLE_WAITED_S:
|
|
133
|
+
raise MigrationsRefused(NO_ROLE.format(role=role, waited=ROLE_WAITED_S))
|
|
134
|
+
await asyncio.sleep(ROLE_ASKED_EVERY_S)
|
|
135
|
+
waited += ROLE_ASKED_EVERY_S
|
|
136
|
+
names = {"schema": sql.Identifier(schema), "role": sql.Identifier(role)}
|
|
137
|
+
async with connection.transaction():
|
|
138
|
+
for grant in GRANTS:
|
|
139
|
+
await connection.execute(sql.SQL(grant).format(**names))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def migrations_behind(pool: Pool) -> tuple[str, ...]:
|
|
143
|
+
"""Return the migrations the database has not applied; every one, when it has no table."""
|
|
144
|
+
async with pool.connection() as connection:
|
|
145
|
+
try:
|
|
146
|
+
rows = await (await connection.execute(APPLIED)).fetchall()
|
|
147
|
+
except psycopg.errors.UndefinedTable:
|
|
148
|
+
rows = []
|
|
149
|
+
done = {str(row["name"]) for row in rows}
|
|
150
|
+
return tuple(path.name for path in migration_files() if path.name not in done)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def migration_files() -> list[Path]:
|
|
154
|
+
"""Return every shipped migration, in apply order."""
|
|
155
|
+
return sorted(MIGRATIONS.glob("*.sql"))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def file_hash(path: Path) -> str:
|
|
159
|
+
"""Return the SHA-256 of the file's bytes."""
|
|
160
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def in_a_transaction(statements: bytes) -> bool:
|
|
164
|
+
"""Return False when the file's first line is the no-transaction marker."""
|
|
165
|
+
first = statements.decode("utf-8").lstrip().split("\n", 1)[0].strip()
|
|
166
|
+
return first != NO_TRANSACTION
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _applied(connection: Connection) -> set[str]:
|
|
170
|
+
on_disk = {path.name: path for path in migration_files()}
|
|
171
|
+
rows = await (await connection.execute(APPLIED)).fetchall()
|
|
172
|
+
recorded = {str(row["name"]): row["sha256"] for row in rows}
|
|
173
|
+
# A database the previous runtime migrated: its schema is what the first file holds, so the
|
|
174
|
+
# first file is recorded as applied and the old bookkeeping goes.
|
|
175
|
+
if recorded and FIRST not in recorded:
|
|
176
|
+
await connection.execute("delete from schema_migrations")
|
|
177
|
+
await connection.execute(RECORD, (FIRST, file_hash(on_disk[FIRST])))
|
|
178
|
+
return {FIRST}
|
|
179
|
+
for name, was in recorded.items():
|
|
180
|
+
path = on_disk.get(name)
|
|
181
|
+
if path is None:
|
|
182
|
+
raise MigrationsRefused(MISSING.format(name=name))
|
|
183
|
+
now = file_hash(path)
|
|
184
|
+
if str(was) != now:
|
|
185
|
+
raise MigrationsRefused(EDITED.format(name=name, was=was, now=now))
|
|
186
|
+
return set(recorded)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _apply_one(connection: Connection, path: Path) -> str:
|
|
190
|
+
statements = await asyncio.to_thread(path.read_bytes)
|
|
191
|
+
recorded = (path.name, hashlib.sha256(statements).hexdigest())
|
|
192
|
+
if not in_a_transaction(statements):
|
|
193
|
+
await connection.execute(statements)
|
|
194
|
+
await connection.execute(RECORD, recorded)
|
|
195
|
+
return path.name
|
|
196
|
+
async with connection.transaction():
|
|
197
|
+
await connection.execute(
|
|
198
|
+
sql.SQL("set local lock_timeout = {}").format(sql.Literal(LOCK_TIMEOUT_MS))
|
|
199
|
+
)
|
|
200
|
+
await connection.execute(statements)
|
|
201
|
+
await connection.execute(RECORD, recorded)
|
|
202
|
+
return path.name
|