Spaces:
Running on Zero
Running on Zero
| """Kith Local — SQLite storage layer. | |
| Local file storage is the product story: your conversations never leave the | |
| machine. One connection per call (handlers may run concurrently on ZeroGPU). | |
| """ | |
| import os | |
| import sqlite3 | |
| from datetime import date, datetime, timedelta | |
| DB_PATH = os.environ.get("KITH_DB", os.path.join(os.path.dirname(__file__), "data", "kith.db")) | |
| SCHEMA = """ | |
| CREATE TABLE IF NOT EXISTS conversations ( | |
| id INTEGER PRIMARY KEY, | |
| title TEXT, | |
| transcript TEXT NOT NULL, | |
| occurred_at TEXT NOT NULL, | |
| processed_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS people ( | |
| id INTEGER PRIMARY KEY, | |
| name TEXT NOT NULL, | |
| name_key TEXT NOT NULL UNIQUE, | |
| relationship TEXT NOT NULL DEFAULT 'other', | |
| summary TEXT NOT NULL DEFAULT '', | |
| last_contact TEXT, | |
| talk_count INTEGER NOT NULL DEFAULT 0 | |
| ); | |
| CREATE TABLE IF NOT EXISTS signals ( | |
| id INTEGER PRIMARY KEY, | |
| person_id INTEGER NOT NULL REFERENCES people(id), | |
| type TEXT NOT NULL, | |
| text TEXT NOT NULL, | |
| event_on TEXT, | |
| quote TEXT, | |
| source_id INTEGER REFERENCES conversations(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS commitments ( | |
| id INTEGER PRIMARY KEY, | |
| person_id INTEGER NOT NULL REFERENCES people(id), | |
| owner TEXT NOT NULL CHECK (owner IN ('me','them')), | |
| text TEXT NOT NULL, | |
| promised_on TEXT, | |
| due TEXT, | |
| status TEXT NOT NULL DEFAULT 'open', | |
| quote TEXT, | |
| source_id INTEGER REFERENCES conversations(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS decisions ( | |
| id INTEGER PRIMARY KEY, | |
| text TEXT NOT NULL, | |
| rationale TEXT, | |
| decided_on TEXT, | |
| source_id INTEGER REFERENCES conversations(id) | |
| ); | |
| -- A dismissed card hides the suggestion without forgetting the underlying | |
| -- memory: the feed query filters these out, the person/signal row stays. | |
| CREATE TABLE IF NOT EXISTS dismissals ( | |
| card_id TEXT PRIMARY KEY, | |
| dismissed_at TEXT NOT NULL | |
| ); | |
| """ | |
| def connect() -> sqlite3.Connection: | |
| os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) | |
| conn = sqlite3.connect(DB_PATH, timeout=15) | |
| conn.row_factory = sqlite3.Row | |
| conn.executescript(SCHEMA) | |
| _migrate(conn) | |
| return conn | |
| def _migrate(conn: sqlite3.Connection) -> None: | |
| """Add columns that may be missing on a DB created by an earlier version.""" | |
| for table in ("signals", "commitments"): | |
| cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")} | |
| if "quote" not in cols: | |
| conn.execute(f"ALTER TABLE {table} ADD COLUMN quote TEXT") | |
| def _name_key(name: str) -> str: | |
| return " ".join(name.lower().split()) | |
| def insert_conversation(transcript: str, title: str | None = None) -> int: | |
| with connect() as conn: | |
| cur = conn.execute( | |
| "INSERT INTO conversations (title, transcript, occurred_at) VALUES (?, ?, ?)", | |
| (title or "Conversation", transcript, datetime.now().isoformat(timespec="seconds")), | |
| ) | |
| return cur.lastrowid | |
| def apply_extraction(conversation_id: int, extraction: dict, today: str | None = None) -> dict: | |
| """Upsert extracted people, then attach signals/commitments/decisions. | |
| Returns counts per entity type for the UI. | |
| """ | |
| today = today or date.today().isoformat() | |
| counts = {"people": 0, "signals": 0, "commitments": 0, "decisions": 0} | |
| with connect() as conn: | |
| ids_by_key: dict[str, int] = {} | |
| for p in extraction.get("people", []): | |
| key = _name_key(p["name"]) | |
| conn.execute( | |
| """INSERT INTO people (name, name_key, relationship, summary, last_contact, talk_count) | |
| VALUES (?, ?, ?, ?, ?, 1) | |
| ON CONFLICT(name_key) DO UPDATE SET | |
| relationship = excluded.relationship, | |
| summary = excluded.summary, | |
| last_contact = excluded.last_contact, | |
| talk_count = people.talk_count + 1""", | |
| (p["name"], key, p.get("relationship", "other"), p.get("summary", ""), today), | |
| ) | |
| counts["people"] += 1 | |
| for row in conn.execute("SELECT id, name_key FROM people"): | |
| ids_by_key[row["name_key"]] = row["id"] | |
| def pid(name: str) -> int | None: | |
| return ids_by_key.get(_name_key(name or "")) | |
| for s in extraction.get("signals", []): | |
| person_id = pid(s.get("person")) | |
| if person_id is None or not s.get("text"): | |
| continue | |
| dup = conn.execute( | |
| "SELECT 1 FROM signals WHERE person_id = ? AND type = ? AND text = ?", | |
| (person_id, s.get("type", "preference"), s["text"]), | |
| ).fetchone() | |
| if dup: | |
| continue | |
| conn.execute( | |
| "INSERT INTO signals (person_id, type, text, event_on, quote, source_id) VALUES (?, ?, ?, ?, ?, ?)", | |
| (person_id, s.get("type", "preference"), s["text"], s.get("event_on"), s.get("quote"), conversation_id), | |
| ) | |
| counts["signals"] += 1 | |
| for c in extraction.get("commitments", []): | |
| person_id = pid(c.get("person")) | |
| if person_id is None or not c.get("text"): | |
| continue | |
| dup = conn.execute( | |
| "SELECT 1 FROM commitments WHERE person_id = ? AND owner = ? AND text = ? AND status = 'open'", | |
| (person_id, c.get("owner", "me"), c["text"]), | |
| ).fetchone() | |
| if dup: | |
| continue | |
| conn.execute( | |
| """INSERT INTO commitments (person_id, owner, text, promised_on, due, status, quote, source_id) | |
| VALUES (?, ?, ?, ?, ?, 'open', ?, ?)""", | |
| (person_id, c.get("owner", "me"), c["text"], c.get("promised_on", today), c.get("due"), c.get("quote"), conversation_id), | |
| ) | |
| counts["commitments"] += 1 | |
| for d in extraction.get("decisions", []): | |
| if not d.get("text"): | |
| continue | |
| if conn.execute("SELECT 1 FROM decisions WHERE text = ?", (d["text"],)).fetchone(): | |
| continue | |
| conn.execute( | |
| "INSERT INTO decisions (text, rationale, decided_on, source_id) VALUES (?, ?, ?, ?)", | |
| (d["text"], d.get("rationale"), d.get("decided_on", today), conversation_id), | |
| ) | |
| counts["decisions"] += 1 | |
| conn.execute( | |
| "UPDATE conversations SET processed_at = ? WHERE id = ?", | |
| (datetime.now().isoformat(timespec="seconds"), conversation_id), | |
| ) | |
| return counts | |
| def today_feed(today: str | None = None, limit: int = 6) -> list[dict]: | |
| """The Today feed. Four tiers, most-actionable first: | |
| thoughtful — a dated event coming up (birthday, appointment) within 14 days | |
| owed — an open promise YOU made (overdue ones first) | |
| reconnect — a personal contact you haven't spoken to in 21+ days | |
| remember — something meaningful you just learned (undated signal) | |
| The 'remember' tier is what guarantees a fresh capture always produces | |
| cards: right after recording, nothing is yet overdue or in the future, but | |
| you've still learned things worth surfacing. | |
| """ | |
| today_d = date.fromisoformat(today) if today else date.today() | |
| today_s = today_d.isoformat() | |
| horizon = (today_d + timedelta(days=14)).isoformat() | |
| reconnect_before = (today_d - timedelta(days=21)).isoformat() | |
| cards: list[dict] = [] | |
| with connect() as conn: | |
| dismissed = {r["card_id"] for r in conn.execute("SELECT card_id FROM dismissals")} | |
| # 1. thoughtful — upcoming dated events | |
| rows = conn.execute( | |
| """SELECT s.*, p.name FROM signals s JOIN people p ON p.id = s.person_id | |
| WHERE s.type IN ('date','want','life_event') AND s.event_on IS NOT NULL | |
| AND s.event_on >= ? AND s.event_on <= ? | |
| ORDER BY s.event_on, (s.type = 'date') DESC LIMIT ?""", | |
| (today_s, horizon, limit), | |
| ).fetchall() | |
| seen_events: set[tuple[int, str]] = set() | |
| for r in rows: | |
| event_key = (r["person_id"], r["event_on"]) | |
| if event_key in seen_events: | |
| continue # one card per person per dated event | |
| seen_events.add(event_key) | |
| days = (date.fromisoformat(r["event_on"]) - today_d).days | |
| when = "today" if days == 0 else ("tomorrow" if days == 1 else f"in {days} days") | |
| want = conn.execute( | |
| "SELECT text FROM signals WHERE person_id = ? AND type = 'want' AND id != ? ORDER BY id DESC", | |
| (r["person_id"], r["id"]), | |
| ).fetchone() | |
| cards.append({ | |
| "id": f"thoughtful:{r['id']}", | |
| "kind": "thoughtful", | |
| "personName": r["name"], | |
| "title": f"{r['text']} — {when}", | |
| "why": want["text"] if want else f"You talked about this with {r['name']} recently.", | |
| }) | |
| # 2. owed — every open promise I made; overdue first, newest of the rest next | |
| rows = conn.execute( | |
| """SELECT c.*, p.name FROM commitments c JOIN people p ON p.id = c.person_id | |
| WHERE c.owner = 'me' AND c.status = 'open' | |
| ORDER BY (c.due IS NOT NULL AND c.due <= ?) DESC, c.due, c.id DESC | |
| LIMIT ?""", | |
| (today_s, limit), | |
| ).fetchall() | |
| for r in rows: | |
| overdue = r["due"] and r["due"] <= today_s | |
| if overdue: | |
| why = f"Was due {r['due']}" | |
| elif r["due"]: | |
| why = f"Due {r['due']}" | |
| elif r["promised_on"]: | |
| why = f"You offered this on {r['promised_on']}" | |
| else: | |
| why = f"Something you offered {r['name']}" | |
| cards.append({ | |
| "id": f"owed:{r['id']}", | |
| "kind": "owed", | |
| "personName": r["name"], | |
| "title": f"You owe {r['name']}: {r['text']}", | |
| "why": why, | |
| }) | |
| # 3. reconnect — personal contacts gone quiet | |
| rows = conn.execute( | |
| """SELECT * FROM people | |
| WHERE relationship = 'personal' AND last_contact IS NOT NULL AND last_contact <= ? | |
| ORDER BY last_contact LIMIT ?""", | |
| (reconnect_before, limit), | |
| ).fetchall() | |
| for r in rows: | |
| cards.append({ | |
| "id": f"reconnect:{r['id']}", | |
| "kind": "reconnect", | |
| "personName": r["name"], | |
| "title": f"It's been a while since you talked to {r['name']}", | |
| "why": r["summary"] or f"Last contact {r['last_contact']}.", | |
| }) | |
| # 4. remember — meaningful undated things you just learned (fills a fresh feed) | |
| rows = conn.execute( | |
| """SELECT s.*, p.name FROM signals s JOIN people p ON p.id = s.person_id | |
| WHERE s.type IN ('want','life_event','health','preference') AND s.event_on IS NULL | |
| ORDER BY s.id DESC LIMIT ?""", | |
| (limit,), | |
| ).fetchall() | |
| verb = {"want": "wants", "life_event": "", "health": "", "preference": "mentioned"} | |
| for r in rows: | |
| cards.append({ | |
| "id": f"remember:{r['id']}", | |
| "kind": "remember", | |
| "personName": r["name"], | |
| "title": r["text"], | |
| "why": f"From your conversation with {r['name']}", | |
| }) | |
| # de-dupe, drop dismissed, cap. Tiers are already in priority order. | |
| seen_ids: set[str] = set() | |
| out: list[dict] = [] | |
| for c in cards: | |
| if c["id"] in dismissed or c["id"] in seen_ids: | |
| continue | |
| seen_ids.add(c["id"]) | |
| out.append(c) | |
| return out[:limit] | |
| def dismiss_card(card_id: str) -> None: | |
| """Hide one suggestion. The underlying memory is kept.""" | |
| with connect() as conn: | |
| conn.execute( | |
| "INSERT OR IGNORE INTO dismissals (card_id, dismissed_at) VALUES (?, ?)", | |
| (card_id, datetime.now().isoformat(timespec="seconds")), | |
| ) | |
| # How each kind of card maps back to the row that produced it, so we can show | |
| # its source quote and find the person it's about. | |
| _CARD_SOURCE = { | |
| "thoughtful": ("signals", "person_id"), | |
| "remember": ("signals", "person_id"), | |
| "owed": ("commitments", "person_id"), | |
| "reconnect": ("people", "id"), | |
| } | |
| def card_detail(card_id: str) -> dict: | |
| """Everything behind one card: the source quote + the rest of what we | |
| remember about that person. This is what makes a card auditable.""" | |
| kind, _, rid = card_id.partition(":") | |
| if kind not in _CARD_SOURCE or not rid.isdigit(): | |
| return {} | |
| table, person_col = _CARD_SOURCE[kind] | |
| with connect() as conn: | |
| row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (int(rid),)).fetchone() | |
| if row is None: | |
| return {} | |
| person_id = row[person_col] | |
| person = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone() | |
| if person is None: | |
| return {} | |
| quote = row["quote"] if kind in ("thoughtful", "owed", "remember") else None | |
| # the conversation this fact came from (for "from your conversation on …") | |
| occurred = None | |
| if kind in ("thoughtful", "owed", "remember") and row["source_id"]: | |
| conv = conn.execute( | |
| "SELECT occurred_at FROM conversations WHERE id = ?", (row["source_id"],) | |
| ).fetchone() | |
| occurred = conv["occurred_at"][:10] if conv else None | |
| memories = [] | |
| for s in conn.execute( | |
| "SELECT type, text, event_on FROM signals WHERE person_id = ? ORDER BY id", (person_id,) | |
| ): | |
| memories.append({"kind": s["type"], "text": s["text"], "date": s["event_on"]}) | |
| for c in conn.execute( | |
| "SELECT owner, text, due, status FROM commitments WHERE person_id = ? ORDER BY id", (person_id,) | |
| ): | |
| label = ("You owe" if c["owner"] == "me" else f"{person['name']} owes you") | |
| memories.append({"kind": "commitment", "text": f"{label}: {c['text']}", "date": c["due"]}) | |
| return { | |
| "kind": kind, | |
| "person": { | |
| "name": person["name"], | |
| "relationship": person["relationship"], | |
| "summary": person["summary"], | |
| "talkCount": person["talk_count"], | |
| "lastContact": person["last_contact"], | |
| }, | |
| "quote": quote, | |
| "occurredOn": occurred, | |
| "memories": memories, | |
| } | |
| def _conversation_title(conn: sqlite3.Connection, conv_id: int) -> str: | |
| """A human title for a conversation: the people it's about, else a snippet.""" | |
| names = [ | |
| r["name"] | |
| for r in conn.execute( | |
| """SELECT DISTINCT p.name FROM people p | |
| WHERE p.id IN (SELECT person_id FROM signals WHERE source_id = ?) | |
| OR p.id IN (SELECT person_id FROM commitments WHERE source_id = ?) | |
| ORDER BY p.name""", | |
| (conv_id, conv_id), | |
| ) | |
| ] | |
| if names: | |
| if len(names) == 1: | |
| return names[0] | |
| if len(names) == 2: | |
| return f"{names[0]} & {names[1]}" | |
| return f"{names[0]}, {names[1]} +{len(names) - 2}" | |
| row = conn.execute("SELECT transcript FROM conversations WHERE id = ?", (conv_id,)).fetchone() | |
| snippet = (row["transcript"][:38].strip() + "…") if row and row["transcript"] else "Conversation" | |
| return snippet | |
| def list_conversations(limit: int = 50) -> list[dict]: | |
| """Newest-first history for the sidebar, with per-conversation counts.""" | |
| out = [] | |
| with connect() as conn: | |
| rows = conn.execute( | |
| "SELECT id, occurred_at FROM conversations ORDER BY id DESC LIMIT ?", (limit,) | |
| ).fetchall() | |
| for r in rows: | |
| cid = r["id"] | |
| people = conn.execute( | |
| """SELECT COUNT(DISTINCT person_id) AS n FROM ( | |
| SELECT person_id FROM signals WHERE source_id = ? | |
| UNION SELECT person_id FROM commitments WHERE source_id = ? | |
| )""", | |
| (cid, cid), | |
| ).fetchone()["n"] | |
| signals = conn.execute("SELECT COUNT(*) AS n FROM signals WHERE source_id = ?", (cid,)).fetchone()["n"] | |
| commitments = conn.execute("SELECT COUNT(*) AS n FROM commitments WHERE source_id = ?", (cid,)).fetchone()["n"] | |
| out.append({ | |
| "id": cid, | |
| "title": _conversation_title(conn, cid), | |
| "occurredOn": r["occurred_at"][:10], | |
| "counts": {"people": people, "signals": signals, "commitments": commitments}, | |
| }) | |
| return out | |
| def conversation_detail(conversation_id: int) -> dict: | |
| """Full record of one past conversation: transcript + what was extracted.""" | |
| with connect() as conn: | |
| conv = conn.execute( | |
| "SELECT * FROM conversations WHERE id = ?", (conversation_id,) | |
| ).fetchone() | |
| if conv is None: | |
| return {} | |
| memories = [] | |
| for s in conn.execute( | |
| """SELECT s.type, s.text, s.event_on, p.name FROM signals s | |
| JOIN people p ON p.id = s.person_id WHERE s.source_id = ? ORDER BY s.id""", | |
| (conversation_id,), | |
| ): | |
| memories.append({"kind": s["type"], "person": s["name"], "text": s["text"], "date": s["event_on"]}) | |
| for c in conn.execute( | |
| """SELECT c.owner, c.text, c.due, p.name FROM commitments c | |
| JOIN people p ON p.id = c.person_id WHERE c.source_id = ? ORDER BY c.id""", | |
| (conversation_id,), | |
| ): | |
| label = "You owe" if c["owner"] == "me" else f"{c['name']} owes you" | |
| memories.append({"kind": "commitment", "person": c["name"], "text": f"{label}: {c['text']}", "date": c["due"]}) | |
| for d in conn.execute( | |
| "SELECT text FROM decisions WHERE source_id = ? ORDER BY id", (conversation_id,) | |
| ): | |
| memories.append({"kind": "decision", "person": None, "text": d["text"], "date": None}) | |
| return { | |
| "id": conv["id"], | |
| "title": _conversation_title(conn, conversation_id), | |
| "occurredOn": conv["occurred_at"][:10], | |
| "transcript": conv["transcript"], | |
| "memories": memories, | |
| } | |
| def reset_all() -> dict: | |
| """Forget everything — the honest 'it's your local file' wipe.""" | |
| with connect() as conn: | |
| for table in ("dismissals", "signals", "commitments", "decisions", "people", "conversations"): | |
| conn.execute(f"DELETE FROM {table}") | |
| return stats() | |
| def stats() -> dict: | |
| with connect() as conn: | |
| out = {} | |
| for table in ("conversations", "people", "signals", "commitments", "decisions"): | |
| out[table] = conn.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"] | |
| return out | |