cortex-mem 1.0.0__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.
- cortex/__init__.py +0 -0
- cortex/db.py +135 -0
- cortex/schema.sql +75 -0
- cortex/tier_generator.py +280 -0
- cortex/tiered_retrieval.py +335 -0
- cortex_mem/__init__.py +5 -0
- cortex_mem/__version__.py +1 -0
- cortex_mem/cli.py +170 -0
- cortex_mem/openclaw_plugin.py +73 -0
- cortex_mem/openclaw_provider.py +68 -0
- cortex_mem-1.0.0.dist-info/METADATA +153 -0
- cortex_mem-1.0.0.dist-info/RECORD +22 -0
- cortex_mem-1.0.0.dist-info/WHEEL +5 -0
- cortex_mem-1.0.0.dist-info/entry_points.txt +5 -0
- cortex_mem-1.0.0.dist-info/licenses/LICENSE +21 -0
- cortex_mem-1.0.0.dist-info/top_level.txt +3 -0
- service/__init__.py +0 -0
- service/api.py +311 -0
- service/client.py +105 -0
- service/config.yaml +36 -0
- service/models.py +69 -0
- service/storage.py +336 -0
cortex/__init__.py
ADDED
|
File without changes
|
cortex/db.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite metadata store for Cortex tiered documents.
|
|
3
|
+
Thread-safe, async-compatible via aiosqlite.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
DB_PATH = Path(__file__).parent.parent / "index" / "cortex_tiers.db"
|
|
12
|
+
SCHEMA_PATH = Path(__file__).parent / "schema.sql"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_conn(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
|
16
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
conn = sqlite3.connect(str(db_path))
|
|
18
|
+
conn.row_factory = sqlite3.Row
|
|
19
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
20
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
21
|
+
return conn
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
|
25
|
+
conn = _get_conn(db_path)
|
|
26
|
+
with open(SCHEMA_PATH) as f:
|
|
27
|
+
conn.executescript(f.read())
|
|
28
|
+
conn.commit()
|
|
29
|
+
return conn
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def insert_document(conn: sqlite3.Connection, doc: Dict[str, Any]) -> str:
|
|
33
|
+
doc_id = doc.get("doc_id") or str(uuid.uuid4())
|
|
34
|
+
tags = json.dumps(doc.get("tags", []))
|
|
35
|
+
conn.execute(
|
|
36
|
+
"""
|
|
37
|
+
INSERT OR REPLACE INTO documents (
|
|
38
|
+
doc_id, hierarchy_path, title, doc_type,
|
|
39
|
+
l0_abstract, l0_token_count,
|
|
40
|
+
l1_overview, l1_token_count,
|
|
41
|
+
l2_file_path, l2_token_count, l2_checksum,
|
|
42
|
+
chromadb_l0_id, chromadb_l1_id,
|
|
43
|
+
parent_path, depth, tags,
|
|
44
|
+
l0_generated_at, l1_generated_at,
|
|
45
|
+
source_type, cortex_tier
|
|
46
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
""",
|
|
48
|
+
(
|
|
49
|
+
doc_id,
|
|
50
|
+
doc["hierarchy_path"],
|
|
51
|
+
doc["title"],
|
|
52
|
+
doc["doc_type"],
|
|
53
|
+
doc["l0_abstract"],
|
|
54
|
+
doc["l0_token_count"],
|
|
55
|
+
doc.get("l1_overview"),
|
|
56
|
+
doc.get("l1_token_count", 0),
|
|
57
|
+
doc["l2_file_path"],
|
|
58
|
+
doc.get("l2_token_count", 0),
|
|
59
|
+
doc["l2_checksum"],
|
|
60
|
+
doc.get("chromadb_l0_id"),
|
|
61
|
+
doc.get("chromadb_l1_id"),
|
|
62
|
+
doc.get("parent_path"),
|
|
63
|
+
doc.get("depth", 0),
|
|
64
|
+
tags,
|
|
65
|
+
doc.get("l0_generated_at"),
|
|
66
|
+
doc.get("l1_generated_at"),
|
|
67
|
+
doc.get("source_type", "manual"),
|
|
68
|
+
doc.get("cortex_tier"),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
conn.commit()
|
|
72
|
+
return doc_id
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_document(conn: sqlite3.Connection, doc_id: str) -> Optional[Dict[str, Any]]:
|
|
76
|
+
row = conn.execute("SELECT * FROM documents WHERE doc_id = ?", (doc_id,)).fetchone()
|
|
77
|
+
if row is None:
|
|
78
|
+
return None
|
|
79
|
+
return _row_to_dict(row)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_all_documents(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
|
|
83
|
+
rows = conn.execute("SELECT * FROM documents ORDER BY updated_at DESC").fetchall()
|
|
84
|
+
return [_row_to_dict(r) for r in rows]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def search_by_hierarchy(
|
|
88
|
+
conn: sqlite3.Connection, prefix: str
|
|
89
|
+
) -> List[Dict[str, Any]]:
|
|
90
|
+
rows = conn.execute(
|
|
91
|
+
"SELECT * FROM documents WHERE hierarchy_path LIKE ? ORDER BY hierarchy_path",
|
|
92
|
+
(prefix + "%",),
|
|
93
|
+
).fetchall()
|
|
94
|
+
return [_row_to_dict(r) for r in rows]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def mark_stale(conn: sqlite3.Connection, doc_id: str, reason: str):
|
|
98
|
+
conn.execute(
|
|
99
|
+
"UPDATE documents SET is_stale = 1, stale_reason = ? WHERE doc_id = ?",
|
|
100
|
+
(reason, doc_id),
|
|
101
|
+
)
|
|
102
|
+
conn.commit()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def log_query(
|
|
106
|
+
conn: sqlite3.Connection,
|
|
107
|
+
query_text: str,
|
|
108
|
+
l0_results: int,
|
|
109
|
+
l1_expansions: int,
|
|
110
|
+
l2_loads: int,
|
|
111
|
+
total_tokens: int,
|
|
112
|
+
latency_ms: int,
|
|
113
|
+
agent_id: Optional[str] = None,
|
|
114
|
+
):
|
|
115
|
+
conn.execute(
|
|
116
|
+
"""
|
|
117
|
+
INSERT INTO query_log (query_id, query_text, agent_id,
|
|
118
|
+
l0_results, l1_expansions, l2_loads, total_tokens, latency_ms)
|
|
119
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
120
|
+
""",
|
|
121
|
+
(str(uuid.uuid4()), query_text, agent_id,
|
|
122
|
+
l0_results, l1_expansions, l2_loads, total_tokens, latency_ms),
|
|
123
|
+
)
|
|
124
|
+
conn.commit()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
|
128
|
+
d = dict(row)
|
|
129
|
+
if isinstance(d.get("tags"), str):
|
|
130
|
+
try:
|
|
131
|
+
d["tags"] = json.loads(d["tags"])
|
|
132
|
+
except (json.JSONDecodeError, TypeError):
|
|
133
|
+
d["tags"] = []
|
|
134
|
+
d["is_stale"] = bool(d.get("is_stale"))
|
|
135
|
+
return d
|
cortex/schema.sql
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- Cortex Tiered Retrieval — SQLite schema
|
|
2
|
+
-- Equivalent to cortex_tiers PostgreSQL schema from master plan.
|
|
3
|
+
-- Stores document metadata + L0/L1 content; L2 lives on filesystem.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
6
|
+
doc_id TEXT PRIMARY KEY,
|
|
7
|
+
hierarchy_path TEXT NOT NULL,
|
|
8
|
+
title TEXT NOT NULL,
|
|
9
|
+
doc_type TEXT NOT NULL CHECK (doc_type IN (
|
|
10
|
+
'strategy', 'backtest', 'research', 'episode',
|
|
11
|
+
'skill', 'pattern', 'session_learning', 'reference'
|
|
12
|
+
)),
|
|
13
|
+
|
|
14
|
+
-- Tier content
|
|
15
|
+
l0_abstract TEXT NOT NULL,
|
|
16
|
+
l0_token_count INTEGER NOT NULL,
|
|
17
|
+
l1_overview TEXT,
|
|
18
|
+
l1_token_count INTEGER DEFAULT 0,
|
|
19
|
+
l2_file_path TEXT NOT NULL,
|
|
20
|
+
l2_token_count INTEGER DEFAULT 0,
|
|
21
|
+
l2_checksum TEXT NOT NULL,
|
|
22
|
+
|
|
23
|
+
-- ChromaDB references
|
|
24
|
+
chromadb_l0_id TEXT,
|
|
25
|
+
chromadb_l1_id TEXT,
|
|
26
|
+
|
|
27
|
+
-- Hierarchy
|
|
28
|
+
parent_path TEXT,
|
|
29
|
+
depth INTEGER DEFAULT 0,
|
|
30
|
+
tags TEXT DEFAULT '[]',
|
|
31
|
+
|
|
32
|
+
-- Timestamps
|
|
33
|
+
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
34
|
+
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
35
|
+
l0_generated_at TEXT,
|
|
36
|
+
l1_generated_at TEXT,
|
|
37
|
+
|
|
38
|
+
-- Invalidation
|
|
39
|
+
is_stale INTEGER DEFAULT 0,
|
|
40
|
+
stale_reason TEXT,
|
|
41
|
+
|
|
42
|
+
-- Source tracking
|
|
43
|
+
source_type TEXT DEFAULT 'manual',
|
|
44
|
+
cortex_tier TEXT CHECK (cortex_tier IN ('episodic', 'semantic', 'procedural') OR cortex_tier IS NULL)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_docs_hierarchy ON documents (hierarchy_path);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_docs_parent ON documents (parent_path);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_docs_type ON documents (doc_type);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_docs_stale ON documents (is_stale) WHERE is_stale = 1;
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS directories (
|
|
53
|
+
dir_id TEXT PRIMARY KEY,
|
|
54
|
+
path TEXT UNIQUE NOT NULL,
|
|
55
|
+
parent_path TEXT,
|
|
56
|
+
name TEXT NOT NULL,
|
|
57
|
+
description TEXT,
|
|
58
|
+
doc_count INTEGER DEFAULT 0,
|
|
59
|
+
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
60
|
+
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_dirs_parent ON directories (parent_path);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS query_log (
|
|
66
|
+
query_id TEXT PRIMARY KEY,
|
|
67
|
+
query_text TEXT NOT NULL,
|
|
68
|
+
agent_id TEXT,
|
|
69
|
+
l0_results INTEGER DEFAULT 0,
|
|
70
|
+
l1_expansions INTEGER DEFAULT 0,
|
|
71
|
+
l2_loads INTEGER DEFAULT 0,
|
|
72
|
+
total_tokens INTEGER DEFAULT 0,
|
|
73
|
+
latency_ms INTEGER DEFAULT 0,
|
|
74
|
+
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
75
|
+
);
|
cortex/tier_generator.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TierGenerator — auto-generates L0/L1 tiers from L2 (full) documents.
|
|
3
|
+
|
|
4
|
+
Uses Ollama (deepseek-r1:7b) for summarization and ChromaDB for vector indexing.
|
|
5
|
+
Prompts follow MEMORY_ARCHITECTURE_MASTER_PLAN.md Section 1.4.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
import chromadb
|
|
18
|
+
|
|
19
|
+
from . import db
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("aoms.cortex")
|
|
22
|
+
|
|
23
|
+
OLLAMA_URL = "http://localhost:11434"
|
|
24
|
+
GENERATION_MODEL = "deepseek-r1:7b"
|
|
25
|
+
L0_MAX_TOKENS = 100
|
|
26
|
+
L1_MAX_TOKENS = 2000
|
|
27
|
+
|
|
28
|
+
L2_STORAGE_ROOT = Path("/home/dhawal/cortex-mem/cortex-mem/cortex/l2_docs")
|
|
29
|
+
CHROMA_PATH = Path("/home/dhawal/cortex-mem/cortex-mem/index/chroma")
|
|
30
|
+
|
|
31
|
+
L0_PROMPT = """Summarize in ONE sentence (max 100 tokens): what this document \
|
|
32
|
+
is about, its primary conclusion, and one key differentiator.
|
|
33
|
+
|
|
34
|
+
DOCUMENT TYPE: {doc_type}
|
|
35
|
+
HIERARCHY: {hierarchy_path}
|
|
36
|
+
TITLE: {title}
|
|
37
|
+
|
|
38
|
+
CONTENT:
|
|
39
|
+
{content}
|
|
40
|
+
|
|
41
|
+
ABSTRACT:"""
|
|
42
|
+
|
|
43
|
+
L1_PROMPT = """Create a structured overview (under 2000 tokens):
|
|
44
|
+
|
|
45
|
+
1. PURPOSE: What this is and why it exists (1-2 sentences)
|
|
46
|
+
2. KEY POINTS: 3-5 most important facts/findings
|
|
47
|
+
3. METRICS: Quantitative results (percentages, performance)
|
|
48
|
+
4. RELATIONSHIPS: What this relates to
|
|
49
|
+
5. ACTIONABILITY: When/how to use this
|
|
50
|
+
|
|
51
|
+
Format as clean markdown. Be data-dense.
|
|
52
|
+
|
|
53
|
+
DOCUMENT TYPE: {doc_type}
|
|
54
|
+
HIERARCHY: {hierarchy_path}
|
|
55
|
+
TITLE: {title}
|
|
56
|
+
|
|
57
|
+
CONTENT:
|
|
58
|
+
{content}
|
|
59
|
+
|
|
60
|
+
OVERVIEW:"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _estimate_tokens(text: str) -> int:
|
|
64
|
+
"""Rough token estimate: ~4 chars per token for English."""
|
|
65
|
+
return len(text) // 4
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _strip_thinking_tags(text: str) -> str:
|
|
69
|
+
"""Remove <think>...</think> blocks that deepseek-r1 sometimes emits."""
|
|
70
|
+
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _ollama_generate(prompt: str, max_tokens: int = 2048) -> str:
|
|
74
|
+
"""Call Ollama chat API and return the response text."""
|
|
75
|
+
payload = {
|
|
76
|
+
"model": GENERATION_MODEL,
|
|
77
|
+
"prompt": prompt,
|
|
78
|
+
"stream": False,
|
|
79
|
+
"options": {"num_predict": max_tokens, "temperature": 0.3},
|
|
80
|
+
}
|
|
81
|
+
async with aiohttp.ClientSession() as session:
|
|
82
|
+
async with session.post(
|
|
83
|
+
f"{OLLAMA_URL}/api/generate",
|
|
84
|
+
json=payload,
|
|
85
|
+
timeout=aiohttp.ClientTimeout(total=120),
|
|
86
|
+
) as resp:
|
|
87
|
+
if resp.status != 200:
|
|
88
|
+
body = await resp.text()
|
|
89
|
+
raise RuntimeError(f"Ollama returned {resp.status}: {body}")
|
|
90
|
+
data = await resp.json()
|
|
91
|
+
return _strip_thinking_tags(data.get("response", ""))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_chroma_client() -> chromadb.ClientAPI:
|
|
95
|
+
CHROMA_PATH.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
return chromadb.PersistentClient(path=str(CHROMA_PATH))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TierGenerator:
|
|
100
|
+
"""Auto-generates L0 and L1 tiers from L2 content."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, db_conn=None):
|
|
103
|
+
self.conn = db_conn or db.init_db()
|
|
104
|
+
self._chroma = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def chroma(self):
|
|
108
|
+
if self._chroma is None:
|
|
109
|
+
self._chroma = _get_chroma_client()
|
|
110
|
+
return self._chroma
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def l0_collection(self):
|
|
114
|
+
return self.chroma.get_or_create_collection(
|
|
115
|
+
"cortex_l0", metadata={"hnsw:space": "cosine"}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def l1_collection(self):
|
|
120
|
+
return self.chroma.get_or_create_collection(
|
|
121
|
+
"cortex_l1", metadata={"hnsw:space": "cosine"}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def ingest_document(
|
|
125
|
+
self,
|
|
126
|
+
content: str,
|
|
127
|
+
title: str,
|
|
128
|
+
hierarchy_path: str,
|
|
129
|
+
doc_type: str = "reference",
|
|
130
|
+
tags: Optional[list] = None,
|
|
131
|
+
source_file: Optional[str] = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Full pipeline: store L2, generate L0/L1, index in ChromaDB, save metadata.
|
|
135
|
+
Returns doc_id.
|
|
136
|
+
"""
|
|
137
|
+
doc_id = str(uuid.uuid4())
|
|
138
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
139
|
+
l2_checksum = hashlib.sha256(content.encode()).hexdigest()
|
|
140
|
+
|
|
141
|
+
# 1. Store L2 on filesystem
|
|
142
|
+
l2_path = self._resolve_l2_path(hierarchy_path, title)
|
|
143
|
+
l2_path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
l2_path.write_text(content, encoding="utf-8")
|
|
145
|
+
l2_tokens = _estimate_tokens(content)
|
|
146
|
+
|
|
147
|
+
logger.info(f"Ingesting '{title}' ({l2_tokens} tokens) → generating L0/L1...")
|
|
148
|
+
|
|
149
|
+
# 2. Generate L0 via Ollama
|
|
150
|
+
t0 = time.monotonic()
|
|
151
|
+
l0_abstract = await self._generate_l0(content, title, hierarchy_path, doc_type)
|
|
152
|
+
l0_tokens = _estimate_tokens(l0_abstract)
|
|
153
|
+
logger.info(f" L0 generated: {l0_tokens} tokens ({time.monotonic()-t0:.1f}s)")
|
|
154
|
+
|
|
155
|
+
# 3. Generate L1 via Ollama
|
|
156
|
+
t1 = time.monotonic()
|
|
157
|
+
l1_overview = await self._generate_l1(content, title, hierarchy_path, doc_type)
|
|
158
|
+
l1_tokens = _estimate_tokens(l1_overview)
|
|
159
|
+
logger.info(f" L1 generated: {l1_tokens} tokens ({time.monotonic()-t1:.1f}s)")
|
|
160
|
+
|
|
161
|
+
# 4. Index in ChromaDB
|
|
162
|
+
self.l0_collection.upsert(
|
|
163
|
+
ids=[doc_id],
|
|
164
|
+
documents=[l0_abstract],
|
|
165
|
+
metadatas=[{"title": title, "hierarchy_path": hierarchy_path, "doc_type": doc_type}],
|
|
166
|
+
)
|
|
167
|
+
self.l1_collection.upsert(
|
|
168
|
+
ids=[doc_id],
|
|
169
|
+
documents=[l1_overview],
|
|
170
|
+
metadatas=[{"title": title, "hierarchy_path": hierarchy_path, "doc_type": doc_type}],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# 5. Save to SQLite
|
|
174
|
+
parent_path = "/".join(hierarchy_path.rstrip("/").split("/")[:-1]) or "/"
|
|
175
|
+
depth = hierarchy_path.strip("/").count("/")
|
|
176
|
+
|
|
177
|
+
db.insert_document(self.conn, {
|
|
178
|
+
"doc_id": doc_id,
|
|
179
|
+
"hierarchy_path": hierarchy_path,
|
|
180
|
+
"title": title,
|
|
181
|
+
"doc_type": doc_type,
|
|
182
|
+
"l0_abstract": l0_abstract,
|
|
183
|
+
"l0_token_count": l0_tokens,
|
|
184
|
+
"l1_overview": l1_overview,
|
|
185
|
+
"l1_token_count": l1_tokens,
|
|
186
|
+
"l2_file_path": str(l2_path),
|
|
187
|
+
"l2_token_count": l2_tokens,
|
|
188
|
+
"l2_checksum": l2_checksum,
|
|
189
|
+
"chromadb_l0_id": doc_id,
|
|
190
|
+
"chromadb_l1_id": doc_id,
|
|
191
|
+
"parent_path": parent_path,
|
|
192
|
+
"depth": depth,
|
|
193
|
+
"tags": tags or [],
|
|
194
|
+
"l0_generated_at": now,
|
|
195
|
+
"l1_generated_at": now,
|
|
196
|
+
"source_type": "file" if source_file else "manual",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
total_time = time.monotonic() - t0
|
|
200
|
+
logger.info(
|
|
201
|
+
f" Ingested '{title}': L2={l2_tokens}tok → L0={l0_tokens}tok + L1={l1_tokens}tok "
|
|
202
|
+
f"({(1 - (l0_tokens + l1_tokens) / max(l2_tokens, 1)) * 100:.0f}% reduction, {total_time:.1f}s)"
|
|
203
|
+
)
|
|
204
|
+
return doc_id
|
|
205
|
+
|
|
206
|
+
async def regenerate(self, doc_id: str) -> bool:
|
|
207
|
+
"""Re-generate L0/L1 for an existing document (e.g., after L2 update)."""
|
|
208
|
+
doc = db.get_document(self.conn, doc_id)
|
|
209
|
+
if doc is None:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
l2_path = Path(doc["l2_file_path"])
|
|
213
|
+
if not l2_path.exists():
|
|
214
|
+
logger.error(f"L2 file missing for {doc_id}: {l2_path}")
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
content = l2_path.read_text(encoding="utf-8")
|
|
218
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
219
|
+
|
|
220
|
+
l0 = await self._generate_l0(content, doc["title"], doc["hierarchy_path"], doc["doc_type"])
|
|
221
|
+
l1 = await self._generate_l1(content, doc["title"], doc["hierarchy_path"], doc["doc_type"])
|
|
222
|
+
|
|
223
|
+
self.conn.execute(
|
|
224
|
+
"""
|
|
225
|
+
UPDATE documents SET
|
|
226
|
+
l0_abstract = ?, l0_token_count = ?,
|
|
227
|
+
l1_overview = ?, l1_token_count = ?,
|
|
228
|
+
l2_checksum = ?, l2_token_count = ?,
|
|
229
|
+
is_stale = 0, stale_reason = NULL,
|
|
230
|
+
l0_generated_at = ?, l1_generated_at = ?
|
|
231
|
+
WHERE doc_id = ?
|
|
232
|
+
""",
|
|
233
|
+
(
|
|
234
|
+
l0, _estimate_tokens(l0),
|
|
235
|
+
l1, _estimate_tokens(l1),
|
|
236
|
+
hashlib.sha256(content.encode()).hexdigest(),
|
|
237
|
+
_estimate_tokens(content),
|
|
238
|
+
now, now, doc_id,
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
self.conn.commit()
|
|
242
|
+
|
|
243
|
+
self.l0_collection.upsert(ids=[doc_id], documents=[l0])
|
|
244
|
+
self.l1_collection.upsert(ids=[doc_id], documents=[l1])
|
|
245
|
+
|
|
246
|
+
logger.info(f"Regenerated L0/L1 for '{doc['title']}'")
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
async def _generate_l0(
|
|
250
|
+
self, content: str, title: str, hierarchy_path: str, doc_type: str
|
|
251
|
+
) -> str:
|
|
252
|
+
truncated = content[:12000]
|
|
253
|
+
prompt = L0_PROMPT.format(
|
|
254
|
+
doc_type=doc_type,
|
|
255
|
+
hierarchy_path=hierarchy_path,
|
|
256
|
+
title=title,
|
|
257
|
+
content=truncated,
|
|
258
|
+
)
|
|
259
|
+
result = await _ollama_generate(prompt, max_tokens=400)
|
|
260
|
+
if len(result.split()) < 10:
|
|
261
|
+
logger.warning(f"L0 too short ({len(result.split())} words), using title-based fallback")
|
|
262
|
+
first_500 = content[:2000].replace("\n", " ").strip()
|
|
263
|
+
result = f"{title}: {first_500[:300]}"
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
async def _generate_l1(
|
|
267
|
+
self, content: str, title: str, hierarchy_path: str, doc_type: str
|
|
268
|
+
) -> str:
|
|
269
|
+
truncated = content[:16000]
|
|
270
|
+
prompt = L1_PROMPT.format(
|
|
271
|
+
doc_type=doc_type,
|
|
272
|
+
hierarchy_path=hierarchy_path,
|
|
273
|
+
title=title,
|
|
274
|
+
content=truncated,
|
|
275
|
+
)
|
|
276
|
+
return await _ollama_generate(prompt, max_tokens=3000)
|
|
277
|
+
|
|
278
|
+
def _resolve_l2_path(self, hierarchy_path: str, title: str) -> Path:
|
|
279
|
+
safe_title = re.sub(r"[^\w\-.]", "_", title)[:80]
|
|
280
|
+
return L2_STORAGE_ROOT / hierarchy_path.strip("/") / f"{safe_title}.md"
|