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,463 @@
|
|
|
1
|
+
"""The settings doors: an agent's tuning and lexicon per world and scope, and a call's own."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.agent import AgentConfig, Lexicon, Tuning, Turn, Version
|
|
11
|
+
from pinecall.domain.errors import DeclarationRefused, NotAllowed, NotFound
|
|
12
|
+
from pinecall.domain.names import Env
|
|
13
|
+
from pinecall.domain.scope import THE_ORGS_OWN, Scope
|
|
14
|
+
from pinecall.gateway._call_setup import keys_of
|
|
15
|
+
from pinecall.gateway._deps import Acting, CallsKey, GatewayDep, PipelineKey, ScopeDep, WordsKey
|
|
16
|
+
from pinecall.gateway._gateway import Gateway
|
|
17
|
+
from pinecall.log import queries
|
|
18
|
+
from pinecall.postgres.pool import Pool
|
|
19
|
+
from pinecall.providers import build, catalog, credentials, tried
|
|
20
|
+
from pinecall.providers.build import Modality
|
|
21
|
+
from pinecall.providers.credentials import Pipeline
|
|
22
|
+
from pinecall.providers.declared import apply_tuning
|
|
23
|
+
from pinecall.tenancy import canary, keys, scopes
|
|
24
|
+
from pinecall.tenancy.canary import Canary, CanarySet
|
|
25
|
+
from pinecall.tenancy.scopes import TUNING, Written
|
|
26
|
+
from pinecall.wire.parts import Pronunciation
|
|
27
|
+
from pinecall.wire.rest.settings import (
|
|
28
|
+
CallSettingsResponse,
|
|
29
|
+
CanaryQuery,
|
|
30
|
+
CanaryResponse,
|
|
31
|
+
CanaryRow,
|
|
32
|
+
HistoryQuery,
|
|
33
|
+
LexiconBody,
|
|
34
|
+
LexiconHistoryResponse,
|
|
35
|
+
LexiconResponse,
|
|
36
|
+
LexiconRow,
|
|
37
|
+
PutCanaryRequest,
|
|
38
|
+
PutLexiconRequest,
|
|
39
|
+
PutSettingsRequest,
|
|
40
|
+
RollbackSettingsRequest,
|
|
41
|
+
SettingsBody,
|
|
42
|
+
SettingsDiffResponse,
|
|
43
|
+
SettingsHistoryResponse,
|
|
44
|
+
SettingsResponse,
|
|
45
|
+
SettingsRow,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
router = APIRouter()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# A `words` key (a supervisor's) sets the opening's words, what is remembered and the agent's
|
|
52
|
+
# lexicon; the rest is the pipeline's, and the key is refused by the fields it touched.
|
|
53
|
+
PIPELINE_ONLY = (
|
|
54
|
+
"voice",
|
|
55
|
+
"tts",
|
|
56
|
+
"tts_model",
|
|
57
|
+
"stt",
|
|
58
|
+
"llm",
|
|
59
|
+
"language",
|
|
60
|
+
"hangup",
|
|
61
|
+
"turn",
|
|
62
|
+
"bases",
|
|
63
|
+
"record",
|
|
64
|
+
"max_duration_s",
|
|
65
|
+
"llm_timeout_s",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
NOT_WORDS = "{fields}: the pipeline's, and this key opens words alone"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
NO_SUCH_VERSION = "no version {version} in this scope of {slug}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# The ears that take these knobs take them only inside these bands, and refuse the connection
|
|
76
|
+
# outside one, so the call runs deaf. Checked where a set is written, never where one is read.
|
|
77
|
+
BANDS = {"eot_threshold": (0.5, 0.9), "eager_eot_threshold": (0.3, 0.9)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
OUT_OF_BAND = "{knob} {value} is outside {low} to {high}, the band the ears take it in"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# The knobs of each stage: a set that changes one has the stage tried before it is kept, and a
|
|
84
|
+
# save that changes none asks no vendor anything.
|
|
85
|
+
STAGE_KNOBS: dict[Modality, tuple[str, ...]] = {
|
|
86
|
+
"tts": ("voice", "tts", "tts_model", "language"),
|
|
87
|
+
"llm": ("llm",),
|
|
88
|
+
"stt": ("stt", "turn", "language"),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
NO_SUCH_CALL = "no call {call} in this org"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/v1/agents/{slug}/settings", response_model_exclude_unset=True)
|
|
96
|
+
async def get_settings(
|
|
97
|
+
slug: str, key: WordsKey, scope: ScopeDep, gateway: GatewayDep
|
|
98
|
+
) -> SettingsResponse:
|
|
99
|
+
"""The agent's settings as this key sees them: yours, the team's and production's."""
|
|
100
|
+
return await _side_by_side(gateway, scope, slug, world=key.env)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# A whole replacement, guarded by the version it was read at; the read shows each scope's own
|
|
104
|
+
# newest and never the fallthrough, because if_version names the scope being written. A knob
|
|
105
|
+
# nobody set is left out of the answer, never sent as null: the body's fields are optional.
|
|
106
|
+
@router.put("/v1/agents/{slug}/settings", response_model_exclude_unset=True)
|
|
107
|
+
async def put_settings(
|
|
108
|
+
slug: str, body: PutSettingsRequest, key: WordsKey, scope: ScopeDep, gateway: GatewayDep
|
|
109
|
+
) -> SettingsResponse:
|
|
110
|
+
"""The agent's next version in this scope or the team's, checked as a call would build it."""
|
|
111
|
+
pool = gateway.connections.pool
|
|
112
|
+
written_to = _written_to(scope, team=body.team)
|
|
113
|
+
wanted = _tuning_of(body.config)
|
|
114
|
+
newest = await _newest(pool, written_to, slug)
|
|
115
|
+
if "pipeline" not in key.bearer.key.scopes:
|
|
116
|
+
wanted = _words_only(wanted, newest)
|
|
117
|
+
stages = await _checked(gateway, written_to, slug, wanted)
|
|
118
|
+
# A words key carries the pipeline over untouched: only a knob this set sets is refused.
|
|
119
|
+
if "pipeline" in key.bearer.key.scopes:
|
|
120
|
+
_refuse_out_of_band(wanted.turn)
|
|
121
|
+
build.refuse_untaken(stages.stt, stages.tts, wanted)
|
|
122
|
+
await _tried_where_changed(stages, wanted, newest)
|
|
123
|
+
written = Written(author=_author(key), note=body.note, if_version=body.if_version)
|
|
124
|
+
await scopes.put_tuning(pool, written_to, slug, wanted, written)
|
|
125
|
+
return await _side_by_side(gateway, scope, slug, world=key.env)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.get("/v1/agents/{slug}/settings/history", response_model_exclude_unset=True)
|
|
129
|
+
async def settings_history(
|
|
130
|
+
slug: str,
|
|
131
|
+
_key: WordsKey,
|
|
132
|
+
scope: ScopeDep,
|
|
133
|
+
gateway: GatewayDep,
|
|
134
|
+
query: Annotated[HistoryQuery, Query()],
|
|
135
|
+
) -> SettingsHistoryResponse:
|
|
136
|
+
"""One scope's versions of the agent's settings, newest first."""
|
|
137
|
+
written_to = _written_to(scope, team=query.team)
|
|
138
|
+
rows = await scopes.tuning_history(
|
|
139
|
+
gateway.connections.pool, written_to, slug, limit=query.limit
|
|
140
|
+
)
|
|
141
|
+
return SettingsHistoryResponse(
|
|
142
|
+
world=scope.env, holder=written_to.holder, rows=[_settings_row(row) for row in rows]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.get("/v1/agents/{slug}/settings/diff", response_model_exclude_unset=True)
|
|
147
|
+
async def settings_diff(
|
|
148
|
+
slug: str,
|
|
149
|
+
_key: WordsKey,
|
|
150
|
+
scope: ScopeDep,
|
|
151
|
+
gateway: GatewayDep,
|
|
152
|
+
against: Annotated[Literal["team", "production"], Query()] = "production",
|
|
153
|
+
) -> SettingsDiffResponse:
|
|
154
|
+
"""This key's scope's newest against the team's or production's, and which fields differ."""
|
|
155
|
+
kept = await scopes.tuning_side_by_side(gateway.connections.pool, scope, slug)
|
|
156
|
+
ours = kept.yours if scope.holder else kept.team
|
|
157
|
+
theirs = kept.production if against == "production" else kept.team
|
|
158
|
+
return SettingsDiffResponse(
|
|
159
|
+
ours=None if ours is None else _settings_row(ours),
|
|
160
|
+
theirs=None if theirs is None else _settings_row(theirs),
|
|
161
|
+
changed=_differing(ours, theirs),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# A rollback writes a new version copying an old one; nothing is ever deleted.
|
|
166
|
+
@router.post("/v1/agents/{slug}/settings/rollback", response_model_exclude_unset=True)
|
|
167
|
+
async def rollback_settings(
|
|
168
|
+
slug: str, body: RollbackSettingsRequest, key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
|
|
169
|
+
) -> SettingsResponse:
|
|
170
|
+
"""An old version brought back as the scope's next one."""
|
|
171
|
+
pool = gateway.connections.pool
|
|
172
|
+
written_to = _written_to(scope, team=body.team)
|
|
173
|
+
row = await scopes.tuning_at(pool, written_to, slug, body.version)
|
|
174
|
+
if row is None:
|
|
175
|
+
raise NotFound(NO_SUCH_VERSION.format(version=body.version, slug=slug))
|
|
176
|
+
written = Written(author=_author(key), note=f"rollback to v{body.version}")
|
|
177
|
+
await scopes.put_tuning(pool, written_to, slug, row.value, written)
|
|
178
|
+
return await _side_by_side(gateway, scope, slug, world=key.env)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/v1/agents/{slug}/settings/canary", response_model_exclude_unset=True)
|
|
182
|
+
async def get_canary(
|
|
183
|
+
slug: str,
|
|
184
|
+
key: WordsKey,
|
|
185
|
+
scope: ScopeDep,
|
|
186
|
+
gateway: GatewayDep,
|
|
187
|
+
query: Annotated[CanaryQuery, Query()],
|
|
188
|
+
) -> CanaryResponse:
|
|
189
|
+
"""The version this scope, or the team's, runs on a share of the agent's calls, or none."""
|
|
190
|
+
return await _canary_of(gateway, _written_to(scope, team=query.team), slug, world=key.env)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# The pipeline's alone, as the vendors are: a canary decides what a share of the calls runs.
|
|
194
|
+
@router.put("/v1/agents/{slug}/settings/canary", response_model_exclude_unset=True)
|
|
195
|
+
async def put_canary(
|
|
196
|
+
slug: str, body: PutCanaryRequest, key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
|
|
197
|
+
) -> CanaryResponse:
|
|
198
|
+
"""One of the scope's versions on a share of the agent's calls; the rest run the others."""
|
|
199
|
+
pool = gateway.connections.pool
|
|
200
|
+
written_to = _written_to(scope, team=body.team)
|
|
201
|
+
row = await scopes.tuning_at(pool, written_to, slug, body.version)
|
|
202
|
+
if row is None or row.holder != written_to.holder:
|
|
203
|
+
raise NotFound(NO_SUCH_VERSION.format(version=body.version, slug=slug))
|
|
204
|
+
wanted = Canary(version=body.version, share=body.share)
|
|
205
|
+
now = gateway.logs.store.clock()
|
|
206
|
+
await canary.put(pool, written_to, slug, CanarySet(wanted, _author(key), now, body.note))
|
|
207
|
+
return await _canary_of(gateway, written_to, slug, world=key.env)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Cleared, every call runs the scope's newest version again, the canary's included.
|
|
211
|
+
@router.delete("/v1/agents/{slug}/settings/canary", response_model_exclude_unset=True)
|
|
212
|
+
async def clear_canary(
|
|
213
|
+
slug: str,
|
|
214
|
+
key: PipelineKey,
|
|
215
|
+
scope: ScopeDep,
|
|
216
|
+
gateway: GatewayDep,
|
|
217
|
+
query: Annotated[CanaryQuery, Query()],
|
|
218
|
+
) -> CanaryResponse:
|
|
219
|
+
"""The scope's canary cleared: one version for every call."""
|
|
220
|
+
pool = gateway.connections.pool
|
|
221
|
+
written_to = _written_to(scope, team=query.team)
|
|
222
|
+
if await canary.current(pool, written_to, slug) is not None:
|
|
223
|
+
now = gateway.logs.store.clock()
|
|
224
|
+
await canary.put(pool, written_to, slug, CanarySet(None, _author(key), now))
|
|
225
|
+
return await _canary_of(gateway, written_to, slug, world=key.env)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# By the versions the call's head row kept, never the current ones.
|
|
229
|
+
@router.get("/v1/calls/{call}/settings", response_model_exclude_unset=True)
|
|
230
|
+
async def call_settings(call: str, key: CallsKey, gateway: GatewayDep) -> CallSettingsResponse:
|
|
231
|
+
"""The exact settings and lexicon a call was built on."""
|
|
232
|
+
pool = gateway.connections.pool
|
|
233
|
+
kept = await queries.scope_of_call(pool, call)
|
|
234
|
+
if kept is None or kept.scope is None or kept.scope.org != key.org:
|
|
235
|
+
raise NotFound(NO_SUCH_CALL.format(call=call))
|
|
236
|
+
keys.check_agent(key.bearer, kept.agent)
|
|
237
|
+
versions = kept.versions
|
|
238
|
+
config = (
|
|
239
|
+
None
|
|
240
|
+
if versions.config is None
|
|
241
|
+
else await scopes.tuning_at(pool, kept.scope, kept.agent, versions.config)
|
|
242
|
+
)
|
|
243
|
+
words = (
|
|
244
|
+
None
|
|
245
|
+
if versions.lexicon is None
|
|
246
|
+
else await scopes.lexicon_at(pool, kept.scope, kept.agent, versions.lexicon)
|
|
247
|
+
)
|
|
248
|
+
opened_at = kept.started_at
|
|
249
|
+
ran = opened_at is not None and await canary.ran_the_canary(
|
|
250
|
+
pool, kept.scope, kept.agent, versions.config, opened_at
|
|
251
|
+
)
|
|
252
|
+
return CallSettingsResponse(
|
|
253
|
+
config_version=versions.config,
|
|
254
|
+
lexicon_version=versions.lexicon,
|
|
255
|
+
config=None if config is None else _settings_row(config),
|
|
256
|
+
lexicon=None if words is None else _lexicon_row(words),
|
|
257
|
+
canary=ran,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@router.get("/v1/agents/{slug}/lexicon")
|
|
262
|
+
async def get_lexicon(
|
|
263
|
+
slug: str, key: WordsKey, scope: ScopeDep, gateway: GatewayDep
|
|
264
|
+
) -> LexiconResponse:
|
|
265
|
+
"""The agent's words as this key sees them: yours, the team's and production's."""
|
|
266
|
+
return await _lexicon_side_by_side(gateway, scope, slug, world=key.env)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# A supervisor's key may write it: the words are theirs to fix.
|
|
270
|
+
@router.put("/v1/agents/{slug}/lexicon")
|
|
271
|
+
async def put_lexicon(
|
|
272
|
+
slug: str, body: PutLexiconRequest, key: WordsKey, scope: ScopeDep, gateway: GatewayDep
|
|
273
|
+
) -> LexiconResponse:
|
|
274
|
+
"""The agent's next lexicon in this scope or the team's."""
|
|
275
|
+
written_to = _written_to(scope, team=body.team)
|
|
276
|
+
wanted = Lexicon(
|
|
277
|
+
said={item.word: item.spoken for item in body.lexicon.said}, heard=tuple(body.lexicon.heard)
|
|
278
|
+
)
|
|
279
|
+
written = Written(author=_author(key), note=body.note, if_version=body.if_version)
|
|
280
|
+
await scopes.put_lexicon(gateway.connections.pool, written_to, slug, wanted, written)
|
|
281
|
+
return await _lexicon_side_by_side(gateway, scope, slug, world=key.env)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@router.get("/v1/agents/{slug}/lexicon/history")
|
|
285
|
+
async def lexicon_history(
|
|
286
|
+
slug: str,
|
|
287
|
+
_key: WordsKey,
|
|
288
|
+
scope: ScopeDep,
|
|
289
|
+
gateway: GatewayDep,
|
|
290
|
+
query: Annotated[HistoryQuery, Query()],
|
|
291
|
+
) -> LexiconHistoryResponse:
|
|
292
|
+
"""One scope's versions of the agent's lexicon, newest first."""
|
|
293
|
+
written_to = _written_to(scope, team=query.team)
|
|
294
|
+
rows = await scopes.lexicon_history(
|
|
295
|
+
gateway.connections.pool, written_to, slug, limit=query.limit
|
|
296
|
+
)
|
|
297
|
+
return LexiconHistoryResponse(
|
|
298
|
+
world=scope.env, holder=written_to.holder, rows=[_lexicon_row(row) for row in rows]
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# pydantic wraps the dataclass's own refusal; the person reads that sentence, not the report.
|
|
303
|
+
def _tuning_of(body: SettingsBody) -> Tuning:
|
|
304
|
+
"""The body as the tuning a call is built from; refused in the shape's own words."""
|
|
305
|
+
try:
|
|
306
|
+
return TUNING.validate_python(body.model_dump(mode="python", exclude_none=True))
|
|
307
|
+
except ValidationError as invalid:
|
|
308
|
+
for error in invalid.errors():
|
|
309
|
+
cause = error.get("ctx", {}).get("error")
|
|
310
|
+
if isinstance(cause, DeclarationRefused):
|
|
311
|
+
raise cause from invalid
|
|
312
|
+
raise DeclarationRefused(str(invalid)) from invalid
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _settings_row(kept: Version[Tuning]) -> SettingsRow:
|
|
316
|
+
"""A stored version as the doors send it."""
|
|
317
|
+
return SettingsRow(
|
|
318
|
+
holder=kept.holder,
|
|
319
|
+
version=kept.version,
|
|
320
|
+
author=kept.author,
|
|
321
|
+
note=kept.note,
|
|
322
|
+
set_at=kept.set_at.timestamp(),
|
|
323
|
+
config=SettingsBody.model_validate(_body_of(kept.value)),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _lexicon_row(kept: Version[Lexicon]) -> LexiconRow:
|
|
328
|
+
"""A stored lexicon as the doors send it."""
|
|
329
|
+
return LexiconRow(
|
|
330
|
+
holder=kept.holder,
|
|
331
|
+
version=kept.version,
|
|
332
|
+
author=kept.author,
|
|
333
|
+
note=kept.note,
|
|
334
|
+
set_at=kept.set_at.timestamp(),
|
|
335
|
+
lexicon=LexiconBody(
|
|
336
|
+
said=[
|
|
337
|
+
Pronunciation(word=word, spoken=spoken) for word, spoken in kept.value.said.items()
|
|
338
|
+
],
|
|
339
|
+
heard=list(kept.value.heard),
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# The settings are checked as a call would be built from them: the declaration when an app
|
|
345
|
+
# holds the agent, a bare one otherwise, its lexicon in the scope, and the vendors on the org's
|
|
346
|
+
# keys, so a vendor the box does not lend is refused here and not on the next call.
|
|
347
|
+
async def _checked(gateway: Gateway, scope: Scope, slug: str, wanted: Tuning) -> Pipeline:
|
|
348
|
+
pool = gateway.connections.pool
|
|
349
|
+
registration = gateway.sockets.of(scope, slug)
|
|
350
|
+
declared = AgentConfig(slug=slug) if registration is None else registration.config
|
|
351
|
+
configured = await catalog.providers(pool)
|
|
352
|
+
words = await scopes.current(pool, scope, slug)
|
|
353
|
+
config = apply_tuning(declared, wanted, words.lexicon, defaults=configured.defaults)
|
|
354
|
+
keyring = await keys_of(pool, gateway.connections.vault, scope)
|
|
355
|
+
return credentials.pipeline(config, configured, keyring)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def _newest(pool: Pool, written_to: Scope, slug: str) -> Tuning:
|
|
359
|
+
kept = await scopes.tuning_side_by_side(pool, written_to, slug)
|
|
360
|
+
newest = kept.team if written_to.holder == THE_ORGS_OWN else kept.yours
|
|
361
|
+
return Tuning() if newest is None else newest.value
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def _tried_where_changed(stages: Pipeline, wanted: Tuning, newest: Tuning) -> None:
|
|
365
|
+
running = {"llm": stages.llm, "stt": stages.stt, "tts": stages.tts}
|
|
366
|
+
for stage, knobs in STAGE_KNOBS.items():
|
|
367
|
+
if any(_changes(wanted, newest, knob) for knob in knobs):
|
|
368
|
+
await tried.tried(stage, running[stage], wanted.turn)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _changes(wanted: Tuning, newest: Tuning, knob: str) -> bool:
|
|
372
|
+
value: object = getattr(wanted, knob)
|
|
373
|
+
return value is not None and value != getattr(newest, knob)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _refuse_out_of_band(turn: Turn | None) -> None:
|
|
377
|
+
for knob, (low, high) in BANDS.items():
|
|
378
|
+
value = None if turn is None else getattr(turn, knob)
|
|
379
|
+
if value is not None and not low <= value <= high:
|
|
380
|
+
raise DeclarationRefused(OUT_OF_BAND.format(knob=knob, value=value, low=low, high=high))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _words_only(wanted: Tuning, newest: Tuning) -> Tuning:
|
|
384
|
+
carried = {
|
|
385
|
+
name: getattr(newest, name) for name in PIPELINE_ONLY if getattr(wanted, name) is None
|
|
386
|
+
}
|
|
387
|
+
touched = [
|
|
388
|
+
name
|
|
389
|
+
for name in PIPELINE_ONLY
|
|
390
|
+
if getattr(wanted, name) is not None and getattr(wanted, name) != getattr(newest, name)
|
|
391
|
+
]
|
|
392
|
+
opening = wanted.greeting
|
|
393
|
+
if (
|
|
394
|
+
opening is not None
|
|
395
|
+
and opening != newest.greeting
|
|
396
|
+
and (opening.reply is not None or opening.allow_interruptions is not None)
|
|
397
|
+
):
|
|
398
|
+
touched = [*touched, "greeting.reply"]
|
|
399
|
+
if touched:
|
|
400
|
+
raise NotAllowed(NOT_WORDS.format(fields=", ".join(touched)))
|
|
401
|
+
return dataclasses.replace(wanted, **carried)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _differing(ours: Version[Tuning] | None, theirs: Version[Tuning] | None) -> list[str]:
|
|
405
|
+
mine = {} if ours is None else _body_of(ours.value)
|
|
406
|
+
yours = {} if theirs is None else _body_of(theirs.value)
|
|
407
|
+
return sorted(name for name in set(mine) | set(yours) if mine.get(name) != yours.get(name))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def _side_by_side(
|
|
411
|
+
gateway: Gateway, scope: Scope, slug: str, *, world: Env
|
|
412
|
+
) -> SettingsResponse:
|
|
413
|
+
kept = await scopes.tuning_side_by_side(gateway.connections.pool, scope, slug)
|
|
414
|
+
return SettingsResponse(
|
|
415
|
+
world=world,
|
|
416
|
+
yours=None if kept.yours is None else _settings_row(kept.yours),
|
|
417
|
+
team=None if kept.team is None else _settings_row(kept.team),
|
|
418
|
+
production=None if kept.production is None else _settings_row(kept.production),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def _lexicon_side_by_side(
|
|
423
|
+
gateway: Gateway, scope: Scope, slug: str, *, world: Env
|
|
424
|
+
) -> LexiconResponse:
|
|
425
|
+
kept = await scopes.lexicon_side_by_side(gateway.connections.pool, scope, slug)
|
|
426
|
+
return LexiconResponse(
|
|
427
|
+
world=world,
|
|
428
|
+
yours=None if kept.yours is None else _lexicon_row(kept.yours),
|
|
429
|
+
team=None if kept.team is None else _lexicon_row(kept.team),
|
|
430
|
+
production=None if kept.production is None else _lexicon_row(kept.production),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# A key holding no agent writes the org's own scope: no call resolves in its holder's.
|
|
435
|
+
async def _canary_of(gateway: Gateway, scope: Scope, slug: str, *, world: Env) -> CanaryResponse:
|
|
436
|
+
found = await canary.current(gateway.connections.pool, scope, slug)
|
|
437
|
+
return CanaryResponse(
|
|
438
|
+
world=world,
|
|
439
|
+
holder=scope.holder,
|
|
440
|
+
canary=None
|
|
441
|
+
if found is None
|
|
442
|
+
else CanaryRow(
|
|
443
|
+
holder=found.holder,
|
|
444
|
+
version=found.canary.version,
|
|
445
|
+
share=found.canary.share,
|
|
446
|
+
author=found.author,
|
|
447
|
+
note=found.note,
|
|
448
|
+
set_at=found.set_at.timestamp(),
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _written_to(scope: Scope, *, team: bool) -> Scope:
|
|
454
|
+
return replace(scope, holder=THE_ORGS_OWN) if team else scope
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _author(key: Acting) -> str:
|
|
458
|
+
return key.bearer.key.subject or key.bearer.key.key_id
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _body_of(tuning: Tuning) -> dict[str, object]:
|
|
462
|
+
dumped: dict[str, object] = TUNING.dump_python(tuning, mode="json", exclude_none=True)
|
|
463
|
+
return dumped
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Sign-up: the code mailed, the org founded on the code, the code sent again."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from hmac import compare_digest
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Request
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import (
|
|
10
|
+
Conflict,
|
|
11
|
+
DeclarationRefused,
|
|
12
|
+
NotAvailable,
|
|
13
|
+
NotFound,
|
|
14
|
+
NotSignedIn,
|
|
15
|
+
)
|
|
16
|
+
from pinecall.domain.names import AN_ADDRESS, parse_slug
|
|
17
|
+
from pinecall.gateway._deps import GatewayDep, bearer_of, check_knock, client_of
|
|
18
|
+
from pinecall.tenancy import letters, orgs, people, signin
|
|
19
|
+
from pinecall.tenancy.signin import Refusal, Signup
|
|
20
|
+
from pinecall.wire.rest.accounts import (
|
|
21
|
+
CodeMailedResponse,
|
|
22
|
+
EmptyResponse,
|
|
23
|
+
KeyIssuedResponse,
|
|
24
|
+
MemberRow,
|
|
25
|
+
OrgMadeResponse,
|
|
26
|
+
ResendCodeRequest,
|
|
27
|
+
SignupRequest,
|
|
28
|
+
VerifySignupRequest,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
NOT_OPEN = (
|
|
35
|
+
"sign-ups are not open on this box: its operator sets PINECALL_SIGNUP, or makes the org and "
|
|
36
|
+
"invites you"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
NO_MAIL = (
|
|
41
|
+
"this box cannot mail a code: its operator sets PINECALL_SMTP_URL and PINECALL_MAIL_FROM, or "
|
|
42
|
+
"makes the org and invites you"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
TOO_MANY = "too many sign-ups from here: try again in a minute"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
NOT_THE_SHIELD = "the sign-up doors take the key of the page in front of them"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
NOT_AN_ADDRESS = "an email has one @ and a domain, not {email!r}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
NO_NAME = "a person has a name: it is what a seat says when they sit"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# An address with no sign-up waiting reads as a wrong code, so nobody learns who signed up.
|
|
59
|
+
REFUSED: dict[Refusal, str] = {
|
|
60
|
+
"wrong": "that code is not valid",
|
|
61
|
+
"expired": "that code has expired: ask for a new one",
|
|
62
|
+
"burned": "too many tries: ask for a new code",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Where the page in front of the sign-up says the person is. Believed only with its key: the
|
|
67
|
+
# header is anybody's to write.
|
|
68
|
+
CLIENT_HEADER = "x-pinecall-client"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# The doors are shut unless the operator opens them; behind a shield, they take its key alone.
|
|
72
|
+
def signup_client(request: Request, gateway: GatewayDep) -> str:
|
|
73
|
+
"""The address a sign-up is counted by; refused where sign-ups are shut or shielded."""
|
|
74
|
+
settings = gateway.connections.settings
|
|
75
|
+
if not settings.signup:
|
|
76
|
+
raise NotFound(NOT_OPEN)
|
|
77
|
+
if settings.signup_key is None:
|
|
78
|
+
return client_of(request)
|
|
79
|
+
data = bearer_of(request.headers) or ""
|
|
80
|
+
if not compare_digest(data, settings.signup_key):
|
|
81
|
+
raise NotSignedIn(NOT_THE_SHIELD)
|
|
82
|
+
return request.headers.get(CLIENT_HEADER, "").strip() or client_of(request)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
SignupClient = Annotated[str, Depends(signup_client)]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# No org exists until the code comes back: an address nobody proves never takes a slug. Every
|
|
89
|
+
# address is answered alike, so the door says nobody whether one has an account: a person with a
|
|
90
|
+
# password signs up with it, and otherwise their mailbox, not the page, is told why no code came.
|
|
91
|
+
@router.post("/v1/signup", status_code=202)
|
|
92
|
+
async def sign_up(
|
|
93
|
+
body: SignupRequest, client: SignupClient, gateway: GatewayDep
|
|
94
|
+
) -> CodeMailedResponse:
|
|
95
|
+
"""Keep the sign-up and mail its six digits."""
|
|
96
|
+
connections = gateway.connections
|
|
97
|
+
pool = connections.pool
|
|
98
|
+
if await gateway.outbox.mailbox_for(None) is None:
|
|
99
|
+
raise NotAvailable(NO_MAIL)
|
|
100
|
+
await check_knock(gateway, f"{client} signup", TOO_MANY)
|
|
101
|
+
email = body.email.strip().lower()
|
|
102
|
+
slug = parse_slug(body.org)
|
|
103
|
+
if not AN_ADDRESS.match(email):
|
|
104
|
+
raise DeclarationRefused(NOT_AN_ADDRESS.format(email=body.email))
|
|
105
|
+
if not body.person.strip():
|
|
106
|
+
raise DeclarationRefused(NO_NAME)
|
|
107
|
+
hashed = await people.hash_password(body.password, connections.settings.min_password)
|
|
108
|
+
brand = await letters.brand_of(pool)
|
|
109
|
+
# Somebody who has a password signs up with it, or anybody could mint keys in their name; a
|
|
110
|
+
# sign-up for an invited address would choose that person's password for them.
|
|
111
|
+
known = await people.password_of(pool, email)
|
|
112
|
+
notice = None
|
|
113
|
+
if known is not None and not await people.matches(body.password, known):
|
|
114
|
+
notice = letters.account_kept_letter(email, brand)
|
|
115
|
+
elif known is None and await people.orgs_of(pool, email):
|
|
116
|
+
notice = letters.invitation_waiting_letter(email, brand)
|
|
117
|
+
if notice is not None:
|
|
118
|
+
await gateway.outbox.post(None, notice)
|
|
119
|
+
return CodeMailedResponse(email=email, code_expires_at=time.time() + signin.CODE_TTL_S)
|
|
120
|
+
if await orgs.find(pool, slug) is not None:
|
|
121
|
+
raise Conflict(orgs.SLUG_TAKEN.format(slug=slug))
|
|
122
|
+
signup = Signup(email, slug, body.person, hashed, name=body.name, device=body.device)
|
|
123
|
+
code, expires_at = await gateway.signins.signups.begin(signup)
|
|
124
|
+
letter = letters.signup_code_letter(email, code, body.person, brand)
|
|
125
|
+
await gateway.outbox.post(None, letter)
|
|
126
|
+
return CodeMailedResponse(email=email, code_expires_at=expires_at)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@router.post("/v1/signup/verify", status_code=201)
|
|
130
|
+
async def verify_signup(
|
|
131
|
+
body: VerifySignupRequest, client: SignupClient, gateway: GatewayDep
|
|
132
|
+
) -> OrgMadeResponse:
|
|
133
|
+
"""The mailed code back: the org made, its admin seated, their first key and a login code."""
|
|
134
|
+
await check_knock(gateway, f"{client} signup/verify", TOO_MANY)
|
|
135
|
+
taken = await gateway.signins.signups.verify(body.email.strip().lower(), body.code.strip())
|
|
136
|
+
if isinstance(taken, str):
|
|
137
|
+
raise DeclarationRefused(REFUSED[taken])
|
|
138
|
+
founded = await signin.found(gateway.connections.pool, taken, gateway.signins.codes)
|
|
139
|
+
issued = KeyIssuedResponse.of(founded.signed_in.key, founded.signed_in.secret)
|
|
140
|
+
return OrgMadeResponse(
|
|
141
|
+
**issued.model_dump(),
|
|
142
|
+
slug=founded.org.slug,
|
|
143
|
+
member=MemberRow.of(founded.admin),
|
|
144
|
+
code=founded.code,
|
|
145
|
+
code_expires_at=founded.code_expires_at,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# The same answer for any address, so nobody learns whose sign-up is waiting.
|
|
150
|
+
@router.post("/v1/signup/resend", status_code=202)
|
|
151
|
+
async def resend_code(
|
|
152
|
+
body: ResendCodeRequest, client: SignupClient, gateway: GatewayDep
|
|
153
|
+
) -> EmptyResponse:
|
|
154
|
+
"""A new code for a sign-up still waiting; the first one no longer works."""
|
|
155
|
+
await check_knock(gateway, f"{client} signup/resend", TOO_MANY)
|
|
156
|
+
renewed = await gateway.signins.signups.renewed(body.email.strip().lower())
|
|
157
|
+
if renewed is None:
|
|
158
|
+
return EmptyResponse()
|
|
159
|
+
signup, code = renewed
|
|
160
|
+
brand = await letters.brand_of(gateway.connections.pool)
|
|
161
|
+
await gateway.outbox.post(
|
|
162
|
+
None, letters.signup_code_letter(signup.email, code, signup.person, brand)
|
|
163
|
+
)
|
|
164
|
+
return EmptyResponse()
|