pinecall-runtime 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -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}"
|