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 = '
No history yet. Run your first analysis.
'; + return; + } + historyList.innerHTML = history.map(h => ` +
+ ${h.lang || '?'} + ${escHtml(h.preview)}… + ${h.ts} +
`).join(''); + } } /* ═══════════════════════════════════════════════════════════════ - SCROLLBAR + ANALYZE CORE LOGIC PIPELINE ═══════════════════════════════════════════════════════════════ */ - ::-webkit-scrollbar { - width: 5px; - height: 5px - } - - ::-webkit-scrollbar-track { - background: var(--bg) - } + const analyzeBtn = document.getElementById('analyzeBtn'); + const resultActions = document.getElementById('resultActions'); + const engineInfo = document.getElementById('engineInfo'); + const timeInfo = document.getElementById('timeInfo'); + + analyzeBtn.addEventListener('click', doAnalyze); + + async function doAnalyze() { + const code = editor.value.trim(); + if (!code) { + toast('Please paste some code first.', 'error'); + return; + } + const base = apiUrlInput.value.replace(/\/$/, ''); + const endpointMap = { analyze: '/analyze/', explain: '/explanation/', debug: '/debugging/', suggest: '/suggestions/' }; + const endpoint = endpointMap[selectedMode] || '/analyze/'; + + analyzeBtn.classList.add('loading'); + analyzeBtn.disabled = true; + showShimmers(); + + try { + const res = await fetch(`${base}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, language: selectedLang }), + signal: AbortSignal.timeout(30000), + }); - ::-webkit-scrollbar-thumb { - background: var(--border2); +document.getElementById('clearHistoryBtn').addEventListener('click', async () => { + if (!confirm('Clear all history? This cannot be undone.')) return; + const base = apiUrlInput.value.replace(/\/$/, ''); + const entries = await fetch(`${base}/history/?limit=100`).then(r => r.json()).catch(() => []); + for (const e of entries) { + fetch(`${base}/history/${e.id}`, { method: 'DELETE' }).catch(() => {}); + } + history = []; + localStorage.removeItem('qyx_history'); + renderHistory(); + toast('History cleared', 'info'); +}); border-radius: 99px } @@ -3836,6 +3831,21 @@

${getTranslation('suggest_quality_score')}

