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,603 @@
1
+ """Contact memory: the facts table, recalled per turn, held and forgotten, and its goldens."""
2
+
3
+ import dataclasses
4
+ import logging
5
+ import time
6
+ import unicodedata
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from typing import Literal
11
+ from uuid import UUID, uuid4
12
+
13
+ from psycopg import sql
14
+ from psycopg.rows import DictRow
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from pinecall.domain.errors import DeclarationRefused
18
+ from pinecall.domain.names import Env
19
+ from pinecall.domain.scope import Scope
20
+ from pinecall.postgres.pool import Connection, Pool
21
+ from pinecall.retrieval._search import (
22
+ CANDIDATES_PER_BRANCH,
23
+ Evidence,
24
+ Figures,
25
+ Hit,
26
+ SearchedTable,
27
+ evidence_of,
28
+ figures,
29
+ hybrid,
30
+ relative_to_the_best,
31
+ top_cosine,
32
+ )
33
+ from pinecall.retrieval.embed import Embedder, halfvec
34
+ from pinecall.wire.rest.retrieval import (
35
+ MemoryGolden,
36
+ MemoryMiss,
37
+ MemoryQuestion,
38
+ MemoryScore,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ DEFAULT_FACTS_PER_TURN = 6
45
+
46
+
47
+ # A fact learned a half-life ago weighs half of one learned now.
48
+ HALF_LIFE_DAYS = 90
49
+
50
+
51
+ A_DAY_S = 86_400
52
+
53
+
54
+ TEXT_INDEX = "contact_memories_text_bm25"
55
+
56
+
57
+ COLUMNS = (
58
+ "id",
59
+ "contact",
60
+ "category",
61
+ "text",
62
+ "valid_from",
63
+ "invalidated_at",
64
+ "supersedes",
65
+ "source_call",
66
+ "model",
67
+ "confidence",
68
+ )
69
+
70
+
71
+ # There is no delete: a fact is only ever superseded or ended, and the history keeps it.
72
+ type OpName = Literal["add", "update", "invalidate"]
73
+
74
+
75
+ BAD_CURSOR = "{after!r} is not a cursor: pass the `next` a page answered, or nothing"
76
+
77
+
78
+ HELD_NOW = sql.SQL(
79
+ "org = %(org)s AND env = %(env)s AND holder = %(holder)s AND contact = %(contact)s"
80
+ " AND invalidated_at IS NULL"
81
+ )
82
+
83
+
84
+ HELD_THEN = sql.SQL(
85
+ "org = %(org)s AND env = %(env)s AND holder = %(holder)s AND contact = %(contact)s"
86
+ " AND valid_from <= %(as_of)s AND (invalidated_at IS NULL OR invalidated_at > %(as_of)s)"
87
+ )
88
+
89
+
90
+ # Vectors of two models share a width and still mean nothing to each other.
91
+ SAME_SPACE = sql.SQL("model = %(model)s")
92
+
93
+
94
+ ENDED = """
95
+ UPDATE contact_memories SET invalidated_at = %(at)s
96
+ WHERE id = %(of)s::uuid AND org = %(org)s AND env = %(env)s AND holder = %(holder)s
97
+ AND invalidated_at IS NULL
98
+ RETURNING id
99
+ """
100
+
101
+
102
+ # The count comes back as a row, not read off the command tag.
103
+ FORGOTTEN = """
104
+ WITH gone AS (
105
+ DELETE FROM contact_memories
106
+ WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND contact = %(contact)s
107
+ RETURNING id
108
+ )
109
+ SELECT count(*) AS forgotten FROM gone
110
+ """
111
+
112
+
113
+ # What the quota counts: this world's current facts, every holder's; history is not a fact held.
114
+ KEPT = """
115
+ SELECT count(*) AS kept FROM contact_memories
116
+ WHERE org = %(org)s AND env = %(env)s AND invalidated_at IS NULL
117
+ """
118
+
119
+
120
+ # A batch at a time: each one written leaves the model's, so the next read starts past it.
121
+ STALE = """
122
+ SELECT id, text FROM contact_memories WHERE model <> %(model)s ORDER BY created_at, id
123
+ LIMIT %(batch)s
124
+ """
125
+
126
+
127
+ REEMBEDDED = """
128
+ UPDATE contact_memories SET embedding = %(embedding)s::halfvec, model = %(model)s
129
+ WHERE id = %(id)s AND model <> %(model)s
130
+ """
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Fact:
135
+ """One row of a contact's memory: what it says, when it held, and what wrote it."""
136
+
137
+ id: str
138
+ contact: str
139
+ category: str | None
140
+ text: str
141
+ valid_from: datetime
142
+ invalidated_at: datetime | None = None
143
+ supersedes: str | None = None
144
+ # The call that taught it; None for a golden's.
145
+ taught_by: str | None = None
146
+ model: str = ""
147
+ confidence: float = 1.0
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class RecalledFact:
152
+ """A fact recalled, and its score relative to the best of the recall."""
153
+
154
+ fact: Fact
155
+ score: float
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class Recalled:
160
+ """What a recall found, best first, and how strongly the best vector says it answers."""
161
+
162
+ facts: list[RecalledFact]
163
+ top_cosine: float
164
+ evidence: Evidence
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class Recall:
169
+ """A recall of one contact's facts: the query, the moment, how many, and what held when."""
170
+
171
+ contact: str
172
+ query: str
173
+ at: datetime
174
+ k: int = DEFAULT_FACTS_PER_TURN
175
+ # Reads the facts that held at that moment rather than the current ones.
176
+ as_of: datetime | None = None
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class Taught:
181
+ """When a contact's memory was taught, and on which call: what every written fact carries."""
182
+
183
+ contact: str
184
+ call: str | None
185
+ at: datetime
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Held:
190
+ """Sentences written as a contact's facts with no model, all at one moment."""
191
+
192
+ contact: str
193
+ sentences: tuple[str, ...]
194
+ at: datetime
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Op:
199
+ """One change the extraction model asked of a contact's memory."""
200
+
201
+ op: OpName
202
+ text: str = ""
203
+ category: str | None = None
204
+ of: str | None = None
205
+
206
+
207
+ class Paging(BaseModel):
208
+ """A page of current facts: after which cursor, the words to look for, and how many."""
209
+
210
+ model_config = ConfigDict(frozen=True)
211
+
212
+ after: str | None = None
213
+ q: str | None = Field(default=None, max_length=200)
214
+ limit: int = Field(default=50, ge=1, le=200)
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class FactsPage:
219
+ """A page of facts, newest first, the cursor of the next, and the agent that taught each."""
220
+
221
+ facts: list[Fact]
222
+ next: str | None
223
+ agents: Mapping[str, str] = field(default_factory=dict[str, str])
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class Answered:
228
+ """A golden's question, and the texts of the facts recalled for it, best first."""
229
+
230
+ question: MemoryQuestion
231
+ found: tuple[str, ...]
232
+
233
+ # Only the first fact that says it counts: memory repeating itself answered once.
234
+ @property
235
+ def ranks(self) -> tuple[int | None, ...]:
236
+ """The rank of each expected fact, from 1, or None when it was not recalled."""
237
+ return tuple(
238
+ next((at for at, text in enumerate(self.found, 1) if says(text, expected)), None)
239
+ for expected in self.question.expects
240
+ )
241
+
242
+ @property
243
+ def missing(self) -> tuple[str, ...]:
244
+ """The expected facts that were not recalled."""
245
+ return tuple(
246
+ expected
247
+ for expected, rank in zip(self.question.expects, self.ranks, strict=True)
248
+ if rank is None
249
+ )
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class Score:
254
+ """A memory golden's figures over every question, and the questions it missed."""
255
+
256
+ questions: int
257
+ k: int
258
+ figures: Figures
259
+ misses: tuple[Answered, ...]
260
+
261
+
262
+ OP_NAMES: tuple[OpName, ...] = ("add", "update", "invalidate")
263
+
264
+
265
+ WRITES: frozenset[OpName] = frozenset({"add", "update"})
266
+
267
+
268
+ NAMES_A_FACT: frozenset[OpName] = frozenset({"update", "invalidate"})
269
+
270
+
271
+ _LISTED = sql.SQL(", ").join(sql.Identifier(column) for column in COLUMNS)
272
+
273
+
274
+ CURRENT = sql.SQL(
275
+ "SELECT {columns} FROM contact_memories WHERE org = %(org)s AND env = %(env)s"
276
+ " AND holder = %(holder)s AND contact = %(contact)s AND invalidated_at IS NULL"
277
+ " ORDER BY valid_from, id"
278
+ ).format(columns=_LISTED)
279
+
280
+
281
+ HISTORY = sql.SQL(
282
+ "SELECT {columns} FROM contact_memories WHERE org = %(org)s AND env = %(env)s"
283
+ " AND holder = %(holder)s AND contact = %(contact)s"
284
+ " ORDER BY (invalidated_at IS NULL) DESC, valid_from DESC, id"
285
+ ).format(columns=_LISTED)
286
+
287
+
288
+ ADDED = sql.SQL(
289
+ "INSERT INTO contact_memories"
290
+ " (org, env, holder, contact, text, category, embedding, valid_from, source_call, model)"
291
+ " VALUES (%(org)s, %(env)s, %(holder)s, %(contact)s, %(text)s, %(category)s,"
292
+ " %(embedding)s::halfvec, %(at)s, %(call)s, %(model)s)"
293
+ " RETURNING {columns}"
294
+ ).format(columns=_LISTED)
295
+
296
+
297
+ # The old row ends and its replacement is written in one statement; nothing is written when the
298
+ # old row had already ended.
299
+ UPDATED = sql.SQL(
300
+ "WITH ended AS ("
301
+ " UPDATE contact_memories SET invalidated_at = %(at)s"
302
+ " WHERE id = %(of)s::uuid AND org = %(org)s AND env = %(env)s AND holder = %(holder)s"
303
+ " AND contact = %(contact)s AND invalidated_at IS NULL RETURNING id)"
304
+ " INSERT INTO contact_memories"
305
+ " (org, env, holder, contact, text, category, embedding, valid_from, source_call, model,"
306
+ " supersedes)"
307
+ " SELECT %(org)s, %(env)s, %(holder)s, %(contact)s, %(text)s, %(category)s,"
308
+ " %(embedding)s::halfvec, %(at)s, %(call)s, %(model)s, ended.id FROM ended"
309
+ " RETURNING {columns}"
310
+ ).format(columns=_LISTED)
311
+
312
+
313
+ # A fact is an agent's through the call that taught it: a golden's fact has no call, and no agent.
314
+ TAUGHT_BY = sql.SQL(
315
+ "SELECT {columns}, head.agent AS taught_by_agent FROM contact_memories AS memory"
316
+ " JOIN call_log_head AS head ON head.log = memory.source_call"
317
+ " WHERE memory.org = %(org)s AND memory.env = %(env)s AND memory.holder = %(holder)s"
318
+ " AND (%(agent)s::text IS NULL OR head.agent = %(agent)s)"
319
+ " AND memory.invalidated_at IS NULL"
320
+ " AND (%(q)s::text IS NULL OR memory.text ILIKE %(q)s OR memory.contact ILIKE %(q)s"
321
+ " OR memory.category ILIKE %(q)s)"
322
+ " AND (%(after_at)s::timestamptz IS NULL"
323
+ " OR (memory.valid_from, memory.id) < (%(after_at)s::timestamptz, %(after_id)s::uuid))"
324
+ " ORDER BY memory.valid_from DESC, memory.id DESC"
325
+ " LIMIT %(limit)s"
326
+ ).format(columns=sql.SQL(", ").join(sql.Identifier("memory", column) for column in COLUMNS))
327
+
328
+
329
+ # Memory has no fallback to the org's own facts: a corner recalls its own or nothing.
330
+ async def recall(pool: Pool, embedder: Embedder, scope: Scope, request: Recall) -> Recalled:
331
+ """The contact's best facts for the query: both branches fused, then recency and confidence."""
332
+ (vector,) = await embedder.embed([request.query])
333
+ model = embedder.embedding.model
334
+ table = SearchedTable(
335
+ name="contact_memories",
336
+ text_index=TEXT_INDEX,
337
+ scope=HELD_NOW if request.as_of is None else HELD_THEN,
338
+ params={**_where(scope, request.contact), "as_of": request.as_of, "model": model},
339
+ columns=COLUMNS,
340
+ same_space=SAME_SPACE,
341
+ )
342
+ async with pool.connection() as connection:
343
+ hits = await hybrid(
344
+ connection, table, vector=vector, words=request.query, room=CANDIDATES_PER_BRANCH
345
+ )
346
+ # A cosine against another model's vector measures nothing.
347
+ best = top_cosine([hit for hit in hits if hit.row["model"] == model])
348
+ return Recalled(
349
+ facts=ranked(hits, now=request.as_of or request.at, k=request.k),
350
+ top_cosine=best,
351
+ evidence=evidence_of(best),
352
+ )
353
+
354
+
355
+ def ranked(hits: Sequence[Hit], *, now: datetime, k: int) -> list[RecalledFact]:
356
+ """The k best hits once each is weighed by recency and confidence, relative to the best."""
357
+ weighed = [
358
+ dataclasses.replace(
359
+ hit,
360
+ fused=hit.fused * recency(hit.row["valid_from"], now) * hit.row["confidence"],
361
+ )
362
+ for hit in hits
363
+ ]
364
+ return [
365
+ RecalledFact(fact=_fact_of(hit.row), score=hit.fused)
366
+ for hit in relative_to_the_best(weighed)[:k]
367
+ ]
368
+
369
+
370
+ def recency(learned: datetime, now: datetime) -> float:
371
+ """1.0 for a fact learned now, halving every half-life; a date after now counts as now."""
372
+ days = max((now - learned).total_seconds(), 0.0) / A_DAY_S
373
+ return 0.5 ** (days / HALF_LIFE_DAYS)
374
+
375
+
376
+ async def current(pool: Pool, scope: Scope, contact: str) -> list[Fact]:
377
+ """The contact's facts that hold now, oldest first."""
378
+ return await _facts(pool, CURRENT, _where(scope, contact))
379
+
380
+
381
+ async def history(pool: Pool, scope: Scope, contact: str) -> list[Fact]:
382
+ """Every fact ever kept of the contact in the corner, current first, newest first."""
383
+ return await _facts(pool, HISTORY, _where(scope, contact))
384
+
385
+
386
+ async def taught_by(pool: Pool, scope: Scope, agent: str | None, *, page: Paging) -> FactsPage:
387
+ """A page of the current facts the agent's calls taught, newest first; None for every agent."""
388
+ after = _cursor(page.after)
389
+ params: dict[str, object] = {
390
+ "org": scope.org,
391
+ "env": scope.env,
392
+ "holder": scope.holder,
393
+ "agent": agent,
394
+ "q": f"%{_like_escaped(page.q)}%" if page.q else None,
395
+ "after_at": None if after is None else after[0],
396
+ "after_id": None if after is None else after[1],
397
+ "limit": page.limit + 1,
398
+ }
399
+ async with pool.connection() as connection:
400
+ rows = await (await connection.execute(TAUGHT_BY, params)).fetchall()
401
+ shown = rows[: page.limit]
402
+ facts = [_fact_of(row) for row in shown]
403
+ more = _cursor_of(facts[-1]) if len(rows) > page.limit else None
404
+ agents = {fact.id: str(row["taught_by_agent"]) for fact, row in zip(facts, shown, strict=True)}
405
+ return FactsPage(facts=facts, next=more, agents=agents)
406
+
407
+
408
+ async def kept(pool: Pool, org: str, env: Env) -> int:
409
+ """How many current facts the org keeps in the world, across every holder and contact."""
410
+ async with pool.connection() as connection:
411
+ row = await (await connection.execute(KEPT, {"org": org, "env": env})).fetchone()
412
+ return 0 if row is None else int(row["kept"])
413
+
414
+
415
+ async def hold(pool: Pool, embedder: Embedder, scope: Scope, request: Held) -> list[Fact]:
416
+ """The sentences written as the contact's facts, embedded, with no model asked."""
417
+ vectors = await embedder.embed(list(request.sentences)) if request.sentences else []
418
+ base = {**_where(scope, request.contact), "at": request.at, "call": None, "category": None}
419
+ written: list[Fact] = []
420
+ async with pool.connection() as connection, connection.transaction():
421
+ for text, vector in zip(request.sentences, vectors, strict=True):
422
+ fact = await _added(connection, embedder, {**base, "text": text, "of": None}, vector)
423
+ if fact is not None:
424
+ written.append(fact)
425
+ return written
426
+
427
+
428
+ # Every row, superseded ones included: an erasure that left the history would erase nothing.
429
+ async def forget(pool: Pool, scope: Scope, contact: str) -> int:
430
+ """Delete every fact of the contact in the corner; how many went, zero included."""
431
+ async with pool.connection() as connection:
432
+ row = await (await connection.execute(FORGOTTEN, _where(scope, contact))).fetchone()
433
+ return 0 if row is None else int(row["forgotten"])
434
+
435
+
436
+ async def invalidated(pool: Pool, scope: Scope, fact_id: str, *, at: datetime) -> bool:
437
+ """End one current fact of the corner at `at`; False when there is none by that id."""
438
+ params = {"org": scope.org, "env": scope.env, "holder": scope.holder, "of": fact_id}
439
+ async with pool.connection() as connection:
440
+ ended = await (await connection.execute(ENDED, {**params, "at": at})).fetchone()
441
+ return ended is not None
442
+
443
+
444
+ # One transaction per batch: a failure leaves the batches before it written, and a second run
445
+ # skips them.
446
+ async def reembed(pool: Pool, embedder: Embedder, *, batch: int = 64) -> int:
447
+ """Re-embed every fact another model wrote, in every org and world; how many there were."""
448
+ model = embedder.embedding.model
449
+ done = 0
450
+ while True:
451
+ async with pool.connection() as connection:
452
+ params = {"model": model, "batch": batch}
453
+ rows = await (await connection.execute(STALE, params)).fetchall()
454
+ if not rows:
455
+ return done
456
+ vectors = await embedder.embed([str(row["text"]) for row in rows])
457
+ written = [
458
+ {"id": row["id"], "embedding": halfvec(vector), "model": model}
459
+ for row, vector in zip(rows, vectors, strict=True)
460
+ ]
461
+ async with (
462
+ pool.connection() as connection,
463
+ connection.transaction(),
464
+ connection.cursor() as cursor,
465
+ ):
466
+ await cursor.executemany(REEMBEDDED, written)
467
+ done += len(rows)
468
+
469
+
470
+ async def ask_golden(
471
+ pool: Pool, embedder: Embedder, scope: Scope, golden: MemoryGolden, *, at: datetime
472
+ ) -> MemoryScore:
473
+ """Each question's facts written to a scratch contact, recalled, scored, and forgotten."""
474
+ k = golden.k or DEFAULT_FACTS_PER_TURN
475
+ scratch = f"golden-{uuid4().hex}"
476
+ started = time.perf_counter()
477
+ answered: list[Answered] = []
478
+ try:
479
+ for question in golden.questions:
480
+ await hold(pool, embedder, scope, Held(scratch, tuple(question.holds), at))
481
+ recalled = await recall(
482
+ pool, embedder, scope, Recall(scratch, question.asks, at=at, k=k)
483
+ )
484
+ await forget(pool, scope, scratch)
485
+ found = tuple(item.fact.text for item in recalled.facts)
486
+ answered.append(Answered(question=question, found=found))
487
+ finally:
488
+ await forget(pool, scope, scratch)
489
+ score = score_memory(answered, k)
490
+ return MemoryScore(
491
+ model=embedder.embedding.model,
492
+ questions=score.questions,
493
+ k=score.k,
494
+ recall_at_k=score.figures.recall_at_k,
495
+ ndcg_at_10=score.figures.ndcg_at_10,
496
+ took_ms=(time.perf_counter() - started) * 1000,
497
+ misses=[
498
+ MemoryMiss(asks=item.question.asks, missing=list(item.missing), found=list(item.found))
499
+ for item in score.misses
500
+ ],
501
+ )
502
+
503
+
504
+ def score_memory(answered: Sequence[Answered], k: int) -> Score:
505
+ """recall@k and nDCG@10 over every question; a question missing any expected fact is a miss."""
506
+ return Score(
507
+ questions=len(answered),
508
+ k=k,
509
+ figures=figures([item.ranks for item in answered]),
510
+ misses=tuple(item for item in answered if None in item.ranks),
511
+ )
512
+
513
+
514
+ # Facts are written by a model, so the golden is held to containment, one direction: a fact that
515
+ # says less than expected is a miss.
516
+ def says(found: str, expected: str) -> bool:
517
+ """Whether a recalled fact contains the expected text, accents, case and spacing aside."""
518
+ return _unaccented(expected) in _unaccented(found)
519
+
520
+
521
+ # Embedded before the transaction opens: an update never ends a fact without its replacement.
522
+ async def apply_ops(
523
+ pool: Pool, embedder: Embedder, scope: Scope, taught: Taught, ops: Sequence[Op]
524
+ ) -> list[Fact]:
525
+ """Write the ops as one: adds and updates embedded first; an update ends the row it replaces."""
526
+ writing = [op for op in ops if op.op in WRITES]
527
+ vectors = await embedder.embed([op.text for op in writing]) if writing else []
528
+ vector_of = dict(zip(writing, vectors, strict=True))
529
+ base = {**_where(scope, taught.contact), "at": taught.at, "call": taught.call}
530
+ written: list[Fact] = []
531
+ async with pool.connection() as connection, connection.transaction():
532
+ for op in ops:
533
+ if op.op == "invalidate":
534
+ await connection.execute(ENDED, {**base, "of": op.of})
535
+ continue
536
+ row = {**base, "text": op.text, "category": op.category, "of": op.of}
537
+ fact = await _added(connection, embedder, row, vector_of[op])
538
+ if fact is not None:
539
+ written.append(fact)
540
+ return written
541
+
542
+
543
+ def folded(text: str) -> str:
544
+ """The text case-folded with its spaces squeezed: how two sentences are compared."""
545
+ return " ".join(text.casefold().split())
546
+
547
+
548
+ def _where(scope: Scope, contact: str) -> dict[str, object]:
549
+ return {"org": scope.org, "env": scope.env, "holder": scope.holder, "contact": contact}
550
+
551
+
552
+ async def _facts(pool: Pool, query: sql.Composed, params: Mapping[str, object]) -> list[Fact]:
553
+ async with pool.connection() as connection:
554
+ rows = await (await connection.execute(query, params)).fetchall()
555
+ return [_fact_of(row) for row in rows]
556
+
557
+
558
+ def _fact_of(row: DictRow) -> Fact:
559
+ supersedes = row["supersedes"]
560
+ return Fact(
561
+ id=str(row["id"]),
562
+ contact=row["contact"],
563
+ category=row["category"],
564
+ text=row["text"],
565
+ valid_from=row["valid_from"],
566
+ invalidated_at=row["invalidated_at"],
567
+ supersedes=None if supersedes is None else str(supersedes),
568
+ taught_by=row["source_call"],
569
+ model=row["model"],
570
+ confidence=row["confidence"],
571
+ )
572
+
573
+
574
+ async def _added(
575
+ connection: Connection, embedder: Embedder, params: Mapping[str, object], vector: list[float]
576
+ ) -> Fact | None:
577
+ written = {**params, "embedding": halfvec(vector), "model": embedder.embedding.model}
578
+ query = UPDATED if params.get("of") is not None else ADDED
579
+ row = await (await connection.execute(query, written)).fetchone()
580
+ return None if row is None else _fact_of(row)
581
+
582
+
583
+ def _cursor_of(fact: Fact) -> str:
584
+ return f"{fact.valid_from.isoformat()}|{fact.id}"
585
+
586
+
587
+ def _cursor(after: str | None) -> tuple[datetime, UUID] | None:
588
+ if not after:
589
+ return None
590
+ moment, _, named = after.partition("|")
591
+ try:
592
+ return datetime.fromisoformat(moment), UUID(named)
593
+ except ValueError as unread:
594
+ raise DeclarationRefused(BAD_CURSOR.format(after=after)) from unread
595
+
596
+
597
+ def _like_escaped(words: str) -> str:
598
+ return words.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
599
+
600
+
601
+ def _unaccented(text: str) -> str:
602
+ letters = unicodedata.normalize("NFD", text)
603
+ return folded("".join(item for item in letters if not unicodedata.combining(item)))
File without changes