engraphis 0.9.2__tar.gz → 0.9.4__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.
- {engraphis-0.9.2/engraphis.egg-info → engraphis-0.9.4}/PKG-INFO +6 -1
- {engraphis-0.9.2 → engraphis-0.9.4}/README.md +5 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/automation.py +93 -1
- engraphis-0.9.4/engraphis/backends/extractor.py +368 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/cloud_license.py +96 -22
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/config.py +4 -2
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/consolidate.py +156 -1
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/dashboard_app.py +95 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/auth.py +47 -10
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/license_cloud.py +264 -17
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/webhooks.py +39 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/licensing.py +60 -28
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/v2_api.py +29 -12
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/service.py +60 -12
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/index.html +9 -9
- {engraphis-0.9.2 → engraphis-0.9.4/engraphis.egg-info}/PKG-INFO +6 -1
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/SOURCES.txt +6 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/pyproject.toml +1 -1
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/auto_maintain.py +6 -4
- engraphis-0.9.4/tests/test_chunking_eval.py +42 -0
- engraphis-0.9.4/tests/test_chunking_extractor.py +126 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_cloud_license.py +310 -51
- engraphis-0.9.4/tests/test_dashboard_dream_ui.py +26 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_dashboard_v2.py +108 -10
- engraphis-0.9.4/tests/test_dreaming_trigger.py +125 -0
- engraphis-0.9.4/tests/test_import_chunking.py +66 -0
- engraphis-0.9.4/tests/test_inference.py +91 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_online_only_enforcement.py +3 -2
- engraphis-0.9.2/engraphis/backends/extractor.py +0 -148
- {engraphis-0.9.2 → engraphis-0.9.4}/LICENSE +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/NOTICE +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/analytics.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/app.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/autosync.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/codegraph.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/embedder_api.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/embedder_deterministic.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/embedder_st.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/encrypted_db.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/graph_extractor.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/reranker.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/sync_folder.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/sync_relay.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/vector_numpy.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/backends/vector_sqlitevec.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/billing.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/engine.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/graphrank.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/grounded.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/ids.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/interfaces.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/recall.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/resolve.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/schema.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/scoring.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/store.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/sync.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/core/textutil.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/embedder.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/ingest.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/intelligence.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/recall.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/reweight.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/engines/thoughts.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/graphdata.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/app.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/cloud_mount.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/license_registry.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/inspector/sync_relay.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/llm/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/llm/client.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/logging_setup.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/mcp_server.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/models.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/redirector.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/memory.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/v2_team.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/routes/vault.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/engraphis-icon.png +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/engraphis.ico +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/favicon-32.png +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/d3.min.js +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/force-graph.LICENSE +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/force-graph.min.js +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/marked.min.js +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/static/vendor/purify.min.js +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/graph.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/ledger.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/vaults.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis/stores/vectors.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/dependency_links.txt +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/entry_points.txt +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/requires.txt +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/engraphis.egg-info/top_level.txt +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/__init__.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/backfill_graph.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/cli.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/consolidate.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/init.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/inspector.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/install_shortcuts.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/license_admin.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/mcp_server_http.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/migrate_to_v2.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/sdk_compat.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/seed_from_obsidian.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/smoke_cloud.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/start_dashboard.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/start_server.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/sync.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/test_routes.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/scripts/update.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/setup.cfg +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_analytics.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_app_auth.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_autosync.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_backends_factories.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_billing.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_cloud_endpoints_mounted.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_codegraph.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_config.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_consolidate.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_core_ids.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_core_store.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_encrypted_store.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_engine.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_eval_external.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_eval_harness.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_extractor.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_graph_extractor.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_graphdata.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_graphrank.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_grounded.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_ingest_entities.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_init.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_inspector.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_inspector_pro.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_licensing.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_logging_setup.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_mcp_server.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_merge.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_migration.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_password_reset.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_personal_folders.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_provenance_flags.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_ready.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_recall.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_resolve.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_scoring.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_service.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_service_graph.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_service_isolation.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_session_idempotent.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync_cli.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync_dashboard.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_sync_relay.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_team_audit.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_v1_auto_migrate.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_v1_hardening.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_v1_licensing.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_vector_numpy.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_webhook_e2e.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_webhooks.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_workspace_isolation.py +0 -0
- {engraphis-0.9.2 → engraphis-0.9.4}/tests/test_workspace_ops.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: engraphis
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.4
|
|
4
4
|
Summary: Local-first AI memory engine for agents — Ebbinghaus decay, interaction-aware recall, bi-temporal facts, hybrid retrieval, and an MCP server. You bring the LLM.
|
|
5
5
|
Author: The Engraphis Authors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -197,6 +197,11 @@ pip install "engraphis[encryption]" # SQLCipher encryption-at-rest extra
|
|
|
197
197
|
pip install engraphis # core library — numpy only, fully offline
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
+
> **Linux / macOS:** if `pip install` fails with `error: externally-managed-environment`,
|
|
201
|
+
> your system Python is marked read-only (PEP 668). Install into a virtual environment
|
|
202
|
+
> instead — `python3 -m venv venv && source venv/bin/activate && pip install "engraphis[server]"`
|
|
203
|
+
> — or use Docker (`docker compose up`). `pipx install "engraphis[server]"` also works.
|
|
204
|
+
|
|
200
205
|
> First run downloads `all-MiniLM-L6-v2` (~80 MB). Without it, the engine falls back
|
|
201
206
|
> to a deterministic offline embedder so it always runs.
|
|
202
207
|
|
|
@@ -129,6 +129,11 @@ pip install "engraphis[encryption]" # SQLCipher encryption-at-rest extra
|
|
|
129
129
|
pip install engraphis # core library — numpy only, fully offline
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
> **Linux / macOS:** if `pip install` fails with `error: externally-managed-environment`,
|
|
133
|
+
> your system Python is marked read-only (PEP 668). Install into a virtual environment
|
|
134
|
+
> instead — `python3 -m venv venv && source venv/bin/activate && pip install "engraphis[server]"`
|
|
135
|
+
> — or use Docker (`docker compose up`). `pipx install "engraphis[server]"` also works.
|
|
136
|
+
|
|
132
137
|
> First run downloads `all-MiniLM-L6-v2` (~80 MB). Without it, the engine falls back
|
|
133
138
|
> to a deterministic offline embedder so it always runs.
|
|
134
139
|
|
|
@@ -29,6 +29,18 @@ DEFAULT_POLICY: dict = {
|
|
|
29
29
|
"min_cluster": 3,
|
|
30
30
|
"archive_below": 0.05,
|
|
31
31
|
"workspaces": [], # empty = every workspace the caller can see
|
|
32
|
+
# "Dreaming" trigger: run a sweep *before* the cadence elapses when enough new
|
|
33
|
+
# episodic memories have piled up AND the store has gone quiet (the user paused).
|
|
34
|
+
# Purely additive to the cadence in ``due()`` — it can only cause more sweeps, never
|
|
35
|
+
# fewer — so existing cron behaviour is unchanged when left at defaults.
|
|
36
|
+
"dream": True,
|
|
37
|
+
"dream_min_new": 25, # new episodics since last run before an early sweep is worth it
|
|
38
|
+
"dream_idle_minutes": 15, # ...and this long since the most recent write ("went quiet")
|
|
39
|
+
# Cross-cluster inference (consolidate pass 4). OFF by default: it writes new
|
|
40
|
+
# (low-salience, untrusted, linked) memories, so a human opts in. When on, a sweep
|
|
41
|
+
# (manual *or* the dream loop) runs the inference pass too — following the sweep's
|
|
42
|
+
# own dry_run flag, so a dry-run preview proposes and a real run applies.
|
|
43
|
+
"infer": False,
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
_POLICY_KEYS = set(DEFAULT_POLICY)
|
|
@@ -78,6 +90,16 @@ def normalize_policy(raw: dict) -> dict:
|
|
|
78
90
|
p["archive_below"] = 0.05
|
|
79
91
|
wss = p.get("workspaces") or []
|
|
80
92
|
p["workspaces"] = [str(w) for w in wss] if isinstance(wss, list) else []
|
|
93
|
+
p["dream"] = bool(p["dream"])
|
|
94
|
+
p["infer"] = bool(p["infer"])
|
|
95
|
+
try:
|
|
96
|
+
p["dream_min_new"] = max(1, int(p["dream_min_new"] or 25))
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
p["dream_min_new"] = 25
|
|
99
|
+
try:
|
|
100
|
+
p["dream_idle_minutes"] = max(0, int(p["dream_idle_minutes"]))
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
p["dream_idle_minutes"] = 15
|
|
81
103
|
return p
|
|
82
104
|
|
|
83
105
|
|
|
@@ -131,6 +153,75 @@ def due(policy: dict, *, now: Optional[float] = None) -> bool:
|
|
|
131
153
|
return True
|
|
132
154
|
|
|
133
155
|
|
|
156
|
+
def dream_signals(memories: Any, *, last_run: Optional[float],
|
|
157
|
+
now: float) -> tuple[int, Optional[float]]:
|
|
158
|
+
"""From an iterable of memory records, compute ``(new_episodic, idle_seconds)``:
|
|
159
|
+
how many *episodic* memories were ingested since ``last_run`` (accumulation) and how
|
|
160
|
+
long since the most recent write of any kind (idle). ``idle_seconds`` is ``None`` for
|
|
161
|
+
an empty store. Pure and side-effect-free so it unit-tests without a database."""
|
|
162
|
+
newest = 0.0
|
|
163
|
+
new_episodic = 0
|
|
164
|
+
for m in memories:
|
|
165
|
+
ts = float(getattr(m, "ingested_at", 0) or 0)
|
|
166
|
+
if ts > newest:
|
|
167
|
+
newest = ts
|
|
168
|
+
mtype = getattr(getattr(m, "mtype", ""), "value", getattr(m, "mtype", ""))
|
|
169
|
+
if str(mtype) == "episodic" and (last_run is None or ts > float(last_run)):
|
|
170
|
+
new_episodic += 1
|
|
171
|
+
return new_episodic, (now - newest if newest else None)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def should_dream(policy: dict, memories: Any, *, now: Optional[float] = None) -> bool:
|
|
175
|
+
"""True when accumulation + idle warrant a sweep *before* the cadence is due.
|
|
176
|
+
|
|
177
|
+
Requires the policy be enabled and dreaming on, then: at least ``dream_min_new`` new
|
|
178
|
+
episodic memories since ``last_run`` **and** the store idle for ``dream_idle_minutes``.
|
|
179
|
+
The idle guard means a sweep never fights a live editing burst; the accumulation guard
|
|
180
|
+
means it never runs on a store nothing has been added to."""
|
|
181
|
+
if not policy.get("enabled") or not policy.get("dream", True):
|
|
182
|
+
return False
|
|
183
|
+
now = time.time() if now is None else now
|
|
184
|
+
new_episodic, idle = dream_signals(
|
|
185
|
+
memories, last_run=policy.get("last_run"), now=now)
|
|
186
|
+
if new_episodic < int(policy.get("dream_min_new", 25)):
|
|
187
|
+
return False
|
|
188
|
+
if idle is None:
|
|
189
|
+
return False
|
|
190
|
+
return idle >= int(policy.get("dream_idle_minutes", 15)) * 60.0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def dream_due(service: Any, *, policy: Optional[dict] = None,
|
|
194
|
+
now: Optional[float] = None, scan_limit: int = 5000) -> bool:
|
|
195
|
+
"""Should a scheduled sweep run now? ``due()`` (cadence) **or** ``should_dream()``
|
|
196
|
+
(accumulation+idle). Reads recent memories from the service's store — the only IO in
|
|
197
|
+
the trigger — and is purely additive to the cadence, so cron users are unaffected."""
|
|
198
|
+
pol = load_policy() if policy is None else policy
|
|
199
|
+
if due(pol, now=now):
|
|
200
|
+
return True
|
|
201
|
+
try:
|
|
202
|
+
from engraphis.core.interfaces import SearchFilter
|
|
203
|
+
targets = pol.get("workspaces") or []
|
|
204
|
+
if targets:
|
|
205
|
+
# Only accumulation/idle *within* the scoped workspaces can fire a sweep
|
|
206
|
+
# — a burst in an out-of-scope workspace must not trigger one. Names are
|
|
207
|
+
# resolved to ids with a read-only lookup (never create) so a stale policy
|
|
208
|
+
# entry can't mint an empty folder.
|
|
209
|
+
memories: list = []
|
|
210
|
+
conn = service.store.conn
|
|
211
|
+
for name in targets:
|
|
212
|
+
row = conn.execute(
|
|
213
|
+
"SELECT id FROM workspaces WHERE name=?", (str(name),)).fetchone()
|
|
214
|
+
if not row:
|
|
215
|
+
continue
|
|
216
|
+
memories.extend(service.store.list_memories(
|
|
217
|
+
SearchFilter(workspace_id=row["id"]), limit=scan_limit))
|
|
218
|
+
else:
|
|
219
|
+
memories = service.store.list_memories(SearchFilter(), limit=scan_limit)
|
|
220
|
+
except Exception: # noqa: BLE001 - a trigger must never crash the scheduled job
|
|
221
|
+
return False
|
|
222
|
+
return should_dream(pol, memories, now=now)
|
|
223
|
+
|
|
224
|
+
|
|
134
225
|
def run_maintenance(service: Any, *, dry_run: bool = True,
|
|
135
226
|
policy: Optional[dict] = None, record: bool = True,
|
|
136
227
|
now: Optional[float] = None) -> dict:
|
|
@@ -156,7 +247,8 @@ def run_maintenance(service: Any, *, dry_run: bool = True,
|
|
|
156
247
|
entry["consolidate"] = service.consolidate(
|
|
157
248
|
workspace=ws, dry_run=dry_run,
|
|
158
249
|
min_cluster=pol["min_cluster"],
|
|
159
|
-
archive_below=pol["archive_below"]
|
|
250
|
+
archive_below=pol["archive_below"],
|
|
251
|
+
infer=bool(pol.get("infer", False)))
|
|
160
252
|
except Exception as exc: # noqa: BLE001 - isolate a bad workspace
|
|
161
253
|
entry["error"] = str(exc)
|
|
162
254
|
runs.append(entry)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Fact extractors — implementations of the ``core.interfaces.Extractor`` protocol.
|
|
2
|
+
|
|
3
|
+
Every SOTA memory system (mem0, A-Mem, Letta) auto-distills raw text into discrete
|
|
4
|
+
facts before storage; Engraphis makes that step *pluggable and optional* so the core
|
|
5
|
+
stays offline-capable (AGENTS.md §3.8):
|
|
6
|
+
|
|
7
|
+
* ``PassthroughExtractor`` — the default: the caller's text is stored exactly as given
|
|
8
|
+
(today's behaviour, zero dependencies, zero network).
|
|
9
|
+
* ``ChunkingExtractor`` — splits a document into retrieval-sized, structure-aware
|
|
10
|
+
chunks (one ``ExtractedFact`` each) *without* an LLM: headings start new chunks and
|
|
11
|
+
become the title, fenced code blocks stay intact, prose is packed to a token budget
|
|
12
|
+
with a small sentence overlap. Deterministic and offline (numpy/stdlib only) so it
|
|
13
|
+
runs under the offline gate; the answer to "one memory per file dilutes recall".
|
|
14
|
+
* ``LLMExtractor`` — distills a raw blob (a conversation turn, a log, a diff
|
|
15
|
+
summary) into discrete, self-contained facts with type/importance/keyword hints,
|
|
16
|
+
using any configured LLM. Fails soft: any error degrades to passthrough, never to a
|
|
17
|
+
lost write.
|
|
18
|
+
|
|
19
|
+
Selected via ``get_extractor()`` from ``ENGRAPHIS_EXTRACTOR`` (= ``none`` | ``chunk`` |
|
|
20
|
+
``llm``) — a config change, not a refactor, matching every other backend swap here.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from engraphis.core.interfaces import ExtractedFact, MemoryType
|
|
30
|
+
from engraphis.core.textutil import estimate_tokens, tokenize
|
|
31
|
+
|
|
32
|
+
MAX_FACTS = 12
|
|
33
|
+
|
|
34
|
+
# Structure-aware chunking defaults (offline, deterministic). Overridable via
|
|
35
|
+
# ENGRAPHIS_CHUNK_TOKENS / ENGRAPHIS_CHUNK_OVERLAP / ENGRAPHIS_CHUNK_MAX.
|
|
36
|
+
CHUNK_TARGET_TOKENS = 256 # target tokens per prose chunk
|
|
37
|
+
CHUNK_OVERLAP_TOKENS = 32 # sentence-level overlap carried between adjacent chunks
|
|
38
|
+
CHUNK_MAX = 200 # hard cap on chunks per document (amplification guard)
|
|
39
|
+
|
|
40
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*$")
|
|
41
|
+
_FENCE_RE = re.compile(r"^(```+|~~~+)")
|
|
42
|
+
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
|
|
43
|
+
_PARA_SPLIT_RE = re.compile(r"\n\s*\n")
|
|
44
|
+
|
|
45
|
+
# LLM output is untrusted input too (indirect prompt injection can steer it): strip the
|
|
46
|
+
# same control characters service.py strips from direct writes, so extracted facts can't
|
|
47
|
+
# smuggle hidden-instruction / terminal-escape payloads past the validation layer.
|
|
48
|
+
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _defang(value: str, limit: int) -> str:
|
|
52
|
+
return _CONTROL_RE.sub("", value)[:limit].strip()
|
|
53
|
+
|
|
54
|
+
_EXTRACT_SYSTEM_PROMPT = (
|
|
55
|
+
"You distill raw text into discrete, self-contained memory facts for an AI agent's "
|
|
56
|
+
"long-term memory. Each fact must stand alone (no pronouns that depend on the "
|
|
57
|
+
"original text), be worth remembering beyond this moment, and be stated in one or "
|
|
58
|
+
"two sentences. Skip filler, pleasantries, and transient chatter.\n\n"
|
|
59
|
+
"Classify each fact:\n"
|
|
60
|
+
"- semantic: durable facts, preferences, conventions ('The API uses PASETO tokens')\n"
|
|
61
|
+
"- episodic: events and decisions with a when ('On 2026-06-30 PR #99 was merged')\n"
|
|
62
|
+
"- procedural: how-tos and playbooks ('To rebuild the index, run ...')\n"
|
|
63
|
+
"- working: transient state only relevant right now ('Currently blocked on CI')\n\n"
|
|
64
|
+
"Respond with JSON only, no markdown fences, no prose:\n"
|
|
65
|
+
'{"facts": [{"content": str, "title": str, "mtype": "semantic|episodic|procedural|'
|
|
66
|
+
'working", "importance": <0..1>, "keywords": [str, ...]}]}'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PassthroughExtractor:
|
|
71
|
+
"""The offline default: one fact, the text as given."""
|
|
72
|
+
|
|
73
|
+
def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
|
|
74
|
+
return [ExtractedFact(content=text)]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LLMExtractor:
|
|
78
|
+
"""LLM-backed fact distillation behind the ``Extractor`` protocol.
|
|
79
|
+
|
|
80
|
+
``llm`` may be anything with a ``chat(messages, system=...) -> str`` method (the
|
|
81
|
+
engraphis v1 ``LLMClient``) or a ``complete(messages) -> str`` method (the
|
|
82
|
+
``core.interfaces.LLM`` protocol). Untrusted input goes *into* the prompt; the
|
|
83
|
+
output is parsed defensively and every fact re-validated — a malformed or
|
|
84
|
+
adversarial response degrades to passthrough rather than corrupting the store.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, llm: Any, *, max_facts: int = MAX_FACTS) -> None:
|
|
88
|
+
self.llm = llm
|
|
89
|
+
self.max_facts = max_facts
|
|
90
|
+
|
|
91
|
+
def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
|
|
92
|
+
prompt = f"Context: {context}\n\nText to distill:\n{text}" if context else text
|
|
93
|
+
try:
|
|
94
|
+
raw = self._ask(prompt)
|
|
95
|
+
facts = self._parse(raw)
|
|
96
|
+
except Exception:
|
|
97
|
+
facts = []
|
|
98
|
+
return facts or [ExtractedFact(content=text)]
|
|
99
|
+
|
|
100
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
101
|
+
def _ask(self, prompt: str) -> str:
|
|
102
|
+
messages = [{"role": "user", "content": prompt}]
|
|
103
|
+
if hasattr(self.llm, "chat"):
|
|
104
|
+
return self.llm.chat(messages, system=_EXTRACT_SYSTEM_PROMPT)
|
|
105
|
+
return self.llm.complete(
|
|
106
|
+
[{"role": "system", "content": _EXTRACT_SYSTEM_PROMPT}, *messages])
|
|
107
|
+
|
|
108
|
+
def _parse(self, raw: str) -> list[ExtractedFact]:
|
|
109
|
+
data = _loads_lenient(raw)
|
|
110
|
+
out: list[ExtractedFact] = []
|
|
111
|
+
for item in (data.get("facts") or [])[: self.max_facts]:
|
|
112
|
+
if not isinstance(item, dict):
|
|
113
|
+
continue
|
|
114
|
+
content = _defang(str(item.get("content") or ""), 100_000)
|
|
115
|
+
if not content:
|
|
116
|
+
continue
|
|
117
|
+
mtype: Optional[MemoryType] = None
|
|
118
|
+
try:
|
|
119
|
+
mtype = MemoryType(str(item.get("mtype", "")).strip().lower())
|
|
120
|
+
except ValueError:
|
|
121
|
+
mtype = None
|
|
122
|
+
try:
|
|
123
|
+
importance = max(0.0, min(1.0, float(item.get("importance", 0.0))))
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
importance = 0.0
|
|
126
|
+
keywords = [_defang(str(k), 128) for k in (item.get("keywords") or [])[:16]
|
|
127
|
+
if isinstance(k, (str, int, float))]
|
|
128
|
+
out.append(ExtractedFact(content=content,
|
|
129
|
+
title=_defang(str(item.get("title") or ""), 1_000),
|
|
130
|
+
mtype=mtype, importance=importance,
|
|
131
|
+
keywords=[k for k in keywords if k]))
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ChunkingExtractor:
|
|
136
|
+
"""Deterministic, offline, structure-aware chunker (``Extractor`` protocol).
|
|
137
|
+
|
|
138
|
+
Splits a document into retrieval-sized ``ExtractedFact`` chunks that preserve
|
|
139
|
+
meaning rather than cutting at arbitrary character counts:
|
|
140
|
+
|
|
141
|
+
* Markdown headings (``#``..``######``) start a new chunk; the heading path
|
|
142
|
+
(``H1 > H2``) becomes the chunk title and is kept as context.
|
|
143
|
+
* Fenced code blocks (```` ``` ````/``~~~``) are emitted whole — never split
|
|
144
|
+
mid-fence.
|
|
145
|
+
* Prose is packed paragraph-by-paragraph up to ``target_tokens``, with a small
|
|
146
|
+
sentence-level overlap so a fact straddling a boundary survives in both chunks.
|
|
147
|
+
|
|
148
|
+
numpy/stdlib only — no model, no network — so it is safe inside the offline gate
|
|
149
|
+
and identical across runs (a requirement for deterministic-embedder eval).
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, *, target_tokens: int = CHUNK_TARGET_TOKENS,
|
|
153
|
+
overlap_tokens: int = CHUNK_OVERLAP_TOKENS,
|
|
154
|
+
max_chunks: int = CHUNK_MAX) -> None:
|
|
155
|
+
self.target_tokens = max(16, int(target_tokens))
|
|
156
|
+
self.overlap_tokens = max(0, min(int(overlap_tokens), self.target_tokens // 2))
|
|
157
|
+
self.max_chunks = max(1, int(max_chunks))
|
|
158
|
+
|
|
159
|
+
def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
|
|
160
|
+
text = text or ""
|
|
161
|
+
if not text.strip():
|
|
162
|
+
return []
|
|
163
|
+
facts: list[ExtractedFact] = []
|
|
164
|
+
for heading_path, content in self._chunks(text):
|
|
165
|
+
content = _defang(content, 100_000)
|
|
166
|
+
if not content:
|
|
167
|
+
continue
|
|
168
|
+
leaf = heading_path.split(" > ")[-1] if heading_path else ""
|
|
169
|
+
title = _defang(leaf or _first_line(content), 1_000)
|
|
170
|
+
facts.append(ExtractedFact(content=content, title=title[:200],
|
|
171
|
+
keywords=_keywords(content)))
|
|
172
|
+
if len(facts) >= self.max_chunks:
|
|
173
|
+
break
|
|
174
|
+
# Never lose the write: an all-whitespace/degenerate parse falls back to the
|
|
175
|
+
# whole text, exactly like PassthroughExtractor.
|
|
176
|
+
return facts or [ExtractedFact(content=_defang(text, 100_000))]
|
|
177
|
+
|
|
178
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
179
|
+
def _chunks(self, text: str) -> list[tuple[str, str]]:
|
|
180
|
+
out: list[tuple[str, str]] = []
|
|
181
|
+
for heading_path, kind, body in self._segment(text):
|
|
182
|
+
if len(out) >= self.max_chunks:
|
|
183
|
+
break
|
|
184
|
+
if kind == "code":
|
|
185
|
+
out.append((heading_path, body)) # atomic — never split
|
|
186
|
+
else:
|
|
187
|
+
for piece in self._pack(body):
|
|
188
|
+
out.append((heading_path, piece))
|
|
189
|
+
if len(out) >= self.max_chunks:
|
|
190
|
+
break
|
|
191
|
+
return out[: self.max_chunks]
|
|
192
|
+
|
|
193
|
+
def _segment(self, text: str) -> list[tuple[str, str, str]]:
|
|
194
|
+
"""Split into ``(heading_path, kind, body)`` segments, preserving code fences
|
|
195
|
+
whole and tracking the active markdown heading stack."""
|
|
196
|
+
lines = text.split("\n")
|
|
197
|
+
heading_stack: list[tuple[int, str]] = []
|
|
198
|
+
segments: list[tuple[str, str, str]] = []
|
|
199
|
+
prose: list[str] = []
|
|
200
|
+
n = len(lines)
|
|
201
|
+
|
|
202
|
+
def path() -> str:
|
|
203
|
+
return " > ".join(t for _, t in heading_stack)
|
|
204
|
+
|
|
205
|
+
def flush() -> None:
|
|
206
|
+
if prose:
|
|
207
|
+
body = "\n".join(prose).strip()
|
|
208
|
+
if body:
|
|
209
|
+
segments.append((path(), "prose", body))
|
|
210
|
+
prose.clear()
|
|
211
|
+
|
|
212
|
+
i = 0
|
|
213
|
+
while i < n:
|
|
214
|
+
line = lines[i]
|
|
215
|
+
stripped = line.strip()
|
|
216
|
+
fence = _FENCE_RE.match(stripped)
|
|
217
|
+
heading = _HEADING_RE.match(line)
|
|
218
|
+
if fence:
|
|
219
|
+
flush()
|
|
220
|
+
marker = fence.group(1)[:3]
|
|
221
|
+
block = [line]
|
|
222
|
+
i += 1
|
|
223
|
+
while i < n:
|
|
224
|
+
block.append(lines[i])
|
|
225
|
+
closed = lines[i].strip().startswith(marker)
|
|
226
|
+
i += 1
|
|
227
|
+
if closed:
|
|
228
|
+
break
|
|
229
|
+
segments.append((path(), "code", "\n".join(block).strip()))
|
|
230
|
+
continue
|
|
231
|
+
if heading:
|
|
232
|
+
flush()
|
|
233
|
+
level = len(heading.group(1))
|
|
234
|
+
heading_stack[:] = [(lv, t) for lv, t in heading_stack if lv < level]
|
|
235
|
+
heading_stack.append((level, heading.group(2).strip()))
|
|
236
|
+
prose.append(line) # keep heading text in the body too, for lexical recall
|
|
237
|
+
i += 1
|
|
238
|
+
continue
|
|
239
|
+
prose.append(line)
|
|
240
|
+
i += 1
|
|
241
|
+
flush()
|
|
242
|
+
return segments
|
|
243
|
+
|
|
244
|
+
def _pack(self, body: str) -> list[str]:
|
|
245
|
+
"""Greedily pack paragraphs to the token budget with sentence overlap."""
|
|
246
|
+
paras = [p.strip() for p in _PARA_SPLIT_RE.split(body) if p.strip()]
|
|
247
|
+
chunks: list[str] = []
|
|
248
|
+
cur: list[str] = []
|
|
249
|
+
cur_tokens = 0
|
|
250
|
+
for para in paras:
|
|
251
|
+
ptokens = estimate_tokens(para)
|
|
252
|
+
if cur and cur_tokens + ptokens > self.target_tokens:
|
|
253
|
+
joined = "\n\n".join(cur)
|
|
254
|
+
chunks.append(joined)
|
|
255
|
+
tail = self._overlap_tail(joined)
|
|
256
|
+
cur = [tail] if tail else []
|
|
257
|
+
cur_tokens = estimate_tokens(tail) if tail else 0
|
|
258
|
+
if ptokens > self.target_tokens:
|
|
259
|
+
for group in self._split_paragraph(para):
|
|
260
|
+
chunks.append(group)
|
|
261
|
+
cur, cur_tokens = [], 0
|
|
262
|
+
continue
|
|
263
|
+
cur.append(para)
|
|
264
|
+
cur_tokens += ptokens
|
|
265
|
+
if cur:
|
|
266
|
+
chunks.append("\n\n".join(cur))
|
|
267
|
+
return chunks
|
|
268
|
+
|
|
269
|
+
def _split_paragraph(self, para: str) -> list[str]:
|
|
270
|
+
"""Split an oversized paragraph on sentence boundaries (never mid-sentence)."""
|
|
271
|
+
sentences = [s for s in _SENTENCE_RE.split(para.strip()) if s]
|
|
272
|
+
groups: list[str] = []
|
|
273
|
+
cur: list[str] = []
|
|
274
|
+
cur_tokens = 0
|
|
275
|
+
for sent in sentences:
|
|
276
|
+
stokens = estimate_tokens(sent)
|
|
277
|
+
if cur and cur_tokens + stokens > self.target_tokens:
|
|
278
|
+
joined = " ".join(cur)
|
|
279
|
+
groups.append(joined)
|
|
280
|
+
tail = self._overlap_tail(joined)
|
|
281
|
+
cur = [tail] if tail else []
|
|
282
|
+
cur_tokens = estimate_tokens(tail) if tail else 0
|
|
283
|
+
cur.append(sent)
|
|
284
|
+
cur_tokens += stokens
|
|
285
|
+
if cur:
|
|
286
|
+
groups.append(" ".join(cur))
|
|
287
|
+
return groups
|
|
288
|
+
|
|
289
|
+
def _overlap_tail(self, text: str) -> str:
|
|
290
|
+
"""Trailing whole sentences of ``text`` up to ``overlap_tokens``."""
|
|
291
|
+
if self.overlap_tokens <= 0:
|
|
292
|
+
return ""
|
|
293
|
+
sentences = [s for s in _SENTENCE_RE.split(text.strip()) if s]
|
|
294
|
+
tail: list[str] = []
|
|
295
|
+
tokens = 0
|
|
296
|
+
for sent in reversed(sentences):
|
|
297
|
+
if tail and tokens + estimate_tokens(sent) > self.overlap_tokens:
|
|
298
|
+
break
|
|
299
|
+
tail.insert(0, sent)
|
|
300
|
+
tokens += estimate_tokens(sent)
|
|
301
|
+
if tokens >= self.overlap_tokens:
|
|
302
|
+
break
|
|
303
|
+
return " ".join(tail).strip()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _first_line(text: str) -> str:
|
|
307
|
+
for line in text.splitlines():
|
|
308
|
+
candidate = line.strip().lstrip("#").strip()
|
|
309
|
+
if candidate:
|
|
310
|
+
return candidate[:200]
|
|
311
|
+
return "chunk"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _keywords(text: str, k: int = 8) -> list[str]:
|
|
315
|
+
counts: dict[str, int] = {}
|
|
316
|
+
for token in tokenize(text):
|
|
317
|
+
counts[token] = counts.get(token, 0) + 1
|
|
318
|
+
return sorted(counts, key=lambda t: (-counts[t], t))[:k]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _env_int(name: str, default: int) -> int:
|
|
322
|
+
try:
|
|
323
|
+
return int(os.environ.get(name, "") or default)
|
|
324
|
+
except (TypeError, ValueError):
|
|
325
|
+
return default
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _loads_lenient(raw: str) -> dict:
|
|
329
|
+
"""Parse JSON that may arrive wrapped in markdown fences or prose."""
|
|
330
|
+
raw = (raw or "").strip()
|
|
331
|
+
try:
|
|
332
|
+
return json.loads(raw)
|
|
333
|
+
except (json.JSONDecodeError, RecursionError):
|
|
334
|
+
pass
|
|
335
|
+
m = re.search(r"\{.*\}", raw, flags=re.DOTALL)
|
|
336
|
+
if m:
|
|
337
|
+
try:
|
|
338
|
+
return json.loads(m.group(0))
|
|
339
|
+
except (json.JSONDecodeError, RecursionError):
|
|
340
|
+
pass
|
|
341
|
+
return {}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_extractor(kind: str = "none", llm: Any = None):
|
|
345
|
+
"""Factory mirroring ``get_embedder``/``get_vector_index``: config in, backend out.
|
|
346
|
+
|
|
347
|
+
``kind='chunk'`` returns the deterministic, offline ``ChunkingExtractor`` (knobs from
|
|
348
|
+
``ENGRAPHIS_CHUNK_TOKENS``/``_OVERLAP``/``_MAX``). ``kind='llm'`` with no ``llm``
|
|
349
|
+
builds the v1 multi-provider ``LLMClient`` from settings (heavy import gated here,
|
|
350
|
+
never in ``core/``). Anything else — including an LLM kind with no usable client —
|
|
351
|
+
returns the offline passthrough.
|
|
352
|
+
"""
|
|
353
|
+
kind = (kind or "none").lower()
|
|
354
|
+
if kind == "chunk":
|
|
355
|
+
return ChunkingExtractor(
|
|
356
|
+
target_tokens=_env_int("ENGRAPHIS_CHUNK_TOKENS", CHUNK_TARGET_TOKENS),
|
|
357
|
+
overlap_tokens=_env_int("ENGRAPHIS_CHUNK_OVERLAP", CHUNK_OVERLAP_TOKENS),
|
|
358
|
+
max_chunks=_env_int("ENGRAPHIS_CHUNK_MAX", CHUNK_MAX),
|
|
359
|
+
)
|
|
360
|
+
if kind != "llm":
|
|
361
|
+
return PassthroughExtractor()
|
|
362
|
+
if llm is None:
|
|
363
|
+
try:
|
|
364
|
+
from engraphis.llm.client import LLMClient
|
|
365
|
+
llm = LLMClient()
|
|
366
|
+
except Exception:
|
|
367
|
+
return PassthroughExtractor()
|
|
368
|
+
return LLMExtractor(llm)
|