/* ═══════════════════════════════════════════════════════════════ HISTORY ═══════════════════════════════════════════════════════════════ */ + async function saveHistoryToBackend(code, lang, result) { + try { + const base = apiUrlInput.value.replace(/\/$/, ''); + const score = result.suggestions?.overall_score ?? null; + const issue_count = result.debugging?.error_count ?? null; + await fetch(`${base}/history/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, language: lang, score, issue_count }), + }); + } catch (_) { + // backend unavailable — localStorage fallback is still active + } + } + function addToHistory(code, result) { const lang = result.explanation?.language || result.debugging?.language||result.suggestions?.language||selectedLang; const entry = { @@ -3850,6 +3860,7 @@

${getTranslation('suggest_quality_score')}

if (history.length > MAX_HISTORY) history = history.slice(0, MAX_HISTORY); localStorage.setItem('qyx_history', JSON.stringify(history)); renderHistory(); + saveHistoryToBackend(code, lang, result); } function renderHistory() { @@ -3865,9 +3876,14 @@

${getTranslation('suggest_quality_score')}

`).join(''); } - document.getElementById('clearHistoryBtn').addEventListener('click', () => { + document.getElementById('clearHistoryBtn').addEventListener('click', async () => { if (!confirm(getTranslation('confirm_clear_history'))) return; + const base = apiUrlInput.value.replace(/\/$/, ''); + const entries = await fetch(`${base}/history/?limit=100`).then(r => r.json()).catch(() => []); + for (const e of entries) { + fetch(`${base}/history/${e.id}`, { method: 'DELETE' }).catch(() => {}); + } history = []; localStorage.removeItem('qyx_history'); renderHistory(); From ebc3397f4564a6c7580bfe6e81285aa9647dae1c Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 25 May 2026 17:15:06 +0530 Subject: [PATCH 02/10] fix(frontend): restore hero sizing; remove JS from - + .stat-bar { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 16px; + } + + .stat-chip { + flex: 1; + min-width: 80px; + padding: 10px 14px; + border-radius: var(--r2); + background: var(--bg3); + border: 1px solid var(--border); + text-align: center; + } + + .stat-chip-num { + font-family: var(--font-disp); + font-size: 1.4rem; + font-weight: 800; + line-height: 1; + } + + .stat-chip-label { + font-size: 0.7rem; + color: var(--text3); + margin-top: 2px + } + + .stat-chip.error-chip .stat-chip-num { + color: var(--red) + } + + .stat-chip.warn-chip .stat-chip-num { + color: var(--yellow) + } + + .stat-chip.info-chip .stat-chip-num { + color: var(--accent) + } + + /* responsive */ + @media(max-width:600px) { + .hero h1 { + font-size: 2.2rem + } + + nav { + flex-wrap: wrap; + gap: 8px + } + + .nav-links .nav-link span { + display: none + } + + .hero { + padding: 50px 0 40px + } + + .hero-sub { + font-size: 0.98rem + } + + .features-row { + gap: 8px; + padding: 16px 0 40px + } + + .feat-pill { + padding: 10px 14px; + min-width: 140px + } + + .workspace { + gap: 14px + } + + .panel-header { + padding: 12px 14px + } + + .mode-row { + gap: 6px; + padding: 10px 14px + } + + .mode-btn { + padding: 8px 4px; + font-size: 0.74rem + } + + .analyze-row { + padding: 10px 14px + } + + .btn-analyze { + padding: 14px + } + + .result-content { + min-height: 280px; + max-height: 420px; + padding: 14px + } + + .bottom-grid { + gap: 14px + } + + .history-list, + .fav-list { + max-height: 200px + } + + .score-ring-wrap { + flex-direction: column; + text-align: center; + gap: 12px + } + + .stat-bar { + gap: 6px + } + + .stat-chip { + padding: 8px 10px + } + + .stat-chip-num { + font-size: 1.2rem + } + + #toastContainer { + bottom: 12px; + right: 12px; + left: 12px + } + + .toast { + min-width: unset; + width: 100% + } + + .hero-cta { + gap: 8px + } + + .btn { + padding: 11px 18px; + font-size: 0.85rem + } + } + + @media(max-width:400px) { + .app { + padding: 0 14px 60px + } + + .hero h1 { + font-size: 1.9rem + } + + .feat-pill-info p { + display: none + } + + .lang-tab { + padding: 7px 10px; + font-size: 0.72rem + } + + .result-tab { + padding: 9px 12px; + font-size: 0.75rem + } + } + + + -
-
- -
- - - - - -
-
-
- Open Source · AI-Powered · Free -
-

Debug. Understand.
Ship faster.

-

- Paste your code and get instant bug detection, plain-English explanations, and actionable improvement suggestions — no account needed. -

-
- - - - - Star on GitHub - -
-
- - -
-
-
-
-

40+ Bug Patterns

-

Python, JS, TS, Java, C++

-
-
-
-
-
-

Code Explanation

-

Plain English breakdowns

-
-
-
-
-
-

Smart Suggestions

-

Quality score + grade

-
-
-
-
-
-

Dark / Light Mode

-

Persisted preference

-
-
-
-
-
-

File Upload

-

.py .js .ts .java .cpp

-
-
-
- - -
- - -
-
-
- - Code Editor +
+
+ +
+ + + + + +
+
+
+ Open Source · AI-Powered · Free +
+

Debug. Understand.
Ship faster.

+

+ Paste your code and get instant bug detection, plain-English explanations, and actionable improvement + suggestions — no account needed. +

+
+ - + + + + + Star on GitHub +
-
+
- -
- - - - - + +
+
+
+ + +
+
+

40+ Bug Patterns

+

Python, JS, TS, Java, C++

+
+
+
+
+ + +
+
+

Code Explanation

+

Plain English breakdowns

+
+
+
+
+ +
+
+

Smart Suggestions

+

Quality score + grade

+
+
+
+
+ +
+
+

Dark / Light Mode

+

Persisted preference

+
+
+
+
+ +
+
+

File Upload

+

.py .js .ts .java .cpp

+
+
+ + +
+ + +
+
+
+ + Code Editor +
+
+ + + +
+
+ + +
+ + + + + +
+ spellcheck="false" autocorrect="off" autocapitalize="off" autocomplete="off" + aria-label="Code Editor">
- - - -
-
-
- -

- GitHub - · API Docs - · MIT License -

-

Built for the open source community

-
-
-

Weekly Digest

-
- - -
-

Get a Sunday email with your weekly analysis stats.

-
+ + + +
+
+
+ +

+ GitHub + · API Docs + · MIT License +

+

Built for the open source community

-
+
+

Weekly Digest

+
+ + +
+

Get a Sunday email with your weekly analysis + stats.

+
+
+
- -
+ +
- + \ No newline at end of file From 89d88da30898988b7d86aac4f4167bf48928e553 Mon Sep 17 00:00:00 2001 From: Medora Date: Mon, 25 May 2026 19:48:42 +0530 Subject: [PATCH 07/10] Fix upstream merge regressions --- frontend/index.html | 2138 ++++++++++++++++++++++--------------------- 1 file changed, 1104 insertions(+), 1034 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 21abf935..12204162 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -38,7 +38,7 @@ --font-mono: 'JetBrains Mono', monospace; --transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); } - + ::-webkit-scrollbar-thumb:hover { background: var(--text3) @@ -422,6 +422,7 @@ border: 1px solid var(--border); transition: all var(--transition); cursor: default; + color: var(--text2); } .feat-pill:hover { @@ -669,6 +670,55 @@ font-weight: 600 } + /* ── API Config ── */ + + .api-config-row { + padding: 14px 18px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 10px; + } + + .api-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: var(--text2); + } + + .api-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--red); + } + + .api-dot.connected { + background: var(--green); + } + + .api-config-inner { + display: flex; + gap: 8px; + } + + #apiUrl { + flex: 1; + padding: 8px 10px; + border-radius: var(--r2); + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text); + font-size: 0.8rem; + outline: none; + } + + #apiUrl:focus { + border-color: var(--accent); + } + /* ── Analyze Button ── */ .analyze-row { padding: 14px 18px @@ -1040,7 +1090,7 @@ border: 1px solid var(--border); margin-bottom: 16px; font-size: 0.88rem; - color: var(--text2); + color: var(--text); line-height: 1.6; } @@ -1061,6 +1111,7 @@ display: flex; align-items: center; gap: 6px; + color: var(--text2); } .meta-chip .dot { @@ -1142,6 +1193,7 @@ margin-bottom: 6px; border-radius: var(--r2); background: var(--bg3); + color: var(--text2); font-size: 0.83rem; line-height: 1.5; display: flex; @@ -2096,7 +2148,8 @@ style="font-size:0.62rem;font-weight:700;padding:2px 8px;border-radius:99px;background:rgba(91,156,246,0.15);color:var(--accent);border:1px solid rgba(91,156,246,0.25);letter-spacing:0.04em;margin-left:4px">v3.0
- - - - - - - + + + + +
1
- +
+ +
+
+ + Disconnected +
+ +
+ + + +
+
- - +
@@ -2318,17 +2391,26 @@

File Upload

@@ -2381,6 +2463,12 @@

No suggestions yet

+ + +