Add AI Council architecture: Tier 2/3/Graph implementation + Integration Plan
Architecture (Agent 1):
- hermes_memory/tier2/{schema,facts,entities,relations,timeline}.py
- hermes_memory/tier3/{backend,chroma_backend,embedder}.py
- hermes_memory/graph/nx_store.py
- hermes_memory/api/memory_api.py (unified API)
- hermes_memory/cron/{consolidate,embed_queue,graph_refresh,prune}.py
- hermes_memory/config.py + pyproject.toml
Integration Plan (Agent 3):
- INTEGRATION_PLAN.md: Memory Provider Plugin strategy
- Hermes Core needs minimal changes
- sync_turn() + prefetch() hooks
- Skills integration via nextlevel_search/remember
Auto-Extraction (Agent 2):
- ARCHITECTURE.md: Full extraction pipeline docs
- Chunking, Pre-Filter, LLM Prompts, Classification
- Entity-Linking, Temporal Reasoning, Deduplication
All files: Python syntax checked, ECC standards applied.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"""Tier 2 — Structured Knowledge (SQLite)."""
|
||||
|
||||
from hermes_memory.tier2.facts import Fact, FactStore
|
||||
from hermes_memory.tier2.entities import Entity, EntityStore
|
||||
from hermes_memory.tier2.relations import Relation, RelationStore
|
||||
from hermes_memory.tier2.timeline import TimelineEvent, TimelineStore
|
||||
from hermes_memory.tier2.schema import init_schema, migrate
|
||||
|
||||
__all__ = [
|
||||
"Fact",
|
||||
"FactStore",
|
||||
"Entity",
|
||||
"EntityStore",
|
||||
"Relation",
|
||||
"RelationStore",
|
||||
"TimelineEvent",
|
||||
"TimelineStore",
|
||||
"init_schema",
|
||||
"migrate",
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""EntityStore — Entitäts-Verwaltung für Tier 2."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid as uuid_mod
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
uuid: str
|
||||
name: str
|
||||
aliases: List[str]
|
||||
entity_type: str
|
||||
description: Optional[str]
|
||||
first_seen: float
|
||||
last_seen: float
|
||||
occurrence_count: int
|
||||
metadata: dict
|
||||
|
||||
|
||||
class EntityStore:
|
||||
def __init__(self, conn: sqlite3.Connection):
|
||||
self.conn = conn
|
||||
|
||||
def ensure(
|
||||
self,
|
||||
name: str,
|
||||
entity_type: str,
|
||||
aliases: Optional[List[str]] = None,
|
||||
description: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Entity:
|
||||
existing = self._find_by_name(name)
|
||||
if existing:
|
||||
# Aktualisiere last_seen und occurrence_count
|
||||
self.conn.execute(
|
||||
"UPDATE entities SET last_seen = ?, occurrence_count = occurrence_count + 1 WHERE uuid = ?",
|
||||
(time.time(), existing.uuid),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(existing.uuid)
|
||||
|
||||
ent_uuid = str(uuid_mod.uuid4())
|
||||
now = time.time()
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO entities (uuid, name, aliases, entity_type, description, first_seen, last_seen, occurrence_count, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ent_uuid,
|
||||
name,
|
||||
json.dumps(aliases or []),
|
||||
entity_type,
|
||||
description,
|
||||
now,
|
||||
now,
|
||||
1,
|
||||
json.dumps(metadata or {}),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(ent_uuid)
|
||||
|
||||
def _find_by_name(self, name: str) -> Optional[Entity]:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM entities WHERE name = ? COLLATE NOCASE LIMIT 1", (name,)
|
||||
).fetchone()
|
||||
return self._row_to_entity(row) if row else None
|
||||
|
||||
def get_by_uuid(self, ent_uuid: str) -> Optional[Entity]:
|
||||
row = self.conn.execute("SELECT * FROM entities WHERE uuid = ? LIMIT 1", (ent_uuid,)).fetchone()
|
||||
return self._row_to_entity(row) if row else None
|
||||
|
||||
def query(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> List[Entity]:
|
||||
sql = "SELECT * FROM entities WHERE 1=1"
|
||||
params: List = []
|
||||
if name:
|
||||
sql += " AND (name LIKE ? OR aliases LIKE ?)"
|
||||
params.extend([f"%{name}%", f"%{name}%"])
|
||||
if entity_type:
|
||||
sql += " AND entity_type = ?"
|
||||
params.append(entity_type)
|
||||
sql += " ORDER BY occurrence_count DESC, last_seen DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
rows = self.conn.execute(sql, params).fetchall()
|
||||
return [self._row_to_entity(r) for r in rows]
|
||||
|
||||
def _row_to_entity(self, row: sqlite3.Row) -> Entity:
|
||||
return Entity(
|
||||
uuid=row["uuid"],
|
||||
name=row["name"],
|
||||
aliases=json.loads(row["aliases"] or "[]"),
|
||||
entity_type=row["entity_type"],
|
||||
description=row["description"],
|
||||
first_seen=row["first_seen"],
|
||||
last_seen=row["last_seen"],
|
||||
occurrence_count=row["occurrence_count"],
|
||||
metadata=json.loads(row["metadata"] or "{}"),
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""FactStore — CRUD für strukturierte Fakten in Tier 2."""
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid as uuid_mod
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fact:
|
||||
uuid: str
|
||||
content: str
|
||||
content_hash: str
|
||||
category: Optional[str]
|
||||
confidence: float
|
||||
source_type: str
|
||||
source_id: Optional[str]
|
||||
created_at: float
|
||||
updated_at: float
|
||||
expires_at: Optional[float]
|
||||
access_count: int
|
||||
last_accessed: Optional[float]
|
||||
is_archived: bool
|
||||
|
||||
|
||||
class FactStore:
|
||||
def __init__(self, conn: sqlite3.Connection):
|
||||
self.conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _hash(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
def store(
|
||||
self,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
confidence: float = 1.0,
|
||||
source_type: str = "user",
|
||||
source_id: Optional[str] = None,
|
||||
expires_at: Optional[float] = None,
|
||||
) -> Fact:
|
||||
content_hash = self._hash(content)
|
||||
existing = self.get_by_hash(content_hash)
|
||||
if existing and not existing.is_archived:
|
||||
# Deduplizierung: Aktualisiere confidence und updated_at
|
||||
self.conn.execute(
|
||||
"UPDATE facts SET confidence = MAX(confidence, ?), updated_at = ?, access_count = access_count + 1 WHERE uuid = ?",
|
||||
(confidence, time.time(), existing.uuid),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(existing.uuid)
|
||||
|
||||
fact_uuid = str(uuid_mod.uuid4())
|
||||
now = time.time()
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO facts (uuid, content, content_hash, category, confidence, source_type, source_id, created_at, updated_at, expires_at, access_count, last_accessed, is_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(fact_uuid, content, content_hash, category, confidence, source_type, source_id, now, now, expires_at, 0, None, 0),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(fact_uuid)
|
||||
|
||||
def get_by_hash(self, content_hash: str) -> Optional[Fact]:
|
||||
row = self.conn.execute("SELECT * FROM facts WHERE content_hash = ? LIMIT 1", (content_hash,)).fetchone()
|
||||
return self._row_to_fact(row) if row else None
|
||||
|
||||
def get_by_uuid(self, fact_uuid: str) -> Optional[Fact]:
|
||||
row = self.conn.execute("SELECT * FROM facts WHERE uuid = ? LIMIT 1", (fact_uuid,)).fetchone()
|
||||
return self._row_to_fact(row) if row else None
|
||||
|
||||
def query(
|
||||
self,
|
||||
query: str = "",
|
||||
category: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
min_confidence: float = 0.5,
|
||||
fts: bool = True,
|
||||
) -> List[Fact]:
|
||||
if fts and query:
|
||||
# FTS5 + Filter
|
||||
sql = """
|
||||
SELECT f.* FROM facts f
|
||||
JOIN facts_fts fts ON f.id = fts.rowid
|
||||
WHERE fts.facts_fts MATCH ? AND f.confidence >= ? AND f.is_archived = 0
|
||||
"""
|
||||
params = [query, min_confidence]
|
||||
if category:
|
||||
sql += " AND f.category = ?"
|
||||
params.append(category)
|
||||
sql += " ORDER BY f.confidence DESC, f.created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
else:
|
||||
sql = "SELECT * FROM facts WHERE confidence >= ? AND is_archived = 0"
|
||||
params = [min_confidence]
|
||||
if category:
|
||||
sql += " AND category = ?"
|
||||
params.append(category)
|
||||
if query:
|
||||
sql += " AND content LIKE ?"
|
||||
params.append(f"%{query}%")
|
||||
sql += " ORDER BY confidence DESC, created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = self.conn.execute(sql, params).fetchall()
|
||||
return [self._row_to_fact(r) for r in rows]
|
||||
|
||||
def update(self, fact_uuid: str, **fields) -> Optional[Fact]:
|
||||
allowed = {"content", "category", "confidence", "expires_at", "is_archived"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return self.get_by_uuid(fact_uuid)
|
||||
if "content" in updates:
|
||||
updates["content_hash"] = self._hash(updates["content"])
|
||||
updates["updated_at"] = time.time()
|
||||
cols = ", ".join(f"{k} = ?" for k in updates)
|
||||
vals = list(updates.values()) + [fact_uuid]
|
||||
self.conn.execute(f"UPDATE facts SET {cols} WHERE uuid = ?", vals)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(fact_uuid)
|
||||
|
||||
def delete(self, fact_uuid: str, soft: bool = True) -> bool:
|
||||
if soft:
|
||||
self.conn.execute("UPDATE facts SET is_archived = 1, updated_at = ? WHERE uuid = ?", (time.time(), fact_uuid))
|
||||
else:
|
||||
self.conn.execute("DELETE FROM facts WHERE uuid = ?", (fact_uuid,))
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def deduplicate(self) -> int:
|
||||
"""Merge Fakten mit identischem content_hash. Returns merged count."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT content_hash, COUNT(*) as c FROM facts WHERE is_archived = 0 GROUP BY content_hash HAVING c > 1"
|
||||
).fetchall()
|
||||
merged = 0
|
||||
for row in rows:
|
||||
hashes = self.conn.execute(
|
||||
"SELECT uuid FROM facts WHERE content_hash = ? AND is_archived = 0 ORDER BY created_at",
|
||||
(row["content_hash"],),
|
||||
).fetchall()
|
||||
keep = hashes[0]["uuid"]
|
||||
for dup in hashes[1:]:
|
||||
self.delete(dup["uuid"], soft=False)
|
||||
merged += 1
|
||||
return merged
|
||||
|
||||
def _row_to_fact(self, row: sqlite3.Row) -> Fact:
|
||||
return Fact(
|
||||
uuid=row["uuid"],
|
||||
content=row["content"],
|
||||
content_hash=row["content_hash"],
|
||||
category=row["category"],
|
||||
confidence=row["confidence"],
|
||||
source_type=row["source_type"],
|
||||
source_id=row["source_id"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
expires_at=row["expires_at"],
|
||||
access_count=row["access_count"],
|
||||
last_accessed=row["last_accessed"],
|
||||
is_archived=bool(row["is_archived"]),
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""RelationStore — Relationen-Management für Tier 2."""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid as uuid_mod
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Relation:
|
||||
uuid: str
|
||||
from_entity_id: str
|
||||
to_entity_id: str
|
||||
relation_type: str
|
||||
strength: float
|
||||
evidence_fact_id: Optional[str]
|
||||
created_at: float
|
||||
updated_at: float
|
||||
|
||||
|
||||
class RelationStore:
|
||||
def __init__(self, conn: sqlite3.Connection):
|
||||
self.conn = conn
|
||||
|
||||
def link(
|
||||
self,
|
||||
from_entity_id: str,
|
||||
to_entity_id: str,
|
||||
relation_type: str,
|
||||
strength: float = 1.0,
|
||||
evidence_fact_id: Optional[str] = None,
|
||||
) -> Relation:
|
||||
existing = self.conn.execute(
|
||||
"SELECT uuid FROM relations WHERE from_entity_id = ? AND to_entity_id = ? AND relation_type = ?",
|
||||
(from_entity_id, to_entity_id, relation_type),
|
||||
).fetchone()
|
||||
|
||||
now = time.time()
|
||||
if existing:
|
||||
self.conn.execute(
|
||||
"UPDATE relations SET strength = MAX(strength, ?), updated_at = ? WHERE uuid = ?",
|
||||
(strength, now, existing["uuid"]),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(existing["uuid"])
|
||||
|
||||
rel_uuid = str(uuid_mod.uuid4())
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO relations (uuid, from_entity_id, to_entity_id, relation_type, strength, evidence_fact_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(rel_uuid, from_entity_id, to_entity_id, relation_type, strength, evidence_fact_id, now, now),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(rel_uuid)
|
||||
|
||||
def get_by_uuid(self, rel_uuid: str) -> Optional[Relation]:
|
||||
row = self.conn.execute("SELECT * FROM relations WHERE uuid = ? LIMIT 1", (rel_uuid,)).fetchone()
|
||||
return self._row_to_relation(row) if row else None
|
||||
|
||||
def query(
|
||||
self,
|
||||
from_entity_id: Optional[str] = None,
|
||||
to_entity_id: Optional[str] = None,
|
||||
relation_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> List[Relation]:
|
||||
sql = "SELECT * FROM relations WHERE 1=1"
|
||||
params: List = []
|
||||
if from_entity_id:
|
||||
sql += " AND from_entity_id = ?"
|
||||
params.append(from_entity_id)
|
||||
if to_entity_id:
|
||||
sql += " AND to_entity_id = ?"
|
||||
params.append(to_entity_id)
|
||||
if relation_type:
|
||||
sql += " AND relation_type = ?"
|
||||
params.append(relation_type)
|
||||
sql += " ORDER BY strength DESC, updated_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
rows = self.conn.execute(sql, params).fetchall()
|
||||
return [self._row_to_relation(r) for r in rows]
|
||||
|
||||
def _row_to_relation(self, row: sqlite3.Row) -> Relation:
|
||||
return Relation(
|
||||
uuid=row["uuid"],
|
||||
from_entity_id=row["from_entity_id"],
|
||||
to_entity_id=row["to_entity_id"],
|
||||
relation_type=row["relation_type"],
|
||||
strength=row["strength"],
|
||||
evidence_fact_id=row["evidence_fact_id"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""SQLite Schema & Migrationen für Tier 2.
|
||||
|
||||
Enthält alle CREATE TABLE / INDEX / TRIGGER Statements
|
||||
mit Schema-Versionierung.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
SCHEMA_SQL = """
|
||||
-- Schema-Version
|
||||
CREATE TABLE IF NOT EXISTS memory_schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at REAL NOT NULL
|
||||
);
|
||||
|
||||
-- Fakten
|
||||
CREATE TABLE IF NOT EXISTS facts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
category TEXT,
|
||||
confidence REAL DEFAULT 1.0,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
expires_at REAL,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
last_accessed REAL,
|
||||
is_archived INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_source ON facts(source_type, source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_created ON facts(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_hash ON facts(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence DESC);
|
||||
|
||||
-- Entitäten
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
aliases TEXT,
|
||||
entity_type TEXT NOT NULL,
|
||||
description TEXT,
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
occurrence_count INTEGER DEFAULT 1,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(entity_type);
|
||||
|
||||
-- Relationen
|
||||
CREATE TABLE IF NOT EXISTS relations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
from_entity_id TEXT NOT NULL,
|
||||
to_entity_id TEXT NOT NULL,
|
||||
relation_type TEXT NOT NULL,
|
||||
strength REAL DEFAULT 1.0,
|
||||
evidence_fact_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relations_type ON relations(relation_type);
|
||||
|
||||
-- Timeline
|
||||
CREATE TABLE IF NOT EXISTS timeline (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
related_entities TEXT,
|
||||
related_facts TEXT,
|
||||
session_id TEXT,
|
||||
timestamp REAL NOT NULL,
|
||||
importance REAL DEFAULT 0.5
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_time ON timeline(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_type ON timeline(event_type);
|
||||
|
||||
-- Audit-Log
|
||||
CREATE TABLE IF NOT EXISTS memory_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
tier TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
target_uuid TEXT,
|
||||
diff TEXT,
|
||||
success INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON memory_audit_log(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON memory_audit_log(actor);
|
||||
|
||||
-- Embedding-Queue
|
||||
CREATE TABLE IF NOT EXISTS embedding_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fact_id TEXT,
|
||||
content TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
message_id INTEGER,
|
||||
queued_at REAL NOT NULL,
|
||||
processed INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_processed ON embedding_queue(processed, queued_at);
|
||||
"""
|
||||
|
||||
FTS_SQL = """
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
|
||||
content,
|
||||
content_rowid='id',
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS facts_fts_insert AFTER INSERT ON facts BEGIN
|
||||
INSERT INTO facts_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS facts_fts_delete AFTER DELETE ON facts BEGIN
|
||||
DELETE FROM facts_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS facts_fts_update AFTER UPDATE ON facts BEGIN
|
||||
DELETE FROM facts_fts WHERE rowid = old.id;
|
||||
INSERT INTO facts_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
||||
name,
|
||||
content_rowid='id',
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS entities_fts_insert AFTER INSERT ON entities BEGIN
|
||||
INSERT INTO entities_fts(rowid, name) VALUES (new.id, new.name);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS entities_fts_delete AFTER DELETE ON entities BEGIN
|
||||
DELETE FROM entities_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS entities_fts_update AFTER UPDATE ON entities BEGIN
|
||||
DELETE FROM entities_fts WHERE rowid = old.id;
|
||||
INSERT INTO entities_fts(rowid, name) VALUES (new.id, new.name);
|
||||
END;
|
||||
"""
|
||||
|
||||
|
||||
def init_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Initialisiert alle Tabellen, Indizes und FTS5."""
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
conn.executescript(FTS_SQL)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO memory_schema_version (version, applied_at) VALUES (?, ?)",
|
||||
(SCHEMA_VERSION, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Führt Migrationen durch (aktuell: nur Schema-Version prüfen)."""
|
||||
row = conn.execute("SELECT version FROM memory_schema_version ORDER BY version DESC LIMIT 1").fetchone()
|
||||
current = row[0] if row else 0
|
||||
if current < SCHEMA_VERSION:
|
||||
init_schema(conn)
|
||||
|
||||
|
||||
def connect(db_path: Path, wal_mode: bool = True) -> sqlite3.Connection:
|
||||
"""Erstellt Verbindung mit WAL-Mode und Foreign Keys."""
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), check_same_thread=False, timeout=10.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
if wal_mode:
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
except sqlite3.OperationalError:
|
||||
conn.execute("PRAGMA journal_mode=DELETE")
|
||||
return conn
|
||||
@@ -0,0 +1,98 @@
|
||||
"""TimelineStore — Zeitachsen-Management für Tier 2."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid as uuid_mod
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimelineEvent:
|
||||
uuid: str
|
||||
event_type: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
related_entities: List[str]
|
||||
related_facts: List[str]
|
||||
session_id: Optional[str]
|
||||
timestamp: float
|
||||
importance: float
|
||||
|
||||
|
||||
class TimelineStore:
|
||||
def __init__(self, conn: sqlite3.Connection):
|
||||
self.conn = conn
|
||||
|
||||
def add(
|
||||
self,
|
||||
event_type: str,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
importance: float = 0.5,
|
||||
related_entities: Optional[List[str]] = None,
|
||||
related_facts: Optional[List[str]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> TimelineEvent:
|
||||
ev_uuid = str(uuid_mod.uuid4())
|
||||
now = time.time()
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO timeline (uuid, event_type, title, description, related_entities, related_facts, session_id, timestamp, importance)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ev_uuid,
|
||||
event_type,
|
||||
title,
|
||||
description,
|
||||
json.dumps(related_entities or []),
|
||||
json.dumps(related_facts or []),
|
||||
session_id,
|
||||
now,
|
||||
importance,
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_by_uuid(ev_uuid)
|
||||
|
||||
def get_by_uuid(self, ev_uuid: str) -> Optional[TimelineEvent]:
|
||||
row = self.conn.execute("SELECT * FROM timeline WHERE uuid = ? LIMIT 1", (ev_uuid,)).fetchone()
|
||||
return self._row_to_event(row) if row else None
|
||||
|
||||
def query(
|
||||
self,
|
||||
start: Optional[float] = None,
|
||||
end: Optional[float] = None,
|
||||
event_type: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> List[TimelineEvent]:
|
||||
sql = "SELECT * FROM timeline WHERE 1=1"
|
||||
params: List = []
|
||||
if start:
|
||||
sql += " AND timestamp >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
sql += " AND timestamp <= ?"
|
||||
params.append(end)
|
||||
if event_type:
|
||||
sql += " AND event_type = ?"
|
||||
params.append(event_type)
|
||||
sql += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
rows = self.conn.execute(sql, params).fetchall()
|
||||
return [self._row_to_event(r) for r in rows]
|
||||
|
||||
def _row_to_event(self, row: sqlite3.Row) -> TimelineEvent:
|
||||
return TimelineEvent(
|
||||
uuid=row["uuid"],
|
||||
event_type=row["event_type"],
|
||||
title=row["title"],
|
||||
description=row["description"],
|
||||
related_entities=json.loads(row["related_entities"] or "[]"),
|
||||
related_facts=json.loads(row["related_facts"] or "[]"),
|
||||
session_id=row["session_id"],
|
||||
timestamp=row["timestamp"],
|
||||
importance=row["importance"],
|
||||
)
|
||||
Reference in New Issue
Block a user