ketannnn commited on
Commit
022fb5a
·
1 Parent(s): 48a85ea

feat: implement admin endpoint and UI for database and vector store reset

Browse files
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
- AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
 
 
 
 
 
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
- await db.execute(
87
- delete(MatchResult).where(
88
- MatchResult.jd_id == jd_id,
89
- MatchResult.session_id == session_id if session_id else MatchResult.session_id.is_(None),
 
 
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-1">
 
 
 
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
+ }