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/reduce.py
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"""The reducer: a log's entries folded into State; and the usage and latencies a log counts."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from statistics import median
|
|
7
|
+
from typing import Self
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.call import PhoneLeg
|
|
10
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
11
|
+
from pinecall.wire.events import (
|
|
12
|
+
AgentRegistered,
|
|
13
|
+
AgentStateChanged,
|
|
14
|
+
AgentTranscript,
|
|
15
|
+
AgentTurnEnded,
|
|
16
|
+
AttentionAnswered,
|
|
17
|
+
AttentionRequested,
|
|
18
|
+
CallDialing,
|
|
19
|
+
CallEnded,
|
|
20
|
+
CallLine,
|
|
21
|
+
CallRinging,
|
|
22
|
+
CallStarted,
|
|
23
|
+
CallSummary,
|
|
24
|
+
CallTransferred,
|
|
25
|
+
ConfirmDeclined,
|
|
26
|
+
ConfirmGranted,
|
|
27
|
+
ConfirmRequest,
|
|
28
|
+
Custom,
|
|
29
|
+
DocsSources,
|
|
30
|
+
ErrorEvent,
|
|
31
|
+
EventReceived,
|
|
32
|
+
LogGap,
|
|
33
|
+
MemoryOps,
|
|
34
|
+
PromptChanged,
|
|
35
|
+
StateChanged,
|
|
36
|
+
SupervisorReleased,
|
|
37
|
+
SupervisorTookOver,
|
|
38
|
+
SupervisorTransferred,
|
|
39
|
+
ToolCall,
|
|
40
|
+
ToolsChanged,
|
|
41
|
+
UserStateChanged,
|
|
42
|
+
UserTranscript,
|
|
43
|
+
UserTurnEnded,
|
|
44
|
+
event_of,
|
|
45
|
+
)
|
|
46
|
+
from pinecall.wire.frames import Entry, WireModel
|
|
47
|
+
from pinecall.wire.metrics import (
|
|
48
|
+
AvatarMetrics,
|
|
49
|
+
EOTInferenceMetrics,
|
|
50
|
+
EOUMetrics,
|
|
51
|
+
InterruptionMetrics,
|
|
52
|
+
LLMMetrics,
|
|
53
|
+
LLMModelUsage,
|
|
54
|
+
RealtimeModelMetrics,
|
|
55
|
+
STTMetrics,
|
|
56
|
+
TTSMetrics,
|
|
57
|
+
TTSModelUsage,
|
|
58
|
+
VADMetrics,
|
|
59
|
+
)
|
|
60
|
+
from pinecall.wire.parts import ToolResult
|
|
61
|
+
from pinecall.wire.room import ParticipantJoined, ParticipantLeft, ParticipantSpeaking, RoomOpened
|
|
62
|
+
from pinecall.wire.scores import CallScore
|
|
63
|
+
from pinecall.wire.state import (
|
|
64
|
+
AgentTurn,
|
|
65
|
+
AttentionState,
|
|
66
|
+
CollectedMetrics,
|
|
67
|
+
Confirm,
|
|
68
|
+
CustomNote,
|
|
69
|
+
Gap,
|
|
70
|
+
Handoff,
|
|
71
|
+
LiveTranscript,
|
|
72
|
+
LoggedError,
|
|
73
|
+
Participant,
|
|
74
|
+
PromptBlockState,
|
|
75
|
+
ReceivedEvent,
|
|
76
|
+
Room,
|
|
77
|
+
State,
|
|
78
|
+
ToolRun,
|
|
79
|
+
TransferState,
|
|
80
|
+
Turn,
|
|
81
|
+
UserTurn,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger(__name__)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
UNREADABLE = "unreadable"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
NOT_THIS_SHAPE = "{type} at seq {seq} is not the shape this reader knows: {why}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# The only metered types. Usage is a projection of the log, never stored beside it.
|
|
94
|
+
METERED_TYPES = ("call.summary", "call.score")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
UNOWNED = "unowned"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# livekit's names, in the order they happen within a turn, then two of ours: dead air is the
|
|
101
|
+
# silence from the caller stopping to the agent starting, one per reply that followed the caller;
|
|
102
|
+
# talk share is the agent's part of the time anybody spoke, one per call, a fraction and no seconds.
|
|
103
|
+
MEASURES = (
|
|
104
|
+
"transcription_delay",
|
|
105
|
+
"end_of_turn_delay",
|
|
106
|
+
"llm_node_ttft",
|
|
107
|
+
"tts_node_ttfb",
|
|
108
|
+
"e2e_latency",
|
|
109
|
+
"dead_air",
|
|
110
|
+
"talk_share",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
A_MINUTE_S = 60.0
|
|
115
|
+
|
|
116
|
+
# livekit-sip's attributes on a participant that is a phone leg.
|
|
117
|
+
SIP_CALL = "sip.callID"
|
|
118
|
+
# Only a leg that came in through a dispatch rule carries one.
|
|
119
|
+
SIP_RULE = "sip.ruleID"
|
|
120
|
+
TWILIO_CALL = "sip.twilio.callSid"
|
|
121
|
+
OWN_NUMBER = "sip.trunkPhoneNumber"
|
|
122
|
+
FAR_NUMBER = "sip.phoneNumber"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# position is the store's row order across every log; the meter resumes from it.
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class Metered:
|
|
128
|
+
"""A metered entry with its place in the whole store and the org its log belongs to."""
|
|
129
|
+
|
|
130
|
+
position: int
|
|
131
|
+
org: str | None
|
|
132
|
+
entry: Entry
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# cost_usd is the vendors' estimate, what the call cost the operator, never what is charged.
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class Usage:
|
|
138
|
+
"""What was consumed: calls, minutes, turns, tokens, characters, judge calls, the cost."""
|
|
139
|
+
|
|
140
|
+
calls: int = 0
|
|
141
|
+
minutes: float = 0.0
|
|
142
|
+
messages: int = 0
|
|
143
|
+
input_tokens: int = 0
|
|
144
|
+
output_tokens: int = 0
|
|
145
|
+
characters: int = 0
|
|
146
|
+
judge_calls: int = 0
|
|
147
|
+
cost_usd: float = 0.0
|
|
148
|
+
|
|
149
|
+
def __add__(self, other: Self) -> Self:
|
|
150
|
+
"""Return the two added up, field by field."""
|
|
151
|
+
return type(self)(
|
|
152
|
+
calls=self.calls + other.calls,
|
|
153
|
+
minutes=self.minutes + other.minutes,
|
|
154
|
+
messages=self.messages + other.messages,
|
|
155
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
156
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
157
|
+
characters=self.characters + other.characters,
|
|
158
|
+
judge_calls=self.judge_calls + other.judge_calls,
|
|
159
|
+
cost_usd=self.cost_usd + other.cost_usd,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True)
|
|
164
|
+
class UsageRow:
|
|
165
|
+
"""What one metered entry consumed, and whose it was."""
|
|
166
|
+
|
|
167
|
+
cursor: int
|
|
168
|
+
org: str
|
|
169
|
+
agent: str
|
|
170
|
+
call: str
|
|
171
|
+
type: str
|
|
172
|
+
at: float
|
|
173
|
+
used: Usage = field(default_factory=Usage)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class Median:
|
|
178
|
+
"""A measure's median over one call, and how many turns reported it."""
|
|
179
|
+
|
|
180
|
+
name: str
|
|
181
|
+
seconds: float
|
|
182
|
+
turns: int
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Must fold as the TypeScript and Ruby reducers do: the protocol's golden log holds all three.
|
|
186
|
+
def reduce(entries: Iterable[Entry]) -> State:
|
|
187
|
+
"""Fold the entries, in order, into the State of an empty log."""
|
|
188
|
+
state = initial_state()
|
|
189
|
+
for entry in entries:
|
|
190
|
+
state = apply(state, entry)
|
|
191
|
+
return state
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Built from a mapping because `from` is a Python keyword.
|
|
195
|
+
def initial_state() -> State:
|
|
196
|
+
"""Return the State of a log nothing was written to."""
|
|
197
|
+
return State.model_validate(
|
|
198
|
+
{
|
|
199
|
+
"seq": 0,
|
|
200
|
+
"agent": "",
|
|
201
|
+
"call": None,
|
|
202
|
+
"status": "idle",
|
|
203
|
+
"channel": None,
|
|
204
|
+
"direction": None,
|
|
205
|
+
"from": None,
|
|
206
|
+
"to": None,
|
|
207
|
+
"caller": None,
|
|
208
|
+
"room": None,
|
|
209
|
+
"started_at": None,
|
|
210
|
+
"ended_at": None,
|
|
211
|
+
"end_reason": None,
|
|
212
|
+
"outcome": None,
|
|
213
|
+
"user_state": None,
|
|
214
|
+
"agent_state": None,
|
|
215
|
+
"live": {"user": None, "agent": None},
|
|
216
|
+
"turns": [],
|
|
217
|
+
"metrics": {block: [] for block in CollectedMetrics.model_fields},
|
|
218
|
+
"tools": [],
|
|
219
|
+
"app_state": {},
|
|
220
|
+
"events": [],
|
|
221
|
+
"prompt": {},
|
|
222
|
+
"tools_visible": [],
|
|
223
|
+
"confirms": [],
|
|
224
|
+
"memory": [],
|
|
225
|
+
"sources": [],
|
|
226
|
+
"handoff": {"active": False, "by": None},
|
|
227
|
+
"held": False,
|
|
228
|
+
"muted": False,
|
|
229
|
+
"transfer": None,
|
|
230
|
+
"attention": None,
|
|
231
|
+
"usage": [],
|
|
232
|
+
"cost": None,
|
|
233
|
+
"routes": [],
|
|
234
|
+
"gaps": [],
|
|
235
|
+
"errors": [],
|
|
236
|
+
"custom": [],
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# An old log may hold a shape this version cannot read: it becomes one line of errors and the
|
|
242
|
+
# fold goes on, as the other two reducers do. A gap with a snapshot replaces the state whole.
|
|
243
|
+
def apply(state: State, entry: Entry) -> State:
|
|
244
|
+
"""Fold one entry into the state and return the state it leaves."""
|
|
245
|
+
try:
|
|
246
|
+
data = event_of(entry)
|
|
247
|
+
except DeclarationRefused as why:
|
|
248
|
+
first_line = str(why).split("\n", 1)[0].strip()
|
|
249
|
+
message = NOT_THIS_SHAPE.format(type=entry.type, seq=entry.seq, why=first_line)
|
|
250
|
+
state.errors.append(LoggedError(seq=entry.seq, code=UNREADABLE, message=message))
|
|
251
|
+
else:
|
|
252
|
+
if isinstance(data, LogGap):
|
|
253
|
+
state = _resumed(state, data)
|
|
254
|
+
_call(state, data)
|
|
255
|
+
_talk(state, data)
|
|
256
|
+
_tools(state, entry, data)
|
|
257
|
+
_room(state, entry, data)
|
|
258
|
+
_people(state, entry, data)
|
|
259
|
+
_metrics(state.metrics, data)
|
|
260
|
+
_notes(state, entry, data)
|
|
261
|
+
state.seq = entry.seq
|
|
262
|
+
state.agent = entry.agent
|
|
263
|
+
if entry.call is not None:
|
|
264
|
+
state.call = entry.call
|
|
265
|
+
return state
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def usage_row(metered: Metered) -> UsageRow:
|
|
269
|
+
"""Return what a call.summary or a call.score consumed; an entry nobody can read, nothing."""
|
|
270
|
+
entry = metered.entry
|
|
271
|
+
row = UsageRow(
|
|
272
|
+
cursor=metered.position,
|
|
273
|
+
org=metered.org or UNOWNED,
|
|
274
|
+
agent=entry.agent,
|
|
275
|
+
call=entry.call or "",
|
|
276
|
+
type=entry.type,
|
|
277
|
+
at=entry.ts,
|
|
278
|
+
)
|
|
279
|
+
try:
|
|
280
|
+
data = event_of(entry)
|
|
281
|
+
except DeclarationRefused:
|
|
282
|
+
logger.warning("usage: %s at seq %d is unreadable", entry.type, entry.seq, exc_info=True)
|
|
283
|
+
return row
|
|
284
|
+
match data:
|
|
285
|
+
case CallSummary():
|
|
286
|
+
return replace(row, used=_used_by_a_call(data))
|
|
287
|
+
case CallScore():
|
|
288
|
+
used = Usage(judge_calls=data.judge_calls, cost_usd=data.judge_cost_usd or 0.0)
|
|
289
|
+
return replace(row, used=used)
|
|
290
|
+
case _:
|
|
291
|
+
return row
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def totals_by_org(rows: Iterable[UsageRow]) -> dict[str, Usage]:
|
|
295
|
+
"""Add the rows up per org, in the order each org first appears."""
|
|
296
|
+
totals: dict[str, Usage] = {}
|
|
297
|
+
for row in rows:
|
|
298
|
+
totals[row.org] = totals.get(row.org, Usage()) + row.used
|
|
299
|
+
return totals
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# One call's turns: dead air pairs a reply with the caller's turn before it, and a speaker's time
|
|
303
|
+
# is what each of its turns says it spoke, from starting to stopping.
|
|
304
|
+
def samples(turns: Iterable[Turn]) -> dict[str, list[float]]:
|
|
305
|
+
"""Return each measure's values in turn order; a measure nobody took is left out."""
|
|
306
|
+
found: dict[str, list[float]] = {name: [] for name in MEASURES}
|
|
307
|
+
spoke = {"user": 0.0, "agent": 0.0}
|
|
308
|
+
before: Turn | None = None
|
|
309
|
+
for turn in turns:
|
|
310
|
+
for name, value in _measured(turn):
|
|
311
|
+
if value is not None:
|
|
312
|
+
found[name].append(value)
|
|
313
|
+
gap = _dead_air(before, turn)
|
|
314
|
+
if gap is not None:
|
|
315
|
+
found["dead_air"].append(gap)
|
|
316
|
+
spoke[turn.role] += _spoken_s(turn)
|
|
317
|
+
before = turn
|
|
318
|
+
if spoke["agent"] + spoke["user"] > 0:
|
|
319
|
+
found["talk_share"].append(spoke["agent"] / (spoke["agent"] + spoke["user"]))
|
|
320
|
+
return {name: values for name, values in found.items() if values}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# The median, not the mean: one interrupted turn would move an average. Each call is measured
|
|
324
|
+
# on its own, so no reply is paired with the caller of another call.
|
|
325
|
+
def medians(calls: Iterable[Iterable[Turn]]) -> list[Median]:
|
|
326
|
+
"""Return one Median per measure some call reported, pooled over the calls."""
|
|
327
|
+
pooled: dict[str, list[float]] = {name: [] for name in MEASURES}
|
|
328
|
+
for turns in calls:
|
|
329
|
+
for name, values in samples(turns).items():
|
|
330
|
+
pooled[name] += values
|
|
331
|
+
return [
|
|
332
|
+
Median(name=name, seconds=median(values), turns=len(values))
|
|
333
|
+
for name, values in pooled.items()
|
|
334
|
+
if values
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# How long a barge-in takes to be obeyed: from the caller starting to speak over the agent to the
|
|
339
|
+
# agent leaving `speaking`, on each reply written as interrupted. livekit writes the reply and the
|
|
340
|
+
# state change in either order, so each waits for the other.
|
|
341
|
+
def interruption_delays(entries: Iterable[Entry]) -> list[float]:
|
|
342
|
+
"""Seconds from the caller cutting in to the agent falling quiet, per interrupted reply."""
|
|
343
|
+
delays: list[float] = []
|
|
344
|
+
speaking = False
|
|
345
|
+
cut_in: float | None = None
|
|
346
|
+
quiet_after: float | None = None
|
|
347
|
+
interrupted = False
|
|
348
|
+
for entry in entries:
|
|
349
|
+
match entry.type:
|
|
350
|
+
case "user.state" if entry.data.get("state") == "speaking":
|
|
351
|
+
if speaking and cut_in is None:
|
|
352
|
+
cut_in = entry.ts
|
|
353
|
+
case "turn.agent":
|
|
354
|
+
interrupted = entry.data.get("interrupted") is True
|
|
355
|
+
case "agent.state":
|
|
356
|
+
now_speaking = entry.data.get("state") == "speaking"
|
|
357
|
+
if speaking and not now_speaking and cut_in is not None:
|
|
358
|
+
quiet_after = entry.ts - cut_in
|
|
359
|
+
if now_speaking and not speaking:
|
|
360
|
+
cut_in, quiet_after, interrupted = None, None, False
|
|
361
|
+
speaking = now_speaking
|
|
362
|
+
case _:
|
|
363
|
+
continue
|
|
364
|
+
if interrupted and quiet_after is not None:
|
|
365
|
+
delays.append(quiet_after)
|
|
366
|
+
cut_in, quiet_after, interrupted = None, None, False
|
|
367
|
+
return delays
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# A leg still up when the log ends is up until the call's last entry.
|
|
371
|
+
def phone_legs(entries: Iterable[Entry]) -> list[PhoneLeg]:
|
|
372
|
+
"""Each leg of the call on the phone network, from when it joined the room until it left."""
|
|
373
|
+
joined: dict[str, tuple[float, PhoneLeg]] = {}
|
|
374
|
+
legs: list[PhoneLeg] = []
|
|
375
|
+
last = 0.0
|
|
376
|
+
for entry in entries:
|
|
377
|
+
last = entry.ts
|
|
378
|
+
if entry.type == "participant.joined":
|
|
379
|
+
arrived = ParticipantJoined.model_validate(entry.data)
|
|
380
|
+
leg = _leg_of(arrived.attributes)
|
|
381
|
+
if leg is not None:
|
|
382
|
+
joined[arrived.identity] = (entry.ts, leg)
|
|
383
|
+
elif entry.type == "participant.left":
|
|
384
|
+
gone = ParticipantLeft.model_validate(entry.data)
|
|
385
|
+
if gone.identity in joined:
|
|
386
|
+
began, leg = joined.pop(gone.identity)
|
|
387
|
+
legs.append(replace(leg, seconds=entry.ts - began))
|
|
388
|
+
legs += [replace(leg, seconds=last - began) for began, leg in joined.values()]
|
|
389
|
+
return legs
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _resumed(state: State, gap: LogGap) -> State:
|
|
393
|
+
resumed = state if gap.snapshot is None else gap.snapshot.model_copy(deep=True)
|
|
394
|
+
resumed.gaps.append(Gap(from_seq=gap.from_seq, to_seq=gap.to_seq))
|
|
395
|
+
return resumed
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _call(state: State, data: WireModel) -> None:
|
|
399
|
+
match data:
|
|
400
|
+
case CallRinging():
|
|
401
|
+
state.status, state.direction = "ringing", "inbound"
|
|
402
|
+
_line(state, data)
|
|
403
|
+
case CallDialing():
|
|
404
|
+
state.status, state.direction = "dialing", "outbound"
|
|
405
|
+
_line(state, data)
|
|
406
|
+
case CallStarted():
|
|
407
|
+
state.status, state.direction, state.started_at = (
|
|
408
|
+
"active",
|
|
409
|
+
data.direction,
|
|
410
|
+
data.started_at,
|
|
411
|
+
)
|
|
412
|
+
_line(state, data)
|
|
413
|
+
case CallEnded():
|
|
414
|
+
state.status, state.ended_at, state.end_reason = "ended", data.ended_at, data.reason
|
|
415
|
+
state.live = LiveTranscript(user=None, agent=None)
|
|
416
|
+
if state.attention is not None and state.attention.status == "open":
|
|
417
|
+
state.attention = state.attention.model_copy(update={"status": "lapsed"})
|
|
418
|
+
case CallLine():
|
|
419
|
+
state.held, state.muted = data.held, data.muted
|
|
420
|
+
case CallSummary():
|
|
421
|
+
state.usage, state.cost, state.outcome = list(data.usage), data.cost, data.outcome
|
|
422
|
+
state.end_reason = state.end_reason or data.reason
|
|
423
|
+
case _:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _line(state: State, data: CallRinging | CallDialing | CallStarted) -> None:
|
|
428
|
+
state.channel, state.from_, state.to, state.caller = (
|
|
429
|
+
data.channel,
|
|
430
|
+
data.from_,
|
|
431
|
+
data.to,
|
|
432
|
+
data.caller,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# A turn keeps only the fields that were on the wire, so it is built from what was written.
|
|
437
|
+
def _talk(state: State, data: WireModel) -> None:
|
|
438
|
+
match data:
|
|
439
|
+
case UserStateChanged():
|
|
440
|
+
state.user_state = data.state
|
|
441
|
+
case AgentStateChanged():
|
|
442
|
+
state.agent_state = data.state
|
|
443
|
+
case UserTranscript():
|
|
444
|
+
state.live.user = None if data.final else data.text
|
|
445
|
+
case AgentTranscript():
|
|
446
|
+
state.live.agent = None if data.final else _joined(state.live.agent or "", data)
|
|
447
|
+
case UserTurnEnded():
|
|
448
|
+
state.turns.append(UserTurn.model_validate({"role": "user", **data.written()}))
|
|
449
|
+
state.live.user = None
|
|
450
|
+
case AgentTurnEnded():
|
|
451
|
+
state.turns.append(AgentTurn.model_validate({"role": "agent", **data.written()}))
|
|
452
|
+
state.live.agent = None
|
|
453
|
+
case MemoryOps():
|
|
454
|
+
state.memory.extend(data.ops)
|
|
455
|
+
case DocsSources():
|
|
456
|
+
state.sources = list(data.sources)
|
|
457
|
+
case _:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# A word aligned to the audio (it has a start) comes without its space; a token brings its own.
|
|
462
|
+
def _joined(so_far: str, delta: AgentTranscript) -> str:
|
|
463
|
+
glued = not so_far or so_far[-1].isspace() or delta.text[:1].isspace() or delta.start is None
|
|
464
|
+
return f"{so_far}{'' if glued else ' '}{delta.text}"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _tools(state: State, entry: Entry, data: WireModel) -> None:
|
|
468
|
+
match data:
|
|
469
|
+
case ToolCall():
|
|
470
|
+
run = {**data.written(), "status": "running", "seq": entry.seq}
|
|
471
|
+
state.tools.append(ToolRun.model_validate(run))
|
|
472
|
+
case ToolResult():
|
|
473
|
+
outcome = {k: v for k, v in data.written().items() if k not in {"call_id", "name"}}
|
|
474
|
+
outcome["status"] = "done" if data.error is None else "failed"
|
|
475
|
+
_settled(state.tools, data.call_id, outcome)
|
|
476
|
+
case StateChanged():
|
|
477
|
+
state.app_state = dict(data.state)
|
|
478
|
+
case PromptChanged():
|
|
479
|
+
state.prompt[data.name] = PromptBlockState(
|
|
480
|
+
hash=data.hash, chars=data.chars, seq=entry.seq
|
|
481
|
+
)
|
|
482
|
+
case ToolsChanged():
|
|
483
|
+
state.tools_visible = list(data.visible)
|
|
484
|
+
case ConfirmRequest():
|
|
485
|
+
params = {"tool": data.tool, "call_id": data.call_id, "audience": data.audience}
|
|
486
|
+
state.confirms.append(Confirm(**params, phrase=data.phrase, status="pending"))
|
|
487
|
+
case ConfirmGranted():
|
|
488
|
+
_settled(state.confirms, data.call_id, {"status": "granted", "said": data.said})
|
|
489
|
+
case ConfirmDeclined():
|
|
490
|
+
text = {} if data.said is None else {"said": data.said}
|
|
491
|
+
_settled(
|
|
492
|
+
state.confirms, data.call_id, {"status": "declined", "reason": data.reason, **text}
|
|
493
|
+
)
|
|
494
|
+
case _:
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# The last one with that call_id, since an app may reuse an id across turns.
|
|
499
|
+
def _settled[T: ToolRun | Confirm](
|
|
500
|
+
items: list[T], call_id: str, update: Mapping[str, object]
|
|
501
|
+
) -> None:
|
|
502
|
+
for index in range(len(items) - 1, -1, -1):
|
|
503
|
+
if items[index].call_id == call_id:
|
|
504
|
+
items[index] = items[index].model_copy(update=update)
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# joined_at comes from the entry's ts: the event carries no time.
|
|
509
|
+
def _room(state: State, entry: Entry, data: WireModel) -> None:
|
|
510
|
+
match data:
|
|
511
|
+
case RoomOpened():
|
|
512
|
+
state.room = Room(name=data.name, sid=data.sid, participants=[], caller=None)
|
|
513
|
+
case EventReceived():
|
|
514
|
+
fact = {k: v for k, v in data.written().items() if k != "data"}
|
|
515
|
+
state.events.append(ReceivedEvent.model_validate({**fact, "seq": entry.seq}))
|
|
516
|
+
case ParticipantJoined() if state.room is not None:
|
|
517
|
+
arrived = {**data.written(), "joined_at": entry.ts, "speaking": False}
|
|
518
|
+
state.room.participants.append(Participant.model_validate(arrived))
|
|
519
|
+
if data.kind == "caller":
|
|
520
|
+
state.room.caller = data.identity
|
|
521
|
+
case ParticipantLeft() if state.room is not None:
|
|
522
|
+
state.room.participants = [
|
|
523
|
+
seat for seat in state.room.participants if seat.identity != data.identity
|
|
524
|
+
]
|
|
525
|
+
if state.room.caller == data.identity:
|
|
526
|
+
state.room.caller = None
|
|
527
|
+
case ParticipantSpeaking() if state.room is not None:
|
|
528
|
+
for seat in state.room.participants:
|
|
529
|
+
if seat.identity == data.identity:
|
|
530
|
+
seat.speaking = data.speaking
|
|
531
|
+
case _:
|
|
532
|
+
pass
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# asked_at comes from the entry's ts: the event carries no time.
|
|
536
|
+
def _people(state: State, entry: Entry, data: WireModel) -> None:
|
|
537
|
+
match data:
|
|
538
|
+
case CallTransferred():
|
|
539
|
+
by = "agent" if state.transfer is None else state.transfer.by
|
|
540
|
+
status = "done" if data.ok else "failed"
|
|
541
|
+
state.transfer = TransferState(to=data.to, mode=data.mode, status=status, by=by)
|
|
542
|
+
case SupervisorTookOver():
|
|
543
|
+
state.handoff = Handoff(active=True, by=data.by)
|
|
544
|
+
case SupervisorReleased():
|
|
545
|
+
state.handoff = Handoff(active=False, by=None)
|
|
546
|
+
case SupervisorTransferred():
|
|
547
|
+
state.transfer = TransferState(
|
|
548
|
+
to=data.to, mode=data.mode, status="requested", by="supervisor"
|
|
549
|
+
)
|
|
550
|
+
case AttentionRequested():
|
|
551
|
+
state.attention = AttentionState(
|
|
552
|
+
reason=data.reason, wait_s=data.wait_s, status="open", asked_at=entry.ts, by=None
|
|
553
|
+
)
|
|
554
|
+
case AttentionAnswered() if state.attention is not None:
|
|
555
|
+
settled = "answered" if data.ok else "lapsed"
|
|
556
|
+
state.attention = state.attention.model_copy(update={"status": settled, "by": data.by})
|
|
557
|
+
case _:
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _metrics(blocks: CollectedMetrics, data: WireModel) -> None:
|
|
562
|
+
match data:
|
|
563
|
+
case LLMMetrics():
|
|
564
|
+
blocks.llm.append(data)
|
|
565
|
+
case STTMetrics():
|
|
566
|
+
blocks.stt.append(data)
|
|
567
|
+
case TTSMetrics():
|
|
568
|
+
blocks.tts.append(data)
|
|
569
|
+
case VADMetrics():
|
|
570
|
+
blocks.vad.append(data)
|
|
571
|
+
case EOUMetrics():
|
|
572
|
+
blocks.eou.append(data)
|
|
573
|
+
case EOTInferenceMetrics():
|
|
574
|
+
blocks.eot.append(data)
|
|
575
|
+
case InterruptionMetrics():
|
|
576
|
+
blocks.interruption.append(data)
|
|
577
|
+
case RealtimeModelMetrics():
|
|
578
|
+
blocks.realtime.append(data)
|
|
579
|
+
case AvatarMetrics():
|
|
580
|
+
blocks.avatar.append(data)
|
|
581
|
+
case _:
|
|
582
|
+
pass
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# supervisor.said, .whispered and .ended, the tracks, room.sent, call.score and call.attached
|
|
586
|
+
# change nothing here: what they caused arrives as turns, prompt changes and call.ended.
|
|
587
|
+
def _notes(state: State, entry: Entry, data: WireModel) -> None:
|
|
588
|
+
match data:
|
|
589
|
+
case AgentRegistered():
|
|
590
|
+
state.routes = list(data.routes)
|
|
591
|
+
case ErrorEvent():
|
|
592
|
+
state.errors.append(LoggedError(seq=entry.seq, code=data.code, message=data.message))
|
|
593
|
+
case Custom():
|
|
594
|
+
state.custom.append(CustomNote(seq=entry.seq, name=data.name, data=dict(data.data)))
|
|
595
|
+
case _:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# Tokens are counted from the LLM rows and characters from the TTS rows; every other row's
|
|
600
|
+
# audio seconds are already the call's minutes.
|
|
601
|
+
def _used_by_a_call(summary: CallSummary) -> Usage:
|
|
602
|
+
llm = [row for row in summary.usage if isinstance(row, LLMModelUsage)]
|
|
603
|
+
tts = [row for row in summary.usage if isinstance(row, TTSModelUsage)]
|
|
604
|
+
return Usage(
|
|
605
|
+
calls=1,
|
|
606
|
+
minutes=summary.duration_s / A_MINUTE_S,
|
|
607
|
+
messages=summary.turns,
|
|
608
|
+
input_tokens=sum(row.input_tokens or 0 for row in llm),
|
|
609
|
+
output_tokens=sum(row.output_tokens or 0 for row in llm),
|
|
610
|
+
characters=sum(row.characters_count or 0 for row in tts),
|
|
611
|
+
cost_usd=summary.cost.usd,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _measured(turn: Turn) -> tuple[tuple[str, float | None], ...]:
|
|
616
|
+
match turn:
|
|
617
|
+
case UserTurn():
|
|
618
|
+
took = turn.metrics
|
|
619
|
+
return (
|
|
620
|
+
("transcription_delay", took.transcription_delay),
|
|
621
|
+
("end_of_turn_delay", took.end_of_turn_delay),
|
|
622
|
+
)
|
|
623
|
+
case AgentTurn():
|
|
624
|
+
took = turn.metrics
|
|
625
|
+
return (
|
|
626
|
+
("llm_node_ttft", took.llm_node_ttft),
|
|
627
|
+
("tts_node_ttfb", took.tts_node_ttfb),
|
|
628
|
+
("e2e_latency", took.e2e_latency),
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# A reply that started before the caller stopped talked over them: that is no silence.
|
|
633
|
+
def _dead_air(before: Turn | None, turn: Turn) -> float | None:
|
|
634
|
+
if not isinstance(before, UserTurn) or not isinstance(turn, AgentTurn):
|
|
635
|
+
return None
|
|
636
|
+
stopped, started = before.metrics.stopped_speaking_at, turn.metrics.started_speaking_at
|
|
637
|
+
if stopped is None or started is None or started < stopped:
|
|
638
|
+
return None
|
|
639
|
+
return started - stopped
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _spoken_s(turn: Turn) -> float:
|
|
643
|
+
started, stopped = turn.metrics.started_speaking_at, turn.metrics.stopped_speaking_at
|
|
644
|
+
if started is None or stopped is None or stopped < started:
|
|
645
|
+
return 0.0
|
|
646
|
+
return stopped - started
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _leg_of(attributes: Mapping[str, object]) -> PhoneLeg | None:
|
|
650
|
+
if SIP_CALL not in attributes:
|
|
651
|
+
return None
|
|
652
|
+
inbound = SIP_RULE in attributes
|
|
653
|
+
number = attributes.get(OWN_NUMBER if inbound else FAR_NUMBER)
|
|
654
|
+
return PhoneLeg(
|
|
655
|
+
carrier="twilio" if TWILIO_CALL in attributes else "sip",
|
|
656
|
+
direction="inbound" if inbound else "outbound",
|
|
657
|
+
number=number if isinstance(number, str) else "",
|
|
658
|
+
seconds=0.0,
|
|
659
|
+
)
|