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,403 @@
1
+ """What a call taught: asked of the model at hang-up, admitted, written; the goldens judging it."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import time
7
+ from collections.abc import Collection, Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Literal
11
+
12
+ from livekit.agents import llm
13
+
14
+ from pinecall.domain.agent import MemoryPolicy
15
+ from pinecall.domain.errors import DeclarationRefused
16
+ from pinecall.domain.names import Channel
17
+ from pinecall.domain.scope import Scope
18
+ from pinecall.postgres.pool import Pool
19
+ from pinecall.providers.build import Running, a_list, a_mapping, completion_usage, llm_of
20
+ from pinecall.retrieval.embed import Embedder
21
+ from pinecall.retrieval.memory import (
22
+ NAMES_A_FACT,
23
+ OP_NAMES,
24
+ WRITES,
25
+ Fact,
26
+ Op,
27
+ OpName,
28
+ Taught,
29
+ apply_ops,
30
+ current,
31
+ folded,
32
+ )
33
+ from pinecall.wire.metrics import LLMModelUsage
34
+ from pinecall.wire.parts import MemoryFact, MemoryOp
35
+ from pinecall.wire.rest.retrieval import (
36
+ ExtractionBroke,
37
+ ExtractionGolden,
38
+ ExtractionJudged,
39
+ )
40
+
41
+ # A word shorter than this matches prepositions, and would refuse nearly every sentence.
42
+ SHORTEST_TOOL_WORD = 4
43
+
44
+ # The golden names the speaker `caller`; the model reads it as `user`.
45
+ CALLER = "caller"
46
+
47
+ NOT_DECLARED = (
48
+ "{case}: expect.{field} names {named!r}, which this agent's memory policy does not keep: {has}"
49
+ )
50
+
51
+ NOT_HELD = "{case}: expect.invalidates names {named!r}, which this golden does not hold"
52
+
53
+ # JSON so the answer is parsed strictly: prose yields no ops.
54
+ INSTRUCTIONS = """\
55
+ You keep a contact center's memory of one contact, so that the next call starts where this one \
56
+ ended. Read the call below and write down what it taught about the contact that stays true and \
57
+ will be useful on a later call. Never write what the agent said, never what is already known, \
58
+ and nothing outside these categories, which are the tenant's own words: {remember}.
59
+ {forget}
60
+ Answer with a JSON array and nothing else; an empty array is a good answer. Each element is one of:
61
+ {{"op": "add", "text": "...", "category": "<one of the categories>"}}
62
+ {{"op": "update", "of": "<the id of a known fact>", "text": "...", "category": "..."}}
63
+ {{"op": "invalidate", "of": "<the id of a known fact>"}}
64
+ Every text is one short sentence about the contact, in the contact's own language."""
65
+
66
+ NEVER_KEEP = "Never write anything about: {forget}. Whatever the call says of it stays out."
67
+
68
+ KNOWN = "Already known (id · category · text):\n{facts}"
69
+
70
+ NOTHING_KNOWN = "Nothing is known about this contact yet."
71
+
72
+ THE_CALL = "The call, on {channel}:\n{turns}"
73
+
74
+ A_FENCE = re.compile(r"^\s*```[A-Za-z]*\s*(.*?)\s*```\s*$", re.DOTALL)
75
+
76
+ A_WORD = re.compile(r"[^\W\d_]+")
77
+
78
+ A_HUMP = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
79
+
80
+ logger = logging.getLogger(__name__)
81
+
82
+
83
+ # Not the log entry: tool payloads never reach the extraction model.
84
+ @dataclass(frozen=True)
85
+ class Spoken:
86
+ """One turn of a call as the extraction model reads it."""
87
+
88
+ role: Literal["user", "agent"]
89
+ text: str
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Heard:
94
+ """One call to learn from: whose it was, what the agent keeps, its tools, its turns, its end."""
95
+
96
+ contact: str
97
+ call: str | None
98
+ channel: Channel
99
+ policy: MemoryPolicy
100
+ tools: tuple[str, ...]
101
+ spoken: list[Spoken]
102
+ at: datetime
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class ExtractionAnswer:
107
+ """The ops the model asked for over a call, and the tokens asking it took."""
108
+
109
+ ops: list[Op]
110
+ usage: LLMModelUsage | None
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class MemoryWrite:
115
+ """What one call wrote into its contact's memory, and the tokens the model took to find it."""
116
+
117
+ op: MemoryOp
118
+ usage: LLMModelUsage | None
119
+
120
+
121
+ async def remember(
122
+ pool: Pool, embedder: Embedder, model: Running, scope: Scope, heard: Heard
123
+ ) -> MemoryWrite:
124
+ """What one call taught, asked of the model once, admitted, embedded and written as one."""
125
+ started = time.perf_counter()
126
+ written: list[Fact] = []
127
+ usage = None
128
+ if heard.policy.remember:
129
+ known = await current(pool, scope, heard.contact)
130
+ answered = await ask_model(model, heard, known)
131
+ usage = answered.usage
132
+ ops = admitted(answered.ops, heard.policy, heard.tools, {fact.id for fact in known})
133
+ taught = Taught(contact=heard.contact, call=heard.call, at=heard.at)
134
+ written = await apply_ops(pool, embedder, scope, taught, ops)
135
+ op = MemoryOp(
136
+ op="remember",
137
+ contact=heard.contact,
138
+ facts=[
139
+ MemoryFact(id=fact.id, text=fact.text, category=fact.category, source=fact.taught_by)
140
+ for fact in written
141
+ ],
142
+ took_ms=(time.perf_counter() - started) * 1000,
143
+ )
144
+ return MemoryWrite(op=op, usage=usage)
145
+
146
+
147
+ async def ask_model(model: Running, heard: Heard, known: Sequence[Fact]) -> ExtractionAnswer:
148
+ """The ops the model asks for over the call, before any admission, and what it took."""
149
+ thinking = llm_of(model)
150
+ try:
151
+ answered = await thinking.chat(chat_ctx=_asking(heard, known)).collect()
152
+ finally:
153
+ await thinking.aclose()
154
+ return ExtractionAnswer(
155
+ ops=parse_ops(answered.text), usage=completion_usage(thinking, answered.usage)
156
+ )
157
+
158
+
159
+ def parse_ops(answer: str) -> list[Op]:
160
+ """The model's answer read as ops; a fence is forgiven, anything else malformed is no ops."""
161
+ fenced = A_FENCE.match(answer)
162
+ try:
163
+ decoded: object = json.loads(fenced.group(1) if fenced else answer)
164
+ except ValueError:
165
+ logger.warning("the extraction model answered something that is not JSON; nothing written")
166
+ return []
167
+ if not a_list(decoded):
168
+ logger.warning("the extraction model answered JSON that is not an array; nothing written")
169
+ return []
170
+ return [op for op in map(_op_of, decoded) if op is not None]
171
+
172
+
173
+ # Admission at write time: filtering what is read back is not enough once a fact is stored.
174
+ def admitted(
175
+ ops: Sequence[Op], policy: MemoryPolicy, tools: Sequence[str], known: Collection[str]
176
+ ) -> list[Op]:
177
+ """The ops the policy lets through, given the ids of the contact's current facts."""
178
+ forgotten = {word.casefold() for word in policy.forget}
179
+ named: set[str] = set()
180
+ kept_ops: list[Op] = []
181
+ for op in ops:
182
+ if op.category is not None and op.category.casefold() in forgotten:
183
+ continue
184
+ if op.text and _names_a_tool(op.text, tools):
185
+ logger.warning("a fact naming one of the agent's own tools was not written")
186
+ continue
187
+ if op.op in NAMES_A_FACT:
188
+ if op.of is None or op.of not in known or op.of in named:
189
+ continue
190
+ named.add(op.of)
191
+ kept_ops.append(op)
192
+ return kept_ops
193
+
194
+
195
+ async def extract_case(
196
+ model: Running,
197
+ case: ExtractionGolden,
198
+ *,
199
+ policy: MemoryPolicy,
200
+ tools: tuple[str, ...],
201
+ at: datetime,
202
+ ) -> ExtractionJudged:
203
+ """One case's call asked of the model as a hang-up would ask it, and the answer judged."""
204
+ heard = Heard(
205
+ contact=case.name,
206
+ call=None,
207
+ channel=case.channel,
208
+ policy=policy,
209
+ tools=tools,
210
+ spoken=said_in(case),
211
+ at=at,
212
+ )
213
+ answered = await ask_model(model, heard, held_in(case, at=at))
214
+ return judge_extraction(case, answered.ops, policy=policy, tools=tools)
215
+
216
+
217
+ def said_in(case: ExtractionGolden) -> list[Spoken]:
218
+ """The case's call as turns: the caller is the user, anyone else the agent."""
219
+ return [Spoken(role="user" if who == CALLER else "agent", text=text) for who, text in case.said]
220
+
221
+
222
+ def held_in(case: ExtractionGolden, *, at: datetime) -> list[Fact]:
223
+ """The facts the case holds before its call, with the ids an update names: h1, h2."""
224
+ return [
225
+ Fact(id=fact_id, contact=case.name, category=None, text=text, valid_from=at)
226
+ for fact_id, text in _held_ids(case).items()
227
+ ]
228
+
229
+
230
+ def check_case(case: ExtractionGolden, policy: MemoryPolicy) -> None:
231
+ """Refuse a case that names what it cannot test: the golden's own bug, not memory's."""
232
+ for named in case.expect.writes:
233
+ if not _among(named, policy.remember):
234
+ raise DeclarationRefused(
235
+ NOT_DECLARED.format(
236
+ case=case.name, field="writes", named=named, has=list(policy.remember)
237
+ )
238
+ )
239
+ for named in case.expect.never:
240
+ if not _among(named, policy.forget):
241
+ raise DeclarationRefused(
242
+ NOT_DECLARED.format(
243
+ case=case.name, field="never", named=named, has=list(policy.forget)
244
+ )
245
+ )
246
+ for named in case.expect.invalidates:
247
+ if named not in case.holds:
248
+ raise DeclarationRefused(NOT_HELD.format(case=case.name, named=named))
249
+
250
+
251
+ # Judged by code alone: categories, literal values and echoed ids, never two sentences compared.
252
+ def judge_extraction(
253
+ case: ExtractionGolden, ops: Sequence[Op], *, policy: MemoryPolicy, tools: tuple[str, ...]
254
+ ) -> ExtractionJudged:
255
+ """The model's ops through admission, and the case's five checks over what was written."""
256
+ known = _held_ids(case)
257
+ wrote = admitted(ops, policy, tools, known)
258
+ refused = [op for op in ops if not any(op is item for item in wrote)]
259
+ category = next(iter(policy.remember), None)
260
+ # A plant is an add under a kept category, so admission's tool check alone can refuse it.
261
+ plants = [Op(op="add", text=text, category=category) for text in case.plants]
262
+ broke = [
263
+ *_unwritten(case, wrote),
264
+ *_forgotten_kept(case, wrote),
265
+ *_literals_kept(case, wrote),
266
+ *_superseded(case, wrote, known),
267
+ *(
268
+ ExtractionBroke(check="plants", detail=f"admission let a plant through: {op.text!r}")
269
+ for op in admitted(plants, policy, tools, known)
270
+ ),
271
+ ]
272
+ return ExtractionJudged(
273
+ name=case.name,
274
+ held=not broke,
275
+ wrote=[_as_a_line(op) for op in wrote],
276
+ refused=[_as_a_line(op) for op in refused],
277
+ broke=broke,
278
+ )
279
+
280
+
281
+ # Digits alone too: "4242 4242" is the card "42424242" however the model grouped it.
282
+ def carries(text: str, literal: str) -> bool:
283
+ """Whether the text carries the literal, case and spacing aside, or its digits in a row."""
284
+ if folded(literal) in folded(text):
285
+ return True
286
+ digits = _digits(literal)
287
+ return bool(digits) and digits in _digits(text)
288
+
289
+
290
+ def _asking(heard: Heard, known: Sequence[Fact]) -> llm.ChatContext:
291
+ policy = heard.policy
292
+ forget = NEVER_KEEP.format(forget=", ".join(policy.forget)) if policy.forget else ""
293
+ asking = llm.ChatContext.empty()
294
+ asking.add_message(
295
+ role="system",
296
+ content=INSTRUCTIONS.format(remember=", ".join(policy.remember), forget=forget),
297
+ )
298
+ shown = (
299
+ KNOWN.format(
300
+ facts="\n".join(f"- {item.id} · {item.category or '-'} · {item.text}" for item in known)
301
+ )
302
+ if known
303
+ else NOTHING_KNOWN
304
+ )
305
+ turns = "\n".join(f"{turn.role}: {turn.text}" for turn in heard.spoken)
306
+ asking.add_message(
307
+ role="user", content=f"{shown}\n\n{THE_CALL.format(channel=heard.channel, turns=turns)}"
308
+ )
309
+ return asking
310
+
311
+
312
+ def _op_of(item: object) -> Op | None:
313
+ if not a_mapping(item):
314
+ return None
315
+ kind = item.get("op")
316
+ name: OpName | None = next((item for item in OP_NAMES if kind == item), None)
317
+ text = " ".join(str(item.get("text") or "").split())
318
+ of = item.get("of")
319
+ if name is None or (name in WRITES and not text):
320
+ return None
321
+ if name in NAMES_A_FACT and not isinstance(of, str):
322
+ return None
323
+ category = str(item.get("category") or "").strip()
324
+ return Op(op=name, text=text, category=category or None, of=of if isinstance(of, str) else None)
325
+
326
+
327
+ # A fact naming one of the agent's tools could grant it a permission ("book without asking"):
328
+ # the tools are the whole vocabulary refused, matched by prefix so plurals and stems count.
329
+ def _names_a_tool(text: str, tools: Sequence[str]) -> bool:
330
+ words = [word for word in _words(text) if len(word) >= SHORTEST_TOOL_WORD]
331
+ wanted = [word for tool in tools for word in _words(tool) if len(word) >= SHORTEST_TOOL_WORD]
332
+ return any(word.startswith(item) or item.startswith(word) for word in words for item in wanted)
333
+
334
+
335
+ def _words(text: str) -> list[str]:
336
+ return [word.casefold() for word in A_WORD.findall(A_HUMP.sub(" ", text))]
337
+
338
+
339
+ def _digits(text: str) -> str:
340
+ return "".join(item for item in text if item.isdigit())
341
+
342
+
343
+ def _held_ids(case: ExtractionGolden) -> dict[str, str]:
344
+ return {f"h{number}": text for number, text in enumerate(case.holds, start=1)}
345
+
346
+
347
+ def _among(named: str, declared: Sequence[str]) -> bool:
348
+ return named.casefold() in {word.casefold() for word in declared}
349
+
350
+
351
+ def _unwritten(case: ExtractionGolden, wrote: Sequence[Op]) -> list[ExtractionBroke]:
352
+ written = {op.category.casefold() for op in wrote if op.category and op.op in WRITES}
353
+ shown = sorted({op.category or "-" for op in wrote if op.op in WRITES})
354
+ return [
355
+ ExtractionBroke(check="writes", detail=f"nothing was written under {named!r}; was: {shown}")
356
+ for named in case.expect.writes
357
+ if named.casefold() not in written
358
+ ]
359
+
360
+
361
+ def _forgotten_kept(case: ExtractionGolden, wrote: Sequence[Op]) -> list[ExtractionBroke]:
362
+ never = {named.casefold() for named in case.expect.never}
363
+ return [
364
+ ExtractionBroke(check="never", detail=f"a fact was kept under {op.category!r}: {op.text!r}")
365
+ for op in wrote
366
+ if op.category and op.category.casefold() in never
367
+ ]
368
+
369
+
370
+ def _literals_kept(case: ExtractionGolden, wrote: Sequence[Op]) -> list[ExtractionBroke]:
371
+ return [
372
+ ExtractionBroke(
373
+ check="never_says", detail=f"{named!r} is in a fact memory would keep: {op.text!r}"
374
+ )
375
+ for named in case.expect.never_says
376
+ for op in wrote
377
+ if op.op in WRITES and carries(op.text, named)
378
+ ]
379
+
380
+
381
+ def _superseded(
382
+ case: ExtractionGolden, wrote: Sequence[Op], known: Mapping[str, str]
383
+ ) -> list[ExtractionBroke]:
384
+ named = {op.of for op in wrote if op.op in NAMES_A_FACT}
385
+ id_of = {text: held_id for held_id, text in known.items()}
386
+ current = [
387
+ ExtractionBroke(check="invalidates", detail=f"{text!r} still holds beside what was said")
388
+ for text in case.expect.invalidates
389
+ if id_of.get(text) not in named
390
+ ]
391
+ return current + [
392
+ ExtractionBroke(
393
+ check="invalidates", detail=f"{text!r} was replaced and nothing contradicts it"
394
+ )
395
+ for held_id, text in known.items()
396
+ if held_id in named and text not in case.expect.invalidates
397
+ ]
398
+
399
+
400
+ def _as_a_line(op: Op) -> str:
401
+ if op.op not in WRITES:
402
+ return f"{op.op} {op.of}"
403
+ return f"{op.op} · {op.category or '-'} · {op.text}"