Add FastAPI dashboard MVP
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user