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/drift.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Drift: each day's stage latencies and verdicts by agent and version, folded at the seal."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from datetime import UTC, date, datetime
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from psycopg.rows import DictRow
|
|
11
|
+
from psycopg.types.json import Jsonb
|
|
12
|
+
from pydantic import TypeAdapter
|
|
13
|
+
|
|
14
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
15
|
+
from pinecall.domain.names import JsonObject
|
|
16
|
+
from pinecall.domain.scope import Scope
|
|
17
|
+
from pinecall.log import _histogram, queries, refold
|
|
18
|
+
from pinecall.log.store import entry_of
|
|
19
|
+
from pinecall.postgres.pool import Connection, Pool
|
|
20
|
+
from pinecall.wire.events import AgentTurnEnded, UserTurnEnded, event_of
|
|
21
|
+
from pinecall.wire.frames import Entry
|
|
22
|
+
from pinecall.wire.metrics import TurnMetadata
|
|
23
|
+
from pinecall.wire.rest.usage import (
|
|
24
|
+
DriftJudge,
|
|
25
|
+
DriftStage,
|
|
26
|
+
InsightsStage,
|
|
27
|
+
JudgeTally,
|
|
28
|
+
StageTally,
|
|
29
|
+
)
|
|
30
|
+
from pinecall.wire.scores import CallScore
|
|
31
|
+
|
|
32
|
+
type Stage = Literal["stt", "llm", "tts"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# A turn's stage, the vendor and the model its report named ('' for none).
|
|
36
|
+
type StageKey = tuple[Stage, str, str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# A judge by name and by the hash of the question it asked.
|
|
40
|
+
type JudgeKey = tuple[str, str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# The table cannot key a null: a call that ran on no version of its scope's settings is 0.
|
|
47
|
+
NO_VERSION = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# skipped and deferred were never settled, so they count on neither side.
|
|
51
|
+
SETTLED = frozenset({"held", "broken"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Calls named per read of the heads while rebuilding; each is folded in its own transaction.
|
|
55
|
+
A_PAGE = 500
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Taken once per call: a second fold of the same call finds its row and changes only what differs.
|
|
59
|
+
CLAIMED = """
|
|
60
|
+
insert into drift_calls (call, org, env, holder, agent, day, config_version, verdicts)
|
|
61
|
+
values (%(call)s, %(org)s, %(env)s, %(holder)s, %(agent)s, %(day)s, %(version)s, %(verdicts)s)
|
|
62
|
+
on conflict (call) do nothing
|
|
63
|
+
returning call
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
COUNTED = "select verdicts from drift_calls where call = %(call)s for update"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
RECOUNTED = "update drift_calls set verdicts = %(verdicts)s where call = %(call)s"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Two histograms add bucket by bucket (log/_histogram.py); both always have its BUCKETS.
|
|
74
|
+
STAGES_ADDED = """
|
|
75
|
+
insert into stage_days as kept (org, env, holder, agent, day, config_version, stage, vendor,
|
|
76
|
+
model, turns, buckets, confidence_sum, confidence_turns)
|
|
77
|
+
select %(org)s, %(env)s, %(holder)s, %(agent)s, %(day)s, %(version)s, sample.stage,
|
|
78
|
+
sample.vendor, sample.model, sample.turns, sample.buckets, sample.confidence_sum,
|
|
79
|
+
sample.confidence_turns
|
|
80
|
+
from jsonb_to_recordset(%(samples)s) as sample(stage text, vendor text, model text,
|
|
81
|
+
turns integer, buckets integer[], confidence_sum double precision, confidence_turns integer)
|
|
82
|
+
on conflict (org, env, holder, day, agent, config_version, stage, vendor, model) do update set
|
|
83
|
+
turns = kept.turns + excluded.turns,
|
|
84
|
+
buckets = (select array_agg(mine + theirs order by at)
|
|
85
|
+
from unnest(kept.buckets, excluded.buckets) with ordinality as bucket(mine, theirs,
|
|
86
|
+
at)),
|
|
87
|
+
confidence_sum = kept.confidence_sum + excluded.confidence_sum,
|
|
88
|
+
confidence_turns = kept.confidence_turns + excluded.confidence_turns
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# A verdict counted is +1 on its side; a verdict replaced is -1 on the side it was counted on.
|
|
93
|
+
VERDICTS_ADDED = """
|
|
94
|
+
insert into judge_days as kept (org, env, holder, agent, day, config_version, judge, criteria,
|
|
95
|
+
held, broken)
|
|
96
|
+
select %(org)s, %(env)s, %(holder)s, %(agent)s, %(day)s, %(version)s, verdict.judge,
|
|
97
|
+
verdict.criteria, verdict.held, verdict.broken
|
|
98
|
+
from jsonb_to_recordset(%(verdicts)s) as verdict(judge text, criteria text, held integer,
|
|
99
|
+
broken integer)
|
|
100
|
+
on conflict (org, env, holder, day, agent, config_version, judge, criteria) do update set
|
|
101
|
+
held = kept.held + excluded.held,
|
|
102
|
+
broken = kept.broken + excluded.broken
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# A side is a day, a version, or both; one agent's rows in the scope.
|
|
107
|
+
SIDE_STAGES = """
|
|
108
|
+
select stage, vendor, model, config_version, turns, buckets, confidence_sum, confidence_turns
|
|
109
|
+
from stage_days
|
|
110
|
+
where org = %(org)s and env = %(env)s and holder = %(holder)s and agent = %(agent)s
|
|
111
|
+
and (%(day)s::date is null or day = %(day)s)
|
|
112
|
+
and (%(version)s::integer is null or config_version = %(version)s)
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
SIDE_VERDICTS = """
|
|
117
|
+
select judge, criteria, config_version, held, broken
|
|
118
|
+
from judge_days
|
|
119
|
+
where org = %(org)s and env = %(env)s and holder = %(holder)s and agent = %(agent)s
|
|
120
|
+
and (%(day)s::date is null or day = %(day)s)
|
|
121
|
+
and (%(version)s::integer is null or config_version = %(version)s)
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
WINDOW_STAGES = """
|
|
126
|
+
select stage, vendor, model, turns, buckets, confidence_sum, confidence_turns
|
|
127
|
+
from stage_days
|
|
128
|
+
where org = %(org)s and env = %(env)s and holder = %(holder)s
|
|
129
|
+
and day >= %(first)s and day <= %(last)s and (%(agent)s::text is null or agent = %(agent)s)
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# A rebuild forgets what it will fold again, in one transaction, then folds call by call.
|
|
134
|
+
FORGET = """
|
|
135
|
+
with calls as (
|
|
136
|
+
delete from drift_calls
|
|
137
|
+
where (%(org)s::text is null or org = %(org)s) and (%(since)s::date is null or day >= %(since)s)
|
|
138
|
+
), stages as (
|
|
139
|
+
delete from stage_days
|
|
140
|
+
where (%(org)s::text is null or org = %(org)s) and (%(since)s::date is null or day >= %(since)s)
|
|
141
|
+
)
|
|
142
|
+
delete from judge_days
|
|
143
|
+
where (%(org)s::text is null or org = %(org)s) and (%(since)s::date is null or day >= %(since)s)
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
SEALED_PAGE = """
|
|
148
|
+
select log from call_log_head
|
|
149
|
+
where call is not null and sealed and started_at is not null and log > %(after)s
|
|
150
|
+
and (%(org)s::text is null or org = %(org)s)
|
|
151
|
+
and (%(since)s::float8 is null or started_at >= %(since)s)
|
|
152
|
+
order by log
|
|
153
|
+
limit %(limit)s
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class StageSample:
|
|
159
|
+
"""One stage on one vendor and model: its turns, their seconds counted, the ears' confidence."""
|
|
160
|
+
|
|
161
|
+
turns: int = 0
|
|
162
|
+
buckets: tuple[int, ...] = tuple(_histogram.empty())
|
|
163
|
+
confidence_sum: float = 0.0
|
|
164
|
+
confidence_turns: int = 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class Verdict:
|
|
169
|
+
"""One judge's settled verdict on a call, under the hash of the question it asked."""
|
|
170
|
+
|
|
171
|
+
judge: str
|
|
172
|
+
criteria: str
|
|
173
|
+
held: bool
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class JudgeRead:
|
|
178
|
+
"""One judge on one side: its verdicts held and settled, and the questions it asked."""
|
|
179
|
+
|
|
180
|
+
held: int
|
|
181
|
+
judged: int
|
|
182
|
+
criteria: frozenset[str]
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def pass_rate(self) -> float | None:
|
|
186
|
+
"""The share of its settled verdicts that held; None when none settled."""
|
|
187
|
+
return self.held / self.judged if self.judged else None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class Side:
|
|
192
|
+
"""One side of a drift: a day, a version, or both."""
|
|
193
|
+
|
|
194
|
+
day: date | None = None
|
|
195
|
+
version: int | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class Tally:
|
|
200
|
+
"""One side of an agent's drift: its stages and judges, and the versions its calls ran."""
|
|
201
|
+
|
|
202
|
+
stages: dict[StageKey, StageSample] = field(default_factory=dict[StageKey, StageSample])
|
|
203
|
+
judges: dict[str, JudgeRead] = field(default_factory=dict[str, JudgeRead])
|
|
204
|
+
versions: frozenset[int] = frozenset()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class Rebuilt:
|
|
209
|
+
"""What a rebuild folded again."""
|
|
210
|
+
|
|
211
|
+
calls: int
|
|
212
|
+
folded: int
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
VERDICTS: TypeAdapter[list[Verdict]] = TypeAdapter(list[Verdict])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# The seal's: a call is folded once, and folded again (judged again) only its verdicts change.
|
|
219
|
+
async def fold(pool: Pool, call: str, entries: Sequence[Entry], score: CallScore) -> bool:
|
|
220
|
+
"""Count a sealed call's stages and verdicts into its day; False for a call never started."""
|
|
221
|
+
corner = await queries.scope_of_call(pool, call)
|
|
222
|
+
if corner is None or corner.scope is None or corner.started_at is None:
|
|
223
|
+
return False
|
|
224
|
+
where = _where(corner.scope, corner.agent, _day_of(corner.started_at), corner.versions.config)
|
|
225
|
+
verdicts = _verdicts_of(score)
|
|
226
|
+
async with pool.connection() as connection, connection.transaction():
|
|
227
|
+
claimed = await connection.execute(
|
|
228
|
+
CLAIMED,
|
|
229
|
+
{**where, "call": call, "verdicts": Jsonb(VERDICTS.dump_python(verdicts, mode="json"))},
|
|
230
|
+
)
|
|
231
|
+
if await claimed.fetchone() is not None:
|
|
232
|
+
await _stages_added(connection, where, _samples_of(entries))
|
|
233
|
+
await _verdicts_added(connection, where, _tallied(verdicts, sign=1))
|
|
234
|
+
return True
|
|
235
|
+
row = await (await connection.execute(COUNTED, {"call": call})).fetchone()
|
|
236
|
+
counted = [] if row is None else VERDICTS.validate_python(row["verdicts"])
|
|
237
|
+
if counted == verdicts:
|
|
238
|
+
return True
|
|
239
|
+
changed = {**_tallied(counted, sign=-1)}
|
|
240
|
+
for key, (passes, fails) in _tallied(verdicts, sign=1).items():
|
|
241
|
+
before = changed.get(key, (0, 0))
|
|
242
|
+
changed[key] = (before[0] + passes, before[1] + fails)
|
|
243
|
+
await _verdicts_added(connection, where, changed)
|
|
244
|
+
await connection.execute(
|
|
245
|
+
RECOUNTED,
|
|
246
|
+
{"call": call, "verdicts": Jsonb(VERDICTS.dump_python(verdicts, mode="json"))},
|
|
247
|
+
)
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# Every agent's when agent is None; its days and versions are added together either way.
|
|
252
|
+
async def stages_of_window(
|
|
253
|
+
pool: Pool, scope: Scope, first: date, last: date, agent: str | None
|
|
254
|
+
) -> list[InsightsStage]:
|
|
255
|
+
"""Every stage of the scope's days first to last by vendor and model, versions added."""
|
|
256
|
+
params = {**asdict(scope), "first": first, "last": last, "agent": agent}
|
|
257
|
+
async with pool.connection() as connection:
|
|
258
|
+
rows = await (await connection.execute(WINDOW_STAGES, params)).fetchall()
|
|
259
|
+
merged = _merged_stages(rows)
|
|
260
|
+
return [
|
|
261
|
+
InsightsStage(
|
|
262
|
+
stage=stage,
|
|
263
|
+
vendor=vendor or None,
|
|
264
|
+
model=model or None,
|
|
265
|
+
**_tallied_stage(sample).model_dump(),
|
|
266
|
+
)
|
|
267
|
+
for (stage, vendor, model), sample in sorted(merged.items())
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# What moved most comes first: a judge by its pass rate, a stage by its median.
|
|
272
|
+
def compared(before: Tally, after: Tally) -> tuple[list[DriftJudge], list[DriftStage]]:
|
|
273
|
+
"""Each judge and each stage on both sides, and how far it moved, the most moved first."""
|
|
274
|
+
judges = [
|
|
275
|
+
_judge_moved(name, before.judges.get(name), after.judges.get(name))
|
|
276
|
+
for name in sorted({*before.judges, *after.judges})
|
|
277
|
+
]
|
|
278
|
+
stages = [
|
|
279
|
+
_stage_moved(key, before.stages.get(key), after.stages.get(key))
|
|
280
|
+
for key in sorted({*before.stages, *after.stages})
|
|
281
|
+
]
|
|
282
|
+
judges.sort(key=lambda judge: -abs(judge.moved) if judge.moved is not None else 1.0)
|
|
283
|
+
stages.sort(key=lambda row: -abs(row.median_moved_s) if row.median_moved_s is not None else 1.0)
|
|
284
|
+
return judges, stages
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def tally(pool: Pool, scope: Scope, agent: str, side: Side) -> Tally:
|
|
288
|
+
"""One side of an agent's drift in the scope: its stages, its judges, its versions."""
|
|
289
|
+
params = {
|
|
290
|
+
"org": scope.org,
|
|
291
|
+
"env": scope.env,
|
|
292
|
+
"holder": scope.holder,
|
|
293
|
+
"agent": agent,
|
|
294
|
+
"day": side.day,
|
|
295
|
+
"version": side.version,
|
|
296
|
+
}
|
|
297
|
+
# independent: two reads of counted days; a call counted between them moves one by a call
|
|
298
|
+
async with pool.connection() as connection:
|
|
299
|
+
stages = await (await connection.execute(SIDE_STAGES, params)).fetchall()
|
|
300
|
+
verdicts = await (await connection.execute(SIDE_VERDICTS, params)).fetchall()
|
|
301
|
+
judges: dict[str, JudgeRead] = {}
|
|
302
|
+
for row in verdicts:
|
|
303
|
+
before = judges.get(row["judge"], JudgeRead(0, 0, frozenset()))
|
|
304
|
+
judges[row["judge"]] = JudgeRead(
|
|
305
|
+
held=before.held + row["held"],
|
|
306
|
+
judged=before.judged + row["held"] + row["broken"],
|
|
307
|
+
criteria=before.criteria | {row["criteria"]},
|
|
308
|
+
)
|
|
309
|
+
ran = {int(row["config_version"]) for row in (*stages, *verdicts)}
|
|
310
|
+
return Tally(
|
|
311
|
+
stages=_merged_stages(stages),
|
|
312
|
+
judges=judges,
|
|
313
|
+
versions=frozenset(version for version in ran if version != NO_VERSION),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def rebuild(pool: Pool, *, org: str | None, since: float | None) -> Rebuilt:
|
|
318
|
+
"""Forget the drift of the calls the flags name and fold each sealed one again from its log."""
|
|
319
|
+
day = None if since is None else _day_of(since)
|
|
320
|
+
async with pool.connection() as connection, connection.transaction():
|
|
321
|
+
await connection.execute(FORGET, {"org": org, "since": day})
|
|
322
|
+
calls = folded = 0
|
|
323
|
+
after = ""
|
|
324
|
+
while names := await _sealed_after(pool, org, since, after):
|
|
325
|
+
for call in names:
|
|
326
|
+
entries = await _entries_of(pool, call)
|
|
327
|
+
score = _last_score(entries)
|
|
328
|
+
if score is not None and await fold(pool, call, entries, score):
|
|
329
|
+
folded += 1
|
|
330
|
+
calls += 1
|
|
331
|
+
after = names[-1]
|
|
332
|
+
return Rebuilt(calls=calls, folded=folded)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _samples_of(entries: Iterable[Entry]) -> dict[StageKey, StageSample]:
|
|
336
|
+
"""The stages a call's turns reported, by the vendor and model each turn named."""
|
|
337
|
+
seconds: dict[StageKey, list[float]] = {}
|
|
338
|
+
turns: dict[StageKey, int] = {}
|
|
339
|
+
confidence: dict[StageKey, list[float]] = {}
|
|
340
|
+
for entry in entries:
|
|
341
|
+
for key, value, sure in _stages_of(entry):
|
|
342
|
+
turns[key] = turns.get(key, 0) + 1
|
|
343
|
+
if value is not None:
|
|
344
|
+
seconds.setdefault(key, []).append(value)
|
|
345
|
+
if sure is not None:
|
|
346
|
+
confidence.setdefault(key, []).append(sure)
|
|
347
|
+
return {
|
|
348
|
+
key: StageSample(
|
|
349
|
+
turns=count,
|
|
350
|
+
buckets=tuple(_histogram.counted(seconds.get(key, ()))),
|
|
351
|
+
confidence_sum=sum(confidence.get(key, ())),
|
|
352
|
+
confidence_turns=len(confidence.get(key, ())),
|
|
353
|
+
)
|
|
354
|
+
for key, count in turns.items()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _verdicts_of(score: CallScore) -> list[Verdict]:
|
|
359
|
+
"""The settled verdicts of a score, each under the hash of its question."""
|
|
360
|
+
return [
|
|
361
|
+
Verdict(judge.name, _criteria_hash(judge.criteria), judge.verdict == "held")
|
|
362
|
+
for judge in score.judges
|
|
363
|
+
if judge.verdict in SETTLED
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _criteria_hash(criteria: str) -> str:
|
|
368
|
+
"""A judge's question as drift keys it: its hash, so a changed question is told apart."""
|
|
369
|
+
return sha256(criteria.encode()).hexdigest()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _day_of(started_at: float) -> date:
|
|
373
|
+
"""The UTC day a call that started then is counted on."""
|
|
374
|
+
return datetime.fromtimestamp(started_at, UTC).date()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _stages_of(entry: Entry) -> list[tuple[StageKey, float | None, float | None]]:
|
|
378
|
+
if entry.type not in {"turn.user", "turn.agent"}:
|
|
379
|
+
return []
|
|
380
|
+
try:
|
|
381
|
+
turn = event_of(entry)
|
|
382
|
+
except DeclarationRefused:
|
|
383
|
+
logger.warning("call %s: seq %d is not a turn drift reads", entry.call, entry.seq)
|
|
384
|
+
return []
|
|
385
|
+
found: list[tuple[StageKey, float | None, float | None]] = []
|
|
386
|
+
if isinstance(turn, UserTurnEnded):
|
|
387
|
+
heard = turn.metrics
|
|
388
|
+
delay, sure = heard.transcription_delay, turn.transcript_confidence
|
|
389
|
+
if delay is not None or sure is not None:
|
|
390
|
+
found.append((_key("stt", heard.stt_metadata), delay, sure))
|
|
391
|
+
if isinstance(turn, AgentTurnEnded):
|
|
392
|
+
answered = turn.metrics
|
|
393
|
+
if answered.llm_node_ttft is not None:
|
|
394
|
+
found.append((_key("llm", answered.llm_metadata), answered.llm_node_ttft, None))
|
|
395
|
+
if answered.tts_node_ttfb is not None:
|
|
396
|
+
found.append((_key("tts", answered.tts_metadata), answered.tts_node_ttfb, None))
|
|
397
|
+
return found
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _key(stage: Stage, metadata: TurnMetadata | None) -> StageKey:
|
|
401
|
+
if metadata is None:
|
|
402
|
+
return (stage, "", "")
|
|
403
|
+
return (stage, metadata.model_provider or "", metadata.model_name or "")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _where(scope: Scope, agent: str, day: date, version: int | None) -> dict[str, object]:
|
|
407
|
+
return {
|
|
408
|
+
"org": scope.org,
|
|
409
|
+
"env": scope.env,
|
|
410
|
+
"holder": scope.holder,
|
|
411
|
+
"agent": agent,
|
|
412
|
+
"day": day,
|
|
413
|
+
"version": NO_VERSION if version is None else version,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _tallied(verdicts: Iterable[Verdict], *, sign: int) -> dict[JudgeKey, tuple[int, int]]:
|
|
418
|
+
tallied: dict[JudgeKey, tuple[int, int]] = {}
|
|
419
|
+
for item in verdicts:
|
|
420
|
+
passes, fails = tallied.get((item.judge, item.criteria), (0, 0))
|
|
421
|
+
tallied[(item.judge, item.criteria)] = (
|
|
422
|
+
(passes + sign, fails) if item.held else (passes, fails + sign)
|
|
423
|
+
)
|
|
424
|
+
return tallied
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def _stages_added(
|
|
428
|
+
connection: Connection, where: Mapping[str, object], samples: Mapping[StageKey, StageSample]
|
|
429
|
+
) -> None:
|
|
430
|
+
if not samples:
|
|
431
|
+
return
|
|
432
|
+
rows: list[JsonObject] = [
|
|
433
|
+
{
|
|
434
|
+
"stage": stage,
|
|
435
|
+
"vendor": vendor,
|
|
436
|
+
"model": model,
|
|
437
|
+
"turns": sample.turns,
|
|
438
|
+
"buckets": list(sample.buckets),
|
|
439
|
+
"confidence_sum": sample.confidence_sum,
|
|
440
|
+
"confidence_turns": sample.confidence_turns,
|
|
441
|
+
}
|
|
442
|
+
for (stage, vendor, model), sample in samples.items()
|
|
443
|
+
]
|
|
444
|
+
await connection.execute(STAGES_ADDED, {**where, "samples": Jsonb(rows)})
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
async def _verdicts_added(
|
|
448
|
+
connection: Connection, where: Mapping[str, object], tallied: Mapping[JudgeKey, tuple[int, int]]
|
|
449
|
+
) -> None:
|
|
450
|
+
if not tallied:
|
|
451
|
+
return
|
|
452
|
+
rows: list[JsonObject] = [
|
|
453
|
+
{"judge": judge, "criteria": criteria, "held": passes, "broken": fails}
|
|
454
|
+
for (judge, criteria), (passes, fails) in tallied.items()
|
|
455
|
+
]
|
|
456
|
+
await connection.execute(VERDICTS_ADDED, {**where, "verdicts": Jsonb(rows)})
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _merged_stages(rows: Iterable[DictRow]) -> dict[StageKey, StageSample]:
|
|
460
|
+
merged: dict[StageKey, StageSample] = {}
|
|
461
|
+
for row in rows:
|
|
462
|
+
key: StageKey = (row["stage"], row["vendor"], row["model"])
|
|
463
|
+
before = merged.get(key, StageSample())
|
|
464
|
+
merged[key] = StageSample(
|
|
465
|
+
turns=before.turns + row["turns"],
|
|
466
|
+
buckets=tuple(_histogram.added(before.buckets, row["buckets"])),
|
|
467
|
+
confidence_sum=before.confidence_sum + row["confidence_sum"],
|
|
468
|
+
confidence_turns=before.confidence_turns + row["confidence_turns"],
|
|
469
|
+
)
|
|
470
|
+
return merged
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _tallied_stage(sample: StageSample) -> StageTally:
|
|
474
|
+
return StageTally(
|
|
475
|
+
turns=sample.turns,
|
|
476
|
+
median_s=_histogram.rank(sample.buckets, _histogram.MEDIAN),
|
|
477
|
+
p95_s=_histogram.rank(sample.buckets, _histogram.P95),
|
|
478
|
+
confidence=sample.confidence_sum / sample.confidence_turns
|
|
479
|
+
if sample.confidence_turns
|
|
480
|
+
else None,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _judge_moved(name: str, before: JudgeRead | None, after: JudgeRead | None) -> DriftJudge:
|
|
485
|
+
rates = (
|
|
486
|
+
None if before is None else before.pass_rate,
|
|
487
|
+
None if after is None else after.pass_rate,
|
|
488
|
+
)
|
|
489
|
+
return DriftJudge(
|
|
490
|
+
name=name,
|
|
491
|
+
before=_judge_tally(before),
|
|
492
|
+
after=_judge_tally(after),
|
|
493
|
+
moved=None if None in rates else _moved(rates[0], rates[1]),
|
|
494
|
+
criteria_changed=before is not None
|
|
495
|
+
and after is not None
|
|
496
|
+
and before.criteria != after.criteria,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _judge_tally(read: JudgeRead | None) -> JudgeTally | None:
|
|
501
|
+
if read is None:
|
|
502
|
+
return None
|
|
503
|
+
return JudgeTally(held=read.held, judged=read.judged, pass_rate=read.pass_rate)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _stage_moved(
|
|
507
|
+
key: StageKey, before: StageSample | None, after: StageSample | None
|
|
508
|
+
) -> DriftStage:
|
|
509
|
+
stage, vendor, model = key
|
|
510
|
+
first = None if before is None else _tallied_stage(before)
|
|
511
|
+
second = None if after is None else _tallied_stage(after)
|
|
512
|
+
return DriftStage(
|
|
513
|
+
stage=stage,
|
|
514
|
+
vendor=vendor or None,
|
|
515
|
+
model=model or None,
|
|
516
|
+
before=first,
|
|
517
|
+
after=second,
|
|
518
|
+
median_moved_s=None
|
|
519
|
+
if first is None or second is None
|
|
520
|
+
else _moved(first.median_s, second.median_s),
|
|
521
|
+
p95_moved_s=None if first is None or second is None else _moved(first.p95_s, second.p95_s),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _moved(before: float | None, after: float | None) -> float | None:
|
|
526
|
+
return None if before is None or after is None else after - before
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
async def _sealed_after(pool: Pool, org: str | None, since: float | None, after: str) -> list[str]:
|
|
530
|
+
params = {"org": org, "since": since, "after": after, "limit": A_PAGE}
|
|
531
|
+
async with pool.connection() as connection:
|
|
532
|
+
rows = await (await connection.execute(SEALED_PAGE, params)).fetchall()
|
|
533
|
+
return [str(row["log"]) for row in rows]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
async def _entries_of(pool: Pool, call: str) -> list[Entry]:
|
|
537
|
+
async with pool.connection() as connection:
|
|
538
|
+
rows = await (await connection.execute(refold.ENTRIES, {"call": call})).fetchall()
|
|
539
|
+
return [entry_of(row) for row in rows]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _last_score(entries: Sequence[Entry]) -> CallScore | None:
|
|
543
|
+
scored = next((entry for entry in reversed(entries) if entry.type == "call.score"), None)
|
|
544
|
+
if scored is None:
|
|
545
|
+
return None
|
|
546
|
+
try:
|
|
547
|
+
return CallScore.model_validate(scored.data)
|
|
548
|
+
except ValueError:
|
|
549
|
+
logger.warning("call %s: its call.score is not one drift reads", scored.call)
|
|
550
|
+
return None
|