From 96dadbbed3e7aba7bb3774b923736a65516ada35 Mon Sep 17 00:00:00 2001 From: Ravindi Fernando <140165006+RavindiFernando@users.noreply.github.com> Date: Mon, 25 May 2026 16:49:17 +0530 Subject: [PATCH 01/10] feat: add SQLite persistent history with full-text search (closes #125) (#200) * feat: add SQLite persistent history with full-text search (closes #125) * ci: refresh PR mergeability * fix: restore missing defaultdict import in main.py * refactor(history): use FTS5 for full-text search, remove committed DB file --- backend/app/main.py | 15 ++-- backend/app/routers/history.py | 63 ++++++++++++++ backend/app/services/database.py | 111 +++++++++++++++++++++++++ backend/requirements.txt | 1 + backend/tests/test_history.py | 86 +++++++++++++++++++ frontend/index.html | 136 +++++++++++++++++-------------- 6 files changed, 347 insertions(+), 65 deletions(-) create mode 100644 backend/app/routers/history.py create mode 100644 backend/app/services/database.py create mode 100644 backend/tests/test_history.py diff --git a/backend/app/main.py b/backend/app/main.py index e2ccc913..4fc561bb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,11 +13,10 @@ from collections import defaultdict from contextlib import asynccontextmanager - -from .routers import explanation, debugging, suggestions, analyze, subscribe, share +from .routers import explanation, debugging, suggestions, analyze, subscribe, share, history from .services.scheduler import start_scheduler, stop_scheduler - from .schemas import HealthResponse +from .services import database # ── Rate limiter (in-memory, per IP) ────────────────────────────────────────── RATE_LIMIT = int(os.getenv("RATE_LIMIT_PER_MINUTE", "30")) @@ -48,6 +47,7 @@ def rate_limit_headers(remaining: int) -> dict[str, str]: # ── Lifespan ────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): + await database.init_db() print("🚀 QyverixAI backend starting…") start_scheduler() yield @@ -127,9 +127,10 @@ async def add_cache_header(request: Request, call_next): app.include_router(explanation.router, prefix="/explanation", tags=["Explanation"]) app.include_router(debugging.router, prefix="/debugging", tags=["Debugging"]) app.include_router(suggestions.router, prefix="/suggestions", tags=["Suggestions"]) -app.include_router(analyze.router, prefix="/analyze", tags=["Full Analysis"]) -app.include_router(subscribe.router, prefix="/subscribe", tags=["Subscription"]) +app.include_router(analyze.router, prefix="/analyze", tags=["Full Analysis"]) +app.include_router(subscribe.router, prefix="/subscribe", tags=["Subscription"]) app.include_router(share.router) +app.include_router(history.router, prefix="/history", tags=["History"]) # ── Core Endpoints ──────────────────────────────────────────────────────────── @@ -145,7 +146,9 @@ async def root(): "/suggestions/", "/analyze/", "/analyze/zip/", + "/subscribe/", "/share/", + "/history/", ], } @@ -162,7 +165,9 @@ async def health_check(): "/suggestions/", "/analyze/", "/analyze/zip/", + "/subscribe/", "/share/", + "/history/", ], } diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py new file mode 100644 index 00000000..92b2a4fe --- /dev/null +++ b/backend/app/routers/history.py @@ -0,0 +1,63 @@ +""" +History router — save, retrieve, search and delete analysis history entries. +""" + +from __future__ import annotations +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from ..services import database + +router = APIRouter() + + +class HistorySaveRequest(BaseModel): + code: str = Field(..., min_length=1, max_length=50000) + language: str + score: int | None = None + issue_count: int | None = None + + +class HistoryEntry(BaseModel): + id: int + code_hash: str + language: str + score: int | None + issue_count: int | None + timestamp: str + code_preview: str + + +@router.post("/", response_model=dict, status_code=201) +async def save_history(body: HistorySaveRequest): + entry_id = await database.save_entry( + code=body.code, + language=body.language, + score=body.score, + issue_count=body.issue_count, + ) + return {"id": entry_id, "status": "saved"} + + +@router.get("/", response_model=list[HistoryEntry]) +async def get_history( + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), +): + return await database.get_entries(limit=limit, offset=offset) + + +@router.get("/search", response_model=list[HistoryEntry]) +async def search_history( + q: str = Query(..., min_length=1), + limit: int = Query(20, ge=1, le=100), +): + return await database.search_entries(q=q, limit=limit) + + +@router.delete("/{entry_id}", response_model=dict) +async def delete_history(entry_id: int): + deleted = await database.delete_entry(entry_id) + if not deleted: + raise HTTPException(status_code=404, detail="History entry not found.") + return {"id": entry_id, "status": "deleted"} diff --git a/backend/app/services/database.py b/backend/app/services/database.py new file mode 100644 index 00000000..b26b5f62 --- /dev/null +++ b/backend/app/services/database.py @@ -0,0 +1,111 @@ +""" +SQLite database service using aiosqlite for persistent history storage. +Full-text search is powered by SQLite FTS5. +""" + +from __future__ import annotations +import hashlib +import os +import aiosqlite + +DB_PATH = os.getenv("HISTORY_DB_PATH", "history.db") + + +async def init_db() -> None: + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code_hash TEXT NOT NULL, + language TEXT NOT NULL, + score INTEGER, + issue_count INTEGER, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + code_preview TEXT NOT NULL + ) + """) + await db.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS fts_history + USING fts5(code_preview, content=history, content_rowid=id) + """) + await db.execute( + "CREATE INDEX IF NOT EXISTS idx_timestamp ON history(timestamp DESC)" + ) + await db.commit() + + +def hash_code(code: str) -> str: + return hashlib.sha256(code.encode()).hexdigest() + + +async def save_entry( + code: str, + language: str, + score: int | None, + issue_count: int | None, +) -> int: + code_hash = hash_code(code) + preview = code.strip()[:120].replace("\n", " ") + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + """ + INSERT INTO history (code_hash, language, score, issue_count, code_preview) + VALUES (?, ?, ?, ?, ?) + """, + (code_hash, language, score, issue_count, preview), + ) + row_id = cursor.lastrowid + await db.execute( + "INSERT INTO fts_history(rowid, code_preview) VALUES (?, ?)", + (row_id, preview), + ) + await db.commit() + return row_id + + +async def get_entries(limit: int = 20, offset: int = 0) -> list[dict]: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + """ + SELECT id, code_hash, language, score, issue_count, timestamp, code_preview + FROM history + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +async def search_entries(q: str, limit: int = 20) -> list[dict]: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + """ + SELECT h.id, h.code_hash, h.language, h.score, + h.issue_count, h.timestamp, h.code_preview + FROM history h + WHERE h.id IN ( + SELECT rowid FROM fts_history WHERE fts_history MATCH ? + ) + ORDER BY h.timestamp DESC + LIMIT ? + """, + (q, limit), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +async def delete_entry(entry_id: int) -> bool: + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "DELETE FROM history WHERE id = ?", (entry_id,) + ) + await db.execute( + "DELETE FROM fts_history WHERE rowid = ?", (entry_id,) + ) + await db.commit() + return cursor.rowcount > 0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 67e21f3e..2f839c52 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,4 @@ +aiosqlite>=0.20.0 fastapi>=0.115.0 uvicorn[standard]>=0.30.0 pydantic>=2.7.0 diff --git a/backend/tests/test_history.py b/backend/tests/test_history.py new file mode 100644 index 00000000..40f07df5 --- /dev/null +++ b/backend/tests/test_history.py @@ -0,0 +1,86 @@ +""" +Tests for the /history/ endpoints. +""" +import sys, os, tempfile, asyncio +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.services import database + +_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_tmp.close() +database.DB_PATH = _tmp.name + +asyncio.run(database.init_db()) + +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app, raise_server_exceptions=True) + + +def test_save_history(): + r = client.post("/history/", json={ + "code": "print('hello')", + "language": "Python", + "score": 85, + "issue_count": 1, + }) + assert r.status_code == 201 + d = r.json() + assert d["status"] == "saved" + assert "id" in d + + +def test_get_history(): + client.post("/history/", json={"code": "x = 1", "language": "Python", "score": 90, "issue_count": 0}) + r = client.get("/history/") + assert r.status_code == 200 + assert isinstance(r.json(), list) + assert len(r.json()) > 0 + + +def test_get_history_pagination(): + r = client.get("/history/?limit=1&offset=0") + assert r.status_code == 200 + assert len(r.json()) <= 1 + + +def test_search_history(): + client.post("/history/", json={"code": "def my_unique_function(): pass", "language": "Python"}) + r = client.get("/history/search?q=my_unique_function") + assert r.status_code == 200 + results = r.json() + assert any("my_unique_function" in e["code_preview"] for e in results) + + +def test_delete_history(): + r = client.post("/history/", json={"code": "to be deleted", "language": "Python"}) + entry_id = r.json()["id"] + r = client.delete(f"/history/{entry_id}") + assert r.status_code == 200 + assert r.json()["status"] == "deleted" + + +def test_delete_nonexistent(): + r = client.delete("/history/999999") + assert r.status_code == 404 + + +def test_history_entry_fields(): + client.post("/history/", json={"code": "let x = 1;", "language": "JavaScript", "score": 70, "issue_count": 2}) + r = client.get("/history/") + assert r.status_code == 200 + entry = r.json()[0] + assert "id" in entry + assert "code_hash" in entry + assert "language" in entry + assert "score" in entry + assert "issue_count" in entry + assert "timestamp" in entry + assert "code_preview" in entry + + +def test_search_no_results(): + r = client.get("/history/search?q=xyznotfoundever") + assert r.status_code == 200 + assert r.json() == [] diff --git a/frontend/index.html b/frontend/index.html index d2acb810..ff79ec9e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -39,71 +39,66 @@ --transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); } - [data-theme="light"] { - --bg: #f4f6fb; - --bg2: #ffffff; - --bg3: #eef1f7; - --border: rgba(0, 0, 0, 0.07); - --border2: rgba(0, 0, 0, 0.13); - --text: #111827; - --text2: #4b5563; - --text3: #9ca3af; - --shadow: 0 8px 32px rgba(0, 0, 0, 0.12); - --shadow2: 0 2px 8px rgba(0, 0, 0, 0.08); - } - - /* ═══════════════════════════════════════════════════════════════ - RESET & BASE -═══════════════════════════════════════════════════════════════ */ - *, - *::before, - *::after { - box-sizing: border-box; - margin: 0; - padding: 0 - } - - html { - scroll-behavior: smooth; - font-size: 16px - } - - body { - font-family: var(--font-ui); - background: var(--bg); - color: var(--text); - min-height: 100vh; - overflow-x: hidden; - line-height: 1.6; - transition: background var(--transition), color var(--transition); - } - - ::selection { - background: var(--accent); - color: #fff - } + } - *:focus-visible { - outline: 3px solid var(--accent); - outline-offset: 3px; - box-shadow: 0 0 0 4px rgba(91, 156, 246, 0.4); - border-radius: 2px; + function renderHistory() { + if (!history.length) { + historyList.innerHTML = '
- Paste your code and get instant bug detection, plain-English explanations, and actionable improvement suggestions — no account needed. -
-Python, JS, TS, Java, C++
-Plain English breakdowns
-Quality score + grade
-Persisted preference
-.py .js .ts .java .cpp
-