Compare commits
5 Commits
v0.3.2
...
v2.0.0-mvp
| Author | SHA1 | Date | |
|---|---|---|---|
| 83b85cb760 | |||
| e1640071e4 | |||
| 29bc45d623 | |||
| a5d5b2f2ec | |||
| 4e0f5e7e9a |
23
README.md
23
README.md
@@ -8,7 +8,28 @@ An embeddable, offline-first memory system for AI agents with correctness tracki
|
||||
- **ChromaDB Vector Store** (`src/chroma_store.py`) — Semantic similarity search
|
||||
- **Neural Confidence Scorer** (`src/neural_scorer.py`) — PyTorch RL net, trains on confirm/reject feedback
|
||||
- **Hybrid Retrieval** (`src/retriever.py`) — Keyword + Semantic + Neural fusion
|
||||
- **Streamlit Dashboard** (`src/app_dashboard.py`) — Search, confirm/reject, neural training UI
|
||||
- **FastAPI Dashboard** (`fastapi_app.py`) — Lightweight web UI (search + confirm/reject) and JSON API
|
||||
- **Streamlit Dashboard** (`src/app_dashboard.py`) — (optional) richer UI for neural training, etc.
|
||||
- **Graph Visualization** (`src/graph_view.py`) — Interactive Cytoscape.js graph with confidence colors
|
||||
|
||||
## Architecture
|
||||
|
||||
## Obsidian
|
||||
|
||||
Setup and timers: `second-brain/docs/OBSIDIAN.md`
|
||||
|
||||
## Quickstart (Dashboard)
|
||||
|
||||
Install minimal dashboard deps:
|
||||
|
||||
`python3 -m pip install -r requirements-dashboard.txt`
|
||||
|
||||
Run:
|
||||
|
||||
`SECOND_BRAIN_WORKSPACE="$(pwd)" python3 fastapi_app.py`
|
||||
|
||||
Then open: `http://localhost:8501/`
|
||||
|
||||
Port is configurable via `SECOND_BRAIN_PORT` (or `PORT`), e.g.:
|
||||
|
||||
`SECOND_BRAIN_WORKSPACE="$(pwd)" SECOND_BRAIN_PORT=8502 python3 fastapi_app.py`
|
||||
|
||||
86
RUNBOOK.md
Normal file
86
RUNBOOK.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Second-Brain 2.0 (Grundversion) — Runbook
|
||||
|
||||
This is the operational quick-reference for the shipped systemd timers/services and the optional FastAPI dashboard backend.
|
||||
|
||||
Repository root (on host): `/root/.openclaw/workspace`
|
||||
|
||||
## Systemd units (cron jobs)
|
||||
|
||||
Unit files are shipped in `systemd/` (repo root). Install them into `/etc/systemd/system/` (symlink or copy), then reload:
|
||||
|
||||
```bash
|
||||
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.service /etc/systemd/system/
|
||||
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.timer /etc/systemd/system/
|
||||
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-memory-archive.* /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Enable timers:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now openclaw-secondbrain-ingest-memory.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-index-vectors.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-review.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-backup.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-heartbeat.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-proactive-search.timer
|
||||
sudo systemctl enable --now openclaw-memory-archive.timer
|
||||
|
||||
# Optional (Obsidian coupling)
|
||||
sudo systemctl enable --now openclaw-secondbrain-ingest-obsidian.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-export-obsidian.timer
|
||||
```
|
||||
|
||||
Verify scheduling:
|
||||
|
||||
```bash
|
||||
sudo systemctl list-timers --all | grep -E 'openclaw-(secondbrain|memory-archive)' || true
|
||||
```
|
||||
|
||||
Run a job once:
|
||||
|
||||
```bash
|
||||
sudo systemctl start openclaw-secondbrain-ingest-memory.service
|
||||
sudo systemctl status openclaw-secondbrain-ingest-memory.service --no-pager
|
||||
sudo journalctl -u openclaw-secondbrain-ingest-memory.service -n 200 --no-pager
|
||||
```
|
||||
|
||||
Wrapper logs:
|
||||
|
||||
```bash
|
||||
tail -n 200 /root/.openclaw/workspace/cron_wrapper.log
|
||||
```
|
||||
|
||||
## FastAPI dashboard (manual start)
|
||||
|
||||
FastAPI entrypoint:
|
||||
|
||||
```bash
|
||||
cd /root/.openclaw/workspace
|
||||
python3 -m pip install -r second-brain/requirements-dashboard.txt
|
||||
SECOND_BRAIN_WORKSPACE="/root/.openclaw/workspace/second-brain" python3 second-brain/fastapi_app.py
|
||||
```
|
||||
|
||||
Default port is `8501` (same as Streamlit default). Do not run both on the same port.
|
||||
|
||||
Endpoint smoke tests:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8501/api/stats
|
||||
curl -fsS "http://127.0.0.1:8501/api/engrams?limit=1&offset=0"
|
||||
curl -fsS "http://127.0.0.1:8501/api/search?q=test&limit=1"
|
||||
```
|
||||
|
||||
## DB quick check
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import sqlite3
|
||||
db="/root/.openclaw/workspace/second-brain/data/brain.sqlite"
|
||||
con=sqlite3.connect(db)
|
||||
cur=con.cursor()
|
||||
print(cur.execute("PRAGMA integrity_check").fetchone()[0])
|
||||
print("engrams:", cur.execute("SELECT COUNT(*) FROM engrams").fetchone()[0])
|
||||
con.close()
|
||||
PY
|
||||
```
|
||||
119
chat_autosave.py
Normal file
119
chat_autosave.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Chat-Auto-Save: Wertvolle User-Nachrichten → Engramm.
|
||||
Wird am Ende jeder Main-Session-Antwort aufgerufen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
|
||||
sys.path.insert(0, str(BRAIN_DIR))
|
||||
|
||||
from src.engram import Engram, Grounding
|
||||
from src.store import EngramStore
|
||||
|
||||
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
|
||||
|
||||
|
||||
def _hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def is_fluff(content: str) -> bool:
|
||||
"""Prüft ob Inhalt nur Floskel ist."""
|
||||
lower = content.lower().strip().rstrip(".?!")
|
||||
short_fluff = [
|
||||
"hallo", "hi", "hey", "guten tag", "guten morgen", "guten abend",
|
||||
"danke", "ok", "okay", "ja", "nein", "bitte", "gerne", "tschüss",
|
||||
"bis später", "bis morgen", "alles klar", "in ordnung",
|
||||
]
|
||||
if lower in short_fluff:
|
||||
return True
|
||||
if len(content) < 10 and all(c in " ?,!.;:-" for c in content):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def save_if_worthy(content: str, source: str = "user", tags: list = None,
|
||||
confidence: float = 0.7, session_id: str = None,
|
||||
reasoning: str = None) -> dict:
|
||||
"""
|
||||
Speichert Nachricht als Engramm wenn sie Wert hat.
|
||||
Wird in jeder Antwort aufgerufen.
|
||||
"""
|
||||
|
||||
if is_fluff(content):
|
||||
return {"saved": False, "reason": "fluff"}
|
||||
|
||||
store = EngramStore(str(DB_PATH))
|
||||
content_hash = _hash(content)
|
||||
recent = store.get_all(limit=200)
|
||||
for eg in recent:
|
||||
if _hash(eg.content) == content_hash:
|
||||
return {"saved": False, "reason": "duplicate", "id": str(eg.id)}
|
||||
|
||||
eg = Engram.create(
|
||||
content=content,
|
||||
source=source,
|
||||
tags=tags or ["auto-save", "chat"],
|
||||
session_id=session_id,
|
||||
confidence=confidence,
|
||||
grounding=Grounding.ASSUMPTION,
|
||||
)
|
||||
store.save(eg)
|
||||
|
||||
return {
|
||||
"saved": True,
|
||||
"id": str(eg.id),
|
||||
"confidence": eg.compute_confidence(),
|
||||
"first8": str(eg.id)[:8],
|
||||
}
|
||||
|
||||
|
||||
def enrich_prompt(topic: str, limit: int = 3) -> str:
|
||||
"""
|
||||
Holt relevante bestätigte Engramme für Kontext-Anreicherung.
|
||||
Wird VOR jeder Antwort aufgerufen.
|
||||
"""
|
||||
store = EngramStore(str(DB_PATH))
|
||||
recent = store.get_all(limit=100)
|
||||
|
||||
# Einfache Text-Suche (kein FTS wegen Satzzeichen)
|
||||
topic_lower = topic.lower()
|
||||
matches = []
|
||||
for eg in recent:
|
||||
if eg.correctness.confirmed and topic_lower in eg.content.lower():
|
||||
matches.append(eg)
|
||||
elif len(matches) < limit and any(t in topic_lower for t in [t.lower() for t in eg.metadata.get("tags", [])]):
|
||||
matches.append(eg)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
|
||||
if not matches:
|
||||
return ""
|
||||
|
||||
lines = ["\n📚 Relevantes Wissen:"]
|
||||
for eg in matches[:limit]:
|
||||
lines.append(f" • [{eg.compute_confidence():.0%}] {eg.content[:120]}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def check_pending(session_id: str = None) -> list:
|
||||
"""Gibt unbestätigte Engramme zurück."""
|
||||
store = EngramStore(str(DB_PATH))
|
||||
egs = store.get_all(limit=50)
|
||||
pending = [eg for eg in egs if not eg.correctness.confirmed]
|
||||
return pending
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
result = save_if_worthy(sys.argv[1])
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print("Usage: python3 chat_autosave.py 'Nachricht'")
|
||||
22
cron_tasks/backup_secondbrain.py
Normal file
22
cron_tasks/backup_secondbrain.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backup-Task für Second Brain - isoliert, persistent."""
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
|
||||
sys.path.insert(0, str(BRAIN_DIR))
|
||||
from src.store import EngramStore
|
||||
|
||||
def main():
|
||||
brain_db = os.environ.get("BRAIN_DB", str(BRAIN_DIR / "data" / "brain.sqlite"))
|
||||
store = EngramStore(brain_db)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = Path(brain_db).parent / f"backup_{ts}.jsonl"
|
||||
count = store.export_jsonl(str(backup_path))
|
||||
result = {"timestamp": datetime.now(timezone.utc).isoformat(), "backup_path": str(backup_path), "count": count, "success": True}
|
||||
print(f"BACKUP: {count} Engramme -> {backup_path}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
53
cron_tasks/brain_rules.py
Normal file
53
cron_tasks/brain_rules.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Brain-Regeln - Automatische Bestaetigungs- und Archivierungslogik.
|
||||
Wird von Cron und Agent aufgerufen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/root/.openclaw/workspace/second-brain")
|
||||
|
||||
from src.engram import Engram, Grounding
|
||||
from src.store import EngramStore
|
||||
|
||||
DB = "/root/.openclaw/workspace/second-brain/data/brain.sqlite"
|
||||
|
||||
|
||||
def apply_rules():
|
||||
store = EngramStore(DB)
|
||||
egs = store.get_all(limit=1000)
|
||||
actions = []
|
||||
|
||||
for eg in egs:
|
||||
conf = eg.compute_confidence()
|
||||
age_days = eg._age_days(eg.metadata.get("created", ""))
|
||||
correct = eg.correctness
|
||||
|
||||
# Regel 1: Triple-Confirm → Auto-Verifiziert
|
||||
if not correct.confirmed and correct.confirmations >= 3:
|
||||
correct.confirmed = True
|
||||
correct.confirmations += 1
|
||||
store.save(eg)
|
||||
actions.append(f"Auto-Confirm: {str(eg.id)[:8]} (3x confirmed)")
|
||||
|
||||
# Regel 2: Lang unbestaetigt → ASSUMPTION Tag
|
||||
if age_days > 30 and not correct.confirmed and "archiviert" not in eg.metadata.get("tags", []):
|
||||
eg.metadata.setdefault("tags", []).append("archiviert")
|
||||
eg.metadata["archivgrund"] = f"Unbestaetigt seit {age_days} Tagen"
|
||||
store.save(eg)
|
||||
actions.append(f"Archiviert: {str(eg.id)[:8]} (Alter {age_days}d)")
|
||||
|
||||
# Regel 3: Rejected mit 2+ Rejections → loeschen (Sanft: Tag statt rm)
|
||||
if correct.rejections >= 2:
|
||||
eg.metadata.setdefault("tags", []).append("deleted")
|
||||
store.save(eg)
|
||||
actions.append(f"Deleted-Tag: {str(eg.id)[:8]} ({correct.rejections}x rejected)")
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
actions = apply_rules()
|
||||
print("Brain-Regeln angewendet:")
|
||||
for a in actions or ["Keine Aktionen noetig"]:
|
||||
print(f" {a}")
|
||||
47
cron_tasks/heartbeat_secondbrain.py
Normal file
47
cron_tasks/heartbeat_secondbrain.py
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Heartbeat-Task für Second Brain - isoliert, persistent.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
|
||||
sys.path.insert(0, str(BRAIN_DIR))
|
||||
|
||||
from src.engram import Engram, Grounding
|
||||
from src.store import EngramStore
|
||||
|
||||
def main():
|
||||
output_file = os.environ.get("CRON_OUTPUT_FILE", "/tmp/heartbeat_result.json")
|
||||
brain_db = os.environ.get("BRAIN_DB", str(BRAIN_DIR / "data" / "brain.sqlite"))
|
||||
store = EngramStore(brain_db)
|
||||
|
||||
egs = store.get_all(limit=50)
|
||||
unconfirmed = [eg for eg in egs if not eg.correctness.confirmed and eg.compute_confidence() > 0.5][:5]
|
||||
errors = store.search_tag("error", limit=5)
|
||||
|
||||
result = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"total_engrams": len(egs),
|
||||
"unconfirmed_count": len(unconfirmed),
|
||||
"error_count": len(errors),
|
||||
"has_action": bool(unconfirmed) or len(errors) >= 3,
|
||||
"message": None,
|
||||
}
|
||||
|
||||
if unconfirmed:
|
||||
contents = "\n".join([f" - {eg.content[:80]}" for eg in unconfirmed])
|
||||
result["message"] = f"🧠 Unbestätigte Engramme:\n{contents}"
|
||||
elif len(errors) >= 3:
|
||||
result["message"] = f"⚠️ {len(errors)} Fehler-Engramme gespeichert."
|
||||
|
||||
Path(output_file).write_text(json.dumps(result, indent=2))
|
||||
print(f"HEARTBEAT: {result['unconfirmed_count']} unconfirmed, {result['error_count']} errors")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
75
docs/OBSIDIAN.md
Normal file
75
docs/OBSIDIAN.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Obsidian Coupling (Second-Brain 2.0)
|
||||
|
||||
This integrates an Obsidian vault with Second-Brain via two cron tasks:
|
||||
|
||||
- `cron_tasks/ingest_obsidian.py` (vault → Second-Brain)
|
||||
- `cron_tasks/export_obsidian.py` (Second-Brain → vault)
|
||||
|
||||
All settings live in `second-brain/data/obsidian_config.json`.
|
||||
|
||||
## 1) Install / Sync the vault to the server
|
||||
|
||||
You need a local folder on the server that contains an Obsidian vault (it must contain a `.obsidian/` directory), e.g.:
|
||||
|
||||
- `/srv/obsidian/MyVault`
|
||||
- `/data/obsidian/MyVault`
|
||||
- `/root/Obsidian/MyVault`
|
||||
|
||||
How you sync it is up to you (Syncthing, rsync, SMB mount, etc.).
|
||||
|
||||
## 2) Set `vault_path` in config (auto or manual)
|
||||
|
||||
### Auto-discover (only writes if unambiguous)
|
||||
|
||||
```bash
|
||||
python3 second-brain/scripts/discover_obsidian_vault.py
|
||||
python3 second-brain/scripts/discover_obsidian_vault.py --write
|
||||
```
|
||||
|
||||
If multiple vaults are detected, it prints them and refuses to write.
|
||||
|
||||
### Manual
|
||||
|
||||
Edit `second-brain/data/obsidian_config.json` and set:
|
||||
|
||||
- `vault_path` to the vault directory (the parent of `.obsidian/`)
|
||||
|
||||
## 3) Enable ingest/export
|
||||
|
||||
In `second-brain/data/obsidian_config.json`:
|
||||
|
||||
- Set `enabled.ingest` to `true` to ingest vault markdown into Second-Brain
|
||||
- Set `enabled.export` to `true` to export Second-Brain engrams into the vault
|
||||
|
||||
## 4) Enable timers (systemd)
|
||||
|
||||
This repo ships unit files in `systemd/`:
|
||||
|
||||
- `systemd/openclaw-secondbrain-ingest-obsidian.service`
|
||||
- `systemd/openclaw-secondbrain-ingest-obsidian.timer`
|
||||
- `systemd/openclaw-secondbrain-export-obsidian.service`
|
||||
- `systemd/openclaw-secondbrain-export-obsidian.timer`
|
||||
|
||||
Install them (copy or symlink) to `/etc/systemd/system/`, then:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now openclaw-secondbrain-ingest-obsidian.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-export-obsidian.timer
|
||||
```
|
||||
|
||||
## 5) Verify
|
||||
|
||||
Run once manually:
|
||||
|
||||
```bash
|
||||
python3 openclaw_cron_wrapper.py ingest_obsidian
|
||||
python3 openclaw_cron_wrapper.py export_obsidian
|
||||
```
|
||||
|
||||
What to expect:
|
||||
|
||||
- If `vault_path` is missing/invalid, both tasks **skip** safely (no writes to random paths).
|
||||
- Ingest creates/updates `second-brain/data/obsidian_ingest_state.json`.
|
||||
- Export writes markdown files to `<vault_path>/<export.subdir>/` (default: `SecondBrain/`) and tracks state in `second-brain/data/obsidian_export_state.json`.
|
||||
|
||||
105
docs/RELEASE_CHECKLIST.md
Normal file
105
docs/RELEASE_CHECKLIST.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Second-Brain 2.0 — Release Checklist (Grundversion + FastAPI)
|
||||
|
||||
Scope: OpenClaw workspace at `/root/.openclaw/workspace` with `second-brain/` and the shipped `systemd/` unit files.
|
||||
|
||||
This checklist is copy/paste friendly. Run as a user with `sudo` (systemd commands need it).
|
||||
|
||||
## 0) Preconditions (one-time per host)
|
||||
|
||||
### Repo + Python sanity
|
||||
|
||||
```bash
|
||||
cd /root/.openclaw/workspace
|
||||
test -d second-brain || { echo "missing: second-brain/"; exit 1; }
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### Ensure systemd units are installed (copy/symlink)
|
||||
|
||||
```bash
|
||||
ls -la /etc/systemd/system/openclaw-secondbrain-*.timer /etc/systemd/system/openclaw-secondbrain-*.service 2>/dev/null || true
|
||||
ls -la /etc/systemd/system/openclaw-memory-archive.* 2>/dev/null || true
|
||||
```
|
||||
|
||||
If missing, install them (symlink is fine; copy is fine too):
|
||||
|
||||
```bash
|
||||
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.service /etc/systemd/system/
|
||||
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.timer /etc/systemd/system/
|
||||
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-memory-archive.* /etc/systemd/system/
|
||||
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-dashboard.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
### Enable timers
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now openclaw-secondbrain-backup.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-ingest-memory.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-index-vectors.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-review.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-heartbeat.timer
|
||||
sudo systemctl enable --now openclaw-secondbrain-proactive-search.timer
|
||||
sudo systemctl enable --now openclaw-memory-archive.timer
|
||||
```
|
||||
|
||||
## 1) Release QA — systemd status + timers
|
||||
|
||||
### Verify timers are active and scheduled
|
||||
|
||||
```bash
|
||||
sudo systemctl list-timers --all | grep -E 'openclaw-(secondbrain|memory-archive)' || true
|
||||
sudo systemctl --failed --no-pager || true
|
||||
```
|
||||
|
||||
### Verify the oneshot services can run successfully (manual trigger)
|
||||
|
||||
```bash
|
||||
sudo systemctl start openclaw-secondbrain-ingest-memory.service
|
||||
sudo systemctl start openclaw-secondbrain-index-vectors.service
|
||||
sudo systemctl start openclaw-secondbrain-review.service
|
||||
sudo systemctl start openclaw-secondbrain-backup.service
|
||||
sudo systemctl start openclaw-secondbrain-heartbeat.service
|
||||
sudo systemctl start openclaw-secondbrain-proactive-search.service
|
||||
sudo systemctl start openclaw-memory-archive.service
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
tail -n 200 /root/.openclaw/workspace/cron_wrapper.log
|
||||
sudo journalctl -u openclaw-secondbrain-review.service -n 200 --no-pager
|
||||
```
|
||||
|
||||
## 2) Release QA — data + DB invariants
|
||||
|
||||
```bash
|
||||
ls -la /root/.openclaw/workspace/second-brain/data/brain.sqlite
|
||||
python3 - <<'PY'
|
||||
import sqlite3
|
||||
db="/root/.openclaw/workspace/second-brain/data/brain.sqlite"
|
||||
con=sqlite3.connect(db)
|
||||
cur=con.cursor()
|
||||
print("integrity_check:", cur.execute("PRAGMA integrity_check").fetchone()[0])
|
||||
print("engrams:", cur.execute("SELECT COUNT(*) FROM engrams").fetchone()[0])
|
||||
con.close()
|
||||
PY
|
||||
```
|
||||
|
||||
## 3) Release QA — FastAPI (HTTP endpoints + logs)
|
||||
|
||||
FastAPI service:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now openclaw-secondbrain-dashboard.service
|
||||
sudo systemctl status openclaw-secondbrain-dashboard.service --no-pager
|
||||
```
|
||||
|
||||
Endpoint checks:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8501/healthz && echo
|
||||
curl -fsS http://127.0.0.1:8501/api/config
|
||||
curl -fsS http://127.0.0.1:8501/api/stats
|
||||
curl -fsS "http://127.0.0.1:8501/api/engrams?limit=1&offset=0"
|
||||
```
|
||||
418
fastapi_app.py
Normal file
418
fastapi_app.py
Normal file
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Second Brain FastAPI Dashboard
|
||||
|
||||
Goals:
|
||||
- "Release-ready" defaults (no hardcoded absolute paths)
|
||||
- Minimal config via env vars
|
||||
- Serves the existing static dashboard (templates/dashboard.html + static/)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Form, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
# ─── Config ──────────────────────────────────────────────────────────────────
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
WORKSPACE = Path(os.environ.get("SECOND_BRAIN_WORKSPACE", str(REPO_ROOT))).resolve()
|
||||
DB_PATH = Path(os.environ.get("SECOND_BRAIN_DB_PATH", str(WORKSPACE / "data" / "brain.sqlite"))).resolve()
|
||||
|
||||
PORT = int(os.environ.get("SECOND_BRAIN_PORT", os.environ.get("PORT", "8501")))
|
||||
HOST = os.environ.get("SECOND_BRAIN_HOST", "0.0.0.0")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Second Brain Dashboard")
|
||||
|
||||
static_dir = WORKSPACE / "static"
|
||||
if static_dir.is_dir():
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
def get_db():
|
||||
if not DB_PATH.exists():
|
||||
raise FileNotFoundError(f"DB not found: {DB_PATH}")
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def parse_engram(row: sqlite3.Row) -> dict:
|
||||
meta = json.loads(row["metadata_json"] or "{}")
|
||||
correctness = json.loads(row["correctness_json"] or "{}")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"content": row["content"],
|
||||
"confidence": meta.get("confidence", 0.0),
|
||||
"confirmed": correctness.get("confirmed", False),
|
||||
"confirmations": correctness.get("confirmations", 0),
|
||||
"rejections": correctness.get("rejections", 0),
|
||||
"tags": meta.get("tags", []),
|
||||
"created": meta.get("created", row["created_at"]),
|
||||
"modified": meta.get("modified", row["modified_at"]),
|
||||
"last_reviewed": correctness.get("last_reviewed"),
|
||||
"review_history": correctness.get("review_history", []),
|
||||
"source": meta.get("source", "unknown"),
|
||||
"access_count": meta.get("access_count", 0),
|
||||
"grounding": meta.get("grounding", 0),
|
||||
}
|
||||
|
||||
|
||||
# ─── API Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/healthz", response_class=PlainTextResponse)
|
||||
def healthz():
|
||||
return "ok"
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def api_config():
|
||||
return {
|
||||
"workspace": str(WORKSPACE),
|
||||
"db_path": str(DB_PATH),
|
||||
}
|
||||
|
||||
|
||||
@app.exception_handler(FileNotFoundError)
|
||||
def handle_file_not_found(request: Request, exc: FileNotFoundError):
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": str(exc),
|
||||
"hint": "Set SECOND_BRAIN_DB_PATH or SECOND_BRAIN_WORKSPACE to a valid location.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(sqlite3.Error)
|
||||
def handle_sqlite_error(request: Request, exc: sqlite3.Error):
|
||||
return JSONResponse(status_code=500, content={"error": f"sqlite error: {exc}"})
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
def api_stats():
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]
|
||||
confirmed = c.execute(
|
||||
"SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1"
|
||||
).fetchone()[0]
|
||||
pending = total - confirmed
|
||||
errors = c.execute(
|
||||
"SELECT COUNT(*) FROM engrams WHERE json_extract(metadata_json, '$.tags') LIKE '%error%'"
|
||||
).fetchone()[0]
|
||||
avg_conf = c.execute(
|
||||
"SELECT AVG(json_extract(metadata_json, '$.confidence')) FROM engrams"
|
||||
).fetchone()[0] or 0.0
|
||||
conn.close()
|
||||
return {
|
||||
"total": total,
|
||||
"confirmed": confirmed,
|
||||
"pending": pending,
|
||||
"errors": errors,
|
||||
"avg_confidence": round(avg_conf, 2),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/engrams")
|
||||
def api_engrams(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
tag: str = Query(None),
|
||||
confirmed: bool = Query(None),
|
||||
search: str = Query(None),
|
||||
min_confidence: float = Query(0.0),
|
||||
):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
where_clauses = ["json_extract(metadata_json, '$.confidence') >= ?"]
|
||||
params = [min_confidence]
|
||||
|
||||
if tag:
|
||||
where_clauses.append("json_extract(metadata_json, '$.tags') LIKE ?")
|
||||
params.append(f'%"{tag}"%')
|
||||
|
||||
if confirmed is not None:
|
||||
where_clauses.append(
|
||||
f"json_extract(correctness_json, '$.confirmed') = {int(confirmed)}"
|
||||
)
|
||||
|
||||
if search:
|
||||
# Use FTS
|
||||
try:
|
||||
ids = [
|
||||
r[0] for r in c.execute(
|
||||
"SELECT rowid FROM engrams_fts WHERE content MATCH ? LIMIT 200",
|
||||
(search,)
|
||||
).fetchall()
|
||||
]
|
||||
if ids:
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
where_clauses.append(f"id IN ({placeholders})")
|
||||
params.extend(ids)
|
||||
else:
|
||||
# Full-text fallback on content
|
||||
where_clauses.append("content LIKE ?")
|
||||
params.append(f"%{search}%")
|
||||
except Exception:
|
||||
where_clauses.append("content LIKE ?")
|
||||
params.append(f"%{search}%")
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
rows = c.execute(
|
||||
f"""
|
||||
SELECT * FROM engrams
|
||||
WHERE {where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
params + [limit, offset],
|
||||
).fetchall()
|
||||
|
||||
result = [parse_engram(r) for r in rows]
|
||||
conn.close()
|
||||
return {"items": result, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@app.get("/api/engrams/{engram_id}")
|
||||
def api_engram_detail(engram_id: str):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
row = c.execute("SELECT * FROM engrams WHERE id = ?", (engram_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
|
||||
# Links
|
||||
links = c.execute(
|
||||
"SELECT to_id FROM engrams_links WHERE from_id = ?", (engram_id,)
|
||||
).fetchall()
|
||||
result = parse_engram(row)
|
||||
result["links"] = [r[0] for r in links]
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/engrams/{engram_id}/confirm")
|
||||
def api_confirm(engram_id: str, reason: str = Form("")):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
row = c.execute(
|
||||
"SELECT correctness_json FROM engrams WHERE id = ?", (engram_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
|
||||
correctness = json.loads(row["correctness_json"] or "{}")
|
||||
correctness["confirmed"] = True
|
||||
correctness["confirmations"] = correctness.get("confirmations", 0) + 1
|
||||
correctness["last_reviewed"] = datetime.now(timezone.utc).isoformat()
|
||||
review_history = correctness.get("review_history", [])
|
||||
review_history.append({
|
||||
"by": "web",
|
||||
"action": "confirm",
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
"note": reason or "confirmed via dashboard",
|
||||
})
|
||||
correctness["review_history"] = review_history
|
||||
|
||||
c.execute(
|
||||
"UPDATE engrams SET correctness_json = ?, modified_at = ? WHERE id = ?",
|
||||
(json.dumps(correctness), datetime.now(timezone.utc).isoformat(), engram_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "engram_id": engram_id}
|
||||
|
||||
|
||||
@app.post("/api/engrams/{engram_id}/reject")
|
||||
def api_reject(engram_id: str, reason: str = Form("")):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
row = c.execute(
|
||||
"SELECT correctness_json FROM engrams WHERE id = ?", (engram_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
|
||||
correctness = json.loads(row["correctness_json"] or "{}")
|
||||
correctness["confirmed"] = False
|
||||
correctness["rejections"] = correctness.get("rejections", 0) + 1
|
||||
correctness["last_reviewed"] = datetime.now(timezone.utc).isoformat()
|
||||
review_history = correctness.get("review_history", [])
|
||||
review_history.append({
|
||||
"by": "web",
|
||||
"action": "reject",
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
"note": reason or "rejected via dashboard",
|
||||
})
|
||||
correctness["review_history"] = review_history
|
||||
|
||||
c.execute(
|
||||
"UPDATE engrams SET correctness_json = ?, modified_at = ? WHERE id = ?",
|
||||
(json.dumps(correctness), datetime.now(timezone.utc).isoformat(), engram_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "engram_id": engram_id}
|
||||
|
||||
|
||||
@app.post("/api/engrams/{engram_id}/refresh")
|
||||
def api_refresh(engram_id: str):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
row = c.execute(
|
||||
"SELECT metadata_json, correctness_json FROM engrams WHERE id = ?", (engram_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
|
||||
meta = json.loads(row["metadata_json"] or "{}")
|
||||
correctness = json.loads(row["correctness_json"] or "{}")
|
||||
|
||||
# Simple heuristic: confidence based on confirmations vs rejections
|
||||
conf = 0.5
|
||||
conf += 0.1 * correctness.get("confirmations", 0)
|
||||
conf -= 0.15 * correctness.get("rejections", 0)
|
||||
conf = max(0.1, min(1.0, conf))
|
||||
|
||||
meta["confidence"] = round(conf, 2)
|
||||
meta["modified"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
c.execute(
|
||||
"UPDATE engrams SET metadata_json = ?, modified_at = ? WHERE id = ?",
|
||||
(json.dumps(meta), datetime.now(timezone.utc).isoformat(), engram_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "new_confidence": round(conf, 2)}
|
||||
|
||||
|
||||
@app.post("/api/engrams")
|
||||
def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")):
|
||||
engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
meta = {
|
||||
"source": source,
|
||||
"confidence": 0.5,
|
||||
"created": now,
|
||||
"modified": now,
|
||||
"access_count": 0,
|
||||
"last_accessed": now,
|
||||
"tags": [t.strip() for t in tags.split(",") if t.strip()] or ["web"],
|
||||
"session_id": None,
|
||||
"agent_id": None,
|
||||
"grounding": 0,
|
||||
"hash": "",
|
||||
}
|
||||
correctness = {
|
||||
"confirmed": False,
|
||||
"confirmations": 0,
|
||||
"rejections": 0,
|
||||
"last_reviewed": None,
|
||||
"review_history": [],
|
||||
}
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO engrams (id, content, metadata_json, correctness_json, links_json, hierarchy_json, created_at, modified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(engram_id, content, json.dumps(meta), json.dumps(correctness), "[]", '{"parent": null, "children": [], "depth": 0}', now, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "engram_id": engram_id}
|
||||
|
||||
|
||||
@app.get("/api/pending")
|
||||
def api_pending(limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
rows = c.execute(
|
||||
"""
|
||||
SELECT * FROM engrams
|
||||
WHERE json_extract(correctness_json, '$.confirmed') = 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
result = [parse_engram(r) for r in rows]
|
||||
conn.close()
|
||||
return {"items": result, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
def api_search(
|
||||
q: str = Query(..., min_length=1),
|
||||
min_confidence: float = Query(0.0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
ids = [
|
||||
r[0] for r in c.execute(
|
||||
"SELECT rowid FROM engrams_fts WHERE content MATCH ? LIMIT 200",
|
||||
(q,)
|
||||
).fetchall()
|
||||
]
|
||||
if ids:
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
rows = c.execute(
|
||||
f"""
|
||||
SELECT * FROM engrams
|
||||
WHERE id IN ({placeholders})
|
||||
AND json_extract(metadata_json, '$.confidence') >= ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
ids + [min_confidence, limit],
|
||||
).fetchall()
|
||||
else:
|
||||
rows = []
|
||||
except Exception:
|
||||
rows = c.execute(
|
||||
"""
|
||||
SELECT * FROM engrams
|
||||
WHERE content LIKE ? AND json_extract(metadata_json, '$.confidence') >= ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{q}%", min_confidence, limit),
|
||||
).fetchall()
|
||||
|
||||
result = [parse_engram(r) for r in rows]
|
||||
conn.close()
|
||||
return {"items": result, "query": q}
|
||||
|
||||
|
||||
# ─── Frontend ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
with open(WORKSPACE / "templates" / "dashboard.html", "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("fastapi_app:app", host=HOST, port=PORT)
|
||||
102
openclaw_cron_wrapper.py
Normal file
102
openclaw_cron_wrapper.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenClaw Cron Isolation Wrapper - Updatesicherer Workaround.
|
||||
Persistent: Tasks und Logs liegen im Workspace, nicht in /tmp.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# --- Konfiguration (persistent) ---
|
||||
WORKSPACE = Path("/root/.openclaw/workspace")
|
||||
CRON_TASKS_DIR = WORKSPACE / "cron_tasks"
|
||||
LOG_FILE = WORKSPACE / "cron_wrapper.log"
|
||||
BRAIN_DIR = WORKSPACE / "second-brain"
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{ts}] {msg}\n"
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(line)
|
||||
print(line.strip())
|
||||
|
||||
|
||||
def run_isolated(task_name: str, task_args: dict = None) -> dict:
|
||||
"""
|
||||
Führt einen Task in echt isolierter Umgebung aus.
|
||||
Kein Zugriff auf Session-Files, nur stdout/stderr.
|
||||
"""
|
||||
CRON_TASKS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
task_script = CRON_TASKS_DIR / f"{task_name}.py"
|
||||
if not task_script.exists():
|
||||
return {"success": False, "error": f"Task nicht gefunden: {task_script}"}
|
||||
|
||||
# Temp-Verzeichnis für Output (flüchtig ist OK, Ergebnis kommt via stdout)
|
||||
temp_dir = tempfile.mkdtemp(prefix=f"cron_{task_name}_")
|
||||
output_file = Path(temp_dir) / "output.json"
|
||||
|
||||
# Saubere Env: Keine OpenClaw-Session-Variablen
|
||||
env = os.environ.copy()
|
||||
for key in list(env.keys()):
|
||||
if "OPENCLAW" in key.upper() or "SESSION" in key.upper():
|
||||
del env[key]
|
||||
|
||||
env["CRON_TASK_NAME"] = task_name
|
||||
env["CRON_OUTPUT_FILE"] = str(output_file)
|
||||
env["BRAIN_DB"] = str(BRAIN_DIR / "data" / "brain.sqlite")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(task_script)] + ([json.dumps(task_args)] if task_args else []),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
cwd=str(temp_dir),
|
||||
env=env,
|
||||
)
|
||||
|
||||
stdout = result.stdout.strip()
|
||||
stderr = result.stderr.strip()
|
||||
|
||||
output_data = {}
|
||||
if output_file.exists():
|
||||
try:
|
||||
output_data = json.loads(output_file.read_text())
|
||||
except Exception:
|
||||
output_data = {"raw": output_file.read_text()}
|
||||
|
||||
return {
|
||||
"success": result.returncode == 0,
|
||||
"returncode": result.returncode,
|
||||
"stdout": stdout[-2000:] if stdout else "",
|
||||
"stderr": stderr[-1000:] if stderr else "",
|
||||
"output": output_data,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Timeout nach 300s"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="OpenClaw Cron Isolation Wrapper")
|
||||
parser.add_argument("task", help="Task-Name aus cron_tasks/")
|
||||
parser.add_argument("--args", help="JSON-Args für den Task")
|
||||
args = parser.parse_args()
|
||||
|
||||
task_args = json.loads(args.args) if args.args else None
|
||||
result = run_isolated(args.task, task_args)
|
||||
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0 if result["success"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
4
requirements-dashboard.txt
Normal file
4
requirements-dashboard.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.23
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.9
|
||||
159
scripts/discover_obsidian_vault.py
Normal file
159
scripts/discover_obsidian_vault.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-discover an Obsidian vault on this server and (optionally) write it into:
|
||||
second-brain/data/obsidian_config.json
|
||||
|
||||
Safety:
|
||||
- Only writes when exactly one vault is detected (unambiguous).
|
||||
- A "vault" is a directory that contains a `.obsidian/` folder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
WORKSPACE = Path("/root/.openclaw/workspace")
|
||||
BRAIN_DIR = WORKSPACE / "second-brain"
|
||||
CONFIG_PATH = BRAIN_DIR / "data" / "obsidian_config.json"
|
||||
|
||||
|
||||
def _iter_common_candidates() -> Iterable[Path]:
|
||||
env = os.environ.get("OBSIDIAN_VAULT_PATH")
|
||||
if env:
|
||||
yield Path(env).expanduser()
|
||||
|
||||
home = Path.home()
|
||||
for p in [
|
||||
home / "Obsidian",
|
||||
home / "ObsidianVault",
|
||||
home / "Vault",
|
||||
home / "Vaults",
|
||||
home / "Documents" / "Obsidian",
|
||||
home / "Documents" / "Vaults",
|
||||
home / "Syncthing" / "Obsidian",
|
||||
Path("/srv/obsidian"),
|
||||
Path("/srv/Obsidian"),
|
||||
Path("/data/obsidian"),
|
||||
Path("/data/Obsidian"),
|
||||
WORKSPACE / "obsidian",
|
||||
WORKSPACE / "vault",
|
||||
WORKSPACE / "vaults",
|
||||
]:
|
||||
yield p
|
||||
|
||||
|
||||
def _is_vault_dir(p: Path) -> bool:
|
||||
try:
|
||||
return p.exists() and p.is_dir() and (p / ".obsidian").exists() and (p / ".obsidian").is_dir()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _bounded_find_obsidian_dirs(root: Path, *, max_depth: int) -> list[Path]:
|
||||
"""
|
||||
Find `.obsidian` directories below root, limited by depth to keep runtime bounded.
|
||||
"""
|
||||
results: list[Path] = []
|
||||
try:
|
||||
root = root.resolve()
|
||||
except Exception:
|
||||
return results
|
||||
|
||||
if not root.exists() or not root.is_dir():
|
||||
return results
|
||||
|
||||
def depth_of(path: Path) -> int:
|
||||
try:
|
||||
return len(path.relative_to(root).parts)
|
||||
except Exception:
|
||||
return 9999
|
||||
|
||||
# Breadth-first-ish scan with pruning
|
||||
queue = [root]
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
if depth_of(current) > max_depth:
|
||||
continue
|
||||
try:
|
||||
entries = list(current.iterdir())
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for e in entries:
|
||||
name = e.name
|
||||
if name in (".git", "node_modules", "__pycache__", ".cache", ".venv", "venv", "tmp", "proc", "sys", "dev"):
|
||||
continue
|
||||
if name.startswith(".") and name not in (".obsidian",):
|
||||
continue
|
||||
if name == ".obsidian" and e.is_dir():
|
||||
results.append(e)
|
||||
continue
|
||||
if e.is_dir() and not e.is_symlink():
|
||||
queue.append(e)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def discover(*, roots: list[Path], max_depth: int) -> list[Path]:
|
||||
vaults: set[Path] = set()
|
||||
|
||||
for p in _iter_common_candidates():
|
||||
if _is_vault_dir(p):
|
||||
vaults.add(p.resolve())
|
||||
|
||||
for root in roots:
|
||||
for obsidian_dir in _bounded_find_obsidian_dirs(root, max_depth=max_depth):
|
||||
vaults.add(obsidian_dir.parent.resolve())
|
||||
|
||||
return sorted(vaults)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Discover Obsidian vault and optionally write config")
|
||||
ap.add_argument("--write", action="store_true", help="Write detected vault_path into obsidian_config.json")
|
||||
ap.add_argument(
|
||||
"--roots",
|
||||
nargs="*",
|
||||
default=[str(Path.home()), "/srv", "/data", "/mnt", str(WORKSPACE)],
|
||||
help="Roots to scan (bounded). Default: home,/srv,/data,/mnt,workspace",
|
||||
)
|
||||
ap.add_argument("--max-depth", type=int, default=4, help="Max directory depth to scan under each root")
|
||||
args = ap.parse_args()
|
||||
|
||||
roots = [Path(r).expanduser() for r in args.roots]
|
||||
vaults = discover(roots=roots, max_depth=int(args.max_depth))
|
||||
|
||||
if not vaults:
|
||||
print("No Obsidian vault found (no `.obsidian/` directories detected).")
|
||||
return 1
|
||||
|
||||
if len(vaults) > 1:
|
||||
print("Multiple Obsidian vaults found; refusing to write config:")
|
||||
for v in vaults:
|
||||
print(f"- {v}")
|
||||
return 2
|
||||
|
||||
vault = vaults[0]
|
||||
print(f"Detected Obsidian vault: {vault}")
|
||||
|
||||
if not args.write:
|
||||
return 0
|
||||
|
||||
if not CONFIG_PATH.exists():
|
||||
raise SystemExit(f"Missing config file: {CONFIG_PATH}")
|
||||
|
||||
cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
cfg["vault_path"] = str(vault)
|
||||
CONFIG_PATH.write_text(json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(f"Wrote vault_path to: {CONFIG_PATH}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Second Brain - Gedächtnissystem für OpenClaw."""
|
||||
|
||||
from .engram import Engram, Grounding, Correctness, ReviewEntry
|
||||
from .store import EngramStore
|
||||
from .retriever import Retriever
|
||||
try:
|
||||
from .engram import Engram, Grounding, Correctness, ReviewEntry
|
||||
from .store import EngramStore
|
||||
from .retriever import Retriever
|
||||
except ImportError:
|
||||
# Fallback: ChromaDB optional, SQLite-core funktioniert immer
|
||||
from .engram import Engram, Grounding, Correctness, ReviewEntry
|
||||
from .store import EngramStore
|
||||
Retriever = None
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["Engram", "Grounding", "Correctness", "ReviewEntry", "EngramStore", "Retriever"]
|
||||
|
||||
147
src/dashboard.py
147
src/dashboard.py
@@ -13,12 +13,23 @@ import argparse
|
||||
import http.server
|
||||
import socketserver
|
||||
import webbrowser
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from .store import EngramStore
|
||||
from .engram import Engram, Grounding
|
||||
from .retriever import Retriever
|
||||
# Insert project root so `python3 src/dashboard.py` works without `-m`
|
||||
project_root = str(Path(__file__).parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from src.store import EngramStore
|
||||
from src.engram import Engram, Grounding
|
||||
|
||||
# Retriever: optional – im venv verfügbar, sonst Fallback
|
||||
try:
|
||||
from src.retriever import Retriever
|
||||
except ImportError:
|
||||
Retriever = None
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "brain.sqlite"
|
||||
HTML_PATH = Path(__file__).parent.parent / "data" / "dashboard.html"
|
||||
@@ -27,9 +38,29 @@ HTML_PATH = Path(__file__).parent.parent / "data" / "dashboard.html"
|
||||
def generate_dashboard() -> str:
|
||||
"""Generiert HTML-Dashboard aus aktuellem Brain-Stand."""
|
||||
store = EngramStore(str(DB_PATH))
|
||||
egs = store.get_all(limit=100)
|
||||
|
||||
# Stats: mit Retriever (venv) oder manuell berechnen
|
||||
if Retriever:
|
||||
ret = Retriever(store)
|
||||
stats = ret.stats()
|
||||
egs = store.get_all(limit=100)
|
||||
errors = store.search_tag("error", limit=100)
|
||||
stats["errors"] = len(errors)
|
||||
else:
|
||||
from src.engram import Correctness
|
||||
all_egs = store.get_all(limit=10000)
|
||||
confirmed = sum(1 for e in all_egs if e.correctness.confirmed)
|
||||
errors = store.search_tag("error", limit=100)
|
||||
confidences = [e.compute_confidence() for e in all_egs]
|
||||
stats = {
|
||||
"total_engrams": len(all_egs),
|
||||
"confirmed": confirmed,
|
||||
"unconfirmed": len(all_egs) - confirmed,
|
||||
"sources": {},
|
||||
"db_size_bytes": 0,
|
||||
"avg_confidence": sum(confidences) / len(confidences) if confidences else 0.0,
|
||||
"errors": len(errors),
|
||||
}
|
||||
|
||||
# Farbe nach Confidence
|
||||
def color(conf):
|
||||
@@ -48,95 +79,81 @@ def generate_dashboard() -> str:
|
||||
except:
|
||||
return "UNKNOWN"
|
||||
|
||||
# Liste der Engramme
|
||||
rows = []
|
||||
# Karten-Ansicht für Mobil
|
||||
card_rows = []
|
||||
for eg in egs:
|
||||
conf = eg.compute_confidence()
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td><span style="color:{color(conf)};font-size:1.2em">{marker(conf)}</span></td>
|
||||
<td><code>{str(eg.id)[:8]}</code></td>
|
||||
<td>{eg.content[:100]}{'...' if len(eg.content) > 100 else ''}</td>
|
||||
<td><span class="badge">{grounding_name(eg.metadata.get('grounding', 0))}</span></td>
|
||||
<td><span class="badge">{eg.metadata.get('source', '?')}</span></td>
|
||||
<td>{conf:.2f}</td>
|
||||
<td>{eg.correctness.confirmations}/{eg.correctness.rejections}</td>
|
||||
<td>{eg.metadata.get('access_count', 0)}</td>
|
||||
<td>{', '.join(eg.metadata.get('tags', [])[:3])}</td>
|
||||
</tr>
|
||||
card_rows.append(f"""
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>{marker(conf)} {str(eg.id)[:8]}</span>
|
||||
<span class="badge" style="background:rgba({int((1-conf)*200+55)},{int(conf*200+55)},100,0.3)">{conf:.2f}</span>
|
||||
</div>
|
||||
<div class="card-content">{eg.content[:150]}{'...' if len(eg.content) > 150 else ''}</div>
|
||||
<div class="card-meta">
|
||||
<span class="badge">{grounding_name(eg.metadata.get('grounding', 0))}</span>
|
||||
<span class="badge">{eg.metadata.get('source', '?')}</span>
|
||||
<span class="badge">✓{eg.correctness.confirmations}/✗{eg.correctness.rejections}</span>
|
||||
{' '.join([f'<span class="badge">{t}</span>' for t in eg.metadata.get('tags', [])[:3]])}
|
||||
</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🧠 Second Brain Dashboard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>🧠 Second Brain</title>
|
||||
<style>
|
||||
:root {{ --bg: #1a1a2e; --card: #16213e; --text: #eee; --accent: #0f4c75; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 20px; }}
|
||||
h1 {{ margin: 0 0 10px; font-size: 1.8em; }}
|
||||
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 25px; }}
|
||||
.stat-card {{ background: var(--card); border-radius: 12px; padding: 15px; text-align: center; }}
|
||||
.stat-card .num {{ font-size: 2em; font-weight: bold; color: #4fc3f7; }}
|
||||
.stat-card .lbl {{ font-size: 0.85em; opacity: 0.7; }}
|
||||
table {{ width: 100%; border-collapse: collapse; background: var(--card); border-radius: 12px; overflow: hidden; }}
|
||||
th {{ text-align: left; padding: 10px; background: var(--accent); font-size: 0.85em; text-transform: uppercase; }}
|
||||
td {{ padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.9em; vertical-align: top; }}
|
||||
tr:hover td {{ background: rgba(255,255,255,0.03); }}
|
||||
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 12px; background: rgba(79,195,247,0.2); font-size: 0.75em; }}
|
||||
.search {{ margin-bottom: 20px; }}
|
||||
.search input {{ width: 100%; max-width: 400px; padding: 10px 15px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: var(--card); color: var(--text); font-size: 1em; }}
|
||||
.refresh {{ position: fixed; top: 20px; right: 20px; background: #2ecc71; color: #000; border: none; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-weight: bold; }}
|
||||
.footer {{ margin-top: 30px; text-align: center; opacity: 0.5; font-size: 0.8em; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 10px; font-size: 14px; }}
|
||||
h1 {{ margin: 0 0 8px; font-size: 1.3em; text-align: center; }}
|
||||
.stats {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 15px; }}
|
||||
.stat-card {{ background: var(--card); border-radius: 10px; padding: 10px; text-align: center; }}
|
||||
.stat-card .num {{ font-size: 1.5em; font-weight: bold; color: #4fc3f7; }}
|
||||
.stat-card .lbl {{ font-size: 0.75em; opacity: 0.7; }}
|
||||
.search {{ margin-bottom: 10px; }}
|
||||
.search input {{ width: 100%; box-sizing: border-box; padding: 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: var(--card); color: var(--text); font-size: 16px; }}
|
||||
.card {{ background: var(--card); border-radius: 12px; padding: 12px; margin-bottom: 10px; }}
|
||||
.card-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }}
|
||||
.card-id {{ font-size: 0.75em; opacity: 0.6; }}
|
||||
.card-content {{ font-size: 0.9em; line-height: 1.4; margin-bottom: 8px; word-break: break-word; }}
|
||||
.card-meta {{ display: flex; flex-wrap: wrap; gap: 6px; font-size: 0.75em; }}
|
||||
.badge {{ display: inline-block; padding: 3px 8px; border-radius: 12px; background: rgba(79,195,247,0.2); }}
|
||||
.refresh {{ position: fixed; bottom: 15px; right: 15px; background: #2ecc71; color: #000; border: none; padding: 12px 16px; border-radius: 50%; cursor: pointer; font-weight: bold; font-size: 1.2em; box-shadow: 0 2px 8px rgba(0,0,0,0.4); }}
|
||||
.footer {{ margin-top: 20px; text-align: center; opacity: 0.5; font-size: 0.7em; padding-bottom: 60px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧠 Second Brain Dashboard</h1>
|
||||
<button class="refresh" onclick="location.reload()">🔄 Aktualisieren</button>
|
||||
<h1>🧠 Second Brain</h1>
|
||||
<button class="refresh" onclick="location.reload()">↻</button>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card"><div class="num">{stats['total_engrams']}</div><div class="lbl">Engramme</div></div>
|
||||
<div class="stat-card"><div class="num">{stats['confirmed']}</div><div class="lbl">Bestätigt</div></div>
|
||||
<div class="stat-card"><div class="num">{stats['unconfirmed']}</div><div class="lbl">Unbestätigt</div></div>
|
||||
<div class="stat-card"><div class="num">{len(stats.get('sources', dict()))}</div><div class="lbl">Quellen</div></div>
|
||||
<div class="stat-card"><div class="num">{stats['db_size_bytes']/1024:.0f}</div><div class="lbl">KB Größe</div></div>
|
||||
<div class="stat-card"><div class="num">{stats['errors']}</div><div class="lbl">Fehler</div></div>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input type="text" placeholder="🔍 Suche nach Engrammen..." id="searchInput" onkeyup="filterTable()">
|
||||
<input type="text" placeholder="🔍 Suche..." id="searchInput" onkeyup="filterCards()">
|
||||
</div>
|
||||
|
||||
<table id="engramTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>ID</th>
|
||||
<th>Inhalt</th>
|
||||
<th>Grounding</th>
|
||||
<th>Quelle</th>
|
||||
<th>Confidence</th>
|
||||
<th>Feedback</th>
|
||||
<th>Zugriffe</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join(rows)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="cards">
|
||||
{''.join(card_rows)}
|
||||
</div>
|
||||
|
||||
<div class="footer">Generated {__import__('datetime').datetime.now().isoformat()}</div>
|
||||
<div class="footer">Generated {__import__('datetime').datetime.now().strftime('%H:%M')}</div>
|
||||
|
||||
<script>
|
||||
function filterTable() {{
|
||||
function filterCards() {{
|
||||
var input = document.getElementById('searchInput');
|
||||
var filter = input.value.toLowerCase();
|
||||
var table = document.getElementById('engramTable');
|
||||
var rows = table.getElementsByTagName('tr');
|
||||
for (var i=1; i<rows.length; i++) {{
|
||||
var txt = rows[i].textContent || rows[i].innerText;
|
||||
rows[i].style.display = txt.toLowerCase().indexOf(filter) > -1 ? '' : 'none';
|
||||
var cards = document.getElementById('cards').getElementsByClassName('card');
|
||||
for (var i=0; i<cards.length; i++) {{
|
||||
var txt = cards[i].textContent || cards[i].innerText;
|
||||
cards[i].style.display = txt.toLowerCase().indexOf(filter) > -1 ? '' : 'none';
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
|
||||
@@ -66,12 +66,19 @@ class Correctness:
|
||||
return self.confirmations / total
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
# Backwards/robustness: older code paths may have appended raw dicts.
|
||||
review_history: List[dict] = []
|
||||
for entry in self.review_history:
|
||||
if isinstance(entry, dict):
|
||||
review_history.append(entry)
|
||||
else:
|
||||
review_history.append(entry.to_dict())
|
||||
return {
|
||||
"confirmed": self.confirmed,
|
||||
"confirmations": self.confirmations,
|
||||
"rejections": self.rejections,
|
||||
"last_reviewed": self.last_reviewed,
|
||||
"review_history": [r.to_dict() for r in self.review_history],
|
||||
"review_history": review_history,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -16,10 +16,33 @@ from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
# Ensure project root is on sys.path for standalone usage
|
||||
project_root = str(Path(__file__).parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
# Activate virtualenv if available (for chromadb etc.)
|
||||
venv_path = Path(__file__).parent.parent / ".venv"
|
||||
if venv_path.exists():
|
||||
venv_site_packages = list((venv_path / "lib").glob("python3.*/site-packages"))
|
||||
if venv_site_packages and str(venv_site_packages[0]) not in sys.path:
|
||||
sys.path.insert(0, str(venv_site_packages[0]))
|
||||
|
||||
# Second Brain Import
|
||||
from .store import EngramStore
|
||||
from .engram import Engram, Grounding
|
||||
from .retriever import Retriever
|
||||
from src.engram import Engram, Grounding
|
||||
from src.store import EngramStore
|
||||
|
||||
# Retriever: optional (braucht chromadb)
|
||||
try:
|
||||
from src.retriever import Retriever
|
||||
except ImportError:
|
||||
Retriever = None
|
||||
|
||||
# Chroma: optional (braucht chromadb)
|
||||
try:
|
||||
from src.chroma_store import ChromaStore
|
||||
except Exception:
|
||||
ChromaStore = None
|
||||
|
||||
|
||||
# --- Konfiguration ---
|
||||
@@ -75,11 +98,6 @@ def heartbeat_check() -> Optional[str]:
|
||||
Rückgabe: Nachricht für den User, oder None wenn nichts zu tun.
|
||||
"""
|
||||
store = get_brain()
|
||||
ret = Retriever(store)
|
||||
|
||||
# A: Unbestätigte Engramme die seit längerem nicht geprüft wurden
|
||||
# B: Hohe-Prioritäts-Themen (tags wie "wichtig", "dringend")
|
||||
# C: Fehler-Engramme die repeating sind
|
||||
|
||||
# Prüfe auf wichtige unbestätigte Engramme
|
||||
egs = store.get_all(limit=50)
|
||||
@@ -89,8 +107,7 @@ def heartbeat_check() -> Optional[str]:
|
||||
][:5]
|
||||
|
||||
if unconfirmed:
|
||||
ids = ", ".join([str(eg.id)[:8] for eg in unconfirmed])
|
||||
contents = "\n".join([f" - {eg.content[:80]}" for eg in unconfimed])
|
||||
contents = "\n".join([f" - {eg.content[:80]}" for eg in unconfirmed])
|
||||
return (
|
||||
f"🧠 Second Brain Heartbeat\n"
|
||||
f"Unbestätigte Engramme mit gutem Confidence-Score:\n{contents}\n"
|
||||
@@ -195,8 +212,39 @@ def enrich_context(topic: str, limit: int = 3) -> str:
|
||||
# memory_context in das Prompt einbauen
|
||||
"""
|
||||
store = get_brain()
|
||||
ret = Retriever(store)
|
||||
results = ret.retrieve(topic, limit=limit, min_confidence=0.3)
|
||||
|
||||
# Versuche Hybrid-Retrieval (FTS + optional Vector), fallback auf Textsuche
|
||||
if Retriever:
|
||||
chroma = None
|
||||
if ChromaStore:
|
||||
try:
|
||||
chroma = ChromaStore(path=str(Path(__file__).parent.parent / "data" / "chroma"))
|
||||
except Exception:
|
||||
chroma = None
|
||||
ret = Retriever(store, chroma=chroma)
|
||||
try:
|
||||
results = ret.hybrid_retrieve(topic, limit=limit * 3, min_confidence=0.3)
|
||||
except Exception:
|
||||
results = ret.retrieve(topic, limit=limit * 3, min_confidence=0.3)
|
||||
|
||||
# confirmed-first ranking
|
||||
def _rank(r):
|
||||
eg = r["engram"]
|
||||
confirmed = 1 if getattr(eg.correctness, "confirmed", False) else 0
|
||||
return (confirmed, float(r.get("score", 0.0)))
|
||||
|
||||
results.sort(key=_rank, reverse=True)
|
||||
|
||||
# If we have confirmed results, show only confirmed up to limit
|
||||
confirmed_only = [r for r in results if r["engram"].correctness.confirmed]
|
||||
if confirmed_only:
|
||||
results = confirmed_only[:limit]
|
||||
else:
|
||||
results = results[:limit]
|
||||
else:
|
||||
results_raw = store.search_text(topic, limit=limit)
|
||||
results = [{"engram": eg, "score": 0.5} for eg in results_raw]
|
||||
|
||||
if not results:
|
||||
return ""
|
||||
|
||||
|
||||
165
src/proactive_search.py
Normal file
165
src/proactive_search.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
proactive_search.py - Proaktive Websuche für Second Brain.
|
||||
Sucht relevante Themen, speichert Ergebnisse als Engramme.
|
||||
Stoppt wenn neue Aufgaben erkannt werden.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from src.store import EngramStore
|
||||
from src.engram import Engram, Grounding
|
||||
from src.retriever import Retriever
|
||||
from src.embedder import encode
|
||||
from src.chroma_store import ChromaStore
|
||||
|
||||
DB_PATH = Path(__file__).resolve().parent.parent / "data" / "brain.sqlite"
|
||||
CHROMA_PATH = Path(__file__).resolve().parent.parent / "data" / "chroma"
|
||||
|
||||
# Themen die relevant sind für den Benutzer
|
||||
INTEREST_TOPICS = [
|
||||
"OpenClaw AI Agent",
|
||||
"Künstliche Intelligenz Trends 2025",
|
||||
"Second Brain Memory System",
|
||||
"Automation DIY Projects",
|
||||
"Smart Home IoT",
|
||||
"Raspberry Pi Projects",
|
||||
"Deutschland Tech News",
|
||||
"AI Agent Frameworks",
|
||||
"Workflow Automation",
|
||||
]
|
||||
|
||||
|
||||
def get_store():
|
||||
return EngramStore(str(DB_PATH))
|
||||
|
||||
|
||||
def load_state() -> Dict[str, Any]:
|
||||
"""Lädt den Such-Zustand."""
|
||||
state_path = Path(__file__).resolve().parent.parent / "data" / "search_state.json"
|
||||
if state_path.exists():
|
||||
with open(state_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
"last_search": None,
|
||||
"searched_topics": [],
|
||||
"new_tasks_detected": False,
|
||||
"paused_until": None,
|
||||
}
|
||||
|
||||
|
||||
def save_state(state: Dict[str, Any]):
|
||||
state_path = Path(__file__).resolve().parent.parent / "data" / "search_state.json"
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False)
|
||||
|
||||
|
||||
def check_for_new_tasks(store: EngramStore) -> bool:
|
||||
"""Prüft ob in letzten 2h neue Aufgaben-Artige Engramme erstellt wurden."""
|
||||
now = datetime.now(timezone.utc)
|
||||
recent = now - timedelta(hours=2)
|
||||
egs = store.get_all(limit=1000)
|
||||
for eg in egs:
|
||||
created_str = eg.metadata.get("created", "")
|
||||
if not created_str:
|
||||
continue
|
||||
try:
|
||||
eg_time = datetime.fromisoformat(created_str)
|
||||
if eg_time.tzinfo is None:
|
||||
eg_time = eg_time.replace(tzinfo=timezone.utc)
|
||||
if eg_time > recent:
|
||||
tags = eg.metadata.get("tags", [])
|
||||
if "task" in tags or "aufgabe" in tags or "todo" in tags:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def try_web_search(topic: str) -> Optional[List[Dict[str, str]]]:
|
||||
"""Web-Suche via OpenClaw."""
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["python3", "-c", f"""
|
||||
import sys
|
||||
sys.path.insert(0, '/root/.openclaw/workspace/second-brain/src')
|
||||
from src.retriever import Retriever
|
||||
from src.store import EngramStore
|
||||
store = EngramStore('data/brain.sqlite')
|
||||
ret = Retriever(store)
|
||||
results = ret.retrieve('{topic}')
|
||||
print('FOUND ' + str(len(results)))
|
||||
"""],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd="/root/.openclaw/workspace/second-brain",
|
||||
)
|
||||
# Actually do web search
|
||||
print(f"[search] Would search: {topic}")
|
||||
return None # Placeholder: real search would be here
|
||||
except Exception as e:
|
||||
print(f"[search] Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def run_proactive_search():
|
||||
"""Haupt-Funktion für proaktive Suche."""
|
||||
store = get_store()
|
||||
state = load_state()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Check: Neue Aufgaben?
|
||||
if check_for_new_tasks(store):
|
||||
state["new_tasks_detected"] = True
|
||||
state["paused_until"] = (now + timedelta(hours=4)).isoformat()
|
||||
save_state(state)
|
||||
print("🛑 Neue Aufgaben erkannt. Suche pausiert für 4h.")
|
||||
return
|
||||
|
||||
# Check: Pausiert?
|
||||
if state.get("paused_until"):
|
||||
paused = datetime.fromisoformat(state["paused_until"])
|
||||
if now < paused:
|
||||
print(f"⏸️ Suche pausiert bis {state['paused_until']}")
|
||||
return
|
||||
else:
|
||||
state["paused_until"] = None
|
||||
state["new_tasks_detected"] = False
|
||||
|
||||
# Thema auswählen (Round-Robin)
|
||||
searched = set(state.get("searched_topics", []))
|
||||
remaining = [t for t in INTEREST_TOPICS if t not in searched]
|
||||
if not remaining:
|
||||
remaining = INTEREST_TOPICS
|
||||
searched = set()
|
||||
|
||||
topic = remaining[0]
|
||||
print(f"🔍 Suche: {topic}")
|
||||
|
||||
# Als Engramm speichern (als "Suchanfrage", nicht als Faktum)
|
||||
eg = Engram.create(
|
||||
content=f"Proaktive Web-Suche: {topic}\nStatus: Geplant",
|
||||
source="agent",
|
||||
tags=["proactive", "search", "planned"],
|
||||
confidence=0.3,
|
||||
grounding=Grounding.ASSUMPTION,
|
||||
)
|
||||
store.save(eg)
|
||||
|
||||
state["last_search"] = now.isoformat()
|
||||
state["searched_topics"] = list(searched | {topic})
|
||||
save_state(state)
|
||||
|
||||
print(f"✅ Such-Engramm gespeichert: {eg.id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_proactive_search()
|
||||
@@ -7,12 +7,10 @@ Phase 2: + Embedding + Fusion.
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .engram import Engram
|
||||
from .store import EngramStore
|
||||
from .chroma_store import ChromaStore
|
||||
from .embedder import encode
|
||||
|
||||
|
||||
class Retriever:
|
||||
def __init__(self, store: EngramStore, chroma: Optional[ChromaStore] = None):
|
||||
def __init__(self, store: EngramStore, chroma: Optional[object] = None):
|
||||
self.store = store
|
||||
self.chroma = chroma
|
||||
|
||||
@@ -50,7 +48,6 @@ class Retriever:
|
||||
if not self.chroma:
|
||||
return []
|
||||
chroma_results = self.chroma.query(query, top_k=limit * 3)
|
||||
eids = [r["id"] for r in chroma_results]
|
||||
results = []
|
||||
for r in chroma_results:
|
||||
eg = self.store.get(r["id"])
|
||||
|
||||
15
src/store.py
15
src/store.py
@@ -127,6 +127,14 @@ class EngramStore:
|
||||
).fetchall()
|
||||
return [self._row_to_engram(r) for r in rows]
|
||||
|
||||
def get_modified_since(self, iso_ts: str, limit: int = 5000) -> List[Engram]:
|
||||
"""Gibt Engramme zurück, deren `modified_at` nach `iso_ts` liegt."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM engrams WHERE modified_at > ? ORDER BY modified_at ASC LIMIT ?",
|
||||
(iso_ts, limit),
|
||||
).fetchall()
|
||||
return [self._row_to_engram(r) for r in rows]
|
||||
|
||||
def delete(self, engram_id: str) -> bool:
|
||||
"""Löscht ein Engramm und alle Verknüpfungen."""
|
||||
rowid = self._conn.execute(
|
||||
@@ -239,6 +247,13 @@ class EngramStore:
|
||||
"links": json.loads(row["links_json"]),
|
||||
"hierarchy": json.loads(row["hierarchy_json"]),
|
||||
}
|
||||
# Keep Engram metadata timestamps aligned with DB columns so downstream
|
||||
# consumers (e.g. vector indexing watermarks) can rely on them.
|
||||
try:
|
||||
d["metadata"]["created"] = row["created_at"]
|
||||
d["metadata"]["modified"] = row["modified_at"]
|
||||
except Exception:
|
||||
pass
|
||||
emb = row["embedding_json"]
|
||||
if emb:
|
||||
d["embedding"] = json.loads(emb)
|
||||
|
||||
316
static/style.css
Normal file
316
static/style.css
Normal file
@@ -0,0 +1,316 @@
|
||||
/* ─── Reset & Base ────────────────────────────────────────────────────────── */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { font-size: 14px; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #0f0f12;
|
||||
color: #e8e8ee;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
background: #141419;
|
||||
}
|
||||
|
||||
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 10px 8px;
|
||||
background: linear-gradient(180deg, #1a1a22 0%, #131318 100%);
|
||||
border-bottom: 1px solid #252530;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.stat {
|
||||
text-align: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
.stat-num {
|
||||
display: block;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #6c8af5;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
color: #888899;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─── Search ──────────────────────────────────────────────────────────────── */
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #141419;
|
||||
}
|
||||
#searchInput {
|
||||
flex: 1;
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
color: #e8e8ee;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
#searchInput:focus { border-color: #6c8af5; }
|
||||
#filterSelect {
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
color: #e8e8ee;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ─── New Engram ──────────────────────────────────────────────────────────── */
|
||||
.new-engram {
|
||||
padding: 0 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.new-engram textarea {
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
color: #e8e8ee;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
min-height: 50px;
|
||||
}
|
||||
.new-engram input {
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
color: #e8e8ee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.new-engram button {
|
||||
background: #3a7d3a;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ─── Cards ───────────────────────────────────────────────────────────────── */
|
||||
.cards {
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
background: #1a1a24;
|
||||
border: 1px solid #252533;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s ease, border-color 0.2s ease;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.card:active { transform: scale(0.985); }
|
||||
.card.confirmed { border-left: 4px solid #3a7d3a; }
|
||||
.card.rejected { border-left: 4px solid #8a3a3a; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 6px;
|
||||
border-bottom: 1px solid #20202a;
|
||||
}
|
||||
.conf-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
.tags {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.tag {
|
||||
background: #2a2a3a;
|
||||
color: #8a9aff;
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.date {
|
||||
font-size: 0.65rem;
|
||||
color: #666677;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
color: #ccccdd;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 10px;
|
||||
border-top: 1px solid #20202a;
|
||||
}
|
||||
.reason-input {
|
||||
flex: 1;
|
||||
background: #14141a;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
color: #b0b0c0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.actions button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.actions button:active { transform: scale(0.9); }
|
||||
.btn-ok { background: #2a5a2a; }
|
||||
.btn-no { background: #5a2a2a; }
|
||||
.btn-archive { background: #2a2a4a; }
|
||||
|
||||
/* ─── Pagination ──────────────────────────────────────────────────────────── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.pagination button {
|
||||
background: #252535;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #e8e8ee;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
#pageNum {
|
||||
font-weight: 700;
|
||||
color: #6c8af5;
|
||||
}
|
||||
|
||||
/* ─── Footer ──────────────────────────────────────────────────────────────── */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px 20px;
|
||||
color: #555566;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: #252535;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #8888aa;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* ─── Modal ───────────────────────────────────────────────────────────────── */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.modal.open { display: block; }
|
||||
.modal-content {
|
||||
background: #1a1a24;
|
||||
border: 1px solid #333344;
|
||||
border-radius: 16px;
|
||||
padding: 20px 16px;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8888aa;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history {
|
||||
list-style: none;
|
||||
font-size: 0.8rem;
|
||||
color: #9999aa;
|
||||
}
|
||||
.history li {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #20202a;
|
||||
}
|
||||
.detail-content {
|
||||
background: #14141a;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
margin: 8px 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ───────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #333344; border-radius: 4px; }
|
||||
|
||||
/* ─── Touch fix ───────────────────────────────────────────────────────────── */
|
||||
@media (pointer: coarse) {
|
||||
button, .card { -webkit-tap-highlight-color: transparent; }
|
||||
}
|
||||
246
templates/dashboard.html
Normal file
246
templates/dashboard.html
Normal file
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no">
|
||||
<title>🧠 Second Brain</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Stats Header -->
|
||||
<header class="stats-bar" id="statsBar">
|
||||
<div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div>
|
||||
</header>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
|
||||
<select id="filterSelect">
|
||||
<option value="all">Alle</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="errors">Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- New Engram -->
|
||||
<div class="new-engram">
|
||||
<textarea id="newContent" placeholder="Neues Engramm..."></textarea>
|
||||
<input type="text" id="newTags" placeholder="Tags (comma sep)" />
|
||||
<button onclick="createEngram()">➕ Speichern</button>
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="cards" id="cards"></div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="btnPrev" onclick="prevPage()">◀</button>
|
||||
<span id="pageNum">1</span>
|
||||
<button id="btnNext" onclick="nextPage()">▶</button>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span id="lastUpdate">--:--</span>
|
||||
<button onclick="manualRefresh()" class="refresh-btn">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<div class="modal" id="detailModal">
|
||||
<div class="modal-content">
|
||||
<button class="close-btn" onclick="closeModal()">×</button>
|
||||
<div id="modalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
let state = {
|
||||
items: [],
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
filter: 'all',
|
||||
search: '',
|
||||
autoRefresh: true,
|
||||
};
|
||||
|
||||
// ─── Fetch ──────────────────────────────────────────────────────────────────
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(path, opts);
|
||||
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const s = await api('/api/stats');
|
||||
document.getElementById('statTotal').textContent = s.total;
|
||||
document.getElementById('statConfirmed').textContent = s.confirmed;
|
||||
document.getElementById('statPending').textContent = s.pending;
|
||||
document.getElementById('statErrors').textContent = s.errors;
|
||||
}
|
||||
|
||||
async function loadCards() {
|
||||
let url = `/api/engrams?limit=${state.limit}&offset=${state.offset}`;
|
||||
if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
|
||||
if (state.filter === 'confirmed') url += '&confirmed=1';
|
||||
if (state.filter === 'pending') url += '&confirmed=0';
|
||||
if (state.filter === 'errors') url += '&tag=error';
|
||||
|
||||
const data = await api(url);
|
||||
state.items = data.items;
|
||||
renderCards();
|
||||
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
|
||||
document.getElementById('btnPrev').disabled = state.offset === 0;
|
||||
document.getElementById('btnNext').disabled = data.items.length < state.limit;
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
const el = document.getElementById('cards');
|
||||
el.innerHTML = state.items.map(item => `
|
||||
<div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}">
|
||||
<div class="card-header">
|
||||
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
|
||||
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
|
||||
<span class="date">${fmtDate(item.created)}</span>
|
||||
</div>
|
||||
<div class="card-body" onclick="showDetail('${item.id}')">
|
||||
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
|
||||
<div class="actions">
|
||||
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
|
||||
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
|
||||
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
const d = new Date(iso);
|
||||
return `${d.getDate().toString().padStart(2,'0')}.${(d.getMonth()+1).toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(t) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = t;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||
async function confirm(id, ev) {
|
||||
ev.stopPropagation();
|
||||
const reason = document.getElementById('reason-'+id).value;
|
||||
await api(`/api/engrams/${id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({reason})
|
||||
});
|
||||
await loadCards(); await loadStats();
|
||||
}
|
||||
|
||||
async function reject(id, ev) {
|
||||
ev.stopPropagation();
|
||||
const reason = document.getElementById('reason-'+id).value;
|
||||
await api(`/api/engrams/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({reason})
|
||||
});
|
||||
await loadCards(); await loadStats();
|
||||
}
|
||||
|
||||
async function refresh(id, ev) {
|
||||
ev.stopPropagation();
|
||||
await api(`/api/engrams/${id}/refresh`, {method: 'POST'});
|
||||
await loadCards();
|
||||
}
|
||||
|
||||
async function createEngram() {
|
||||
const content = document.getElementById('newContent').value;
|
||||
const tags = document.getElementById('newTags').value;
|
||||
if (!content.trim()) return;
|
||||
await api('/api/engrams', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({content, tags})
|
||||
});
|
||||
document.getElementById('newContent').value = '';
|
||||
document.getElementById('newTags').value = '';
|
||||
await loadCards(); await loadStats();
|
||||
}
|
||||
|
||||
async function showDetail(id) {
|
||||
const item = await api(`/api/engrams/${id}`);
|
||||
const body = document.getElementById('modalBody');
|
||||
body.innerHTML = `
|
||||
<h3>Engramm ${item.id.substring(0,8)}</h3>
|
||||
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p>
|
||||
<p><b>Confirmed:</b> ${item.confirmed ? '✅' : '❌'}</p>
|
||||
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p>
|
||||
<p><b>Content:</b></p>
|
||||
<div class="detail-content">${escapeHtml(item.content)}</div>
|
||||
<p><b>History:</b></p>
|
||||
<ul class="history">
|
||||
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)} — ${h.action} (${h.note})</li>`).join('')}
|
||||
</ul>
|
||||
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
|
||||
`;
|
||||
document.getElementById('detailModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// ─── Pagination ─────────────────────────────────────────────────────────────
|
||||
function nextPage() {
|
||||
state.offset += state.limit;
|
||||
loadCards();
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
state.offset = Math.max(0, state.offset - state.limit);
|
||||
loadCards();
|
||||
}
|
||||
|
||||
function manualRefresh() {
|
||||
loadCards(); loadStats();
|
||||
}
|
||||
|
||||
// ─── Search ─────────────────────────────────────────────────────────────────
|
||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||
state.search = e.target.value;
|
||||
state.offset = 0;
|
||||
loadCards();
|
||||
});
|
||||
|
||||
document.getElementById('filterSelect').addEventListener('change', (e) => {
|
||||
state.filter = e.target.value;
|
||||
state.offset = 0;
|
||||
loadCards();
|
||||
});
|
||||
|
||||
// ─── Auto Refresh ───────────────────────────────────────────────────────────
|
||||
setInterval(() => {
|
||||
if (!state.autoRefresh) return;
|
||||
loadStats();
|
||||
loadCards();
|
||||
const now = new Date();
|
||||
document.getElementById('lastUpdate').textContent =
|
||||
`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
|
||||
}, 5000);
|
||||
|
||||
// ─── Init ───────────────────────────────────────────────────────────────────
|
||||
loadStats();
|
||||
loadCards();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,16 +4,11 @@
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
try:
|
||||
from src.engram import Engram, Grounding, Correctness
|
||||
from src.store import EngramStore
|
||||
from src.retriever import Retriever
|
||||
except ImportError:
|
||||
from engram import Engram, Grounding, Correctness
|
||||
from store import EngramStore
|
||||
from retriever import Retriever
|
||||
from src.engram import Engram, Grounding, Correctness
|
||||
from src.store import EngramStore
|
||||
from src.retriever import Retriever
|
||||
|
||||
|
||||
def test_engram_creation():
|
||||
|
||||
Reference in New Issue
Block a user