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,543 @@
|
|
|
1
|
+
"""People: an org's members, their invitations and one password each, and the login throttle."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from argon2 import PasswordHasher
|
|
13
|
+
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
|
14
|
+
from psycopg import sql
|
|
15
|
+
from psycopg.rows import DictRow
|
|
16
|
+
|
|
17
|
+
from pinecall.domain.errors import (
|
|
18
|
+
Conflict,
|
|
19
|
+
DeclarationRefused,
|
|
20
|
+
NotFound,
|
|
21
|
+
NotSignedIn,
|
|
22
|
+
QuotaExhausted,
|
|
23
|
+
)
|
|
24
|
+
from pinecall.domain.person import Member, MemberStatus, Role
|
|
25
|
+
from pinecall.postgres.pool import Connection, Pool, box_wide
|
|
26
|
+
|
|
27
|
+
MEMBER_PREFIX = "m_"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MEMBER_BYTES = 6
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Stored as sha256 like a key, but it dies in a week: a mailed link gets forwarded.
|
|
34
|
+
INVITATION_PREFIX = "inv_"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
INVITATION_BYTES = 24
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
INVITATION_TTL_S = 7 * 24 * 3600
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
TOO_SHORT = "a password is at least {shortest} characters"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ACCEPTED_ALREADY = "{email} is a member of this org already: they sign in, nobody invites them"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
NO_SEAT_LEFT = "the org holds all {seated} of its seats: free one, or ask for more"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
LAST_ADMIN = "the org keeps one active admin: make another one admin first"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
NOBODY_BY_THAT_ID = "nobody by that id in this org"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# An invitation seats a person; the password they have is theirs to keep and to type.
|
|
59
|
+
TYPE_THE_ONE_KEPT = (
|
|
60
|
+
"this address has a password already, and that is not it: accept with the password you have, "
|
|
61
|
+
"or reset it from the sign-in page first"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# What a link is for: `invite` seats a member, `reset` sets their password again.
|
|
66
|
+
type Purpose = Literal["invite", "reset"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Its own statement, so the insert after it sees what a concurrent invite wrote; inside one
|
|
70
|
+
# statement the seat count raced.
|
|
71
|
+
ROW = sql.SQL("id, org, email, name, role, agents, status, operator, production, verified_at")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
HELD = "SELECT pg_advisory_xact_lock(hashtext(%(org)s))"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
SEATED = "SELECT count(*) AS seated FROM members WHERE org = %(org)s AND status <> 'disabled'"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
INSERT = """
|
|
81
|
+
INSERT INTO members (id, org, email, name, role, agents, status, password_hash, production,
|
|
82
|
+
verified_at)
|
|
83
|
+
VALUES (%(id)s, %(org)s, %(email)s, %(name)s, %(role)s, %(agents)s, %(status)s, %(password)s,
|
|
84
|
+
%(production)s, CASE WHEN %(verified)s THEN now() END)
|
|
85
|
+
ON CONFLICT (org, email) DO NOTHING
|
|
86
|
+
RETURNING id
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# One password per person: the newest row that has one speaks for every row of the address. It
|
|
91
|
+
# is set where the person has none, and set again only by a reset link mailed to the address.
|
|
92
|
+
HASH_OF = """
|
|
93
|
+
SELECT password_hash FROM members WHERE email = %(email)s AND password_hash IS NOT NULL
|
|
94
|
+
ORDER BY created_at DESC LIMIT 1
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
VERIFIED = "SELECT 1 FROM members WHERE email = %(email)s AND verified_at IS NOT NULL LIMIT 1"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
RESET_EVERYWHERE = """
|
|
102
|
+
UPDATE members SET password_hash = %(password)s
|
|
103
|
+
WHERE email = %(email)s AND password_hash IS NOT NULL
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
OTHER_ADMINS = """
|
|
108
|
+
SELECT count(*) AS others FROM members
|
|
109
|
+
WHERE org = %(org)s AND id <> %(id)s AND role = 'admin' AND status = 'active'
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Revoked, not deleted: a log names the key that wrote it.
|
|
114
|
+
REVOKE_THEIR_KEYS = """
|
|
115
|
+
UPDATE api_keys SET revoked_at = now() WHERE subject = %(id)s AND revoked_at IS NULL
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Their invitations go by the foreign key's cascade.
|
|
120
|
+
REMOVE = "DELETE FROM members WHERE org = %(org)s AND id = %(id)s RETURNING id"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
INVITE = """
|
|
124
|
+
INSERT INTO invitations (token_hash, member, expires_at, vouched, purpose)
|
|
125
|
+
VALUES (%(hash)s, %(member)s, %(expires_at)s, %(vouched)s, %(purpose)s)
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
SPEND_OPEN = (
|
|
130
|
+
"UPDATE invitations SET spent_at = now() WHERE member = %(member)s AND spent_at IS NULL"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# One conditional update, so two accepts of one link cannot both win.
|
|
135
|
+
SPEND = """
|
|
136
|
+
UPDATE invitations SET spent_at = now()
|
|
137
|
+
WHERE token_hash = %(hash)s AND spent_at IS NULL AND expires_at > now()
|
|
138
|
+
RETURNING member, vouched, purpose
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# argon2id with the library's defaults (OWASP's). A key is random and hashed with sha256; a
|
|
143
|
+
# password is chosen by a person, so it is hashed slowly.
|
|
144
|
+
_HASHER = PasswordHasher()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class Invitee:
|
|
149
|
+
"""Who is invited, and what they are given."""
|
|
150
|
+
|
|
151
|
+
email: str
|
|
152
|
+
name: str
|
|
153
|
+
role: Role
|
|
154
|
+
agents: frozenset[str] = frozenset()
|
|
155
|
+
production: bool = False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class Change:
|
|
160
|
+
"""What a PATCH of a member names; None keeps what the member has."""
|
|
161
|
+
|
|
162
|
+
role: Role | None = None
|
|
163
|
+
agents: frozenset[str] | None = None
|
|
164
|
+
status: MemberStatus | None = None
|
|
165
|
+
production: bool | None = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class Invited:
|
|
170
|
+
"""The member invited, and the link's token once."""
|
|
171
|
+
|
|
172
|
+
member: Member
|
|
173
|
+
token: str
|
|
174
|
+
expires_at: datetime
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class Spent:
|
|
179
|
+
"""A link spent: whose it was, whether it proves the address, and what it is for."""
|
|
180
|
+
|
|
181
|
+
member: Member
|
|
182
|
+
vouched: bool
|
|
183
|
+
purpose: Purpose
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
LISTED = sql.SQL("SELECT {row} FROM members WHERE org = %(org)s ORDER BY created_at, id").format(
|
|
187
|
+
row=ROW
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
FIND = sql.SQL("SELECT {row} FROM members WHERE org = %(org)s AND id = %(id)s").format(row=ROW)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
BY_EMAIL = sql.SQL("SELECT {row} FROM members WHERE org = %(org)s AND email = %(email)s").format(
|
|
195
|
+
row=ROW
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
ORGS_OF = sql.SQL(
|
|
200
|
+
"SELECT {row} FROM members WHERE email = %(email)s ORDER BY created_at, id"
|
|
201
|
+
).format(row=ROW)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# A NULL parameter keeps what the column holds.
|
|
205
|
+
UPDATE = sql.SQL("""
|
|
206
|
+
UPDATE members SET role = COALESCE(%(role)s, role), agents = COALESCE(%(agents)s, agents),
|
|
207
|
+
status = COALESCE(%(status)s, status),
|
|
208
|
+
production = COALESCE(%(production)s, production)
|
|
209
|
+
WHERE org = %(org)s AND id = %(id)s
|
|
210
|
+
RETURNING {row}
|
|
211
|
+
""").format(row=ROW)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Never wakes a disabled member; a vouched link also proves the address; a NULL password keeps
|
|
215
|
+
# the one the row holds.
|
|
216
|
+
ACTIVATE = sql.SQL("""
|
|
217
|
+
UPDATE members SET status = 'active', password_hash = COALESCE(%(password)s, password_hash),
|
|
218
|
+
verified_at = CASE WHEN %(vouched)s THEN COALESCE(verified_at, now()) ELSE verified_at END
|
|
219
|
+
WHERE id = %(id)s AND status <> 'disabled'
|
|
220
|
+
RETURNING {row}
|
|
221
|
+
""").format(row=ROW)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
BY_ID = sql.SQL("SELECT {row} FROM members WHERE id = %(id)s").format(row=ROW)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
JOIN = sql.SQL("""
|
|
228
|
+
UPDATE members SET status = 'active', password_hash = %(password)s
|
|
229
|
+
WHERE org = %(org)s AND id = %(id)s AND status = 'invited'
|
|
230
|
+
RETURNING {row}
|
|
231
|
+
""").format(row=ROW)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
VOUCHED = sql.SQL("""
|
|
235
|
+
UPDATE members SET status = 'active', verified_at = COALESCE(verified_at, now())
|
|
236
|
+
WHERE org = %(org)s AND id = %(id)s AND status <> 'disabled'
|
|
237
|
+
RETURNING {row}
|
|
238
|
+
""").format(row=ROW)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
OPERATOR = sql.SQL(
|
|
242
|
+
"UPDATE members SET operator = %(on)s WHERE org = %(org)s AND id = %(id)s RETURNING {row}"
|
|
243
|
+
).format(row=ROW)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# Verified when the address is nobody's, so the time taken does not say who is a member.
|
|
247
|
+
_NOBODYS = _HASHER.hash(secrets.token_urlsafe(32))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def fingerprint(secret: str) -> str:
|
|
251
|
+
"""The sha256 a secret handed out once is kept as: a key, an invitation, a code."""
|
|
252
|
+
return hashlib.sha256(secret.encode()).hexdigest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def member_of(row: DictRow) -> Member:
|
|
256
|
+
"""A member read off its row."""
|
|
257
|
+
return Member(
|
|
258
|
+
id=row["id"],
|
|
259
|
+
org=row["org"],
|
|
260
|
+
email=row["email"],
|
|
261
|
+
name=row["name"],
|
|
262
|
+
role=row["role"],
|
|
263
|
+
agents=frozenset(row["agents"]),
|
|
264
|
+
status=row["status"],
|
|
265
|
+
operator=row["operator"],
|
|
266
|
+
production=row["production"],
|
|
267
|
+
verified=row["verified_at"] is not None,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# argon2id costs tens of milliseconds and 64 MiB, so it runs off the event loop.
|
|
272
|
+
def check_password(password: str, shortest: int) -> None:
|
|
273
|
+
"""Refuse a password under the box's floor, before anything is hashed or spent on it."""
|
|
274
|
+
if len(password) < shortest:
|
|
275
|
+
raise DeclarationRefused(TOO_SHORT.format(shortest=shortest))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def hash_password(password: str, shortest: int) -> str:
|
|
279
|
+
"""The password hashed; refused before hashing when it is under the box's floor."""
|
|
280
|
+
check_password(password, shortest)
|
|
281
|
+
return await asyncio.to_thread(_HASHER.hash, password)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def matches(password: str, kept: str | None) -> bool:
|
|
285
|
+
"""Whether the password is the one kept; nobody's costs a verification all the same."""
|
|
286
|
+
return await asyncio.to_thread(_verified, password, kept)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def invite(
|
|
290
|
+
pool: Pool, org: str, invitee: Invitee, *, seats: int | None, vouched: bool = False
|
|
291
|
+
) -> Invited:
|
|
292
|
+
"""Invite a person, or give a link again to one still invited; within the org's seats."""
|
|
293
|
+
email = _folded(invitee.email)
|
|
294
|
+
# Proven in any org is proven: read across orgs, before the org's own transaction.
|
|
295
|
+
with box_wide():
|
|
296
|
+
async with pool.connection() as connection:
|
|
297
|
+
proven = await _verified_anywhere(connection, email)
|
|
298
|
+
async with pool.connection() as connection, connection.transaction():
|
|
299
|
+
await connection.execute(HELD, {"org": org})
|
|
300
|
+
kept = await _fetch_member(connection, BY_EMAIL, {"org": org, "email": email})
|
|
301
|
+
if kept is not None and kept.status != "invited":
|
|
302
|
+
raise Conflict(ACCEPTED_ALREADY.format(email=email))
|
|
303
|
+
if kept is not None:
|
|
304
|
+
await connection.execute(SPEND_OPEN, {"member": kept.id})
|
|
305
|
+
return await _link(connection, kept, vouched=vouched)
|
|
306
|
+
seated = await (await connection.execute(SEATED, {"org": org})).fetchone()
|
|
307
|
+
found = 0 if seated is None else int(seated["seated"])
|
|
308
|
+
if seats is not None and found >= seats:
|
|
309
|
+
raise QuotaExhausted(NO_SEAT_LEFT.format(seated=found))
|
|
310
|
+
# Nobody is seated by being named: a person proven elsewhere takes the seat by opening the
|
|
311
|
+
# org (signin.key_in, or a sign-in that names it), anybody else by the link.
|
|
312
|
+
member = Member(
|
|
313
|
+
id=f"{MEMBER_PREFIX}{secrets.token_hex(MEMBER_BYTES)}",
|
|
314
|
+
org=org,
|
|
315
|
+
email=email,
|
|
316
|
+
name=invitee.name,
|
|
317
|
+
role=invitee.role,
|
|
318
|
+
agents=invitee.agents,
|
|
319
|
+
status="invited",
|
|
320
|
+
production=invitee.production,
|
|
321
|
+
verified=proven,
|
|
322
|
+
)
|
|
323
|
+
await connection.execute(INSERT, _written(member, None))
|
|
324
|
+
return await _link(connection, member, vouched=vouched)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# A reset mailed to the address (vouched) sets the person's one password everywhere; a reset handed
|
|
328
|
+
# to an admin sets this row's alone. An invitation never sets a password the person has: they type
|
|
329
|
+
# it, and the link seats them. A refusal rolls the spend back, so the link stands.
|
|
330
|
+
async def accept(pool: Pool, token: str, password: str, shortest: int) -> Member | None:
|
|
331
|
+
"""Spend the link and seat or reset the member as its purpose says; None for a dead link."""
|
|
332
|
+
async with pool.connection() as connection, connection.transaction():
|
|
333
|
+
spent = await _spent(connection, token)
|
|
334
|
+
if spent is None:
|
|
335
|
+
return None
|
|
336
|
+
kept = await _password_of(connection, spent.member.email)
|
|
337
|
+
if spent.purpose == "reset":
|
|
338
|
+
hashed = await hash_password(password, shortest)
|
|
339
|
+
member = await _activated(connection, spent, hashed)
|
|
340
|
+
if spent.vouched:
|
|
341
|
+
await connection.execute(
|
|
342
|
+
RESET_EVERYWHERE, {"email": spent.member.email, "password": hashed}
|
|
343
|
+
)
|
|
344
|
+
return member
|
|
345
|
+
if kept is None:
|
|
346
|
+
return await _activated(connection, spent, await hash_password(password, shortest))
|
|
347
|
+
if not await matches(password, kept):
|
|
348
|
+
raise NotSignedIn(TYPE_THE_ONE_KEPT)
|
|
349
|
+
return await _activated(connection, spent, None)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# The sign-up proved the password already (signin.py): the hash is set where the person has none.
|
|
353
|
+
async def seat_founder(pool: Pool, token: str, hashed: str) -> Member | None:
|
|
354
|
+
"""Spend a sign-up's link and seat the org's first admin; None for a dead link."""
|
|
355
|
+
async with pool.connection() as connection, connection.transaction():
|
|
356
|
+
spent = await _spent(connection, token)
|
|
357
|
+
if spent is None:
|
|
358
|
+
return None
|
|
359
|
+
kept = await _password_of(connection, spent.member.email)
|
|
360
|
+
return await _activated(connection, spent, None if kept is not None else hashed)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def reset(pool: Pool, org: str, member: str, *, vouched: bool = False) -> Invited | None:
|
|
364
|
+
"""A link that sets an active member's password again; None for anybody else."""
|
|
365
|
+
async with pool.connection() as connection, connection.transaction():
|
|
366
|
+
found = await _fetch_member(connection, FIND, {"org": org, "id": member})
|
|
367
|
+
if found is None or found.status != "active":
|
|
368
|
+
return None
|
|
369
|
+
await connection.execute(SPEND_OPEN, {"member": member})
|
|
370
|
+
return await _link(connection, found, vouched=vouched, purpose="reset")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def update(pool: Pool, org: str, member: str, change: Change) -> Member:
|
|
374
|
+
"""Change what was named of a member; disabling one revokes their keys."""
|
|
375
|
+
agents = None if change.agents is None else sorted(change.agents)
|
|
376
|
+
values = {
|
|
377
|
+
"org": org,
|
|
378
|
+
"id": member,
|
|
379
|
+
"role": change.role,
|
|
380
|
+
"agents": agents,
|
|
381
|
+
"status": change.status,
|
|
382
|
+
"production": change.production,
|
|
383
|
+
}
|
|
384
|
+
async with pool.connection() as connection, connection.transaction():
|
|
385
|
+
updated = await _fetch_member(connection, UPDATE, values)
|
|
386
|
+
if updated is None:
|
|
387
|
+
raise NotFound(NOBODY_BY_THAT_ID)
|
|
388
|
+
if change.status == "disabled":
|
|
389
|
+
await connection.execute(REVOKE_THEIR_KEYS, {"id": member})
|
|
390
|
+
return updated
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
async def remove(pool: Pool, org: str, member: str) -> None:
|
|
394
|
+
"""Take a member out for good, their keys revoked first; never the last active admin."""
|
|
395
|
+
async with pool.connection() as connection, connection.transaction():
|
|
396
|
+
found = await _fetch_member(connection, FIND, {"org": org, "id": member})
|
|
397
|
+
if found is None:
|
|
398
|
+
raise NotFound(NOBODY_BY_THAT_ID)
|
|
399
|
+
if found.role == "admin" and found.status == "active":
|
|
400
|
+
others = await (
|
|
401
|
+
await connection.execute(OTHER_ADMINS, {"org": org, "id": member})
|
|
402
|
+
).fetchone()
|
|
403
|
+
if others is None or others["others"] == 0:
|
|
404
|
+
raise Conflict(LAST_ADMIN)
|
|
405
|
+
await connection.execute(REVOKE_THEIR_KEYS, {"id": member})
|
|
406
|
+
await connection.execute(REMOVE, {"org": org, "id": member})
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def listed(pool: Pool, org: str) -> list[Member]:
|
|
410
|
+
"""The org's people, oldest first, the disabled ones too."""
|
|
411
|
+
async with pool.connection() as connection:
|
|
412
|
+
rows = await (await connection.execute(LISTED, {"org": org})).fetchall()
|
|
413
|
+
return [member_of(row) for row in rows]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def seated(pool: Pool, org: str) -> int:
|
|
417
|
+
"""How many of the org's people hold a seat: every one not disabled."""
|
|
418
|
+
async with pool.connection() as connection:
|
|
419
|
+
row = await (await connection.execute(SEATED, {"org": org})).fetchone()
|
|
420
|
+
return 0 if row is None else int(row["seated"])
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def find(pool: Pool, org: str, member: str) -> Member | None:
|
|
424
|
+
"""The member by id, within the org."""
|
|
425
|
+
async with pool.connection() as connection:
|
|
426
|
+
return await _fetch_member(connection, FIND, {"org": org, "id": member})
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def by_email(pool: Pool, org: str, email: str) -> Member | None:
|
|
430
|
+
"""The member by address, within the org."""
|
|
431
|
+
async with pool.connection() as connection:
|
|
432
|
+
return await _fetch_member(connection, BY_EMAIL, {"org": org, "email": _folded(email)})
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# A person is one across orgs: their rows and their one password are read box-wide, whatever org
|
|
436
|
+
# the request acts for (postgres/pool.py).
|
|
437
|
+
async def orgs_of(pool: Pool, email: str) -> list[Member]:
|
|
438
|
+
"""Every membership of the address, oldest first."""
|
|
439
|
+
with box_wide():
|
|
440
|
+
async with pool.connection() as connection:
|
|
441
|
+
found = await connection.execute(ORGS_OF, {"email": _folded(email)})
|
|
442
|
+
rows = await found.fetchall()
|
|
443
|
+
return [member_of(row) for row in rows]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def password_of(pool: Pool, email: str) -> str | None:
|
|
447
|
+
"""The person's one password hash, or None when they have not set one."""
|
|
448
|
+
with box_wide():
|
|
449
|
+
async with pool.connection() as connection:
|
|
450
|
+
return await _password_of(connection, _folded(email))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
async def join(pool: Pool, org: str, member: str, password_hash: str) -> Member | None:
|
|
454
|
+
"""Seat an invited member who signs in with the password they already have."""
|
|
455
|
+
async with pool.connection() as connection:
|
|
456
|
+
values = {"org": org, "id": member, "password": password_hash}
|
|
457
|
+
return await _fetch_member(connection, JOIN, values)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def vouched(pool: Pool, org: str, member: str) -> Member | None:
|
|
461
|
+
"""Seat a member an identity provider vouched for; never a disabled one."""
|
|
462
|
+
async with pool.connection() as connection:
|
|
463
|
+
return await _fetch_member(connection, VOUCHED, {"org": org, "id": member})
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
async def make_operator(pool: Pool, org: str, member: str, *, on: bool) -> Member | None:
|
|
467
|
+
"""Make a member one who runs the box, or take it back at once."""
|
|
468
|
+
async with pool.connection() as connection:
|
|
469
|
+
return await _fetch_member(connection, OPERATOR, {"org": org, "id": member, "on": on})
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _verified(password: str, kept: str | None) -> bool:
|
|
473
|
+
try:
|
|
474
|
+
return _HASHER.verify(_NOBODYS if kept is None else kept, password) and kept is not None
|
|
475
|
+
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
|
476
|
+
return False
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def _link(
|
|
480
|
+
connection: Connection, member: Member, *, vouched: bool, purpose: Purpose = "invite"
|
|
481
|
+
) -> Invited:
|
|
482
|
+
token = f"{INVITATION_PREFIX}{secrets.token_urlsafe(INVITATION_BYTES)}"
|
|
483
|
+
expires_at = datetime.fromtimestamp(time.time() + INVITATION_TTL_S, UTC)
|
|
484
|
+
values = {
|
|
485
|
+
"hash": fingerprint(token),
|
|
486
|
+
"member": member.id,
|
|
487
|
+
"expires_at": expires_at,
|
|
488
|
+
"vouched": vouched,
|
|
489
|
+
"purpose": purpose,
|
|
490
|
+
}
|
|
491
|
+
await connection.execute(INVITE, values)
|
|
492
|
+
return Invited(member=member, token=token, expires_at=expires_at)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def _fetch_member(
|
|
496
|
+
connection: Connection, query: sql.Composed, values: Mapping[str, object]
|
|
497
|
+
) -> Member | None:
|
|
498
|
+
row = await (await connection.execute(query, values)).fetchone()
|
|
499
|
+
return None if row is None else member_of(row)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def _spent(connection: Connection, token: str) -> Spent | None:
|
|
503
|
+
row = await (await connection.execute(SPEND, {"hash": fingerprint(token)})).fetchone()
|
|
504
|
+
if row is None:
|
|
505
|
+
return None
|
|
506
|
+
member = await _fetch_member(connection, BY_ID, {"id": row["member"]})
|
|
507
|
+
if member is None:
|
|
508
|
+
return None
|
|
509
|
+
purpose: Purpose = "reset" if row["purpose"] == "reset" else "invite"
|
|
510
|
+
return Spent(member=member, vouched=bool(row["vouched"]), purpose=purpose)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def _activated(connection: Connection, spent: Spent, hashed: str | None) -> Member | None:
|
|
514
|
+
values = {"id": spent.member.id, "password": hashed, "vouched": spent.vouched}
|
|
515
|
+
return await _fetch_member(connection, ACTIVATE, values)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
async def _password_of(connection: Connection, email: str) -> str | None:
|
|
519
|
+
row = await (await connection.execute(HASH_OF, {"email": email})).fetchone()
|
|
520
|
+
return None if row is None else row["password_hash"]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def _verified_anywhere(connection: Connection, email: str) -> bool:
|
|
524
|
+
return await (await connection.execute(VERIFIED, {"email": email})).fetchone() is not None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _written(member: Member, password: str | None) -> dict[str, object]:
|
|
528
|
+
return {
|
|
529
|
+
"id": member.id,
|
|
530
|
+
"org": member.org,
|
|
531
|
+
"email": member.email,
|
|
532
|
+
"name": member.name,
|
|
533
|
+
"role": member.role,
|
|
534
|
+
"agents": sorted(member.agents),
|
|
535
|
+
"status": member.status,
|
|
536
|
+
"password": password,
|
|
537
|
+
"production": member.production,
|
|
538
|
+
"verified": member.verified,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _folded(email: str) -> str:
|
|
543
|
+
return email.strip().lower()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The personas an agent is called by in its simulations: who each caller is and how it talks."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from psycopg.rows import DictRow
|
|
7
|
+
from psycopg.types.json import Jsonb
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import Conflict, NotFound
|
|
10
|
+
from pinecall.domain.names import Json, JsonObject
|
|
11
|
+
from pinecall.postgres.pool import Pool
|
|
12
|
+
|
|
13
|
+
NOBODY = "no persona called {name} for {agent}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TAKEN = "{agent} has a persona called {name} already"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PERSONAS = """
|
|
20
|
+
SELECT name, about, goal, style, facts, state, llm, tts, voice, accepts_when, declines_when,
|
|
21
|
+
author, set_at
|
|
22
|
+
FROM agent_personas WHERE org = %(org)s AND agent = %(agent)s
|
|
23
|
+
ORDER BY name
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PERSONA = """
|
|
28
|
+
SELECT name, about, goal, style, facts, state, llm, tts, voice, accepts_when, declines_when,
|
|
29
|
+
author, set_at
|
|
30
|
+
FROM agent_personas WHERE org = %(org)s AND agent = %(agent)s AND name = %(name)s
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# One statement, so a rename never leaves both names or neither. A `was` that is the same name,
|
|
35
|
+
# or none, deletes nothing.
|
|
36
|
+
PUT_PERSONA = """
|
|
37
|
+
WITH gone AS (
|
|
38
|
+
DELETE FROM agent_personas
|
|
39
|
+
WHERE org = %(org)s AND agent = %(agent)s AND name = %(was)s AND %(was)s <> %(name)s
|
|
40
|
+
RETURNING name
|
|
41
|
+
)
|
|
42
|
+
INSERT INTO agent_personas (org, agent, name, about, goal, style, facts, state, author, llm, tts,
|
|
43
|
+
voice, accepts_when, declines_when)
|
|
44
|
+
VALUES (%(org)s, %(agent)s, %(name)s, %(about)s, %(goal)s, %(style)s, %(facts)s, %(state)s,
|
|
45
|
+
%(author)s, %(llm)s, %(tts)s, %(voice)s, %(accepts_when)s, %(declines_when)s)
|
|
46
|
+
ON CONFLICT (org, agent, name) DO UPDATE SET
|
|
47
|
+
about = excluded.about, goal = excluded.goal, style = excluded.style,
|
|
48
|
+
facts = excluded.facts, state = excluded.state, llm = excluded.llm, tts = excluded.tts,
|
|
49
|
+
voice = excluded.voice, accepts_when = excluded.accepts_when,
|
|
50
|
+
declines_when = excluded.declines_when, author = excluded.author, set_at = now()
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
DROP_PERSONA = """
|
|
55
|
+
DELETE FROM agent_personas WHERE org = %(org)s AND agent = %(agent)s AND name = %(name)s
|
|
56
|
+
RETURNING name
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Persona:
|
|
62
|
+
"""A caller an eval plays: who they are, what they want, how they talk, what they know."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
goal: str
|
|
66
|
+
style: str
|
|
67
|
+
about: str = ""
|
|
68
|
+
facts: dict[str, str] = field(default_factory=dict[str, str])
|
|
69
|
+
state: JsonObject = field(default_factory=dict[str, Json])
|
|
70
|
+
# The vendors the caller is played on; None is the runtime's.
|
|
71
|
+
llm: str | None = None
|
|
72
|
+
tts: str | None = None
|
|
73
|
+
voice: str | None = None
|
|
74
|
+
accepts_when: str = ""
|
|
75
|
+
declines_when: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class StoredPersona:
|
|
80
|
+
"""A persona as the agent keeps it: who wrote it last, and when."""
|
|
81
|
+
|
|
82
|
+
persona: Persona
|
|
83
|
+
author: str
|
|
84
|
+
set_at: datetime
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class PersonaEdit:
|
|
89
|
+
"""Who writes a persona, and the name it had when the write renames it."""
|
|
90
|
+
|
|
91
|
+
author: str
|
|
92
|
+
was: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def put_persona(
|
|
96
|
+
pool: Pool, org: str, agent: str, written: Persona, edit: PersonaEdit
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Write one of the agent's callers whole: a new one, the same name again, or a rename."""
|
|
99
|
+
was = edit.was
|
|
100
|
+
if was is not None and was != written.name:
|
|
101
|
+
if await persona(pool, org, agent, was) is None:
|
|
102
|
+
raise NotFound(NOBODY.format(name=was, agent=agent))
|
|
103
|
+
if await persona(pool, org, agent, written.name) is not None:
|
|
104
|
+
raise Conflict(TAKEN.format(name=written.name, agent=agent))
|
|
105
|
+
values = {
|
|
106
|
+
**asdict(written),
|
|
107
|
+
"facts": Jsonb(written.facts),
|
|
108
|
+
"state": Jsonb(written.state),
|
|
109
|
+
"org": org,
|
|
110
|
+
"agent": agent,
|
|
111
|
+
"author": edit.author,
|
|
112
|
+
"was": was,
|
|
113
|
+
}
|
|
114
|
+
async with pool.connection() as connection:
|
|
115
|
+
await connection.execute(PUT_PERSONA, values)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def drop_persona(pool: Pool, org: str, agent: str, name: str) -> None:
|
|
119
|
+
"""Forget one of the agent's callers."""
|
|
120
|
+
values = {"org": org, "agent": agent, "name": name}
|
|
121
|
+
async with pool.connection() as connection:
|
|
122
|
+
dropped = await connection.execute(DROP_PERSONA, values)
|
|
123
|
+
if await dropped.fetchone() is None:
|
|
124
|
+
raise NotFound(NOBODY.format(name=name, agent=agent))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def persona(pool: Pool, org: str, agent: str, name: str) -> StoredPersona | None:
|
|
128
|
+
"""One of the agent's callers, by name."""
|
|
129
|
+
values = {"org": org, "agent": agent, "name": name}
|
|
130
|
+
async with pool.connection() as connection:
|
|
131
|
+
row = await (await connection.execute(PERSONA, values)).fetchone()
|
|
132
|
+
return None if row is None else _persona(row)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def personas_of(pool: Pool, org: str, agent: str) -> list[StoredPersona]:
|
|
136
|
+
"""The agent's callers, by name."""
|
|
137
|
+
async with pool.connection() as connection:
|
|
138
|
+
rows = await (await connection.execute(PERSONAS, {"org": org, "agent": agent})).fetchall()
|
|
139
|
+
return [_persona(row) for row in rows]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _persona(row: DictRow) -> StoredPersona:
|
|
143
|
+
author = row.pop("author")
|
|
144
|
+
set_at = row.pop("set_at")
|
|
145
|
+
return StoredPersona(persona=Persona(**row), author=author, set_at=set_at)
|