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.
Files changed (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. 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]