feat: implement admin endpoint and UI for database and vector store reset
Browse files- backend/main.py +2 -1
- backend/src/database.py +6 -1
- backend/src/routers/admin.py +76 -0
- backend/src/routers/matching.py +9 -5
- frontend/src/app/layout.tsx +4 -1
- frontend/src/app/reset/page.tsx +137 -0
backend/main.py
CHANGED
|
@@ -9,7 +9,7 @@ from qdrant_client.models import Distance, VectorParams, PayloadSchemaType
|
|
| 9 |
|
| 10 |
from src.config import get_settings
|
| 11 |
from src.models import JobDescription, Candidate, MatchResult, Session
|
| 12 |
-
from src.routers import jds, candidates, matching, sessions
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
settings = get_settings()
|
|
@@ -81,6 +81,7 @@ app.include_router(sessions.router, prefix="/api/sessions", tags=["Sessions"])
|
|
| 81 |
app.include_router(jds.router, prefix="/api/jds", tags=["Job Descriptions"])
|
| 82 |
app.include_router(candidates.router, prefix="/api/candidates", tags=["Candidates"])
|
| 83 |
app.include_router(matching.router, prefix="/api/match", tags=["Matching"])
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
@app.get("/health")
|
|
|
|
| 9 |
|
| 10 |
from src.config import get_settings
|
| 11 |
from src.models import JobDescription, Candidate, MatchResult, Session
|
| 12 |
+
from src.routers import jds, candidates, matching, sessions, admin
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
settings = get_settings()
|
|
|
|
| 81 |
app.include_router(jds.router, prefix="/api/jds", tags=["Job Descriptions"])
|
| 82 |
app.include_router(candidates.router, prefix="/api/candidates", tags=["Candidates"])
|
| 83 |
app.include_router(matching.router, prefix="/api/match", tags=["Matching"])
|
| 84 |
+
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
| 85 |
|
| 86 |
|
| 87 |
@app.get("/health")
|
backend/src/database.py
CHANGED
|
@@ -48,7 +48,12 @@ engine = create_async_engine(
|
|
| 48 |
},
|
| 49 |
execution_options={"prepared_statement_cache_size": 0}
|
| 50 |
)
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class Base(DeclarativeBase):
|
|
|
|
| 48 |
},
|
| 49 |
execution_options={"prepared_statement_cache_size": 0}
|
| 50 |
)
|
| 51 |
+
from sqlalchemy.orm import sessionmaker
|
| 52 |
+
AsyncSessionLocal = sessionmaker(
|
| 53 |
+
bind=engine,
|
| 54 |
+
class_=AsyncSession,
|
| 55 |
+
expire_on_commit=False
|
| 56 |
+
)
|
| 57 |
|
| 58 |
|
| 59 |
class Base(DeclarativeBase):
|
backend/src/routers/admin.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import APIRouter, HTTPException
|
| 3 |
+
from sqlalchemy import text
|
| 4 |
+
from qdrant_client import QdrantClient
|
| 5 |
+
from qdrant_client.models import Distance, VectorParams, PayloadSchemaType
|
| 6 |
+
from alembic.config import Config
|
| 7 |
+
from alembic import command
|
| 8 |
+
from fastapi.concurrency import run_in_threadpool
|
| 9 |
+
|
| 10 |
+
from ..database import engine
|
| 11 |
+
from ..config import get_settings
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
@router.post("/reset-db")
|
| 16 |
+
async def reset_db(password: str = None):
|
| 17 |
+
if password != "ketan@D048":
|
| 18 |
+
raise HTTPException(status_code=403, detail="Invalid reset password.")
|
| 19 |
+
|
| 20 |
+
settings = get_settings()
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
# 1. Wipe Postgres natively (drop schema and recreate)
|
| 24 |
+
async with engine.begin() as conn:
|
| 25 |
+
# We use CASCADE to drop everything including tables, types, etc.
|
| 26 |
+
await conn.execute(text('DROP SCHEMA public CASCADE'))
|
| 27 |
+
await conn.execute(text('CREATE SCHEMA public'))
|
| 28 |
+
await conn.execute(text('GRANT ALL ON SCHEMA public TO public'))
|
| 29 |
+
|
| 30 |
+
# 2. Wipe Qdrant Collection
|
| 31 |
+
q = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
|
| 32 |
+
try:
|
| 33 |
+
q.delete_collection(settings.collection_name)
|
| 34 |
+
except Exception:
|
| 35 |
+
# OK if collection didn't exist
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
q.create_collection(
|
| 39 |
+
collection_name=settings.collection_name,
|
| 40 |
+
vectors_config=VectorParams(size=settings.vector_size, distance=Distance.COSINE)
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Reinject indices required by the pipeline
|
| 44 |
+
q.create_payload_index(
|
| 45 |
+
collection_name=settings.collection_name,
|
| 46 |
+
field_name="session_id",
|
| 47 |
+
field_schema=PayloadSchemaType.KEYWORD
|
| 48 |
+
)
|
| 49 |
+
q.create_payload_index(
|
| 50 |
+
collection_name=settings.collection_name,
|
| 51 |
+
field_name="years_of_experience",
|
| 52 |
+
field_schema=PayloadSchemaType.FLOAT
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# 3. Run Alembic Migrations to restore schema
|
| 56 |
+
# We assume the API is running from the 'backend' directory where alembic.ini resides.
|
| 57 |
+
# If not, we attempt to find it relative to this file.
|
| 58 |
+
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 59 |
+
ini_path = os.path.join(base_path, "alembic.ini")
|
| 60 |
+
|
| 61 |
+
if not os.path.exists(ini_path):
|
| 62 |
+
# Fallback for different envs
|
| 63 |
+
ini_path = "alembic.ini"
|
| 64 |
+
|
| 65 |
+
def _run_migrations():
|
| 66 |
+
alembic_cfg = Config(ini_path)
|
| 67 |
+
# Ensure the script location is absolute to avoid confusion
|
| 68 |
+
alembic_cfg.set_main_option("script_location", os.path.join(base_path, "alembic"))
|
| 69 |
+
command.upgrade(alembic_cfg, "head")
|
| 70 |
+
|
| 71 |
+
await run_in_threadpool(_run_migrations)
|
| 72 |
+
|
| 73 |
+
return {"status": "success", "message": "Database and Vector Store have been reset and migrations reapplied."}
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"RESET ERROR: {e}")
|
| 76 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/src/routers/matching.py
CHANGED
|
@@ -83,12 +83,16 @@ async def trigger_match(
|
|
| 83 |
shortlist = await stage1_retrieve(jd_dict, db, qdrant, session_id=sid_str)
|
| 84 |
final_ranked = await stage2_rerank(jd_dict, shortlist)
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
MatchResult.
|
| 89 |
-
|
|
|
|
|
|
|
| 90 |
)
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
|
| 93 |
from ..workers.explain import generate_top_explanations
|
| 94 |
|
|
|
|
| 83 |
shortlist = await stage1_retrieve(jd_dict, db, qdrant, session_id=sid_str)
|
| 84 |
final_ranked = await stage2_rerank(jd_dict, shortlist)
|
| 85 |
|
| 86 |
+
try:
|
| 87 |
+
await db.execute(
|
| 88 |
+
delete(MatchResult).where(
|
| 89 |
+
MatchResult.jd_id == jd_id,
|
| 90 |
+
MatchResult.session_id == session_id if session_id else MatchResult.session_id.is_(None),
|
| 91 |
+
)
|
| 92 |
)
|
| 93 |
+
except Exception:
|
| 94 |
+
await db.rollback()
|
| 95 |
+
raise
|
| 96 |
|
| 97 |
from ..workers.explain import generate_top_explanations
|
| 98 |
|
frontend/src/app/layout.tsx
CHANGED
|
@@ -16,7 +16,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
| 16 |
<Link href="/" className="text-base font-bold tracking-tight bg-gradient-to-r from-[var(--color-brand-light)] to-purple-400 bg-clip-text text-transparent select-none">
|
| 17 |
⚡ TalentPulse
|
| 18 |
</Link>
|
| 19 |
-
<div className="flex items-center gap-
|
|
|
|
|
|
|
|
|
|
| 20 |
<Link href="/pipeline" className="ml-2 px-3 py-1.5 rounded-lg text-sm font-semibold text-[var(--color-brand-light)] bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] hover:bg-[var(--color-brand)] hover:text-white transition-all">
|
| 21 |
⚡ Auto Pipeline
|
| 22 |
</Link>
|
|
|
|
| 16 |
<Link href="/" className="text-base font-bold tracking-tight bg-gradient-to-r from-[var(--color-brand-light)] to-purple-400 bg-clip-text text-transparent select-none">
|
| 17 |
⚡ TalentPulse
|
| 18 |
</Link>
|
| 19 |
+
<div className="flex items-center gap-2">
|
| 20 |
+
<Link href="/reset" className="px-3 py-1.5 rounded-lg text-xs font-medium text-slate-500 hover:text-red-400 transition-colors">
|
| 21 |
+
Reset
|
| 22 |
+
</Link>
|
| 23 |
<Link href="/pipeline" className="ml-2 px-3 py-1.5 rounded-lg text-sm font-semibold text-[var(--color-brand-light)] bg-[var(--color-brand-dim)] border border-[var(--color-brand-glow)] hover:bg-[var(--color-brand)] hover:text-white transition-all">
|
| 24 |
⚡ Auto Pipeline
|
| 25 |
</Link>
|
frontend/src/app/reset/page.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
export default function ResetPage() {
|
| 7 |
+
const [loading, setLoading] = useState(false);
|
| 8 |
+
const [result, setResult] = useState<{ status: string; message: string } | null>(null);
|
| 9 |
+
const [error, setError] = useState<string | null>(null);
|
| 10 |
+
const [password, setPassword] = useState("");
|
| 11 |
+
const router = useRouter();
|
| 12 |
+
|
| 13 |
+
const handleReset = async () => {
|
| 14 |
+
if (!password) {
|
| 15 |
+
setError("Please enter the system password.");
|
| 16 |
+
return;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
if (!confirm("☢️ WARNING: This will PERMANENTLY WIPE all candidates, jobs, and vector embeddings. Are you sure?")) {
|
| 20 |
+
return;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
setLoading(true);
|
| 24 |
+
setError(null);
|
| 25 |
+
setResult(null);
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 29 |
+
const resp = await fetch(`${baseUrl}/api/admin/reset-db?password=${encodeURIComponent(password)}`, {
|
| 30 |
+
method: "POST",
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
if (!resp.ok) {
|
| 34 |
+
const errData = await resp.json();
|
| 35 |
+
throw new Error(errData.detail || "Reset failed");
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const data = await resp.json();
|
| 39 |
+
setResult(data);
|
| 40 |
+
} catch (err: any) {
|
| 41 |
+
setError(err.message);
|
| 42 |
+
} finally {
|
| 43 |
+
setLoading(false);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] p-6 bg-slate-950 text-slate-100">
|
| 49 |
+
<div className="max-w-md w-full p-8 rounded-3xl bg-slate-900/50 border border-slate-800 shadow-2xl backdrop-blur-sm">
|
| 50 |
+
<div className="flex flex-col items-center text-center space-y-6">
|
| 51 |
+
<div className="w-20 h-20 rounded-2xl bg-red-500/10 flex items-center justify-center border border-red-500/20">
|
| 52 |
+
<span className="text-4xl">☢️</span>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="space-y-2">
|
| 56 |
+
<h1 className="text-3xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">
|
| 57 |
+
System Reset
|
| 58 |
+
</h1>
|
| 59 |
+
<p className="text-slate-400 text-sm">
|
| 60 |
+
Perform a clean wipe of the PostgreSQL database and Qdrant vector store.
|
| 61 |
+
</p>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div className="w-full p-4 rounded-xl bg-slate-800/30 border border-slate-800 text-xs text-left space-y-2">
|
| 65 |
+
<p className="font-semibold text-slate-300">This action will:</p>
|
| 66 |
+
<ul className="list-disc list-inside text-slate-500 space-y-1">
|
| 67 |
+
<li>Drop and recreate the Public schema</li>
|
| 68 |
+
<li>Delete and recreate Qdrant collections</li>
|
| 69 |
+
<li>Re-run all Alembic migrations (Head)</li>
|
| 70 |
+
<li>Re-initialize vector indices</li>
|
| 71 |
+
</ul>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="w-full space-y-2">
|
| 75 |
+
<input
|
| 76 |
+
type="password"
|
| 77 |
+
placeholder="System Password"
|
| 78 |
+
value={password}
|
| 79 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 80 |
+
className="w-full px-4 py-3 rounded-xl bg-slate-950 border border-slate-800 focus:outline-none focus:border-red-500/50 text-sm transition-all"
|
| 81 |
+
/>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{result && (
|
| 85 |
+
<div className="w-full p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
|
| 86 |
+
<p className="font-bold">Success!</p>
|
| 87 |
+
<p className="opacity-80">{result.message}</p>
|
| 88 |
+
<button
|
| 89 |
+
onClick={() => router.push("/")}
|
| 90 |
+
className="mt-3 text-xs underline underline-offset-4 hover:text-emerald-300"
|
| 91 |
+
>
|
| 92 |
+
Return to Dashboard
|
| 93 |
+
</button>
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
+
|
| 97 |
+
{error && (
|
| 98 |
+
<div className="w-full p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
| 99 |
+
<p className="font-bold">Error</p>
|
| 100 |
+
<p className="opacity-80 break-words">{error}</p>
|
| 101 |
+
</div>
|
| 102 |
+
)}
|
| 103 |
+
|
| 104 |
+
<button
|
| 105 |
+
onClick={handleReset}
|
| 106 |
+
disabled={loading}
|
| 107 |
+
className={`w-full py-4 rounded-2xl font-bold text-sm tracking-widest uppercase transition-all ${
|
| 108 |
+
loading
|
| 109 |
+
? "bg-slate-800 text-slate-500 cursor-not-allowed"
|
| 110 |
+
: "bg-gradient-to-r from-red-600 to-red-500 hover:from-red-500 hover:to-red-400 active:scale-95 shadow-[0_0_30px_-5px_rgba(220,38,38,0.4)]"
|
| 111 |
+
}`}
|
| 112 |
+
>
|
| 113 |
+
{loading ? (
|
| 114 |
+
<span className="flex items-center justify-center gap-2">
|
| 115 |
+
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 116 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 117 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 118 |
+
</svg>
|
| 119 |
+
Executing Wipe...
|
| 120 |
+
</span>
|
| 121 |
+
) : (
|
| 122 |
+
"Nuke Everything"
|
| 123 |
+
)}
|
| 124 |
+
</button>
|
| 125 |
+
|
| 126 |
+
<button
|
| 127 |
+
onClick={() => router.back()}
|
| 128 |
+
disabled={loading}
|
| 129 |
+
className="text-slate-500 text-xs hover:text-slate-300 transition-colors"
|
| 130 |
+
>
|
| 131 |
+
Cancel and Go Back
|
| 132 |
+
</button>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
);
|
| 137 |
+
}
|