pinecall-runtime 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""The org's own: judging, identity provider, mailbox, policy, erasures, reads and export."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Query, Request
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import Conflict, DeclarationRefused, NotFound, UpstreamFailed
|
|
10
|
+
from pinecall.domain.person import parse_role
|
|
11
|
+
from pinecall.domain.scope import Scope
|
|
12
|
+
from pinecall.gateway._deps import (
|
|
13
|
+
CallsKey,
|
|
14
|
+
GatewayDep,
|
|
15
|
+
TeamKey,
|
|
16
|
+
UsageKey,
|
|
17
|
+
asked_by,
|
|
18
|
+
public_url,
|
|
19
|
+
)
|
|
20
|
+
from pinecall.providers import catalog
|
|
21
|
+
from pinecall.providers.catalog import judge_ceiling
|
|
22
|
+
from pinecall.tenancy import (
|
|
23
|
+
erasure,
|
|
24
|
+
export,
|
|
25
|
+
keys,
|
|
26
|
+
letters,
|
|
27
|
+
mail,
|
|
28
|
+
orgs,
|
|
29
|
+
policy,
|
|
30
|
+
reads,
|
|
31
|
+
sso,
|
|
32
|
+
sso_domains,
|
|
33
|
+
)
|
|
34
|
+
from pinecall.tenancy.mail import Mailbox, MailboxStatus
|
|
35
|
+
from pinecall.tenancy.sso import Client, OrgSso
|
|
36
|
+
from pinecall.wire.rest.accounts import (
|
|
37
|
+
OrgMailRequest,
|
|
38
|
+
OrgMailResponse,
|
|
39
|
+
OrgPolicy,
|
|
40
|
+
OrgPolicyRow,
|
|
41
|
+
OrgSsoRequest,
|
|
42
|
+
OrgSsoResponse,
|
|
43
|
+
SendTestLetterRequest,
|
|
44
|
+
SendTestLetterResponse,
|
|
45
|
+
SsoDomainProof,
|
|
46
|
+
)
|
|
47
|
+
from pinecall.wire.rest.agents import (
|
|
48
|
+
JudgingRequest,
|
|
49
|
+
JudgingSettings,
|
|
50
|
+
)
|
|
51
|
+
from pinecall.wire.rest.calls import ErasureTrail, ReadsResponse
|
|
52
|
+
|
|
53
|
+
router = APIRouter()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# A 404, not a 204: taking away what is not there must not look like it worked.
|
|
57
|
+
NO_SSO = "this org signs in with no identity provider"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Required with no domain proven would lock everybody out: the provider is offered for none.
|
|
61
|
+
NOT_PROVEN_YET = (
|
|
62
|
+
"no domain of this SSO is verified yet: publish each domain's TXT record and ask "
|
|
63
|
+
"POST /v1/org/sso/domains/{domain}/verify, then require it"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
NO_MAIL = "this org sends its letters through the box's own mail"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# The issuer is asked before it is kept, so a typo shows here and not at the first sign-in.
|
|
71
|
+
NOT_KEPT = "{said}: nothing was kept"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# A 409, not a 503: the request is fine, and there is nothing to test.
|
|
75
|
+
NOTHING_TO_TEST = (
|
|
76
|
+
"neither this org nor this box has a mail server: set one at PUT /v1/org/mail, or set "
|
|
77
|
+
"PINECALL_SMTP_URL on the box"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Per org, not per world: judging is billed to the org across both.
|
|
82
|
+
@router.get("/v1/org/judging")
|
|
83
|
+
async def get_judging(key: CallsKey, gateway: GatewayDep) -> JudgingSettings:
|
|
84
|
+
"""Whether hang-up judging is on, and its ceiling per call."""
|
|
85
|
+
pool = gateway.connections.pool
|
|
86
|
+
on = await orgs.judged(pool, key.org)
|
|
87
|
+
return JudgingSettings(on=on, ceiling_usd=judge_ceiling(await catalog.providers(pool)))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.put("/v1/org/judging")
|
|
91
|
+
async def put_judging(body: JudgingRequest, key: UsageKey, gateway: GatewayDep) -> JudgingSettings:
|
|
92
|
+
"""Hang-up judging on or off, from the next call."""
|
|
93
|
+
pool = gateway.connections.pool
|
|
94
|
+
await orgs.set_judging(pool, key.org, on=body.on)
|
|
95
|
+
return JudgingSettings(on=body.on, ceiling_usd=judge_ceiling(await catalog.providers(pool)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# `team`: the provider decides who the org's people are.
|
|
99
|
+
@router.get("/v1/org/sso")
|
|
100
|
+
async def get_sso(key: TeamKey, request: Request, gateway: GatewayDep) -> OrgSsoResponse:
|
|
101
|
+
"""The org's provider, never its secret, and the redirect URI to register there."""
|
|
102
|
+
connections = gateway.connections
|
|
103
|
+
wired = await sso.sso_of(connections.pool, connections.vault, key.org)
|
|
104
|
+
proofs = await sso_domains.of_org(connections.pool, key.org)
|
|
105
|
+
return sso_row(wired, f"{public_url(request, gateway)}{sso.CALLBACK}", proofs)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# The role the provider seats people with counts as granted by this key: a manager makes no admin.
|
|
109
|
+
@router.put("/v1/org/sso")
|
|
110
|
+
async def put_sso(
|
|
111
|
+
body: OrgSsoRequest, key: TeamKey, request: Request, gateway: GatewayDep
|
|
112
|
+
) -> OrgSsoResponse:
|
|
113
|
+
"""Replace the org's provider whole, once its issuer answered as one."""
|
|
114
|
+
connections = gateway.connections
|
|
115
|
+
role = None if body.role is None else parse_role(body.role)
|
|
116
|
+
wanted = OrgSso(
|
|
117
|
+
org=key.org,
|
|
118
|
+
client=Client(body.issuer.strip(), body.client_id.strip(), body.client_secret),
|
|
119
|
+
domains=tuple(domain.strip().lower().removeprefix("@") for domain in body.domains),
|
|
120
|
+
role=role,
|
|
121
|
+
required=body.required,
|
|
122
|
+
)
|
|
123
|
+
keys.check_may_grant(key.bearer, role, production=False)
|
|
124
|
+
try:
|
|
125
|
+
await sso.discovered(connections.http, wanted.client.issuer)
|
|
126
|
+
except UpstreamFailed as unanswered:
|
|
127
|
+
raise DeclarationRefused(NOT_KEPT.format(said=unanswered)) from unanswered
|
|
128
|
+
if wanted.required:
|
|
129
|
+
proven = await sso_domains.verified_domains(connections.pool, key.org)
|
|
130
|
+
if not proven & frozenset(wanted.domains):
|
|
131
|
+
raise Conflict(NOT_PROVEN_YET)
|
|
132
|
+
await sso.put_sso(connections.pool, connections.vault, wanted)
|
|
133
|
+
proofs = await sso_domains.proofs_for(connections.pool, key.org, wanted.domains)
|
|
134
|
+
return sso_row(wanted, f"{public_url(request, gateway)}{sso.CALLBACK}", proofs)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# The org published the record; the box reads it now, once. A record seen stands.
|
|
138
|
+
@router.post("/v1/org/sso/domains/{domain}/verify")
|
|
139
|
+
async def verify_sso_domain(domain: str, key: TeamKey, gateway: GatewayDep) -> SsoDomainProof:
|
|
140
|
+
"""Look for the domain's TXT record and, found, offer the provider for the domain."""
|
|
141
|
+
folded = domain.strip().lower().removeprefix("@")
|
|
142
|
+
proof = await sso_domains.verify(gateway.connections.pool, key.org, folded)
|
|
143
|
+
return SsoDomainProof(domain=proof.domain, txt=proof.txt, verified=proof.verified)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.delete("/v1/org/sso", status_code=204)
|
|
147
|
+
async def drop_sso(key: TeamKey, gateway: GatewayDep) -> None:
|
|
148
|
+
"""Forget the org's provider: passwords open it again from the next attempt."""
|
|
149
|
+
if not await sso.drop_sso(gateway.connections.pool, key.org):
|
|
150
|
+
raise NotFound(NO_SSO)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# `team`: these letters carry invitations and password links.
|
|
154
|
+
@router.get("/v1/org/mail")
|
|
155
|
+
async def get_mail(key: TeamKey, gateway: GatewayDep) -> OrgMailResponse:
|
|
156
|
+
"""The org's own mailbox, never its password, and how its last letter went."""
|
|
157
|
+
connections = gateway.connections
|
|
158
|
+
return _mail_row(await mail.mail_of(connections.pool, connections.vault, key.org))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Sends nothing: the test door does, and a person watches it.
|
|
162
|
+
@router.put("/v1/org/mail")
|
|
163
|
+
async def put_mail(body: OrgMailRequest, key: TeamKey, gateway: GatewayDep) -> OrgMailResponse:
|
|
164
|
+
"""Replace the org's own mailbox; its letters go through it from the next one."""
|
|
165
|
+
connections = gateway.connections
|
|
166
|
+
await mail.put_mail(connections.pool, connections.vault, key.org, mailbox_of(body))
|
|
167
|
+
return _mail_row(await mail.mail_of(connections.pool, connections.vault, key.org))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.delete("/v1/org/mail", status_code=204)
|
|
171
|
+
async def drop_mail(key: TeamKey, gateway: GatewayDep) -> None:
|
|
172
|
+
"""Forget the org's own mailbox: its letters go through the box's again."""
|
|
173
|
+
if not await mail.drop_mail(gateway.connections.pool, key.org):
|
|
174
|
+
raise NotFound(NO_MAIL)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# The one mail door that waits on SMTP; how it went is kept as a real letter's is.
|
|
178
|
+
@router.post("/v1/org/mail/test")
|
|
179
|
+
async def send_test_letter(
|
|
180
|
+
body: SendTestLetterRequest, key: TeamKey, gateway: GatewayDep
|
|
181
|
+
) -> SendTestLetterResponse:
|
|
182
|
+
"""One test letter, waited for: whether it went, and what the server said when not."""
|
|
183
|
+
to = mail.address_of(body.to)
|
|
184
|
+
if await gateway.outbox.mailbox_for(key.org) is None:
|
|
185
|
+
raise Conflict(NOTHING_TO_TEST)
|
|
186
|
+
letter = letters.probe_letter(to, await letters.brand_of(gateway.connections.pool))
|
|
187
|
+
error = await gateway.outbox.sent(key.org, letter)
|
|
188
|
+
return SendTestLetterResponse(sent=error is None, error=error)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def mailbox_of(body: OrgMailRequest) -> Mailbox:
|
|
192
|
+
"""The mailbox a body describes, its words trimmed; refused in the mailbox's own terms."""
|
|
193
|
+
return Mailbox(
|
|
194
|
+
host=body.host.strip(),
|
|
195
|
+
port=body.port,
|
|
196
|
+
security=mail.parse_security(body.security),
|
|
197
|
+
username=body.username.strip(),
|
|
198
|
+
password=body.password,
|
|
199
|
+
sender=body.sender.strip(),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def sso_row(
|
|
204
|
+
wired: OrgSso | None, redirect_uri: str, proofs: list[sso_domains.Proof]
|
|
205
|
+
) -> OrgSsoResponse:
|
|
206
|
+
"""An org's provider as the doors send it: wired or not, never the secret."""
|
|
207
|
+
return OrgSsoResponse(
|
|
208
|
+
configured=wired is not None,
|
|
209
|
+
issuer=None if wired is None else wired.client.issuer,
|
|
210
|
+
client_id=None if wired is None else wired.client.client_id,
|
|
211
|
+
domains=[] if wired is None else list(wired.domains),
|
|
212
|
+
role=None if wired is None else wired.role,
|
|
213
|
+
required=wired is not None and wired.required,
|
|
214
|
+
redirect_uri=redirect_uri,
|
|
215
|
+
proofs=[
|
|
216
|
+
SsoDomainProof(domain=proof.domain, txt=proof.txt, verified=proof.verified)
|
|
217
|
+
for proof in proofs
|
|
218
|
+
],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.get("/v1/org/erasures")
|
|
223
|
+
async def erasures(key: TeamKey, gateway: GatewayDep) -> ErasureTrail:
|
|
224
|
+
"""The org's erasures, newest first: what went, when, and who asked."""
|
|
225
|
+
rows = await erasure.trail(gateway.connections.pool, key.org)
|
|
226
|
+
return ErasureTrail(erasures=rows)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@router.get("/v1/org/reads")
|
|
230
|
+
async def who_read(
|
|
231
|
+
key: TeamKey, gateway: GatewayDep, subject: Annotated[str | None, Query()] = None
|
|
232
|
+
) -> ReadsResponse:
|
|
233
|
+
"""Who read the org's calls, recordings, seats, exports and memory; of one subject if named."""
|
|
234
|
+
rows = await reads.of_org(gateway.connections.pool, key.org, subject=subject)
|
|
235
|
+
return ReadsResponse(reads=rows)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@router.get("/v1/org/policy")
|
|
239
|
+
async def get_policy(key: TeamKey, gateway: GatewayDep) -> OrgPolicyRow:
|
|
240
|
+
"""The org's compliance settings: how many days a sealed call is kept, and who set it."""
|
|
241
|
+
return await policy.policy_of(gateway.connections.pool, key.org)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@router.put("/v1/org/policy")
|
|
245
|
+
async def put_policy(body: OrgPolicy, key: TeamKey, gateway: GatewayDep) -> OrgPolicyRow:
|
|
246
|
+
"""Replace the org's compliance settings whole, from the next nightly run."""
|
|
247
|
+
pool = gateway.connections.pool
|
|
248
|
+
await policy.put_policy(pool, key.org, body, by=asked_by(key))
|
|
249
|
+
return await policy.policy_of(pool, key.org)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@router.get("/v1/org/export", response_model=None)
|
|
253
|
+
async def export_org(key: TeamKey, gateway: GatewayDep) -> StreamingResponse:
|
|
254
|
+
"""The org's data in the key's world as JSON Lines: calls, memories, settings, documents."""
|
|
255
|
+
export_read = reads.Read(key.org, "export", asked_by(key))
|
|
256
|
+
await reads.record(gateway.connections.pool, Scope(key.org, key.env), export_read)
|
|
257
|
+
filename = f"pinecall-{key.org}-{key.env}.jsonl"
|
|
258
|
+
return StreamingResponse(
|
|
259
|
+
_one_per_line(export.lines(gateway.connections.pool, key.org, key.env)),
|
|
260
|
+
media_type="application/x-ndjson",
|
|
261
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _mail_row(kept: MailboxStatus | None) -> OrgMailResponse:
|
|
266
|
+
# By its wire name: `from` is a keyword.
|
|
267
|
+
return OrgMailResponse.model_validate(
|
|
268
|
+
{
|
|
269
|
+
"configured": kept is not None,
|
|
270
|
+
"host": None if kept is None else kept.mailbox.host,
|
|
271
|
+
"port": None if kept is None else kept.mailbox.port,
|
|
272
|
+
"security": None if kept is None else kept.mailbox.security,
|
|
273
|
+
"username": None if kept is None else kept.mailbox.username,
|
|
274
|
+
"from": None if kept is None else kept.mailbox.sender,
|
|
275
|
+
"verified_at": None if kept is None else kept.verified_at,
|
|
276
|
+
"last_error": None if kept is None else kept.last_error,
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def _one_per_line(lines: AsyncIterator[str]) -> AsyncIterator[bytes]:
|
|
282
|
+
async for line in lines:
|
|
283
|
+
yield f"{line}\n".encode()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The persona doors: an agent's synthetic callers, and what each ran."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Query
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import DeclarationRefused, NotFound
|
|
10
|
+
from pinecall.gateway import _deps
|
|
11
|
+
from pinecall.gateway._deps import EvalsKey, GatewayDep, ScopeDep
|
|
12
|
+
from pinecall.gateway._gateway import Gateway
|
|
13
|
+
from pinecall.log import lists
|
|
14
|
+
from pinecall.log.lists import PersonaRunFilters
|
|
15
|
+
from pinecall.providers import catalog
|
|
16
|
+
from pinecall.providers.declared import model_of
|
|
17
|
+
from pinecall.tenancy import personas
|
|
18
|
+
from pinecall.tenancy.personas import Persona, PersonaEdit, StoredPersona
|
|
19
|
+
from pinecall.wire.rest.evals import (
|
|
20
|
+
PersonaList,
|
|
21
|
+
PersonaRequest,
|
|
22
|
+
PersonaRow,
|
|
23
|
+
PersonaRunList,
|
|
24
|
+
PersonaRunRow,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# What `--persona` takes: lower-case words joined by hyphens.
|
|
31
|
+
A_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
NOT_A_NAME = (
|
|
35
|
+
"{name!r} is not a persona's name: lower-case words joined by hyphens, like price-shopper"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
A_SCREENFUL = 20
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PersonaRunQuery(BaseModel):
|
|
43
|
+
"""Where a persona's runs continue, and how many."""
|
|
44
|
+
|
|
45
|
+
limit: int = Field(A_SCREENFUL, ge=1, le=_deps.LONGEST_LIST)
|
|
46
|
+
before: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# One list for both worlds: a persona is test data, never heard by a customer.
|
|
50
|
+
@router.get("/v1/agents/{slug}/personas")
|
|
51
|
+
async def list_personas(slug: str, key: EvalsKey, gateway: GatewayDep) -> PersonaList:
|
|
52
|
+
"""The agent's callers, by name."""
|
|
53
|
+
return await _list_of(gateway, key.org, slug)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# A model or a vendor this box lacks is refused when written, not on the call that plays it.
|
|
57
|
+
@router.put("/v1/agents/{slug}/personas/{name}")
|
|
58
|
+
async def put_persona(
|
|
59
|
+
slug: str, name: str, body: PersonaRequest, key: EvalsKey, gateway: GatewayDep
|
|
60
|
+
) -> PersonaList:
|
|
61
|
+
"""Write one of the agent's callers whole, or rename one from `was`; its list after it."""
|
|
62
|
+
if not A_NAME.match(name):
|
|
63
|
+
raise DeclarationRefused(NOT_A_NAME.format(name=name))
|
|
64
|
+
pool = gateway.connections.pool
|
|
65
|
+
defaults = (await catalog.providers(pool)).defaults
|
|
66
|
+
llm, tts = body.llm or None, body.tts or None
|
|
67
|
+
model_of(llm, "llm", in_use=defaults["llm"].vendor)
|
|
68
|
+
model_of(tts, "tts", in_use=defaults["tts"].vendor)
|
|
69
|
+
written = Persona(
|
|
70
|
+
name=name,
|
|
71
|
+
goal=body.goal,
|
|
72
|
+
style=body.style,
|
|
73
|
+
about=body.about,
|
|
74
|
+
facts=dict(body.facts),
|
|
75
|
+
state=dict(body.state),
|
|
76
|
+
llm=llm,
|
|
77
|
+
tts=tts,
|
|
78
|
+
voice=body.voice or None,
|
|
79
|
+
accepts_when=body.accepts_when or "",
|
|
80
|
+
declines_when=body.declines_when or "",
|
|
81
|
+
)
|
|
82
|
+
bearer = key.bearer.key
|
|
83
|
+
edit = PersonaEdit(author=bearer.subject or bearer.key_id, was=body.was)
|
|
84
|
+
await personas.put_persona(pool, key.org, slug, written, edit)
|
|
85
|
+
return await _list_of(gateway, key.org, slug)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.delete("/v1/agents/{slug}/personas/{name}")
|
|
89
|
+
async def drop_persona(slug: str, name: str, key: EvalsKey, gateway: GatewayDep) -> PersonaList:
|
|
90
|
+
"""Forget one of the agent's callers; its list after it, 404 for a name nobody wrote."""
|
|
91
|
+
await personas.drop_persona(gateway.connections.pool, key.org, slug, name)
|
|
92
|
+
return await _list_of(gateway, key.org, slug)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# A name nobody wrote for the agent is a 404, not an empty page. The key is let through by the
|
|
96
|
+
# route's dependency, since the scope already names its org.
|
|
97
|
+
@router.get(
|
|
98
|
+
"/v1/agents/{slug}/personas/{name}/runs", dependencies=[Depends(_deps.opening("evals"))]
|
|
99
|
+
)
|
|
100
|
+
async def list_persona_runs(
|
|
101
|
+
slug: str,
|
|
102
|
+
name: str,
|
|
103
|
+
scope: ScopeDep,
|
|
104
|
+
gateway: GatewayDep,
|
|
105
|
+
query: Annotated[PersonaRunQuery, Query()],
|
|
106
|
+
) -> PersonaRunList:
|
|
107
|
+
"""The calls the persona made to the agent in the key's world and scope, newest first."""
|
|
108
|
+
pool = gateway.connections.pool
|
|
109
|
+
if await personas.persona(pool, scope.org, slug, name) is None:
|
|
110
|
+
raise NotFound(personas.NOBODY.format(name=name, agent=slug))
|
|
111
|
+
found = await lists.runs_of_persona(
|
|
112
|
+
pool,
|
|
113
|
+
scope,
|
|
114
|
+
PersonaRunFilters(agent=slug, persona=name, before=query.before),
|
|
115
|
+
limit=query.limit,
|
|
116
|
+
)
|
|
117
|
+
rows = [
|
|
118
|
+
PersonaRunRow.model_validate(
|
|
119
|
+
{
|
|
120
|
+
"call": run.facts.call,
|
|
121
|
+
"agent": run.facts.agent,
|
|
122
|
+
"started_at": run.started_at,
|
|
123
|
+
"ended_at": run.facts.ended_at,
|
|
124
|
+
"turns": run.turns,
|
|
125
|
+
"end_reason": run.facts.end_reason,
|
|
126
|
+
"outcome": run.facts.outcome,
|
|
127
|
+
"cost_usd": run.facts.cost_usd,
|
|
128
|
+
"score": run.facts.score,
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
for run in found.runs
|
|
132
|
+
]
|
|
133
|
+
return PersonaRunList(runs=rows, total=found.total, next=found.next)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _row_of(stored: StoredPersona) -> PersonaRow:
|
|
137
|
+
persona = stored.persona
|
|
138
|
+
return PersonaRow(
|
|
139
|
+
name=persona.name,
|
|
140
|
+
about=persona.about,
|
|
141
|
+
goal=persona.goal,
|
|
142
|
+
style=persona.style,
|
|
143
|
+
facts=dict(persona.facts),
|
|
144
|
+
state=dict(persona.state),
|
|
145
|
+
llm=persona.llm,
|
|
146
|
+
tts=persona.tts,
|
|
147
|
+
voice=persona.voice,
|
|
148
|
+
accepts_when=persona.accepts_when,
|
|
149
|
+
declines_when=persona.declines_when,
|
|
150
|
+
author=stored.author,
|
|
151
|
+
set_at=stored.set_at.timestamp(),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _list_of(gateway: Gateway, org: str, agent: str) -> PersonaList:
|
|
156
|
+
kept = await personas.personas_of(gateway.connections.pool, org, agent)
|
|
157
|
+
return PersonaList(personas=[_row_of(stored) for stored in kept])
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""An agent's pipeline: what it hears, decides and speaks with, how fast, and its hold melody."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Query, Request, Response
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.agent import AgentConfig, Greeting
|
|
9
|
+
from pinecall.domain.errors import DeclarationRefused, NotFound, PinecallError
|
|
10
|
+
from pinecall.domain.scope import Scope
|
|
11
|
+
from pinecall.gateway._call_setup import keys_of, tuned
|
|
12
|
+
from pinecall.gateway._deps import GatewayDep, PipelineKey, ScopeDep, capped_body
|
|
13
|
+
from pinecall.gateway._gateway import Gateway
|
|
14
|
+
from pinecall.gateway.api.providers import catalogue_of
|
|
15
|
+
from pinecall.log.reduce import medians, reduce
|
|
16
|
+
from pinecall.providers import catalog, credentials
|
|
17
|
+
from pinecall.providers.build import Modality, primary
|
|
18
|
+
from pinecall.providers.credentials import Keyring
|
|
19
|
+
from pinecall.session import hold
|
|
20
|
+
from pinecall.tenancy import agents
|
|
21
|
+
from pinecall.tenancy.agents import Chosen, Clip
|
|
22
|
+
from pinecall.wire.parts import GreetingConfig
|
|
23
|
+
from pinecall.wire.rest.agents import (
|
|
24
|
+
HoldAudio,
|
|
25
|
+
Measured,
|
|
26
|
+
PipelineReport,
|
|
27
|
+
PipelineStage,
|
|
28
|
+
PlayedRequest,
|
|
29
|
+
)
|
|
30
|
+
from pinecall.wire.state import Turn
|
|
31
|
+
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Medians pool the turns of this many calls, so a short call weighs as much as its turns.
|
|
36
|
+
LAST_CALLS = 20
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# The whole file in one request; a five-minute mp3 is under 10 MB.
|
|
40
|
+
LONGEST_UPLOAD = 20 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
NO_CLIP = "agent {slug} plays no clip of its own: the box's melody is the worker's, off or default"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
OGG = "audio/ogg"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
TOO_BIG = f"a hold melody is {LONGEST_UPLOAD // (1024 * 1024)} MB at most as uploaded"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# The stages are the ones the next call would be built with: the declaration under the scope's
|
|
53
|
+
# settings. Nothing is built: a vendor without a key is a reason, not a failure.
|
|
54
|
+
@router.get("/v1/agents/{slug}/pipeline")
|
|
55
|
+
async def pipeline_report(
|
|
56
|
+
slug: str, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
|
|
57
|
+
) -> PipelineReport:
|
|
58
|
+
"""The agent's three stages, the catalogue, and the latencies of its last calls."""
|
|
59
|
+
pool = gateway.connections.pool
|
|
60
|
+
registration = gateway.sockets.of(scope, slug)
|
|
61
|
+
declared = AgentConfig(slug=slug) if registration is None else registration.config
|
|
62
|
+
configured = await catalog.providers(pool)
|
|
63
|
+
config, _ = await tuned(pool, declared, scope, configured)
|
|
64
|
+
keyring = await keys_of(pool, gateway.connections.vault, scope)
|
|
65
|
+
language = primary(config.language)
|
|
66
|
+
stages = {
|
|
67
|
+
"hears": _stage("stt", config, configured, language=language),
|
|
68
|
+
"decides": _stage("llm", config, configured, language=None),
|
|
69
|
+
"speaks": _stage("tts", config, configured, language=language),
|
|
70
|
+
}
|
|
71
|
+
listed = catalogue_of(configured, keyring)
|
|
72
|
+
turns = await _recent_turns(gateway, scope.org, slug)
|
|
73
|
+
return PipelineReport(
|
|
74
|
+
agent=slug,
|
|
75
|
+
hears=stages["hears"],
|
|
76
|
+
decides=stages["decides"],
|
|
77
|
+
speaks=stages["speaks"],
|
|
78
|
+
greeting=_greeting(config.greeting),
|
|
79
|
+
voices=listed.voices,
|
|
80
|
+
providers=listed.providers,
|
|
81
|
+
defaults=listed.defaults,
|
|
82
|
+
models=listed.models,
|
|
83
|
+
calls=turns[0],
|
|
84
|
+
medians=[
|
|
85
|
+
Measured(name=row.name, seconds=row.seconds, turns=row.turns)
|
|
86
|
+
for row in medians(turns[1])
|
|
87
|
+
],
|
|
88
|
+
unavailable_reasons=_unavailable(stages, keyring),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/v1/agents/{slug}/pipeline/hold-audio")
|
|
93
|
+
async def hold_audio(
|
|
94
|
+
slug: str, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
|
|
95
|
+
) -> HoldAudio:
|
|
96
|
+
"""What the agent plays while a tool runs: the box's melody, silence, or a clip of its own."""
|
|
97
|
+
return _hold_audio_row(await agents.hold_of(gateway.connections.pool, _of(scope), slug))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# The body is the file, no multipart; the name rides the query. Converted here, once, so no
|
|
101
|
+
# worker decodes a tenant's upload in the middle of a call.
|
|
102
|
+
@router.put("/v1/agents/{slug}/pipeline/hold-audio")
|
|
103
|
+
async def upload_hold_audio(
|
|
104
|
+
slug: str,
|
|
105
|
+
request: Request,
|
|
106
|
+
_key: PipelineKey,
|
|
107
|
+
scope: ScopeDep,
|
|
108
|
+
gateway: GatewayDep,
|
|
109
|
+
name: Annotated[str | None, Query(max_length=200)] = None,
|
|
110
|
+
) -> HoldAudio:
|
|
111
|
+
"""A file of the org's as the agent's melody, from the next call on."""
|
|
112
|
+
data = await capped_body(request, LONGEST_UPLOAD)
|
|
113
|
+
if len(data) > LONGEST_UPLOAD:
|
|
114
|
+
raise DeclarationRefused(TOO_BIG)
|
|
115
|
+
melody = await asyncio.to_thread(hold.converted, data)
|
|
116
|
+
clip = Clip(audio=melody.audio, seconds=melody.seconds, name=(name or "").strip() or "melody")
|
|
117
|
+
chosen = await agents.keep_hold(gateway.connections.pool, _of(scope), slug, clip)
|
|
118
|
+
return _hold_audio_row(chosen)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.get("/v1/agents/{slug}/pipeline/hold-audio/audio")
|
|
122
|
+
async def hold_audio_clip(
|
|
123
|
+
slug: str, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
|
|
124
|
+
) -> Response:
|
|
125
|
+
"""The agent's own clip, Ogg Opus, to hear before a caller does."""
|
|
126
|
+
audio = await agents.hold_audio(gateway.connections.pool, _of(scope), slug)
|
|
127
|
+
if audio is None:
|
|
128
|
+
raise NotFound(NO_CLIP.format(slug=slug))
|
|
129
|
+
return Response(audio, media_type=OGG)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.put("/v1/agents/{slug}/pipeline/hold-audio/played")
|
|
133
|
+
async def choose_hold_audio(
|
|
134
|
+
slug: str, body: PlayedRequest, _key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
|
|
135
|
+
) -> HoldAudio:
|
|
136
|
+
"""The box's melody back, or silence; an uploaded clip is forgotten either way."""
|
|
137
|
+
pool = gateway.connections.pool
|
|
138
|
+
if body.played == "default":
|
|
139
|
+
await agents.forget_hold(pool, _of(scope), slug)
|
|
140
|
+
return HoldAudio(played="default")
|
|
141
|
+
await agents.silence_hold(pool, _of(scope), slug)
|
|
142
|
+
return HoldAudio(played="off")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _hold_audio_row(chosen: Chosen | None) -> HoldAudio:
|
|
146
|
+
"""The stored choice as the doors send it; no row is the box's melody."""
|
|
147
|
+
if chosen is None:
|
|
148
|
+
return HoldAudio(played="default")
|
|
149
|
+
return HoldAudio(
|
|
150
|
+
played=chosen.played, sha256=chosen.sha256, seconds=chosen.seconds, name=chosen.name
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _stage(
|
|
155
|
+
modality: Modality, config: AgentConfig, configured: catalog.Providers, *, language: str | None
|
|
156
|
+
) -> PipelineStage:
|
|
157
|
+
default = configured.defaults[modality]
|
|
158
|
+
if modality == "tts":
|
|
159
|
+
voice = config.voice
|
|
160
|
+
return PipelineStage(
|
|
161
|
+
vendor=default.vendor if voice is None else voice.provider,
|
|
162
|
+
model=None if voice is None else voice.model,
|
|
163
|
+
voice_id=None if voice is None else voice.voice_id,
|
|
164
|
+
language=language,
|
|
165
|
+
)
|
|
166
|
+
declared = config.stt if modality == "stt" else config.llm
|
|
167
|
+
return PipelineStage(
|
|
168
|
+
vendor=default.vendor if declared is None else declared.provider,
|
|
169
|
+
model=None if declared is None else declared.model or None,
|
|
170
|
+
language=language,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _greeting(greeting: Greeting | None) -> GreetingConfig | None:
|
|
175
|
+
if greeting is None:
|
|
176
|
+
return None
|
|
177
|
+
return GreetingConfig(
|
|
178
|
+
say=greeting.say, reply=greeting.reply, allow_interruptions=greeting.allow_interruptions
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# A slug is one org's: another org's agent has no calls here, and says nothing of its latencies.
|
|
183
|
+
async def _recent_turns(gateway: Gateway, org: str, slug: str) -> tuple[int, list[list[Turn]]]:
|
|
184
|
+
store = gateway.logs.store
|
|
185
|
+
owner = await store.owner(slug)
|
|
186
|
+
if owner is not None and owner != org:
|
|
187
|
+
return 0, []
|
|
188
|
+
calls = await store.newest_calls(LAST_CALLS, agent=slug)
|
|
189
|
+
turns = [reduce(await store.whole(call)).turns for call in calls]
|
|
190
|
+
return len(calls), turns
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Which stage has no key to run on, and why, before a call fails for want of it.
|
|
194
|
+
def _unavailable(stages: dict[str, PipelineStage], keyring: Keyring) -> dict[str, str]:
|
|
195
|
+
missing: dict[str, str] = {}
|
|
196
|
+
for where, stage in stages.items():
|
|
197
|
+
try:
|
|
198
|
+
credentials.running(keyring, stage.vendor, stage.model)
|
|
199
|
+
except PinecallError as refused:
|
|
200
|
+
missing[where] = str(refused)
|
|
201
|
+
return missing
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# The melody and the widget are the agent's in a world, whoever holds it.
|
|
205
|
+
def _of(scope: Scope) -> Scope:
|
|
206
|
+
return Scope(scope.org, scope.env)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""A call's prompt read back: each block its log names by hash, with the words the org kept."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.errors import NotFound
|
|
6
|
+
from pinecall.gateway import _deps
|
|
7
|
+
from pinecall.gateway._deps import CallsKey, GatewayDep, ScopeDep, asked_by
|
|
8
|
+
from pinecall.log import queries
|
|
9
|
+
from pinecall.tenancy import prompts, reads
|
|
10
|
+
from pinecall.tenancy.reads import Read
|
|
11
|
+
from pinecall.wire.events import PromptChanged
|
|
12
|
+
from pinecall.wire.rest.calls import CallPromptResponse, PromptBlockRow
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# A key's read alone, never a page's token: the prompt is the operator's and the app's words.
|
|
18
|
+
# It is a read of the call, and on the record as one.
|
|
19
|
+
@router.get("/v1/calls/{call}/prompt")
|
|
20
|
+
async def call_prompt(
|
|
21
|
+
call: str, key: CallsKey, scope: ScopeDep, gateway: GatewayDep
|
|
22
|
+
) -> CallPromptResponse:
|
|
23
|
+
"""Every block of prompt the call was told, in order, each with its words when kept."""
|
|
24
|
+
reader = _deps.Reader(acting=key, scope=scope)
|
|
25
|
+
await _deps.check_readable(gateway, reader, call)
|
|
26
|
+
pool = gateway.connections.pool
|
|
27
|
+
kept = await queries.scope_of_call(pool, call)
|
|
28
|
+
entries = await gateway.logs.store.whole(call)
|
|
29
|
+
if kept is None or kept.scope is None or not entries:
|
|
30
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
31
|
+
await reads.record(pool, scope, Read(call, "log", asked_by(key)))
|
|
32
|
+
changes = [
|
|
33
|
+
(entry.seq, PromptChanged.model_validate(entry.data))
|
|
34
|
+
for entry in entries
|
|
35
|
+
if entry.type == "prompt.changed"
|
|
36
|
+
]
|
|
37
|
+
texts = await prompts.texts_of(pool, kept.scope.org, {block.hash for _, block in changes})
|
|
38
|
+
return CallPromptResponse(
|
|
39
|
+
call=call,
|
|
40
|
+
blocks=[
|
|
41
|
+
PromptBlockRow(
|
|
42
|
+
seq=seq,
|
|
43
|
+
name=block.name,
|
|
44
|
+
hash=block.hash,
|
|
45
|
+
chars=block.chars,
|
|
46
|
+
text="" if block.chars == 0 else texts.get(block.hash),
|
|
47
|
+
)
|
|
48
|
+
for seq, block in changes
|
|
49
|
+
],
|
|
50
|
+
)
|