pycontextdb 0.4.5__tar.gz → 0.4.6__tar.gz
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.
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/CHANGELOG.md +20 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/PKG-INFO +2 -2
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/README.md +1 -1
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/__init__.py +1 -1
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/client.py +57 -3
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/slots.py +20 -1
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/trust.py +74 -28
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/factual.py +20 -5
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/pyproject.toml +1 -1
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/evals/test_trust_model.py +205 -1
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/CODEOWNERS +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/copilot-instructions.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/pull_request_template.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/workflows/ci.yml +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/workflows/publish.yml +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.gitignore +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.pre-commit-config.yaml +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/COMMERCIAL.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/CONTRIBUTING.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/LICENSE +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/NOTICE +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/OPEN_CORE.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/.cursorrules +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/CLAUDE.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/README.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/llms-full.txt +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/llms.txt +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/README.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/architecture.png +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/benchmarks.png +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/hero.png +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/performance.png +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/problem.png +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/quickstart.png +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/benchmarks/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/benchmarks/run_benchmarks.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/benchmarks/trust_bakeoff.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/agents/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/agents/memory_bus.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/agents/rl_manager.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/cli.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/connectors/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/connectors/base.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/connectors/postgres.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/clock.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/config.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/exceptions.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/models.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/policy.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/evolution.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/formation.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/retrieval.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/salience.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/base.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/causal.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/entity.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/semantic.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/temporal.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/act.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/autogen.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/crewai.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/langchain.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/livekit.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/openai_tools.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/pipecat.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/prompting.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/mcp.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/experiential.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/working.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/pool.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/audit.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/injection.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/pii_detector.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/retention.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/py.typed +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/serve.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/base.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/factory.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/pg_sql.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/postgres_store.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/sqlite_store.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/vector_index.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/embeddings.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/llm.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/migrations.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/api.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/architecture.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/connectors.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/index.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/memory_evolution.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/migrations.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/multi_tenant.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/postgres.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/prompts.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/quickstart.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/serve.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/trust_policy.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/customer_support.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/multi_agent_team.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/phone_agent.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/research_assistant.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/legal/CLA-entity.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/legal/CLA-individual.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/legal/signed/README.md +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/mkdocs.yml +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/scripts/check_open_core_boundary.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/scripts/check_release.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/conftest.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/evals/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/integration/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/integration/test_client.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/integration/test_langchain_integration.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/pg_util.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/test_version.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/__init__.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_audit.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_config.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_connectors.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_decision_scope.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_embeddings.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_evolution.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_formation.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_graphs.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_host_api.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_init.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_memory_bus.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_memory_evolution.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_models.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_open_core_boundary.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_pg_sql.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_pii_detector.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_postgres_concurrency.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_postgres_connector.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_postgres_memory_evolution.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_retention.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_retrieval.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_scoped_id_access.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_serve.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_store.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_trust_policy.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_vector_index.py +0 -0
- {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_vector_scope.py +0 -0
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.6 — 2026-10-08
|
|
6
|
+
|
|
7
|
+
- A lower-trust write no longer replaces a first-party fact in its slot.
|
|
8
|
+
Writes made under one user id share a speaker, so a third-party document or
|
|
9
|
+
an agent inference stored for that user superseded what the user had said,
|
|
10
|
+
and the user would then be asked to confirm the document's value. A
|
|
11
|
+
user-stated or confirmed fact now stays current and the slot is contested
|
|
12
|
+
until `confirm()`. Corrections of equal or higher trust still supersede.
|
|
13
|
+
Advisory: GHSA-vpf9-5xgr-3x8m.
|
|
14
|
+
- `factual.recall_for_action` and `factual.pending_confirmations` accept the
|
|
15
|
+
`entity` and `attribute` an action needs. With them, only current facts in
|
|
16
|
+
that slot count, read exactly rather than by similarity, and the read is
|
|
17
|
+
audited like a recall. Without them nothing changes: any trusted memory the
|
|
18
|
+
query retrieves counts, whether or not it answers the request.
|
|
19
|
+
- The slot vocabulary (version 2) adds `user/billing_email`, `user/work_phone`,
|
|
20
|
+
and `user/work_address`. A user's billing email, work phone, or work address
|
|
21
|
+
no longer replaces their personal one. An email filed as a billing email
|
|
22
|
+
stays there instead of being moved to `user/email` and stored as
|
|
23
|
+
`agent_inferred`.
|
|
24
|
+
|
|
5
25
|
## 0.4.5 — 2026-10-05
|
|
6
26
|
|
|
7
27
|
- `forget_user` no longer deletes other users' memories. It added every graph
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pycontextdb
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
4
4
|
Summary: The unified context layer for AI agents — memory you can act on without treating wishes as facts
|
|
5
5
|
Project-URL: Homepage, https://github.com/atomsai/contextdb
|
|
6
6
|
Project-URL: Documentation, https://contextdb.dev
|
|
@@ -118,7 +118,7 @@ Hermetic, reproducible — run the suite yourself: `python benchmarks/run_benchm
|
|
|
118
118
|
|
|
119
119
|
Speed is table stakes. The production failure that gets agents fired is **acting on a wish as if it were a fact**: a caller says *"I'd like to come in Thursday"* and the agent answers *"we penciled you in."* A wish and a confirmed fact are the same object in every memory store on the market — so ContextDB types them.
|
|
120
120
|
|
|
121
|
-
Every memory carries **epistemic provenance** (`user_stated` / `agent_inferred` / `third_party`), **confidence**, **independent corroboration** (same-speaker repeats do not count), and an **action-relevant** flag. The action bar is data — `TrustPolicy.hospital()` vs `TrustPolicy.restaurant()` — not a hardcoded `if`. Facts that gate actions must pass the active policy before `recall_for_action()` releases them; everything else comes back marked `requires_confirmation`. `factual.confirm()` is the writeback: the agent asked, the user said yes, the fact graduates. Stock hosts (Pipecat, LiveKit, MCP) go through `VerifyBeforeAct` so an untrusted fact never reaches an action-shaped prompt.
|
|
121
|
+
Every memory carries **epistemic provenance** (`user_stated` / `agent_inferred` / `third_party`), **confidence**, **independent corroboration** (same-speaker repeats do not count), and an **action-relevant** flag. The action bar is data — `TrustPolicy.hospital()` vs `TrustPolicy.restaurant()` — not a hardcoded `if`. Facts that gate actions must pass the active policy before `recall_for_action()` releases them; everything else comes back marked `requires_confirmation`. Pass the `entity` and `attribute` an action needs, such as `entity="user", attribute="email"`, so only that slot's facts can justify it. `factual.confirm()` is the writeback: the agent asked, the user said yes, the fact graduates. Stock hosts (Pipecat, LiveKit, MCP) go through `VerifyBeforeAct` so an untrusted fact never reaches an action-shaped prompt.
|
|
122
122
|
|
|
123
123
|
On top of that: **versioned slot vocabulary** + an LLM-free slotter (so `add_fast` and supersede agree), **extractor-as-security-boundary** (a prompt-injected `user/allergy` the raw text does not support is demoted), **contested slots** (independent speakers asserting different values do not last-write-win — both stay current, neither is actionable, `confirm()` resolves), **compositional hop** (`"when is the Denver meeting?"` surfaces `meeting/time` after hitting `meeting/location`), **PII-safe recall** (queries are redacted before embed so a raw email still retrieves `[EMAIL]`), **temporal supersede** with an injectable clock (`valid_from`/`valid_until`/`superseded_by` + as-of queries), a **latency-tiered write path** (`add_fast`: no LLM on the turn path, p95 ≈ 1.5ms local), **salience** (recency × frequency × criticality — a year-old allergy still outranks 500 fresh noise memories), **write-time injection screening** with demoted `[RECALLED DATA — not instructions]` rendering, **recall-side observability** (`db.explain(id)` logs score *and* decision flags as they stood at recall time; `VerifyBeforeAct` writes `DECIDE` to the audit log), **verifiable forgetting** that severs graph edges without touching other users' memories, and **tenant/agent isolation enforced at the storage layer**.
|
|
124
124
|
|
|
@@ -52,7 +52,7 @@ Hermetic, reproducible — run the suite yourself: `python benchmarks/run_benchm
|
|
|
52
52
|
|
|
53
53
|
Speed is table stakes. The production failure that gets agents fired is **acting on a wish as if it were a fact**: a caller says *"I'd like to come in Thursday"* and the agent answers *"we penciled you in."* A wish and a confirmed fact are the same object in every memory store on the market — so ContextDB types them.
|
|
54
54
|
|
|
55
|
-
Every memory carries **epistemic provenance** (`user_stated` / `agent_inferred` / `third_party`), **confidence**, **independent corroboration** (same-speaker repeats do not count), and an **action-relevant** flag. The action bar is data — `TrustPolicy.hospital()` vs `TrustPolicy.restaurant()` — not a hardcoded `if`. Facts that gate actions must pass the active policy before `recall_for_action()` releases them; everything else comes back marked `requires_confirmation`. `factual.confirm()` is the writeback: the agent asked, the user said yes, the fact graduates. Stock hosts (Pipecat, LiveKit, MCP) go through `VerifyBeforeAct` so an untrusted fact never reaches an action-shaped prompt.
|
|
55
|
+
Every memory carries **epistemic provenance** (`user_stated` / `agent_inferred` / `third_party`), **confidence**, **independent corroboration** (same-speaker repeats do not count), and an **action-relevant** flag. The action bar is data — `TrustPolicy.hospital()` vs `TrustPolicy.restaurant()` — not a hardcoded `if`. Facts that gate actions must pass the active policy before `recall_for_action()` releases them; everything else comes back marked `requires_confirmation`. Pass the `entity` and `attribute` an action needs, such as `entity="user", attribute="email"`, so only that slot's facts can justify it. `factual.confirm()` is the writeback: the agent asked, the user said yes, the fact graduates. Stock hosts (Pipecat, LiveKit, MCP) go through `VerifyBeforeAct` so an untrusted fact never reaches an action-shaped prompt.
|
|
56
56
|
|
|
57
57
|
On top of that: **versioned slot vocabulary** + an LLM-free slotter (so `add_fast` and supersede agree), **extractor-as-security-boundary** (a prompt-injected `user/allergy` the raw text does not support is demoted), **contested slots** (independent speakers asserting different values do not last-write-win — both stay current, neither is actionable, `confirm()` resolves), **compositional hop** (`"when is the Denver meeting?"` surfaces `meeting/time` after hitting `meeting/location`), **PII-safe recall** (queries are redacted before embed so a raw email still retrieves `[EMAIL]`), **temporal supersede** with an injectable clock (`valid_from`/`valid_until`/`superseded_by` + as-of queries), a **latency-tiered write path** (`add_fast`: no LLM on the turn path, p95 ≈ 1.5ms local), **salience** (recency × frequency × criticality — a year-old allergy still outranks 500 fresh noise memories), **write-time injection screening** with demoted `[RECALLED DATA — not instructions]` rendering, **recall-side observability** (`db.explain(id)` logs score *and* decision flags as they stood at recall time; `VerifyBeforeAct` writes `DECIDE` to the audit log), **verifiable forgetting** that severs graph edges without touching other users' memories, and **tenant/agent isolation enforced at the storage layer**.
|
|
58
58
|
|
|
@@ -48,7 +48,12 @@ from contextdb.core.models import (
|
|
|
48
48
|
MemoryType,
|
|
49
49
|
)
|
|
50
50
|
from contextdb.core.policy import TrustPolicy
|
|
51
|
-
from contextdb.core.slots import
|
|
51
|
+
from contextdb.core.slots import (
|
|
52
|
+
canonicalize_slot,
|
|
53
|
+
infer_negation,
|
|
54
|
+
infer_slot,
|
|
55
|
+
slot_supported_by,
|
|
56
|
+
)
|
|
52
57
|
from contextdb.dynamics.trust import TrustEngine, infer_action_relevant, speaker_id
|
|
53
58
|
from contextdb.privacy.injection import screen_injection
|
|
54
59
|
from contextdb.privacy.pii_detector import PIIDetector
|
|
@@ -389,7 +394,7 @@ class ContextDB:
|
|
|
389
394
|
inferred = infer_slot(source_text)
|
|
390
395
|
chosen = explicit or inferred
|
|
391
396
|
if explicit is not None and inferred is not None:
|
|
392
|
-
if (explicit
|
|
397
|
+
if not slot_supported_by(explicit, inferred):
|
|
393
398
|
chosen = inferred
|
|
394
399
|
item.epistemic_source = "agent_inferred"
|
|
395
400
|
item.confidence = min(item.confidence, 0.4)
|
|
@@ -1991,8 +1996,18 @@ class ContextDB:
|
|
|
1991
1996
|
self,
|
|
1992
1997
|
user_id: str | None = None,
|
|
1993
1998
|
limit: int = 100,
|
|
1999
|
+
*,
|
|
2000
|
+
entity: str | None = None,
|
|
2001
|
+
attribute: str | None = None,
|
|
1994
2002
|
) -> list[MemoryItem]:
|
|
1995
|
-
"""Facts that are action-relevant and do not yet pass the trust policy.
|
|
2003
|
+
"""Facts that are action-relevant and do not yet pass the trust policy.
|
|
2004
|
+
|
|
2005
|
+
With ``entity`` and ``attribute``, only the current facts in that slot.
|
|
2006
|
+
"""
|
|
2007
|
+
if entity is not None or attribute is not None:
|
|
2008
|
+
rows = await self.slot_facts(entity, attribute, user_id=user_id)
|
|
2009
|
+
pending = [item for item in rows if self.trust_policy.requires_confirmation(item)]
|
|
2010
|
+
return pending[:limit]
|
|
1996
2011
|
uid = self._resolve_user(user_id)
|
|
1997
2012
|
await self._ensure_init()
|
|
1998
2013
|
store = self._require_store()
|
|
@@ -2001,6 +2016,45 @@ class ContextDB:
|
|
|
2001
2016
|
)
|
|
2002
2017
|
return [item for item in rows if self.trust_policy.requires_confirmation(item)]
|
|
2003
2018
|
|
|
2019
|
+
async def slot_facts(
|
|
2020
|
+
self,
|
|
2021
|
+
entity: str | None,
|
|
2022
|
+
attribute: str | None,
|
|
2023
|
+
*,
|
|
2024
|
+
user_id: str | None = None,
|
|
2025
|
+
as_of: datetime | None = None,
|
|
2026
|
+
) -> list[MemoryItem]:
|
|
2027
|
+
"""Current facts in one slot, read exactly rather than by similarity.
|
|
2028
|
+
|
|
2029
|
+
A similarity search returns the nearest memories whether or not they
|
|
2030
|
+
answer the request. When the caller knows which slot an action needs,
|
|
2031
|
+
this is the evidence that action may rely on.
|
|
2032
|
+
"""
|
|
2033
|
+
slot = canonicalize_slot(entity, attribute)
|
|
2034
|
+
if slot is None:
|
|
2035
|
+
raise ValueError("entity and attribute are both required to name a slot")
|
|
2036
|
+
uid = self._resolve_user(user_id)
|
|
2037
|
+
await self._ensure_init()
|
|
2038
|
+
store = self._require_store()
|
|
2039
|
+
moment = as_of or self.clock()
|
|
2040
|
+
rows = await store.list_by_slot(slot.entity, slot.attribute, user_id=uid)
|
|
2041
|
+
items = sorted(
|
|
2042
|
+
(m for m in rows if m.memory_type == MemoryType.FACTUAL and m.is_valid_at(moment)),
|
|
2043
|
+
key=lambda m: m.write_generation,
|
|
2044
|
+
reverse=True,
|
|
2045
|
+
)
|
|
2046
|
+
audit_details = {
|
|
2047
|
+
"slot": [slot.entity, slot.attribute],
|
|
2048
|
+
"hits": len(items),
|
|
2049
|
+
"returned_ids": [m.id for m in items],
|
|
2050
|
+
"as_of": moment.isoformat(),
|
|
2051
|
+
"abstained": not items,
|
|
2052
|
+
}
|
|
2053
|
+
if self._audit is not None and self.config.enable_read_audit:
|
|
2054
|
+
await self._audit.log(operation="SLOT_READ", user_id=uid, details=audit_details)
|
|
2055
|
+
await self._emit("read_audit", user_id=uid, audit_details=audit_details)
|
|
2056
|
+
return items
|
|
2057
|
+
|
|
2004
2058
|
# ------------------------------------------------------------------ #
|
|
2005
2059
|
# Typed memory surfaces
|
|
2006
2060
|
# ------------------------------------------------------------------ #
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import re
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
|
|
17
|
-
SLOT_VOCAB_VERSION = "
|
|
17
|
+
SLOT_VOCAB_VERSION = "2"
|
|
18
18
|
|
|
19
19
|
# Canonical (entity, attribute) → class. The class drives the per-class
|
|
20
20
|
# corroboration bar in :class:`TrustPolicy`.
|
|
@@ -31,8 +31,11 @@ SLOT_VOCAB: dict[tuple[str, str], str] = {
|
|
|
31
31
|
("reservation", "time"): "booking",
|
|
32
32
|
("reservation", "day"): "booking",
|
|
33
33
|
("user", "email"): "contact",
|
|
34
|
+
("user", "billing_email"): "contact",
|
|
34
35
|
("user", "phone"): "contact",
|
|
36
|
+
("user", "work_phone"): "contact",
|
|
35
37
|
("user", "address"): "contact",
|
|
38
|
+
("user", "work_address"): "contact",
|
|
36
39
|
("user", "name"): "identity",
|
|
37
40
|
("account", "number"): "identity",
|
|
38
41
|
("account", "pin"): "identity",
|
|
@@ -77,6 +80,15 @@ _ATTRIBUTE_ALIASES: dict[str, str] = {
|
|
|
77
80
|
"mobile": "phone",
|
|
78
81
|
"allergies": "allergy",
|
|
79
82
|
"allergic": "allergy",
|
|
83
|
+
"invoice_email": "billing_email",
|
|
84
|
+
"office_phone": "work_phone",
|
|
85
|
+
"office_address": "work_address",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Slots the text slotter cannot tell from a broader one: any email address
|
|
89
|
+
# reads as user/email, whether it is personal or where invoices go.
|
|
90
|
+
_REFINES: dict[tuple[str, str], tuple[str, str]] = {
|
|
91
|
+
("user", "billing_email"): ("user", "email"),
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
|
|
@@ -111,6 +123,13 @@ def canonicalize_slot(entity: str | None, attribute: str | None) -> Slot | None:
|
|
|
111
123
|
return Slot(entity=ent, attribute=attr, slot_class=slot_class)
|
|
112
124
|
|
|
113
125
|
|
|
126
|
+
def slot_supported_by(explicit: Slot, inferred: Slot) -> bool:
|
|
127
|
+
"""Whether the slot the text implies is consistent with the one chosen."""
|
|
128
|
+
chosen = (explicit.entity, explicit.attribute)
|
|
129
|
+
implied = (inferred.entity, inferred.attribute)
|
|
130
|
+
return chosen == implied or _REFINES.get(chosen) == implied
|
|
131
|
+
|
|
132
|
+
|
|
114
133
|
_WEEKDAYS = (
|
|
115
134
|
"monday",
|
|
116
135
|
"tuesday",
|
|
@@ -137,6 +137,21 @@ def speaker_id(
|
|
|
137
137
|
return "anonymous"
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _first_party(item: MemoryItem) -> bool:
|
|
141
|
+
return item.confirmed or item.epistemic_source == "user_stated"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def outranks(current: MemoryItem, incoming: MemoryItem) -> bool:
|
|
145
|
+
"""Whether ``incoming`` may not replace ``current`` in a slot.
|
|
146
|
+
|
|
147
|
+
Speaker identity alone cannot tell a user's correction from a document
|
|
148
|
+
the agent stored on the user's behalf: both arrive under the same user.
|
|
149
|
+
A first-party or confirmed fact is therefore never superseded by a
|
|
150
|
+
lower-trust write; the slot is contested instead.
|
|
151
|
+
"""
|
|
152
|
+
return _first_party(current) and not _first_party(incoming)
|
|
153
|
+
|
|
154
|
+
|
|
140
155
|
class TrustEngine:
|
|
141
156
|
"""Slot-aware write path used by ``factual.add`` and consolidation."""
|
|
142
157
|
|
|
@@ -252,20 +267,27 @@ class TrustEngine:
|
|
|
252
267
|
)
|
|
253
268
|
return updated, "corroborated"
|
|
254
269
|
|
|
270
|
+
protected = [c for c in current if outranks(c, item)]
|
|
255
271
|
same_speaker = [
|
|
256
|
-
c
|
|
272
|
+
c
|
|
273
|
+
for c in current
|
|
274
|
+
if c not in protected and incoming_speaker in (c.corroborated_by or [])
|
|
257
275
|
]
|
|
258
276
|
other_speaker = [
|
|
259
|
-
c
|
|
277
|
+
c
|
|
278
|
+
for c in current
|
|
279
|
+
if c not in protected and incoming_speaker not in (c.corroborated_by or [])
|
|
260
280
|
]
|
|
261
281
|
|
|
262
282
|
item.write_generation = max_gen + 1
|
|
263
283
|
# Independent speakers asserting different values: contest, do not
|
|
264
|
-
# last-write-win. Same-speaker corrections still supersede
|
|
265
|
-
|
|
284
|
+
# last-write-win. Same-speaker corrections still supersede, except a
|
|
285
|
+
# lower-trust write never replaces a first-party fact.
|
|
286
|
+
contest = protected + ([] if same_speaker else other_speaker)
|
|
287
|
+
if contest and not item.pending_consolidation:
|
|
266
288
|
item.contested = True
|
|
267
289
|
stored = await self.store.add(item)
|
|
268
|
-
for candidate in
|
|
290
|
+
for candidate in contest:
|
|
269
291
|
await self.store.update(candidate.id, contested=True)
|
|
270
292
|
await self._log(
|
|
271
293
|
"CONFLICT",
|
|
@@ -278,8 +300,13 @@ class TrustEngine:
|
|
|
278
300
|
"old_value": candidate.content,
|
|
279
301
|
"new_value": stored.content,
|
|
280
302
|
"speaker": incoming_speaker,
|
|
303
|
+
"reason": (
|
|
304
|
+
"outranked_source" if candidate in protected else "other_speaker"
|
|
305
|
+
),
|
|
281
306
|
},
|
|
282
307
|
)
|
|
308
|
+
for candidate in [c for c in current if c not in contest]:
|
|
309
|
+
await self._supersede(candidate, stored, user_id, item, now)
|
|
283
310
|
await self._log(
|
|
284
311
|
"CREATE",
|
|
285
312
|
stored.id,
|
|
@@ -297,25 +324,9 @@ class TrustEngine:
|
|
|
297
324
|
for candidate in current:
|
|
298
325
|
if item.pending_consolidation and not candidate.pending_consolidation:
|
|
299
326
|
continue
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
superseded_by=stored.id,
|
|
304
|
-
contested=False,
|
|
305
|
-
)
|
|
306
|
-
await self._log(
|
|
307
|
-
"SUPERSEDE",
|
|
308
|
-
candidate.id,
|
|
309
|
-
user_id,
|
|
310
|
-
{
|
|
311
|
-
"superseded_by": stored.id,
|
|
312
|
-
"entity": item.entity_key,
|
|
313
|
-
"attribute": item.attribute_key,
|
|
314
|
-
"old_value": candidate.content,
|
|
315
|
-
"new_value": stored.content,
|
|
316
|
-
"generation": item.write_generation,
|
|
317
|
-
},
|
|
318
|
-
)
|
|
327
|
+
if candidate in protected:
|
|
328
|
+
continue
|
|
329
|
+
await self._supersede(candidate, stored, user_id, item, now)
|
|
319
330
|
outcome = "superseded"
|
|
320
331
|
await self._log(
|
|
321
332
|
"CREATE",
|
|
@@ -325,6 +336,34 @@ class TrustEngine:
|
|
|
325
336
|
)
|
|
326
337
|
return stored, outcome
|
|
327
338
|
|
|
339
|
+
async def _supersede(
|
|
340
|
+
self,
|
|
341
|
+
candidate: MemoryItem,
|
|
342
|
+
stored: MemoryItem,
|
|
343
|
+
user_id: str | None,
|
|
344
|
+
item: MemoryItem,
|
|
345
|
+
now: datetime,
|
|
346
|
+
) -> None:
|
|
347
|
+
await self.store.update(
|
|
348
|
+
candidate.id,
|
|
349
|
+
valid_until=now,
|
|
350
|
+
superseded_by=stored.id,
|
|
351
|
+
contested=False,
|
|
352
|
+
)
|
|
353
|
+
await self._log(
|
|
354
|
+
"SUPERSEDE",
|
|
355
|
+
candidate.id,
|
|
356
|
+
user_id,
|
|
357
|
+
{
|
|
358
|
+
"superseded_by": stored.id,
|
|
359
|
+
"entity": item.entity_key,
|
|
360
|
+
"attribute": item.attribute_key,
|
|
361
|
+
"old_value": candidate.content,
|
|
362
|
+
"new_value": stored.content,
|
|
363
|
+
"generation": item.write_generation,
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
|
|
328
367
|
async def evolve_write(
|
|
329
368
|
self,
|
|
330
369
|
item: MemoryItem,
|
|
@@ -591,22 +630,24 @@ class TrustEngine:
|
|
|
591
630
|
previous_memory_ids=previous_ids,
|
|
592
631
|
)
|
|
593
632
|
|
|
633
|
+
protected = [candidate for candidate in current if outranks(candidate, item)]
|
|
594
634
|
same_speaker = [
|
|
595
635
|
candidate
|
|
596
636
|
for candidate in current
|
|
597
|
-
if incoming_speaker in candidate.corroborated_by
|
|
637
|
+
if candidate not in protected and incoming_speaker in candidate.corroborated_by
|
|
598
638
|
]
|
|
599
639
|
other_speaker = [
|
|
600
640
|
candidate
|
|
601
641
|
for candidate in current
|
|
602
|
-
if incoming_speaker not in candidate.corroborated_by
|
|
642
|
+
if candidate not in protected and incoming_speaker not in candidate.corroborated_by
|
|
603
643
|
]
|
|
604
644
|
item.write_generation = max_generation + 1
|
|
605
645
|
|
|
606
|
-
|
|
646
|
+
contest = protected + ([] if same_speaker else other_speaker)
|
|
647
|
+
if contest:
|
|
607
648
|
item.contested = True
|
|
608
649
|
stored = await self.store.add(item)
|
|
609
|
-
for candidate in
|
|
650
|
+
for candidate in contest:
|
|
610
651
|
await self.store.update(candidate.id, contested=True)
|
|
611
652
|
await self._log(
|
|
612
653
|
"CONFLICT",
|
|
@@ -619,8 +660,13 @@ class TrustEngine:
|
|
|
619
660
|
"old_value": candidate.content,
|
|
620
661
|
"new_value": stored.content,
|
|
621
662
|
"speaker": incoming_speaker,
|
|
663
|
+
"reason": (
|
|
664
|
+
"outranked_source" if candidate in protected else "other_speaker"
|
|
665
|
+
),
|
|
622
666
|
},
|
|
623
667
|
)
|
|
668
|
+
for candidate in [c for c in current if c not in contest]:
|
|
669
|
+
await self._supersede(candidate, stored, user_id, item, now)
|
|
624
670
|
await self._log(
|
|
625
671
|
"CREATE",
|
|
626
672
|
stored.id,
|
|
@@ -166,12 +166,24 @@ class FactualMemory:
|
|
|
166
166
|
as_of: datetime | None = None,
|
|
167
167
|
*,
|
|
168
168
|
user_id: str | None = None,
|
|
169
|
+
entity: str | None = None,
|
|
170
|
+
attribute: str | None = None,
|
|
169
171
|
) -> list[MemoryItem]:
|
|
170
|
-
"""Recall only facts an agent may act on without confirming first.
|
|
172
|
+
"""Recall only facts an agent may act on without confirming first.
|
|
173
|
+
|
|
174
|
+
Without a slot, any trusted memory the query retrieves counts, related
|
|
175
|
+
or not. Pass the ``entity`` and ``attribute`` the action needs to rely
|
|
176
|
+
only on trusted facts in that slot.
|
|
177
|
+
"""
|
|
171
178
|
policy = self.client.trust_policy
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
if entity is not None or attribute is not None:
|
|
180
|
+
candidates = await self.client.slot_facts(
|
|
181
|
+
entity, attribute, user_id=self._user(user_id), as_of=as_of
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
candidates = await self.recall(
|
|
185
|
+
query, top_k=top_k * 4, as_of=as_of, user_id=self._user(user_id)
|
|
186
|
+
)
|
|
175
187
|
return [m for m in candidates if policy.is_trusted(m)][:top_k]
|
|
176
188
|
|
|
177
189
|
async def confirm(self, memory_id: str, user_id: str | None = None) -> MemoryItem:
|
|
@@ -182,10 +194,13 @@ class FactualMemory:
|
|
|
182
194
|
self,
|
|
183
195
|
user_id: str | None = None,
|
|
184
196
|
limit: int = 100,
|
|
197
|
+
*,
|
|
198
|
+
entity: str | None = None,
|
|
199
|
+
attribute: str | None = None,
|
|
185
200
|
) -> list[MemoryItem]:
|
|
186
201
|
"""Action-relevant facts that still need a yes from the user."""
|
|
187
202
|
return await self.client.pending_confirmations(
|
|
188
|
-
user_id=self._user(user_id), limit=limit
|
|
203
|
+
user_id=self._user(user_id), limit=limit, entity=entity, attribute=attribute
|
|
189
204
|
)
|
|
190
205
|
|
|
191
206
|
async def update_fact(
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycontextdb"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.6"
|
|
8
8
|
description = "The unified context layer for AI agents — memory you can act on without treating wishes as facts"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -28,7 +28,7 @@ import pytest
|
|
|
28
28
|
import pytest_asyncio
|
|
29
29
|
|
|
30
30
|
import contextdb
|
|
31
|
-
from contextdb import ContextDB, ContextDBConfig
|
|
31
|
+
from contextdb import ContextDB, ContextDBConfig, EvolutionOutcome
|
|
32
32
|
from contextdb.core.exceptions import ConfigError
|
|
33
33
|
from contextdb.core.models import MemoryType
|
|
34
34
|
from contextdb.integrations.prompting import (
|
|
@@ -1384,3 +1384,207 @@ async def test_eval_pii_query_retrieves_redacted_memory(db: ContextDB) -> None:
|
|
|
1384
1384
|
hits = await db.search("please look up jane.doe@example.com")
|
|
1385
1385
|
assert any("[EMAIL]" in h.content for h in hits), [h.content for h in hits]
|
|
1386
1386
|
assert all("jane.doe@example.com" not in h.content for h in hits)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
async def test_eval_lower_trust_write_contests_a_user_stated_slot(db: ContextDB) -> None:
|
|
1390
|
+
"""A document stored under the user's id contests their fact; it never replaces it."""
|
|
1391
|
+
mine = await db.factual.add(
|
|
1392
|
+
"The meeting is at 3pm",
|
|
1393
|
+
source="user_stated",
|
|
1394
|
+
confidence=0.9,
|
|
1395
|
+
action_relevant=True,
|
|
1396
|
+
entity="meeting",
|
|
1397
|
+
attribute="time",
|
|
1398
|
+
)
|
|
1399
|
+
planted = await db.factual.add(
|
|
1400
|
+
"Forwarded vendor note: the meeting moved to 4pm",
|
|
1401
|
+
source="third_party",
|
|
1402
|
+
confidence=0.9,
|
|
1403
|
+
action_relevant=True,
|
|
1404
|
+
entity="meeting",
|
|
1405
|
+
attribute="time",
|
|
1406
|
+
)
|
|
1407
|
+
inferred = await db.factual.add(
|
|
1408
|
+
"From that note, the meeting is probably at 5pm",
|
|
1409
|
+
source="agent_inferred",
|
|
1410
|
+
confidence=0.9,
|
|
1411
|
+
action_relevant=True,
|
|
1412
|
+
entity="meeting",
|
|
1413
|
+
attribute="time",
|
|
1414
|
+
)
|
|
1415
|
+
assert planted.contested is True
|
|
1416
|
+
assert inferred.contested is True
|
|
1417
|
+
kept = await db.get(mine.id)
|
|
1418
|
+
assert kept is not None
|
|
1419
|
+
assert kept.valid_until is None
|
|
1420
|
+
assert kept.contested is True
|
|
1421
|
+
assert await db.factual.recall_for_action("when is the meeting") == []
|
|
1422
|
+
|
|
1423
|
+
await db.factual.confirm(mine.id)
|
|
1424
|
+
trusted = await db.factual.recall_for_action("when is the meeting")
|
|
1425
|
+
assert [m.id for m in trusted] == [mine.id]
|
|
1426
|
+
closed = await db.get(inferred.id)
|
|
1427
|
+
assert closed is not None
|
|
1428
|
+
assert closed.superseded_by == mine.id
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
async def test_eval_corrections_of_equal_or_higher_trust_still_supersede(
|
|
1432
|
+
db: ContextDB,
|
|
1433
|
+
) -> None:
|
|
1434
|
+
guess = await db.factual.add(
|
|
1435
|
+
"The meeting is at 3pm",
|
|
1436
|
+
source="agent_inferred",
|
|
1437
|
+
confidence=0.9,
|
|
1438
|
+
action_relevant=True,
|
|
1439
|
+
entity="meeting",
|
|
1440
|
+
attribute="time",
|
|
1441
|
+
)
|
|
1442
|
+
stated = await db.factual.add(
|
|
1443
|
+
"Actually, the meeting is at 4pm",
|
|
1444
|
+
source="user_stated",
|
|
1445
|
+
confidence=0.9,
|
|
1446
|
+
action_relevant=True,
|
|
1447
|
+
entity="meeting",
|
|
1448
|
+
attribute="time",
|
|
1449
|
+
)
|
|
1450
|
+
assert stated.contested is False
|
|
1451
|
+
replaced = await db.get(guess.id)
|
|
1452
|
+
assert replaced is not None
|
|
1453
|
+
assert replaced.superseded_by == stated.id
|
|
1454
|
+
|
|
1455
|
+
first = await db.factual.add(
|
|
1456
|
+
"Partner note: party of 4",
|
|
1457
|
+
source="third_party",
|
|
1458
|
+
action_relevant=True,
|
|
1459
|
+
entity="reservation",
|
|
1460
|
+
attribute="party_size",
|
|
1461
|
+
)
|
|
1462
|
+
second = await db.factual.add(
|
|
1463
|
+
"Partner note: party of 6",
|
|
1464
|
+
source="third_party",
|
|
1465
|
+
action_relevant=True,
|
|
1466
|
+
entity="reservation",
|
|
1467
|
+
attribute="party_size",
|
|
1468
|
+
)
|
|
1469
|
+
assert second.contested is False
|
|
1470
|
+
older = await db.get(first.id)
|
|
1471
|
+
assert older is not None
|
|
1472
|
+
assert older.superseded_by == second.id
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
async def test_eval_explicit_update_from_a_document_contests_a_user_fact(
|
|
1476
|
+
db: ContextDB,
|
|
1477
|
+
) -> None:
|
|
1478
|
+
added = await db.factual.evolve(
|
|
1479
|
+
"add",
|
|
1480
|
+
"The meeting is at 3pm",
|
|
1481
|
+
source="user_stated",
|
|
1482
|
+
entity="meeting",
|
|
1483
|
+
attribute="time",
|
|
1484
|
+
)
|
|
1485
|
+
assert added.memory is not None
|
|
1486
|
+
update = await db.factual.evolve(
|
|
1487
|
+
"update",
|
|
1488
|
+
"Vendor portal export: the meeting is at 4pm",
|
|
1489
|
+
source="third_party",
|
|
1490
|
+
entity="meeting",
|
|
1491
|
+
attribute="time",
|
|
1492
|
+
)
|
|
1493
|
+
assert update.outcome == EvolutionOutcome.CONTESTED
|
|
1494
|
+
kept = await db.get(added.memory.id)
|
|
1495
|
+
assert kept is not None
|
|
1496
|
+
assert kept.valid_until is None
|
|
1497
|
+
assert kept.contested is True
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
async def test_eval_slot_scoped_action_relies_only_on_that_slot(db: ContextDB) -> None:
|
|
1501
|
+
"""Without a slot any trusted memory counts; with one, only that slot's facts do."""
|
|
1502
|
+
await db.factual.add(
|
|
1503
|
+
"My home address is 12 Elm Street, Springfield",
|
|
1504
|
+
source="user_stated",
|
|
1505
|
+
action_relevant=True,
|
|
1506
|
+
)
|
|
1507
|
+
await db.factual.add(
|
|
1508
|
+
"We are a party of 4",
|
|
1509
|
+
source="user_stated",
|
|
1510
|
+
action_relevant=True,
|
|
1511
|
+
entity="reservation",
|
|
1512
|
+
attribute="party_size",
|
|
1513
|
+
)
|
|
1514
|
+
assert await db.factual.recall_for_action("Email me the receipt")
|
|
1515
|
+
assert (
|
|
1516
|
+
await db.factual.recall_for_action("Email me the receipt", entity="user", attribute="email")
|
|
1517
|
+
== []
|
|
1518
|
+
)
|
|
1519
|
+
email = await db.factual.add(
|
|
1520
|
+
"My email is avery.example@example.com",
|
|
1521
|
+
source="user_stated",
|
|
1522
|
+
action_relevant=True,
|
|
1523
|
+
entity="user",
|
|
1524
|
+
attribute="email",
|
|
1525
|
+
)
|
|
1526
|
+
scoped = await db.factual.recall_for_action(
|
|
1527
|
+
"Email me the receipt", entity="customer", attribute="e-mail"
|
|
1528
|
+
)
|
|
1529
|
+
assert [m.id for m in scoped] == [email.id]
|
|
1530
|
+
|
|
1531
|
+
tip = await db.factual.add(
|
|
1532
|
+
"Clinic portal note: the appointment is on Thursday",
|
|
1533
|
+
source="third_party",
|
|
1534
|
+
action_relevant=True,
|
|
1535
|
+
entity="appointment",
|
|
1536
|
+
attribute="day",
|
|
1537
|
+
)
|
|
1538
|
+
assert [m.id for m in await db.factual.pending_confirmations(
|
|
1539
|
+
entity="appointment", attribute="day"
|
|
1540
|
+
)] == [tip.id]
|
|
1541
|
+
assert await db.factual.pending_confirmations(entity="user", attribute="email") == []
|
|
1542
|
+
with pytest.raises(ValueError):
|
|
1543
|
+
await db.factual.recall_for_action("Email me the receipt", entity="user")
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
async def test_eval_billing_and_personal_email_are_separate_slots(db: ContextDB) -> None:
|
|
1547
|
+
"""Storing where invoices go must not erase the user's own email, or the reverse."""
|
|
1548
|
+
personal = await db.factual.add(
|
|
1549
|
+
"My email is avery.example@example.com",
|
|
1550
|
+
source="user_stated",
|
|
1551
|
+
action_relevant=True,
|
|
1552
|
+
entity="user",
|
|
1553
|
+
attribute="email",
|
|
1554
|
+
)
|
|
1555
|
+
billing = await db.factual.add(
|
|
1556
|
+
"Send invoices to billing.example@example.com",
|
|
1557
|
+
source="user_stated",
|
|
1558
|
+
action_relevant=True,
|
|
1559
|
+
entity="customer",
|
|
1560
|
+
attribute="invoice_email",
|
|
1561
|
+
)
|
|
1562
|
+
assert billing.epistemic_source == "user_stated"
|
|
1563
|
+
assert (billing.entity_key, billing.attribute_key, billing.slot_class) == (
|
|
1564
|
+
"user",
|
|
1565
|
+
"billing_email",
|
|
1566
|
+
"contact",
|
|
1567
|
+
)
|
|
1568
|
+
kept = await db.get(personal.id)
|
|
1569
|
+
assert kept is not None
|
|
1570
|
+
assert kept.valid_until is None
|
|
1571
|
+
trusted = {m.id for m in await db.factual.recall_for_action("send the invoice")}
|
|
1572
|
+
assert {personal.id, billing.id} <= trusted
|
|
1573
|
+
|
|
1574
|
+
phone = await db.factual.add(
|
|
1575
|
+
"Call my desk line at +1 555 0100",
|
|
1576
|
+
source="user_stated",
|
|
1577
|
+
action_relevant=True,
|
|
1578
|
+
entity="user",
|
|
1579
|
+
attribute="office_phone",
|
|
1580
|
+
)
|
|
1581
|
+
assert (phone.attribute_key, phone.slot_class) == ("work_phone", "contact")
|
|
1582
|
+
|
|
1583
|
+
mislabelled = await db.factual.add(
|
|
1584
|
+
"My account number is 12345678",
|
|
1585
|
+
source="user_stated",
|
|
1586
|
+
action_relevant=True,
|
|
1587
|
+
entity="user",
|
|
1588
|
+
attribute="billing_email",
|
|
1589
|
+
)
|
|
1590
|
+
assert mislabelled.epistemic_source == "agent_inferred"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|