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
@@ -0,0 +1,659 @@
1
+ """Knowledge bases: the Markdown cutter, bases and files in Postgres, the search, the golden."""
2
+
3
+ import hashlib
4
+ import re
5
+ from collections.abc import Iterator, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import LiteralString
8
+
9
+ from psycopg import sql
10
+ from psycopg.rows import DictRow
11
+
12
+ from pinecall.domain.agent import KnowledgeFile
13
+ from pinecall.domain.errors import WrongModel
14
+ from pinecall.domain.names import Env
15
+ from pinecall.domain.scope import Scope
16
+ from pinecall.postgres.pool import Pool, unbounded
17
+ from pinecall.retrieval._search import (
18
+ CANDIDATES_PER_BRANCH,
19
+ Evidence,
20
+ Figures,
21
+ SearchedTable,
22
+ evidence_of,
23
+ figures,
24
+ hybrid,
25
+ relative_to_the_best,
26
+ top_cosine,
27
+ )
28
+ from pinecall.retrieval._sections import section_texts, sections_chosen
29
+ from pinecall.retrieval.embed import Embedder, estimated_tokens, halfvec
30
+
31
+ # Small enough for eight chunks a turn, large enough for a whole tariff table.
32
+ CHUNK_TOKENS = 350
33
+
34
+
35
+ # U+203A, the single right-pointing angle quote the stored heading paths are joined with.
36
+ HEADING_SEPARATOR = " \u203a "
37
+
38
+
39
+ # Both indexes read the heading path above the body, so a heading's words are searchable.
40
+ HEADING_JOINT = "\n\n"
41
+
42
+
43
+ # Only levels 1 to 3 cut a file; a deeper heading is body.
44
+ A_HEADING = re.compile(r"^(#{1,3})\s+(.+?)\s*$")
45
+
46
+
47
+ # A line inside a code fence is never a heading: `# install` in a shell block is a comment.
48
+ A_FENCE = re.compile(r"^\s*(```|~~~)")
49
+
50
+
51
+ A_PARAGRAPH_BREAK = re.compile(r"\n\s*\n")
52
+
53
+
54
+ A_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
55
+
56
+
57
+ # Front matter is metadata: left in, a scraped site's became a chunk per page.
58
+ FRONT_MATTER = "---\n"
59
+
60
+
61
+ A_CLOSING_RULE = re.compile(r"^---[ \t]*$", re.MULTILINE)
62
+
63
+
64
+ TEXT_INDEX = "knowledge_chunks_text_bm25"
65
+
66
+
67
+ # '' sorts before any member id, so `holder DESC` puts the holder's own copy first: a read falls
68
+ # back to the org's copy, a write never does.
69
+ BASES = """
70
+ SELECT DISTINCT ON (base) base, chunks, model, extract(epoch FROM pushed_at)::float8 AS pushed_at
71
+ FROM knowledge_bases
72
+ WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '')
73
+ ORDER BY base, holder DESC
74
+ """
75
+
76
+
77
+ COPY = """
78
+ WITH copy AS (
79
+ SELECT holder FROM knowledge_bases
80
+ WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND base = %(base)s
81
+ ORDER BY holder DESC LIMIT 1
82
+ )
83
+ SELECT file.path, length(file.text) AS chars, file.chunks, file.sha256, file.text,
84
+ extract(epoch FROM file.pushed_at)::float8 AS pushed_at
85
+ FROM copy LEFT JOIN knowledge_files AS file
86
+ ON file.org = %(org)s AND file.env = %(env)s AND file.holder = copy.holder
87
+ AND file.base = %(base)s AND file.path = coalesce(%(path)s::text, file.path)
88
+ ORDER BY file.path
89
+ """
90
+
91
+
92
+ OWN = """
93
+ SELECT base.model, file.path, file.sha256
94
+ FROM knowledge_bases AS base LEFT JOIN knowledge_files AS file USING (org, env, holder, base)
95
+ WHERE base.org = %(org)s AND base.env = %(env)s AND base.holder = %(holder)s
96
+ AND base.base = %(base)s
97
+ """
98
+
99
+
100
+ # One statement, so a push lands whole or not at all. The subquery reads the rows as they were
101
+ # before the statement: the chunks it removes. `keep` null keeps every other file.
102
+ WRITE = """
103
+ WITH based AS (
104
+ INSERT INTO knowledge_bases (org, env, holder, base, model, dimensions, chunks, pushed_at)
105
+ VALUES (%(org)s, %(env)s, %(holder)s, %(base)s, %(model)s, %(dimensions)s, %(added)s,
106
+ to_timestamp(%(at)s))
107
+ ON CONFLICT (org, env, holder, base) DO UPDATE
108
+ SET model = excluded.model, dimensions = excluded.dimensions, pushed_at = excluded.pushed_at,
109
+ chunks = knowledge_bases.chunks + excluded.chunks - (
110
+ SELECT count(*) FROM knowledge_chunks AS gone
111
+ WHERE gone.org = %(org)s AND gone.env = %(env)s AND gone.holder = %(holder)s
112
+ AND gone.base = %(base)s
113
+ AND (gone.path <> ALL (coalesce(%(keep)s::text[], ARRAY[gone.path]))
114
+ OR gone.path = ANY (%(changed)s::text[]))
115
+ )
116
+ RETURNING base
117
+ ), replaced AS (
118
+ DELETE FROM knowledge_chunks
119
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
120
+ AND (path <> ALL (coalesce(%(keep)s::text[], ARRAY[path]))
121
+ OR path = ANY (%(changed)s::text[]))
122
+ ), forgotten AS (
123
+ DELETE FROM knowledge_files
124
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
125
+ AND path <> ALL (coalesce(%(keep)s::text[], ARRAY[path]))
126
+ ), filed AS (
127
+ INSERT INTO knowledge_files (org, env, holder, base, path, text, chunks, sha256, pushed_at)
128
+ SELECT %(org)s, %(env)s, %(holder)s, %(base)s, file.path, file.text, file.chunks,
129
+ file.sha256, to_timestamp(%(at)s)
130
+ FROM unnest(%(changed)s::text[], %(texts)s::text[], %(counts)s::integer[],
131
+ %(hashes)s::text[]) AS file (path, text, chunks, sha256)
132
+ ON CONFLICT (org, env, holder, base, path) DO UPDATE
133
+ SET text = excluded.text, chunks = excluded.chunks, sha256 = excluded.sha256,
134
+ pushed_at = excluded.pushed_at
135
+ )
136
+ INSERT INTO knowledge_chunks (org, env, holder, base, path, heading, ordinal, text, embedding)
137
+ SELECT %(org)s, %(env)s, %(holder)s, %(base)s, chunk.path, chunk.heading, chunk.ordinal,
138
+ chunk.text, chunk.embedding::halfvec
139
+ FROM unnest(%(paths)s::text[], %(headings)s::text[], %(ordinals)s::integer[],
140
+ %(indexed)s::text[], %(vectors)s::text[]) AS chunk (path, heading, ordinal, text, embedding)
141
+ """
142
+
143
+
144
+ # Chunks cascade from the base.
145
+ DROP = """
146
+ DELETE FROM knowledge_bases
147
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
148
+ RETURNING base
149
+ """
150
+
151
+
152
+ # The count reads the chunks as they were before the statement: the ones it removes.
153
+ DROP_FILE = """
154
+ WITH gone AS (
155
+ DELETE FROM knowledge_files
156
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
157
+ AND path = %(path)s
158
+ RETURNING path
159
+ ), cut AS (
160
+ DELETE FROM knowledge_chunks
161
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
162
+ AND path IN (SELECT path FROM gone)
163
+ ), counted AS (
164
+ UPDATE knowledge_bases SET chunks = chunks - (
165
+ SELECT count(*) FROM knowledge_chunks
166
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
167
+ AND path IN (SELECT path FROM gone)
168
+ )
169
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
170
+ )
171
+ SELECT count(*) AS gone FROM gone
172
+ """
173
+
174
+
175
+ # A second statement: a row cannot be updated and deleted in the same one.
176
+ DROP_AN_EMPTY_BASE = """
177
+ DELETE FROM knowledge_bases AS base
178
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
179
+ AND NOT EXISTS (
180
+ SELECT 1 FROM knowledge_files AS file
181
+ WHERE file.org = base.org AND file.env = base.env AND file.holder = base.holder
182
+ AND file.base = base.base
183
+ )
184
+ """
185
+
186
+
187
+ # Every holder's rows count: they are the org's, and they fill this world's disk.
188
+ KEPT = """
189
+ SELECT coalesce(sum(chunks), 0)::integer AS kept FROM knowledge_bases
190
+ WHERE org = %(org)s AND env = %(env)s
191
+ """
192
+
193
+
194
+ HELD = """
195
+ SELECT count(*)::integer AS held FROM knowledge_chunks
196
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
197
+ AND path = coalesce(%(path)s::text, path)
198
+ """
199
+
200
+
201
+ # Each base's copy is the holder's own, else the org's: the one its model is checked against.
202
+ COPIES = """
203
+ SELECT DISTINCT ON (base) base, holder, model FROM knowledge_bases
204
+ WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND base = ANY (%(bases)s)
205
+ ORDER BY base, holder DESC
206
+ """
207
+
208
+
209
+ SCOPE: LiteralString = """
210
+ org = %(org)s AND env = %(env)s AND (base, holder) IN (
211
+ SELECT copy.base, copy.holder FROM unnest(%(bases)s::text[], %(holders)s::text[])
212
+ AS copy (base, holder)
213
+ )
214
+ """
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class Piece:
219
+ """One chunk of a file before it is embedded: its heading path and its body."""
220
+
221
+ path: str
222
+ heading: str | None
223
+ ordinal: int
224
+ text: str
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class Cut:
229
+ """A file as a push writes it: its text, the hash of it, and its pieces."""
230
+
231
+ path: str
232
+ text: str
233
+ sha256: str
234
+ pieces: tuple[Piece, ...]
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class Base:
239
+ """One base as listed: its size, the model that wrote its vectors, when it was pushed."""
240
+
241
+ base: str
242
+ chunks: int
243
+ model: str
244
+ pushed_at: float
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class File:
249
+ """One file of a base; its text only when the file is read alone."""
250
+
251
+ path: str
252
+ chars: int
253
+ chunks: int
254
+ pushed_at: float
255
+ sha256: str
256
+ text: str | None = None
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class Push:
261
+ """What a push writes into a base: the files as cut, and when it was pushed."""
262
+
263
+ base: str
264
+ files: tuple[Cut, ...]
265
+ at: float
266
+
267
+ @property
268
+ def chunks(self) -> int:
269
+ """How many chunks the files were cut into."""
270
+ return sum(len(item.pieces) for item in self.files)
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class _Own:
275
+ model: str
276
+ hashes: dict[str, str]
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class SearchQuery:
281
+ """What a search asks: the words, how many chunks at most, and each base with its floor."""
282
+
283
+ query: str
284
+ k: int
285
+ # A chunk's fused score must reach its own base's floor; 0.0 is none.
286
+ bases: Mapping[str, float]
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class Found:
291
+ """One chunk found: where it is, its body, its score in this search, its cosine."""
292
+
293
+ id: str
294
+ base: str
295
+ path: str
296
+ heading: str | None
297
+ text: str
298
+ score: float
299
+ cosine: float
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class Searched:
304
+ """What a search found, and how strongly its best cosine says the answer is there."""
305
+
306
+ found: list[Found]
307
+ evidence: Evidence
308
+ model: str
309
+
310
+
311
+ @dataclass(frozen=True)
312
+ class Question:
313
+ """A golden question and the heading path that should answer it."""
314
+
315
+ asks: str
316
+ expects: str
317
+
318
+
319
+ @dataclass(frozen=True)
320
+ class Answered:
321
+ """A question and the chunks the search handed back, best first."""
322
+
323
+ question: Question
324
+ found: list[Found]
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class Score:
329
+ """A golden run: how many questions, at which k, its two figures, and the questions missed."""
330
+
331
+ questions: int
332
+ k: int
333
+ figures: Figures
334
+ misses: list[Answered]
335
+
336
+
337
+ def chunks_of(path: str, text: str) -> list[Piece]:
338
+ """The file cut at its headings, each piece under the cap, in file order."""
339
+ pieces: list[Piece] = []
340
+ for heading, body in _sections_of(_without_front_matter(text)):
341
+ for group in _under_the_cap(body, heading):
342
+ pieces.append(Piece(path, heading, len(pieces), group))
343
+ return pieces
344
+
345
+
346
+ def cut(file: KnowledgeFile) -> Cut:
347
+ """The file cut once, with the hash a later push compares against."""
348
+ digest = hashlib.sha256(file.text.encode("utf-8")).hexdigest()
349
+ return Cut(file.path, file.text, digest, tuple(chunks_of(file.path, file.text)))
350
+
351
+
352
+ def indexed_text(piece: Piece) -> str:
353
+ """What both indexes read: the heading path above the body, or the body alone."""
354
+ return f"{piece.heading}{HEADING_JOINT}{piece.text}" if piece.heading else piece.text
355
+
356
+
357
+ def body_of(indexed: str, heading: str | None) -> str:
358
+ """The indexed text without its heading path: what the model reads."""
359
+ return indexed.removeprefix(f"{heading}{HEADING_JOINT}") if heading else indexed
360
+
361
+
362
+ async def bases(pool: Pool, scope: Scope) -> list[Base]:
363
+ """The holder's bases, and the org's where the holder has pushed none of that name."""
364
+ async with pool.connection() as connection:
365
+ rows = await (await connection.execute(BASES, _where(scope))).fetchall()
366
+ return [
367
+ Base(base=row["base"], chunks=row["chunks"], model=row["model"], pushed_at=row["pushed_at"])
368
+ for row in rows
369
+ ]
370
+
371
+
372
+ async def files(pool: Pool, scope: Scope, base: str) -> list[File] | None:
373
+ """Every file of the copy the holder reads, by path; None when there is no such base."""
374
+ rows = await _copy(pool, scope, base, None)
375
+ if not rows:
376
+ return None
377
+ return [_file(row, text=None) for row in rows if row["path"] is not None]
378
+
379
+
380
+ async def file(pool: Pool, scope: Scope, base: str, path: str) -> File | None:
381
+ """One file of the copy the holder reads, with its text."""
382
+ rows = await _copy(pool, scope, base, path)
383
+ found = [row for row in rows if row["path"] is not None]
384
+ return _file(found[0], text=found[0]["text"]) if found else None
385
+
386
+
387
+ async def put(pool: Pool, embedder: Embedder, scope: Scope, push: Push) -> None:
388
+ """Replace the holder's base with the push; only the files whose text changed are embedded."""
389
+ await _written(pool, embedder, scope, push, keep=[item.path for item in push.files])
390
+
391
+
392
+ async def put_file(pool: Pool, embedder: Embedder, scope: Scope, push: Push) -> None:
393
+ """Put the push's files beside the base's others, beginning the base when there is none."""
394
+ own = await _own(pool, scope, push.base)
395
+ if own is not None and own.model != embedder.embedding.model:
396
+ raise WrongModel(push.base, own.model, embedder.embedding.model)
397
+ await _written(pool, embedder, scope, push, keep=None)
398
+
399
+
400
+ async def drop(pool: Pool, scope: Scope, base: str) -> bool:
401
+ """Drop the holder's own copy of the base and its chunks; False when it has none."""
402
+ async with pool.connection() as connection:
403
+ dropped = await connection.execute(DROP, {**_where(scope), "base": base})
404
+ return await dropped.fetchone() is not None
405
+
406
+
407
+ async def drop_file(pool: Pool, scope: Scope, base: str, path: str) -> bool:
408
+ """Drop one file of the holder's own copy, and the base with its last file."""
409
+ named = {**_where(scope), "base": base, "path": path}
410
+ async with pool.connection() as connection, connection.transaction():
411
+ row = await (await connection.execute(DROP_FILE, named)).fetchone()
412
+ if row is None or row["gone"] == 0:
413
+ return False
414
+ await connection.execute(DROP_AN_EMPTY_BASE, named)
415
+ return True
416
+
417
+
418
+ async def kept(pool: Pool, org: str, env: Env) -> int:
419
+ """How many chunks the org keeps in the world, every holder's."""
420
+ async with pool.connection() as connection:
421
+ row = await (await connection.execute(KEPT, {"org": org, "env": env})).fetchone()
422
+ return 0 if row is None else row["kept"]
423
+
424
+
425
+ async def chunks_kept(pool: Pool, scope: Scope, base: str, *, path: str | None = None) -> int:
426
+ """How many chunks the holder's own copy of the base holds, or of one file of it."""
427
+ named = {**_where(scope), "base": base, "path": path}
428
+ async with pool.connection() as connection:
429
+ row = await (await connection.execute(HELD, named)).fetchone()
430
+ return 0 if row is None else row["held"]
431
+
432
+
433
+ # Every base in one ranking: fused scores compare only within one, and searched one base at a
434
+ # time, an irrelevant base takes a turn's slots with a 1.0 of its own.
435
+ async def search(pool: Pool, embedder: Embedder, scope: Scope, query: SearchQuery) -> Searched:
436
+ """The best chunks of every base asked, each over its own floor, at most k."""
437
+ model = embedder.embedding.model
438
+ async with pool.connection() as connection:
439
+ named = {**_where(scope), "bases": list(query.bases)}
440
+ copies = await (await connection.execute(COPIES, named)).fetchall()
441
+ for copy in copies:
442
+ if copy["model"] != model:
443
+ raise WrongModel(copy["base"], copy["model"], model)
444
+ if not copies:
445
+ return Searched(found=[], evidence="none", model=model)
446
+ [vector] = await embedder.embed([query.query])
447
+ table = SearchedTable(
448
+ name="knowledge_chunks",
449
+ text_index=TEXT_INDEX,
450
+ scope=sql.SQL(SCOPE),
451
+ params={
452
+ "org": scope.org,
453
+ "env": scope.env,
454
+ "bases": [copy["base"] for copy in copies],
455
+ "holders": [copy["holder"] for copy in copies],
456
+ },
457
+ columns=("base", "holder", "path", "heading", "ordinal", "text"),
458
+ )
459
+ room = CANDIDATES_PER_BRANCH * len(copies)
460
+ # independent: two reads, the sections only widening the chunks the search already found
461
+ async with pool.connection() as connection:
462
+ hits = await hybrid(connection, table, vector=vector, words=query.query, room=room)
463
+ floor = query.bases
464
+ floored = [hit for hit in relative_to_the_best(hits) if hit.fused >= floor[hit.row["base"]]]
465
+ chosen = sections_chosen([hit.row for hit in floored], query.k)
466
+ texts = await section_texts(connection, scope, [floored[at].row for at in chosen])
467
+ found = [
468
+ Found(
469
+ id=floored[at].id,
470
+ base=floored[at].row["base"],
471
+ path=floored[at].row["path"],
472
+ heading=floored[at].row["heading"],
473
+ text=HEADING_JOINT.join(
474
+ body_of(text, floored[at].row["heading"])
475
+ for text in texts.get(place, [floored[at].row["text"]])
476
+ ),
477
+ score=floored[at].fused,
478
+ cosine=floored[at].cosine,
479
+ )
480
+ for place, at in enumerate(chosen)
481
+ ]
482
+ best = top_cosine(hits)
483
+ return Searched(found=found, evidence=evidence_of(best), model=model)
484
+
485
+
486
+ def where(found: Found) -> str:
487
+ """The chunk's file, then its heading path."""
488
+ return f"{found.path}{HEADING_SEPARATOR}{found.heading}" if found.heading else found.path
489
+
490
+
491
+ # A file answers for every chunk of it and a heading for its section and what is under it; by
492
+ # prefix and separator, never substring: `tarifas.md` is not `tarifas-2024.md`.
493
+ def answers(found: str, expects: str) -> bool:
494
+ """Whether a chunk's heading path is the one a golden expects, or under it."""
495
+ wanted = expects.strip()
496
+ return found == wanted or found.startswith(f"{wanted}{HEADING_SEPARATOR}")
497
+
498
+
499
+ def score_docs(answered: Sequence[Answered], k: int) -> Score:
500
+ """Recall at k and nDCG at 10 over every question, and the ones whose chunk never came back."""
501
+ ranks = [_rank(item) for item in answered]
502
+ return Score(
503
+ questions=len(answered),
504
+ k=k,
505
+ figures=figures([[rank] for rank in ranks]),
506
+ misses=[item for item, rank in zip(answered, ranks, strict=True) if rank is None],
507
+ )
508
+
509
+
510
+ # An unclosed `---` is a horizontal rule, and the file is left as it is.
511
+ def _without_front_matter(text: str) -> str:
512
+ if not text.startswith(FRONT_MATTER):
513
+ return text
514
+ closed = A_CLOSING_RULE.search(text, len(FRONT_MATTER))
515
+ return text[closed.end() :].lstrip("\n") if closed else text
516
+
517
+
518
+ # A level-n heading closes the open headings of level n and deeper.
519
+ def _sections_of(text: str) -> Iterator[tuple[str | None, str]]:
520
+ trail: list[tuple[int, str]] = []
521
+ body: list[str] = []
522
+ fence: str | None = None
523
+ for line in text.split("\n"):
524
+ fenced = A_FENCE.match(line)
525
+ if fenced is not None and fence in {None, fenced.group(1)}:
526
+ fence = None if fence else fenced.group(1)
527
+ heading = None if fence or fenced else A_HEADING.match(line)
528
+ if heading is None:
529
+ body.append(line)
530
+ continue
531
+ yield _path_of(trail), "\n".join(body)
532
+ body = []
533
+ level = len(heading.group(1))
534
+ trail = [*(kept for kept in trail if kept[0] < level), (level, heading.group(2))]
535
+ yield _path_of(trail), "\n".join(body)
536
+
537
+
538
+ def _path_of(trail: Sequence[tuple[int, str]]) -> str | None:
539
+ return HEADING_SEPARATOR.join(title for _, title in trail) or None
540
+
541
+
542
+ # The heading's tokens count against the cap: it is indexed with every piece under it.
543
+ def _under_the_cap(body: str, heading: str | None) -> list[str]:
544
+ room = CHUNK_TOKENS - estimated_tokens(heading or "")
545
+ parts = [part for paragraph in _paragraphs(body) for part in _within(paragraph, room)]
546
+ return _grouped(parts, HEADING_JOINT, room)
547
+
548
+
549
+ # A blank line inside a code fence is the code's own: the block stays one paragraph, so a class
550
+ # is never cut between two of its members and handed back as two halves.
551
+ def _paragraphs(body: str) -> list[str]:
552
+ paragraphs: list[str] = []
553
+ in_code = False
554
+ for paragraph in A_PARAGRAPH_BREAK.split(body):
555
+ if in_code:
556
+ paragraphs[-1] += HEADING_JOINT + paragraph
557
+ else:
558
+ paragraphs.append(paragraph)
559
+ if sum(1 for line in paragraph.splitlines() if A_FENCE.match(line)) % 2:
560
+ in_code = not in_code
561
+ return [paragraph.strip() for paragraph in paragraphs if paragraph.strip()]
562
+
563
+
564
+ def _within(paragraph: str, room: int) -> list[str]:
565
+ if estimated_tokens(paragraph) <= room or A_FENCE.match(paragraph):
566
+ return [paragraph]
567
+ return _grouped(A_SENTENCE_END.split(paragraph), " ", room)
568
+
569
+
570
+ # A part longer than the room is kept whole rather than cut mid-sentence.
571
+ def _grouped(parts: Sequence[str], joint: str, room: int) -> list[str]:
572
+ groups: list[str] = []
573
+ current: list[str] = []
574
+ for part in parts:
575
+ if current and estimated_tokens(joint.join([*current, part])) > room:
576
+ groups.append(joint.join(current))
577
+ current = []
578
+ current.append(part)
579
+ if current:
580
+ groups.append(joint.join(current))
581
+ return groups
582
+
583
+
584
+ async def _copy(pool: Pool, scope: Scope, base: str, path: str | None) -> list[DictRow]:
585
+ named = {**_where(scope), "base": base, "path": path}
586
+ async with pool.connection() as connection:
587
+ return await (await connection.execute(COPY, named)).fetchall()
588
+
589
+
590
+ async def _own(pool: Pool, scope: Scope, base: str) -> _Own | None:
591
+ async with pool.connection() as connection:
592
+ rows = await (await connection.execute(OWN, {**_where(scope), "base": base})).fetchall()
593
+ if not rows:
594
+ return None
595
+ hashes = {row["path"]: row["sha256"] for row in rows if row["path"] is not None}
596
+ return _Own(model=rows[0]["model"], hashes=hashes)
597
+
598
+
599
+ # A hash is only worth its vectors under the model that wrote them: under another, every file is
600
+ # embedded again. The vendor answers before the statement runs, so no connection waits on it.
601
+ async def _written(
602
+ pool: Pool, embedder: Embedder, scope: Scope, push: Push, *, keep: list[str] | None
603
+ ) -> None:
604
+ own = await _own(pool, scope, push.base)
605
+ same_space = own is not None and own.model == embedder.embedding.model
606
+ known = own.hashes if own is not None and same_space else {}
607
+ changed = [item for item in push.files if known.get(item.path) != item.sha256]
608
+ documents = [[indexed_text(piece) for piece in item.pieces] for item in changed if item.pieces]
609
+ embedded = await embedder.embed_documents(documents) if documents else []
610
+ pieces = [piece for item in changed for piece in item.pieces]
611
+ written = [vector for document in embedded for vector in document]
612
+ named = {
613
+ **_where(scope),
614
+ "base": push.base,
615
+ "model": embedder.embedding.model,
616
+ "dimensions": embedder.embedding.dimensions,
617
+ "added": len(pieces),
618
+ "at": push.at,
619
+ "keep": keep,
620
+ "changed": [item.path for item in changed],
621
+ "texts": [item.text for item in changed],
622
+ "counts": [len(item.pieces) for item in changed],
623
+ "hashes": [item.sha256 for item in changed],
624
+ "paths": [piece.path for piece in pieces],
625
+ "headings": [piece.heading for piece in pieces],
626
+ "ordinals": [piece.ordinal for piece in pieces],
627
+ "indexed": [indexed_text(piece) for piece in pieces],
628
+ "vectors": [halfvec(vector) for vector in written],
629
+ }
630
+ # Unbounded: a base of thousands of chunks is that many rows into two indexes, in one statement.
631
+ async with unbounded(pool) as connection:
632
+ await connection.execute(WRITE, named)
633
+
634
+
635
+ def _file(row: DictRow, *, text: str | None) -> File:
636
+ return File(
637
+ path=row["path"],
638
+ chars=row["chars"],
639
+ chunks=row["chunks"],
640
+ pushed_at=row["pushed_at"],
641
+ sha256=row["sha256"],
642
+ text=text,
643
+ )
644
+
645
+
646
+ def _where(scope: Scope) -> dict[str, object]:
647
+ return {"org": scope.org, "env": scope.env, "holder": scope.holder}
648
+
649
+
650
+ # Only the first match counts: a base that repeats itself answered once.
651
+ def _rank(item: Answered) -> int | None:
652
+ return next(
653
+ (
654
+ at
655
+ for at, chunk in enumerate(item.found, start=1)
656
+ if answers(where(chunk), item.question.expects)
657
+ ),
658
+ None,
659
+ )