#!/usr/bin/env python3 """SQLite research accumulator for last30days. Stores topics, research runs, and findings with: - WAL mode for safe concurrent access (cron + user) - FTS5 full-text search with porter+unicode61 tokenizer - URL-based dedup with engagement metric updates on re-sighting - Lightweight schema migrations without external dependencies Database location: ~/.local/share/last30days/research.db """ import argparse import json import sqlite3 import sys from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, List, Optional SCRIPT_DIR = Path(__file__).parent.resolve() sys.path.insert(0, str(SCRIPT_DIR)) from lib import schema DB_DIR = Path.home() / ".local" / "share" / "last30days" DB_PATH = DB_DIR / "research.db" # Allow override for testing _db_override = None def _get_db_path() -> Path: return _db_override or DB_PATH SCHEMA_V1 = """ PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA cache_size=-64000; CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS topics ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, search_queries TEXT, schedule TEXT, enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS research_runs ( id INTEGER PRIMARY KEY, topic_id INTEGER REFERENCES topics(id), run_date TEXT NOT NULL, source_mode TEXT, prompt_tokens INTEGER, completion_tokens INTEGER, token_cost REAL, duration_seconds REAL, status TEXT DEFAULT 'completed', error_message TEXT, findings_new INTEGER DEFAULT 0, findings_updated INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS findings ( id INTEGER PRIMARY KEY, run_id INTEGER REFERENCES research_runs(id), topic_id INTEGER REFERENCES topics(id), source TEXT NOT NULL, source_url TEXT UNIQUE, source_title TEXT, author TEXT, content TEXT, summary TEXT, engagement_score REAL, relevance_score REAL, first_seen TEXT DEFAULT (datetime('now')), last_seen TEXT DEFAULT (datetime('now')), sighting_count INTEGER DEFAULT 1, dismissed INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_findings_topic ON findings(topic_id, first_seen); CREATE INDEX IF NOT EXISTS idx_findings_source ON findings(source, topic_id); CREATE INDEX IF NOT EXISTS idx_findings_url ON findings(source_url); CREATE VIRTUAL TABLE IF NOT EXISTS findings_fts USING fts5( content, summary, source_title, author, tokenize='porter unicode61', content='findings', content_rowid='id' ); CREATE TRIGGER IF NOT EXISTS findings_ai AFTER INSERT ON findings BEGIN INSERT INTO findings_fts(rowid, content, summary, source_title, author) VALUES (new.id, new.content, new.summary, new.source_title, new.author); END; CREATE TRIGGER IF NOT EXISTS findings_ad AFTER DELETE ON findings BEGIN INSERT INTO findings_fts(findings_fts, rowid, content, summary, source_title, author) VALUES ('delete', old.id, old.content, old.summary, old.source_title, old.author); END; CREATE TRIGGER IF NOT EXISTS findings_au AFTER UPDATE ON findings BEGIN INSERT INTO findings_fts(findings_fts, rowid, content, summary, source_title, author) VALUES ('delete', old.id, old.content, old.summary, old.source_title, old.author); INSERT INTO findings_fts(rowid, content, summary, source_title, author) VALUES (new.id, new.content, new.summary, new.source_title, new.author); END; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT, updated_at TEXT DEFAULT (datetime('now')) ); """ SCHEMA_V1_DEFAULTS = """ INSERT OR IGNORE INTO schema_version (version) VALUES (1); INSERT OR IGNORE INTO settings (key, value) VALUES ('daily_budget', '5.00'); INSERT OR IGNORE INTO settings (key, value) VALUES ('delivery_channel', ''); INSERT OR IGNORE INTO settings (key, value) VALUES ('delivery_mode', 'announce'); INSERT OR IGNORE INTO settings (key, value) VALUES ('briefing_format', 'concise'); INSERT OR IGNORE INTO settings (key, value) VALUES ('default_schedule', '0 8 * * *'); """ _UPDATABLE_RUN_COLUMNS = frozenset({ "source_mode", "prompt_tokens", "completion_tokens", "token_cost", "duration_seconds", "status", "error_message", "findings_new", "findings_updated", }) _UPDATABLE_FINDING_COLUMNS = frozenset({ "source", "source_url", "source_title", "author", "content", "summary", "engagement_score", "relevance_score", "last_seen", "sighting_count", "dismissed", }) # Future migrations keyed by version number MIGRATIONS: Dict[int, str] = {} def _connect(db_path: Optional[Path] = None) -> sqlite3.Connection: """Open a connection with WAL mode and row factory.""" path = db_path or _get_db_path() conn = sqlite3.connect(str(path)) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA foreign_keys=ON") return conn def init_db(db_path: Optional[Path] = None) -> Path: """Create database and tables if they don't exist. Returns the DB path.""" path = db_path or _get_db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = _connect(path) try: conn.executescript(SCHEMA_V1) conn.executescript(SCHEMA_V1_DEFAULTS) _run_migrations(conn) conn.commit() finally: conn.close() return path def _run_migrations(conn: sqlite3.Connection): """Apply pending schema migrations.""" current = conn.execute( "SELECT MAX(version) FROM schema_version" ).fetchone()[0] or 0 for version in sorted(MIGRATIONS.keys()): if version > current: conn.executescript(MIGRATIONS[version]) conn.execute( "INSERT INTO schema_version (version) VALUES (?)", (version,) ) # --- Topics --- def add_topic( name: str, search_queries: Optional[List[str]] = None, schedule: str = "0 8 * * *", ) -> Dict[str, Any]: """Add a topic to the watchlist. Returns the topic dict.""" init_db() conn = _connect() try: queries_json = json.dumps(search_queries) if search_queries else None conn.execute( """INSERT INTO topics (name, search_queries, schedule) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET search_queries = excluded.search_queries, schedule = excluded.schedule, updated_at = datetime('now')""", (name, queries_json, schedule), ) conn.commit() row = conn.execute( "SELECT * FROM topics WHERE name = ?", (name,) ).fetchone() return dict(row) finally: conn.close() def remove_topic(name: str) -> bool: """Remove a topic from the watchlist. Returns True if found.""" init_db() conn = _connect() try: row = conn.execute( "SELECT id FROM topics WHERE name = ?", (name,) ).fetchone() if not row: return False topic_id = row["id"] # Delete findings and runs for this topic conn.execute("DELETE FROM findings WHERE topic_id = ?", (topic_id,)) conn.execute("DELETE FROM research_runs WHERE topic_id = ?", (topic_id,)) conn.execute("DELETE FROM topics WHERE id = ?", (topic_id,)) conn.commit() return True finally: conn.close() def list_topics() -> List[Dict[str, Any]]: """List all topics with stats.""" init_db() conn = _connect() try: rows = conn.execute( """SELECT t.*, (SELECT COUNT(*) FROM findings WHERE topic_id = t.id) as finding_count, (SELECT MAX(run_date) FROM research_runs WHERE topic_id = t.id) as last_run, (SELECT status FROM research_runs WHERE topic_id = t.id ORDER BY created_at DESC LIMIT 1) as last_status FROM topics t ORDER BY t.name""" ).fetchall() return [dict(r) for r in rows] finally: conn.close() def get_topic(name: str) -> Optional[Dict[str, Any]]: """Get a topic by name.""" init_db() conn = _connect() try: row = conn.execute( "SELECT * FROM topics WHERE name = ?", (name,) ).fetchone() return dict(row) if row else None finally: conn.close() # --- Research Runs --- def record_run( topic_id: int, source_mode: str = "both", status: str = "completed", error_message: Optional[str] = None, duration_seconds: float = 0, prompt_tokens: int = 0, completion_tokens: int = 0, token_cost: float = 0, ) -> int: """Record a research run. Returns the run ID.""" conn = _connect() try: cursor = conn.execute( """INSERT INTO research_runs (topic_id, run_date, source_mode, status, error_message, duration_seconds, prompt_tokens, completion_tokens, token_cost) VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?)""", ( topic_id, source_mode, status, error_message, duration_seconds, prompt_tokens, completion_tokens, token_cost, ), ) conn.commit() return cursor.lastrowid finally: conn.close() def update_run(run_id: int, **kwargs): """Update a research run's fields.""" conn = _connect() try: invalid_columns = sorted(set(kwargs) - _UPDATABLE_RUN_COLUMNS) if invalid_columns: raise ValueError( f"Invalid run update fields: {', '.join(invalid_columns)}" ) sets = ", ".join(f"{k} = ?" for k in kwargs) values = list(kwargs.values()) + [run_id] conn.execute(f"UPDATE research_runs SET {sets} WHERE id = ?", values) conn.commit() finally: conn.close() # --- Findings --- def store_findings( run_id: int, topic_id: int, findings: List[Dict[str, Any]], ) -> Dict[str, int]: """Store findings with URL-based dedup. Returns counts of new/updated.""" conn = _connect() new_count = 0 updated_count = 0 try: for f in findings: url = f.get("source_url") or f.get("url") if not url: continue existing = conn.execute( "SELECT id, engagement_score, sighting_count FROM findings WHERE source_url = ?", (url,), ).fetchone() if existing: # Update engagement and re-sighting info new_engagement = f.get("engagement_score", 0) conn.execute( """UPDATE findings SET last_seen = datetime('now'), sighting_count = sighting_count + 1, engagement_score = ?, run_id = ? WHERE id = ?""", ( max(new_engagement, existing["engagement_score"] or 0), run_id, existing["id"], ), ) updated_count += 1 else: # New finding conn.execute( """INSERT INTO findings (run_id, topic_id, source, source_url, source_title, author, content, summary, engagement_score, relevance_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( run_id, topic_id, f.get("source", "unknown"), url, f.get("source_title") or f.get("title", ""), f.get("author", ""), f.get("content") or f.get("text", ""), f.get("summary", ""), f.get("engagement_score", 0), f.get("relevance_score", 0), ), ) new_count += 1 # Update run stats conn.execute( "UPDATE research_runs SET findings_new = ?, findings_updated = ? WHERE id = ?", (new_count, updated_count, run_id), ) conn.commit() finally: conn.close() return {"new": new_count, "updated": updated_count} def get_new_findings( topic_id: int, since: Optional[str] = None, ) -> List[Dict[str, Any]]: """Get findings for a topic, optionally since a date.""" conn = _connect() try: if since: rows = conn.execute( """SELECT * FROM findings WHERE topic_id = ? AND first_seen >= ? AND dismissed = 0 ORDER BY first_seen DESC""", (topic_id, since), ).fetchall() else: rows = conn.execute( """SELECT * FROM findings WHERE topic_id = ? AND dismissed = 0 ORDER BY first_seen DESC""", (topic_id,), ).fetchall() return [dict(r) for r in rows] finally: conn.close() def search_findings(query: str, limit: int = 20) -> List[Dict[str, Any]]: """FTS5 search across all findings with BM25 ranking.""" conn = _connect() try: rows = conn.execute( """SELECT f.*, bm25(findings_fts) as rank, t.name as topic_name FROM findings_fts JOIN findings f ON f.id = findings_fts.rowid LEFT JOIN topics t ON t.id = f.topic_id WHERE findings_fts MATCH ? ORDER BY rank LIMIT ?""", (query, limit), ).fetchall() return [dict(r) for r in rows] finally: conn.close() def update_finding(finding_id: int, **kwargs): """Update a finding's fields.""" conn = _connect() try: invalid_columns = sorted(set(kwargs) - _UPDATABLE_FINDING_COLUMNS) if invalid_columns: raise ValueError( f"Invalid finding update fields: {', '.join(invalid_columns)}" ) sets = ", ".join(f"{k} = ?" for k in kwargs) values = list(kwargs.values()) + [finding_id] conn.execute(f"UPDATE findings SET {sets} WHERE id = ?", values) conn.commit() finally: conn.close() def delete_finding(finding_id: int): """Delete a finding.""" conn = _connect() try: conn.execute("DELETE FROM findings WHERE id = ?", (finding_id,)) conn.commit() finally: conn.close() def dismiss_finding(finding_id: int): """Mark a finding as dismissed.""" update_finding(finding_id, dismissed=1) # --- Cost Tracking --- def get_daily_cost(date: Optional[str] = None) -> float: """Get total token cost for a given day (default: today).""" conn = _connect() try: if not date: date = datetime.now().strftime("%Y-%m-%d") row = conn.execute( """SELECT COALESCE(SUM(token_cost), 0) as total FROM research_runs WHERE date(run_date) = date(?)""", (date,), ).fetchone() return row["total"] finally: conn.close() # --- Settings --- def get_setting(key: str, default: Optional[str] = None) -> Optional[str]: """Get a setting value.""" init_db() conn = _connect() try: row = conn.execute( "SELECT value FROM settings WHERE key = ?", (key,) ).fetchone() return row["value"] if row else default finally: conn.close() def set_setting(key: str, value: str): """Set a setting value.""" init_db() conn = _connect() try: conn.execute( """INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')""", (key, value), ) conn.commit() finally: conn.close() # --- Stats --- def get_stats() -> Dict[str, Any]: """Get overall database stats.""" conn = _connect() try: topic_count = conn.execute("SELECT COUNT(*) FROM topics WHERE enabled = 1").fetchone()[0] finding_count = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0] week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") runs_7d = conn.execute( "SELECT COUNT(*) FROM research_runs WHERE run_date >= ?", (week_ago,) ).fetchone()[0] successful_7d = conn.execute( "SELECT COUNT(*) FROM research_runs WHERE run_date >= ? AND status = 'completed'", (week_ago,), ).fetchone()[0] failed_7d = conn.execute( "SELECT COUNT(*) FROM research_runs WHERE run_date >= ? AND status = 'failed'", (week_ago,), ).fetchone()[0] cost_7d = conn.execute( "SELECT COALESCE(SUM(token_cost), 0) FROM research_runs WHERE run_date >= ?", (week_ago,), ).fetchone()[0] # Source breakdown sources = {} for row in conn.execute( "SELECT source, COUNT(*) as cnt FROM findings GROUP BY source" ).fetchall(): sources[row["source"]] = row["cnt"] db_path = _get_db_path() db_size = db_path.stat().st_size if db_path.exists() else 0 return { "topics_active": topic_count, "total_findings": finding_count, "db_size_bytes": db_size, "runs_7d": runs_7d, "successful_7d": successful_7d, "failed_7d": failed_7d, "cost_7d": cost_7d, "sources": sources, "daily_budget": get_setting("daily_budget", "5.00"), } finally: conn.close() def get_trending(days: int = 7) -> List[Dict[str, Any]]: """Get topics ranked by recent finding activity.""" conn = _connect() try: since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") rows = conn.execute( """SELECT t.name, t.id, COUNT(f.id) as new_findings, COALESCE(SUM(f.engagement_score), 0) as total_engagement FROM topics t LEFT JOIN findings f ON f.topic_id = t.id AND f.first_seen >= ? WHERE t.enabled = 1 GROUP BY t.id ORDER BY new_findings DESC""", (since,), ).fetchall() return [dict(r) for r in rows] finally: conn.close() def finding_from_candidate(candidate: schema.Candidate) -> Dict[str, Any]: """Convert a ranked candidate into a persisted finding.""" primary_item = schema.candidate_primary_item(candidate) corroborating_sources = [ source for source in schema.candidate_sources(candidate) if source and source != candidate.source ] summary = candidate.explanation or candidate.snippet or "" if corroborating_sources: prefix = f"Also seen in: {', '.join(corroborating_sources)}." summary = f"{prefix} {summary}".strip() body = ( primary_item.body if primary_item and primary_item.body else candidate.snippet or candidate.title ) author = primary_item.author if primary_item and primary_item.author else "" return { "source": candidate.source or "unknown", "source_url": candidate.url, "source_title": candidate.title, "author": author, "content": body, "summary": summary, "engagement_score": candidate.engagement or 0, "relevance_score": candidate.final_score or candidate.rerank_score or candidate.local_relevance, } def findings_from_report( report: schema.Report, *, limit: Optional[int] = None, ) -> List[Dict[str, Any]]: """Convert report into persisted findings. Uses ranked candidates (post-rerank) when available for quality scores and explanations. Supplements with raw items from items_by_source for HN/PM that didn't rank highly but are valuable for watchlist persistence. """ findings = [] seen_urls = set() # Phase 1: Process ranked candidates (high-quality data with explanations and corroboration) for candidate in report.ranked_candidates: finding = finding_from_candidate(candidate) findings.append(finding) seen_urls.add(candidate.url) # Phase 2: Add HN/PM items not already captured in ranked candidates for source_name in ["hackernews", "polymarket"]: if source_name not in report.items_by_source: continue for item in report.items_by_source[source_name]: if item.url in seen_urls: continue # Already captured with rich data findings.append({ "source": source_name, "source_url": item.url, "source_title": item.title, "author": item.author or "", "content": item.body or "", "summary": item.snippet or (item.body[:500] if item.body else ""), "engagement_score": item.engagement_score or 0.0, "relevance_score": item.local_relevance or 0.5, }) seen_urls.add(item.url) # Apply global limit after collecting all findings (fix: was per-source, now global) return findings[:limit] if limit is not None else findings # --- CLI interface --- def _cli_query(args): """Handle CLI query command.""" topic = get_topic(args.topic) if not topic: print(json.dumps({"error": f"Topic not found: {args.topic}"})) return since = None if args.since: # Parse duration like "7d", "30d" days = int(args.since.rstrip("d")) since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") findings = get_new_findings(topic["id"], since) print(json.dumps({"topic": topic["name"], "findings": findings, "count": len(findings)}, default=str)) def _cli_search(args): """Handle CLI search command.""" results = search_findings(args.query, limit=args.limit) print(json.dumps({"query": args.query, "results": results, "count": len(results)}, default=str)) def _cli_trending(args): """Handle CLI trending command.""" results = get_trending(args.days) print(json.dumps({"trending": results}, default=str)) def _cli_stats(args): """Handle CLI stats command.""" stats = get_stats() print(json.dumps(stats, default=str)) def main(): parser = argparse.ArgumentParser(description="Query the last30days research database") sub = parser.add_subparsers(dest="command") # query q = sub.add_parser("query", help="Query findings for a topic") q.add_argument("topic", help="Topic name") q.add_argument("--since", help="Duration like '7d' or '30d'") q.set_defaults(func=_cli_query) # search s = sub.add_parser("search", help="Full-text search across findings") s.add_argument("query", help="Search query") s.add_argument("--limit", type=int, default=20, help="Max results") s.set_defaults(func=_cli_search) # trending t = sub.add_parser("trending", help="Show trending topics") t.add_argument("--days", type=int, default=7, help="Look back N days") t.set_defaults(func=_cli_trending) # stats st = sub.add_parser("stats", help="Show database stats") st.set_defaults(func=_cli_stats) args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) # Ensure DB exists init_db() args.func(args) if __name__ == "__main__": main()