pinecall-runtime 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The hybrid search over one table: nearest vectors and best BM25 words, fused by rank in SQL."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from statistics import fmean
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from psycopg import sql
|
|
11
|
+
from psycopg.rows import DictRow
|
|
12
|
+
|
|
13
|
+
from pinecall.postgres.pool import Connection
|
|
14
|
+
from pinecall.retrieval.embed import halfvec
|
|
15
|
+
|
|
16
|
+
type Evidence = Literal["strong", "weak", "none"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Cormack's constant: a rank weighs 1 / (60 + rank), so cosine and BM25 are never compared.
|
|
20
|
+
RRF_K = 60
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# The cutoff of nDCG, past any k a turn is handed.
|
|
24
|
+
NDCG_AT = 10
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CANDIDATES_PER_BRANCH = 30
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Measured on a golden of real questions; data for the reader and the judge, never a gate.
|
|
31
|
+
STRONG = 0.45
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
NONE_BELOW = 0.30
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# `hnsw.iterative_scan` makes the index scan past its first 40 candidates when the scope filters
|
|
38
|
+
# them out: one tenant's rows among every tenant's would otherwise come back short, or empty.
|
|
39
|
+
ITERATIVE_SCAN = "SET LOCAL hnsw.iterative_scan = relaxed_order"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# `<@>` is BM25 negated, lower is better, and 0 when no term matched: those rows are no match.
|
|
43
|
+
HYBRID = """
|
|
44
|
+
WITH dense AS (
|
|
45
|
+
SELECT id, RANK() OVER (ORDER BY distance) AS r FROM (
|
|
46
|
+
SELECT id, embedding <=> %(q)s::halfvec AS distance FROM {table}
|
|
47
|
+
WHERE ({scope}) AND ({same_space})
|
|
48
|
+
ORDER BY embedding <=> %(q)s::halfvec
|
|
49
|
+
LIMIT %(room)s
|
|
50
|
+
) AS nearest
|
|
51
|
+
),
|
|
52
|
+
sparse AS (
|
|
53
|
+
SELECT id, RANK() OVER (ORDER BY score) AS r FROM (
|
|
54
|
+
SELECT id, score FROM (
|
|
55
|
+
SELECT id, text <@> to_bm25query(%(words)s, {text_index}) AS score FROM {table}
|
|
56
|
+
WHERE ({scope})
|
|
57
|
+
) AS scored
|
|
58
|
+
WHERE score < 0
|
|
59
|
+
ORDER BY score
|
|
60
|
+
LIMIT %(room)s
|
|
61
|
+
) AS worded
|
|
62
|
+
),
|
|
63
|
+
fusion AS (
|
|
64
|
+
SELECT coalesce(dense.id, sparse.id) AS id,
|
|
65
|
+
(coalesce(1.0 / ({k} + dense.r), 0) + coalesce(1.0 / ({k} + sparse.r), 0))::float8 AS fused
|
|
66
|
+
FROM dense FULL OUTER JOIN sparse ON dense.id = sparse.id
|
|
67
|
+
)
|
|
68
|
+
SELECT fusion.id::text AS hit_id, fusion.fused AS hit_fused,
|
|
69
|
+
1 - (found.embedding <=> %(q)s::halfvec) AS hit_cosine, {columns}
|
|
70
|
+
FROM fusion JOIN {table} AS found ON found.id = fusion.id
|
|
71
|
+
ORDER BY fusion.fused DESC, fusion.id
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class SearchedTable:
|
|
77
|
+
"""What differs between the tables searched: which rows a query sees and what a hit carries."""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
# to_bm25query reads its statistics by the index's name.
|
|
81
|
+
text_index: str
|
|
82
|
+
# A WHERE clause over the table, its placeholders named: `q`, `words` and `room` are taken.
|
|
83
|
+
scope: sql.Composable
|
|
84
|
+
params: Mapping[str, object]
|
|
85
|
+
columns: tuple[str, ...]
|
|
86
|
+
# Added to the scope of the vector branch alone: a row embedded by another model is found by
|
|
87
|
+
# its words only.
|
|
88
|
+
same_space: sql.Composable = field(default_factory=lambda: sql.SQL("true"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Hit:
|
|
93
|
+
"""One row found: its id, its fused score, its cosine to the query, and the columns asked."""
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
fused: float
|
|
97
|
+
cosine: float
|
|
98
|
+
row: DictRow
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Figures:
|
|
103
|
+
"""Recall at k, and nDCG at 10."""
|
|
104
|
+
|
|
105
|
+
recall_at_k: float
|
|
106
|
+
ndcg_at_10: float
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def hybrid(
|
|
110
|
+
connection: Connection, table: SearchedTable, *, vector: list[float], words: str, room: int
|
|
111
|
+
) -> list[Hit]:
|
|
112
|
+
"""Up to `room` rows from each branch, fused by reciprocal rank, the best first."""
|
|
113
|
+
query = sql.SQL(HYBRID).format(
|
|
114
|
+
table=sql.Identifier(table.name),
|
|
115
|
+
text_index=sql.Literal(table.text_index),
|
|
116
|
+
scope=table.scope,
|
|
117
|
+
same_space=table.same_space,
|
|
118
|
+
k=sql.Literal(RRF_K),
|
|
119
|
+
columns=sql.SQL(", ").join(sql.Identifier("found", column) for column in table.columns),
|
|
120
|
+
)
|
|
121
|
+
params = {**table.params, "q": halfvec(vector), "words": words, "room": room}
|
|
122
|
+
async with connection.transaction():
|
|
123
|
+
await connection.execute(ITERATIVE_SCAN)
|
|
124
|
+
rows = await (await connection.execute(query, params)).fetchall()
|
|
125
|
+
return [
|
|
126
|
+
Hit(
|
|
127
|
+
id=row["hit_id"],
|
|
128
|
+
fused=row["hit_fused"],
|
|
129
|
+
cosine=row["hit_cosine"],
|
|
130
|
+
row={column: row[column] for column in table.columns},
|
|
131
|
+
)
|
|
132
|
+
for row in rows
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# A fused score means nothing across queries: relative to the best, the top is always 1.0.
|
|
137
|
+
def relative_to_the_best(hits: list[Hit]) -> list[Hit]:
|
|
138
|
+
"""Each hit's fused score divided by the best, the best first and ties by id."""
|
|
139
|
+
best = max((hit.fused for hit in hits), default=0.0)
|
|
140
|
+
ordered = sorted(hits, key=lambda hit: (-hit.fused, hit.id))
|
|
141
|
+
return [dataclasses.replace(hit, fused=hit.fused / best if best else 0.0) for hit in ordered]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def top_cosine(hits: list[Hit]) -> float:
|
|
145
|
+
"""The best cosine among the hits; 0.0 when there are none."""
|
|
146
|
+
return max((hit.cosine for hit in hits), default=0.0)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def evidence_of(cosine: float) -> Evidence:
|
|
150
|
+
"""How strongly the best cosine says the question is answered here."""
|
|
151
|
+
if cosine >= STRONG:
|
|
152
|
+
return "strong"
|
|
153
|
+
if cosine < NONE_BELOW:
|
|
154
|
+
return "none"
|
|
155
|
+
return "weak"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# nDCG is normalised by the ideal order for as many answers as the question wants.
|
|
159
|
+
def figures(ranks: Sequence[Sequence[int | None]]) -> Figures:
|
|
160
|
+
"""The two figures over the ranks of each question's wanted answers; None is not found."""
|
|
161
|
+
if not ranks:
|
|
162
|
+
return Figures(recall_at_k=0.0, ndcg_at_10=0.0)
|
|
163
|
+
return Figures(
|
|
164
|
+
recall_at_k=fmean(_share_found(question) for question in ranks),
|
|
165
|
+
ndcg_at_10=fmean(_normalised(question) for question in ranks),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _share_found(ranks: Sequence[int | None]) -> float:
|
|
170
|
+
if not ranks:
|
|
171
|
+
return 0.0
|
|
172
|
+
return sum(rank is not None for rank in ranks) / len(ranks)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _normalised(ranks: Sequence[int | None]) -> float:
|
|
176
|
+
if not ranks:
|
|
177
|
+
return 0.0
|
|
178
|
+
ideal = sum(_discounted(place) for place in range(1, min(len(ranks), NDCG_AT) + 1))
|
|
179
|
+
return sum(_discounted(rank) for rank in ranks) / ideal
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Past the tenth the model never saw it.
|
|
183
|
+
def _discounted(rank: int | None) -> float:
|
|
184
|
+
if rank is None or rank > NDCG_AT:
|
|
185
|
+
return 0.0
|
|
186
|
+
return 1.0 / math.log2(rank + 1)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""A found chunk handed back as its section, and which sections a search hands back."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.scope import Scope
|
|
6
|
+
from pinecall.postgres.pool import Connection
|
|
7
|
+
|
|
8
|
+
# A found chunk comes back as its section: the chunks of its heading this many either side of it.
|
|
9
|
+
SECTION_SPAN = 2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# The best pages give every section that was found in them; every page after them gives one.
|
|
13
|
+
PAGES_GIVING_ALL = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# The chunks around each found one, under the same heading of the same file, in the file's order.
|
|
17
|
+
SECTIONS = """
|
|
18
|
+
SELECT wanted.at, chunk.text
|
|
19
|
+
FROM unnest(%(ats)s::integer[], %(bases)s::text[], %(holders)s::text[], %(paths)s::text[],
|
|
20
|
+
%(headings)s::text[], %(firsts)s::integer[], %(lasts)s::integer[])
|
|
21
|
+
AS wanted (at, base, holder, path, heading, first, last)
|
|
22
|
+
JOIN knowledge_chunks AS chunk ON chunk.org = %(org)s AND chunk.env = %(env)s
|
|
23
|
+
AND chunk.base = wanted.base AND chunk.holder = wanted.holder AND chunk.path = wanted.path
|
|
24
|
+
AND chunk.heading IS NOT DISTINCT FROM wanted.heading
|
|
25
|
+
AND chunk.ordinal BETWEEN wanted.first AND wanted.last
|
|
26
|
+
ORDER BY wanted.at, chunk.ordinal
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sections_chosen(rows: Sequence[Mapping[str, object]], k: int) -> list[int]:
|
|
31
|
+
"""Which hits come back, best first: one per section, the best pages all theirs, k at most."""
|
|
32
|
+
chosen: list[int] = []
|
|
33
|
+
sections: set[tuple[object, object, object]] = set()
|
|
34
|
+
pages: list[tuple[object, object]] = []
|
|
35
|
+
for at, row in enumerate(rows):
|
|
36
|
+
page = (row["base"], row["path"])
|
|
37
|
+
section = (row["base"], row["path"], row["heading"])
|
|
38
|
+
if section in sections or (page in pages and pages.index(page) >= PAGES_GIVING_ALL):
|
|
39
|
+
continue
|
|
40
|
+
if page not in pages:
|
|
41
|
+
pages.append(page)
|
|
42
|
+
sections.add(section)
|
|
43
|
+
chosen.append(at)
|
|
44
|
+
if len(chosen) == k:
|
|
45
|
+
break
|
|
46
|
+
return chosen
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def section_texts(
|
|
50
|
+
connection: Connection, scope: Scope, rows: Sequence[Mapping[str, object]]
|
|
51
|
+
) -> dict[int, list[str]]:
|
|
52
|
+
"""Each row's section as its chunks' indexed texts in the file's order, by the row's place."""
|
|
53
|
+
if not rows:
|
|
54
|
+
return {}
|
|
55
|
+
ordinals = [int(str(row["ordinal"])) for row in rows]
|
|
56
|
+
named = {
|
|
57
|
+
"org": scope.org,
|
|
58
|
+
"env": scope.env,
|
|
59
|
+
"ats": list(range(len(rows))),
|
|
60
|
+
"bases": [row["base"] for row in rows],
|
|
61
|
+
"holders": [row["holder"] for row in rows],
|
|
62
|
+
"paths": [row["path"] for row in rows],
|
|
63
|
+
"headings": [row["heading"] for row in rows],
|
|
64
|
+
"firsts": [ordinal - SECTION_SPAN for ordinal in ordinals],
|
|
65
|
+
"lasts": [ordinal + SECTION_SPAN for ordinal in ordinals],
|
|
66
|
+
}
|
|
67
|
+
texts: dict[int, list[str]] = {}
|
|
68
|
+
for row in await (await connection.execute(SECTIONS, named)).fetchall():
|
|
69
|
+
texts.setdefault(int(row["at"]), []).append(str(row["text"]))
|
|
70
|
+
return texts
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""The box's embedder over HTTP, OpenAI-shaped or contextual: batched, retried, validated."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import struct
|
|
10
|
+
from array import array
|
|
11
|
+
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
15
|
+
|
|
16
|
+
from pinecall.domain.errors import EmbedderUnreachable, NotAvailable, WrongWidth
|
|
17
|
+
from pinecall.domain.names import Credentials, Json
|
|
18
|
+
from pinecall.providers.catalog import Embedding
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
type Sleep = Callable[[float], Awaitable[None]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# The cutter measures a chunk with the same estimate, so a chunk that fits its cap fits a window.
|
|
26
|
+
TOKENS_PER_WORD = 1.3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# The contextual endpoint counts a window's chunks together against a 32 768-token context; the
|
|
30
|
+
# margin is for the estimate.
|
|
31
|
+
WINDOW_TOKENS = 24_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
REQUEST_TOKENS = 100_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Only a refusal about size is split: halving a credential refusal fails it twice.
|
|
38
|
+
TOO_BIG = re.compile(
|
|
39
|
+
r"exceeds maximum|too many tokens|maximum context length|reduce the length|"
|
|
40
|
+
r"input is too large|max_tokens_per_request",
|
|
41
|
+
re.IGNORECASE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
MAX_SPLITS = 5
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
RETRIES = 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# The request was fine and the server was not: a timeout, a lock, a rate limit, or a 5xx.
|
|
52
|
+
RETRYABLE = frozenset({408, 409, 429})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
FIRST_WAIT_S = 0.5
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
LONGEST_WAIT_S = 8.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# A server that asks for longer is misconfigured; its hour is not waited.
|
|
62
|
+
LONGEST_ASKED_S = 60.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# A push waits for a contextual window of 24 000 tokens, which outlasts any client default; a
|
|
66
|
+
# query keeps the client's own, and the turn's budget bounds it.
|
|
67
|
+
PUSH_TIMEOUT = httpx.Timeout(connect=5.0, read=120.0, write=5.0, pool=5.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Refusal(BaseModel):
|
|
71
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
72
|
+
|
|
73
|
+
message: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Answer(BaseModel):
|
|
77
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
78
|
+
|
|
79
|
+
error: _Refusal | str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Row(BaseModel):
|
|
83
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
84
|
+
|
|
85
|
+
index: int | None = None
|
|
86
|
+
embedding: list[float] | str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Rows(BaseModel):
|
|
90
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
91
|
+
|
|
92
|
+
data: list[_Row]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Documents(BaseModel):
|
|
96
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
97
|
+
|
|
98
|
+
data: list[_Rows]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Embedder:
|
|
102
|
+
"""The embedder the box's `embedding` names, on the box's key, over one HTTP client."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
embedding: Embedding,
|
|
107
|
+
key: Credentials,
|
|
108
|
+
client: httpx.AsyncClient,
|
|
109
|
+
*,
|
|
110
|
+
sleep: Sleep = asyncio.sleep,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Hold the client; nothing is asked of the vendor until the first text."""
|
|
113
|
+
self.embedding = embedding
|
|
114
|
+
path = "embeddings" if embedding.shape == "embeddings" else "contextualizedembeddings"
|
|
115
|
+
self._url = f"{embedding.url.rstrip('/')}/{path}"
|
|
116
|
+
self._headers = {"Authorization": f"Bearer {_key_of(embedding.vendor, key)}"}
|
|
117
|
+
self._client = client
|
|
118
|
+
self._sleep = sleep
|
|
119
|
+
|
|
120
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
121
|
+
"""One unit vector per text, in order; on `contextual` each text is a one-chunk document."""
|
|
122
|
+
waits = self._client.timeout
|
|
123
|
+
if self.embedding.shape == "contextual":
|
|
124
|
+
return [(await self._sent([text], waits))[0] for text in texts]
|
|
125
|
+
return [
|
|
126
|
+
vector
|
|
127
|
+
for batch in _windows(texts, REQUEST_TOKENS)
|
|
128
|
+
for vector in await self._sent(batch, waits)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
async def embed_documents(self, documents: list[list[str]]) -> list[list[list[float]]]:
|
|
132
|
+
"""One list of unit vectors per document, one per chunk, in order."""
|
|
133
|
+
if self.embedding.shape == "contextual":
|
|
134
|
+
return [
|
|
135
|
+
[
|
|
136
|
+
vector
|
|
137
|
+
for window in _windows(chunks, WINDOW_TOKENS)
|
|
138
|
+
for vector in await self._sent(window, PUSH_TIMEOUT)
|
|
139
|
+
]
|
|
140
|
+
for chunks in documents
|
|
141
|
+
]
|
|
142
|
+
every = [chunk for chunks in documents for chunk in chunks]
|
|
143
|
+
flat = [
|
|
144
|
+
vector
|
|
145
|
+
for batch in _windows(every, REQUEST_TOKENS)
|
|
146
|
+
for vector in await self._sent(batch, PUSH_TIMEOUT)
|
|
147
|
+
]
|
|
148
|
+
cut: list[list[list[float]]] = []
|
|
149
|
+
for chunks in documents:
|
|
150
|
+
cut.append(flat[: len(chunks)])
|
|
151
|
+
flat = flat[len(chunks) :]
|
|
152
|
+
return cut
|
|
153
|
+
|
|
154
|
+
# A split keeps the order: the first half's vectors, then the second's.
|
|
155
|
+
async def _sent(
|
|
156
|
+
self, texts: list[str], waits: httpx.Timeout, *, depth: int = 0
|
|
157
|
+
) -> list[list[float]]:
|
|
158
|
+
try:
|
|
159
|
+
return self._vectors(await self._posted(texts, waits), len(texts))
|
|
160
|
+
except EmbedderUnreachable as refused:
|
|
161
|
+
if depth >= MAX_SPLITS or len(texts) == 1 or not TOO_BIG.search(refused.words):
|
|
162
|
+
raise
|
|
163
|
+
middle = len(texts) // 2
|
|
164
|
+
first = await self._sent(texts[:middle], waits, depth=depth + 1)
|
|
165
|
+
return [*first, *await self._sent(texts[middle:], waits, depth=depth + 1)]
|
|
166
|
+
|
|
167
|
+
async def _posted(self, texts: list[str], waits: httpx.Timeout) -> bytes:
|
|
168
|
+
body = self._body(texts)
|
|
169
|
+
attempt = 0
|
|
170
|
+
while True:
|
|
171
|
+
try:
|
|
172
|
+
answer = await self._client.post(
|
|
173
|
+
self._url, json=body, headers=self._headers, timeout=waits
|
|
174
|
+
)
|
|
175
|
+
except httpx.TransportError as failed:
|
|
176
|
+
if attempt == RETRIES:
|
|
177
|
+
raise self._unreachable(str(failed) or type(failed).__name__) from failed
|
|
178
|
+
await self._sleep(_wait(attempt, {}))
|
|
179
|
+
attempt += 1
|
|
180
|
+
continue
|
|
181
|
+
retryable = answer.status_code in RETRYABLE or answer.is_server_error
|
|
182
|
+
if retryable and attempt < RETRIES:
|
|
183
|
+
await self._sleep(_wait(attempt, answer.headers))
|
|
184
|
+
attempt += 1
|
|
185
|
+
continue
|
|
186
|
+
words = _refused_by(answer)
|
|
187
|
+
if words is not None:
|
|
188
|
+
raise self._unreachable(words)
|
|
189
|
+
return answer.content
|
|
190
|
+
|
|
191
|
+
# The request names the encoding: the reply is decoded as asked, never guessed from its bytes.
|
|
192
|
+
def _body(self, texts: list[str]) -> dict[str, Json]:
|
|
193
|
+
wanted = self.embedding
|
|
194
|
+
if wanted.shape == "contextual":
|
|
195
|
+
return {
|
|
196
|
+
"model": wanted.model,
|
|
197
|
+
"input": [list[Json](texts)],
|
|
198
|
+
"encoding_format": "base64_int8",
|
|
199
|
+
"dimensions": wanted.dimensions,
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
"model": wanted.model,
|
|
203
|
+
"input": list[Json](texts),
|
|
204
|
+
"encoding_format": "float",
|
|
205
|
+
"dimensions": wanted.dimensions,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def _vectors(self, content: bytes, sent: int) -> list[list[float]]:
|
|
209
|
+
try:
|
|
210
|
+
rows = self._rows(content)
|
|
211
|
+
except ValidationError as unread:
|
|
212
|
+
raise self._unreachable(f"a reply that is not its vectors: {unread}") from unread
|
|
213
|
+
if len(rows) != sent:
|
|
214
|
+
raise self._unreachable(f"{len(rows)} vectors for {sent} chunks")
|
|
215
|
+
vectors = [self._decoded(row.embedding) for row in self._in_order(rows)]
|
|
216
|
+
widths = sorted({len(vector) for vector in vectors})
|
|
217
|
+
if widths and widths != [self.embedding.dimensions]:
|
|
218
|
+
answered = " and ".join(str(width) for width in widths)
|
|
219
|
+
raise WrongWidth(
|
|
220
|
+
f"{self.embedding.model} answers {answered}-wide vectors; every vector column "
|
|
221
|
+
f"holds {self.embedding.dimensions}"
|
|
222
|
+
)
|
|
223
|
+
return [_unit(vector) for vector in vectors]
|
|
224
|
+
|
|
225
|
+
def _rows(self, content: bytes) -> list[_Row]:
|
|
226
|
+
if self.embedding.shape == "embeddings":
|
|
227
|
+
return _Rows.model_validate_json(content).data
|
|
228
|
+
documents = _Documents.model_validate_json(content).data
|
|
229
|
+
if len(documents) != 1:
|
|
230
|
+
raise self._unreachable(f"{len(documents)} documents for the one it was sent")
|
|
231
|
+
return documents[0].data
|
|
232
|
+
|
|
233
|
+
# Sorting alone is not enough: indexes 0, 0, 2 sort cleanly and give two chunks one vector.
|
|
234
|
+
def _in_order(self, rows: list[_Row]) -> list[_Row]:
|
|
235
|
+
if any(row.index is None for row in rows):
|
|
236
|
+
return rows
|
|
237
|
+
ordered = sorted(rows, key=lambda row: row.index or 0)
|
|
238
|
+
indexes = [row.index for row in ordered]
|
|
239
|
+
if indexes != list(range(len(rows))):
|
|
240
|
+
raise self._unreachable(f"a reply indexed {indexes}, not 0 to {len(rows) - 1}")
|
|
241
|
+
return ordered
|
|
242
|
+
|
|
243
|
+
def _decoded(self, embedding: list[float] | str) -> list[float]:
|
|
244
|
+
if self.embedding.shape == "embeddings":
|
|
245
|
+
if isinstance(embedding, str):
|
|
246
|
+
raise self._unreachable("a base64 vector where floats were asked for")
|
|
247
|
+
return embedding
|
|
248
|
+
if not isinstance(embedding, str):
|
|
249
|
+
raise self._unreachable("floats where base64_int8 was asked for")
|
|
250
|
+
# Signed: -1 travels as 0xff, and read unsigned it would be 255.
|
|
251
|
+
values = array("b")
|
|
252
|
+
values.frombytes(base64.b64decode(embedding))
|
|
253
|
+
return [float(value) for value in values]
|
|
254
|
+
|
|
255
|
+
def _unreachable(self, words: str) -> EmbedderUnreachable:
|
|
256
|
+
logger.warning("the embedder at %s did not answer: %s", self._url, words)
|
|
257
|
+
return EmbedderUnreachable(self.embedding.vendor, self._url, words)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def estimated_tokens(text: str) -> int:
|
|
261
|
+
"""The tokens a text costs, estimated as its words times 1.3; no tokenizer is loaded."""
|
|
262
|
+
return round(len(text.split()) * TOKENS_PER_WORD)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# pgvector parses the text and the SQL casts it (`::halfvec`), so the driver needs no vector type.
|
|
266
|
+
def halfvec(vector: list[float]) -> str:
|
|
267
|
+
"""The vector as pgvector's text, each value rounded to IEEE half precision as the column is."""
|
|
268
|
+
return "[" + ",".join(str(_half(value)) for value in vector) + "]"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _half(value: float) -> float:
|
|
272
|
+
rounded: float = struct.unpack("<e", struct.pack("<e", value))[0]
|
|
273
|
+
return rounded
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# A text over the budget goes alone: the vendor refuses it or cuts it, and a refusal is split.
|
|
277
|
+
def _windows(texts: list[str], budget: int) -> Iterator[list[str]]:
|
|
278
|
+
window: list[str] = []
|
|
279
|
+
tokens = 0
|
|
280
|
+
for text in texts:
|
|
281
|
+
cost = estimated_tokens(text)
|
|
282
|
+
if window and tokens + cost > budget:
|
|
283
|
+
yield window
|
|
284
|
+
window, tokens = [], 0
|
|
285
|
+
window.append(text)
|
|
286
|
+
tokens += cost
|
|
287
|
+
if window:
|
|
288
|
+
yield window
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _key_of(vendor: str, key: Credentials) -> str:
|
|
292
|
+
if isinstance(key, str):
|
|
293
|
+
return key
|
|
294
|
+
named = key.get("api_key")
|
|
295
|
+
if isinstance(named, str):
|
|
296
|
+
return named
|
|
297
|
+
raise NotAvailable(f"the box's credentials for {vendor} hold no api_key")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# A gateway may refuse with a 200 and an `error` body: the words are read whatever the status.
|
|
301
|
+
def _refused_by(answer: httpx.Response) -> str | None:
|
|
302
|
+
try:
|
|
303
|
+
payload = _Answer.model_validate_json(answer.content).error
|
|
304
|
+
except ValidationError:
|
|
305
|
+
payload = None
|
|
306
|
+
if isinstance(payload, _Refusal) and payload.message:
|
|
307
|
+
return payload.message
|
|
308
|
+
if isinstance(payload, str) and payload:
|
|
309
|
+
return payload
|
|
310
|
+
if answer.is_error or payload is not None:
|
|
311
|
+
return f"HTTP {answer.status_code}"
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _wait(attempt: int, headers: Mapping[str, str]) -> float:
|
|
316
|
+
params = _retry_after(headers)
|
|
317
|
+
if params is not None and 0 < params <= LONGEST_ASKED_S:
|
|
318
|
+
return params
|
|
319
|
+
backoff = min(FIRST_WAIT_S * 2**attempt, LONGEST_WAIT_S)
|
|
320
|
+
# The jitter only shortens the wait, so the longest wait stays the longest.
|
|
321
|
+
return backoff * (1 - 0.25 * secrets.SystemRandom().random())
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# `retry-after-ms` first: a server that says 1500 ms is not rounded up to two seconds.
|
|
325
|
+
def _retry_after(headers: Mapping[str, str]) -> float | None:
|
|
326
|
+
for name, scale in (("retry-after-ms", 0.001), ("retry-after", 1.0)):
|
|
327
|
+
raw = headers.get(name)
|
|
328
|
+
if raw is None:
|
|
329
|
+
continue
|
|
330
|
+
try:
|
|
331
|
+
return float(raw) * scale
|
|
332
|
+
except ValueError:
|
|
333
|
+
continue
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Both shapes answer unnormalised vectors; a zero vector has no direction to keep.
|
|
338
|
+
def _unit(vector: list[float]) -> list[float]:
|
|
339
|
+
length = math.sqrt(sum(value * value for value in vector))
|
|
340
|
+
return vector if length == 0 else [value / length for value in vector]
|