majidali1256 commited on
Commit
99e0faf
·
1 Parent(s): 9fc34f7

feat: add interactive web UI dashboard and PDF upload screen (Task #3)

Browse files
Files changed (3) hide show
  1. main.py +143 -7
  2. requirements.txt +3 -0
  3. static/index.html +727 -0
main.py CHANGED
@@ -1,11 +1,147 @@
1
- from fastapi import FastAPI
 
 
 
2
 
3
- app = FastAPI()
 
 
 
4
 
5
- @app.get("/")
6
- def read_root():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  return {
8
  "status": "online",
9
- "project": "AI Resume Screener & Feedback System",
10
- "message": "Welcome! The backend API is running successfully."
11
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI Server Entry Point for AI Resume Scanner & Feedback Dashboard.
3
+ Project 1 — AI & Generative AI Fellowship Program
4
+ """
5
 
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Optional
10
 
11
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
12
+ from fastapi.staticfiles import StaticFiles
13
+ from fastapi.responses import HTMLResponse, FileResponse
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from dotenv import load_dotenv
16
+
17
+ from resume_scanner.assessor import ResumeAssessor, AssessorError
18
+ from resume_scanner.extractor import prepare_scanner_inputs, ExtractionError
19
+ from resume_scanner.models import Assessment
20
+
21
+ load_dotenv()
22
+
23
+ app = FastAPI(
24
+ title="AI Resume Scanner API",
25
+ description="Automated resume vs job description screening and feedback engine powered by Gemini Flash.",
26
+ version="1.0.0",
27
+ )
28
+
29
+ # Enable CORS for frontend flexibility
30
+ app.add_middleware(
31
+ CORSMiddleware,
32
+ allow_origins=["*"],
33
+ allow_credentials=True,
34
+ allow_methods=["*"],
35
+ allow_headers=["*"],
36
+ )
37
+
38
+ UPLOAD_DIR = Path("data/uploads")
39
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
40
+
41
+ # Mount static frontend directory
42
+ STATIC_DIR = Path("static")
43
+ STATIC_DIR.mkdir(parents=True, exist_ok=True)
44
+ app.mount("/static", StaticFiles(directory="static"), name="static")
45
+
46
+
47
+ @app.get("/", response_class=HTMLResponse)
48
+ async def serve_dashboard():
49
+ """Serves the interactive web UI dashboard."""
50
+ index_file = STATIC_DIR / "index.html"
51
+ if index_file.exists():
52
+ return FileResponse(index_file)
53
+ return HTMLResponse("<h1>AI Resume Scanner API is running. Please add static/index.html</h1>")
54
+
55
+
56
+ @app.get("/api/health")
57
+ def health_check():
58
  return {
59
  "status": "online",
60
+ "service": "AI Resume Scanner API",
61
+ "version": "1.0.0",
62
+ }
63
+
64
+
65
+ @app.get("/api/sample", response_model=Assessment)
66
+ def run_sample_assessment():
67
+ """
68
+ Runs an assessment using the preloaded sample resume and sample JD for quick UI demo.
69
+ """
70
+ sample_resume = Path("data/sample_resume.txt")
71
+ sample_jd = Path("data/sample_jd.txt")
72
+
73
+ if not sample_resume.exists() or not sample_jd.exists():
74
+ raise HTTPException(
75
+ status_code=404,
76
+ detail="Sample files not found in data/ directory.",
77
+ )
78
+
79
+ try:
80
+ resume_text, jd_text = prepare_scanner_inputs(str(sample_resume), str(sample_jd))
81
+ assessor = ResumeAssessor()
82
+ return assessor.assess(resume_text, jd_text)
83
+ except Exception as exc:
84
+ raise HTTPException(status_code=500, detail=str(exc))
85
+
86
+
87
+ @app.post("/api/scan", response_model=Assessment)
88
+ async def scan_resume(
89
+ resume_file: UploadFile = File(..., description="Candidate resume (.pdf or .txt)"),
90
+ jd_file: Optional[UploadFile] = File(None, description="Job description file (.pdf or .txt)"),
91
+ jd_text: Optional[str] = Form(None, description="Raw job description text"),
92
+ ):
93
+ """
94
+ Accepts candidate resume and job description (file or raw text),
95
+ extracts text safely, and returns validated Pydantic Assessment JSON.
96
+ """
97
+ # Save resume file safely
98
+ resume_ext = Path(resume_file.filename or "").suffix.lower()
99
+ if resume_ext not in (".pdf", ".txt"):
100
+ raise HTTPException(
101
+ status_code=400,
102
+ detail=f"Unsupported resume file extension '{resume_ext}'. Only .pdf and .txt allowed.",
103
+ )
104
+
105
+ resume_path = UPLOAD_DIR / f"resume_{resume_file.filename}"
106
+ with open(resume_path, "wb") as buffer:
107
+ shutil.copyfileobj(resume_file.file, buffer)
108
+
109
+ try:
110
+ # Determine JD text
111
+ if jd_file and jd_file.filename:
112
+ jd_ext = Path(jd_file.filename).suffix.lower()
113
+ if jd_ext not in (".pdf", ".txt"):
114
+ raise HTTPException(
115
+ status_code=400,
116
+ detail=f"Unsupported job description file extension '{jd_ext}'. Only .pdf and .txt allowed.",
117
+ )
118
+ jd_path = UPLOAD_DIR / f"jd_{jd_file.filename}"
119
+ with open(jd_path, "wb") as buffer:
120
+ shutil.copyfileobj(jd_file.file, buffer)
121
+ resume_extracted, jd_extracted = prepare_scanner_inputs(str(resume_path), str(jd_path))
122
+ elif jd_text and jd_text.strip():
123
+ resume_extracted, _ = prepare_scanner_inputs(str(resume_path), str(resume_path))
124
+ jd_extracted = jd_text.strip()
125
+ else:
126
+ raise HTTPException(
127
+ status_code=400,
128
+ detail="Please provide either a job description file or paste job description text.",
129
+ )
130
+
131
+ assessor = ResumeAssessor()
132
+ assessment = assessor.assess(resume_extracted, jd_extracted)
133
+ return assessment
134
+
135
+ except ExtractionError as exc:
136
+ raise HTTPException(status_code=400, detail=f"Extraction Error: {exc}")
137
+ except AssessorError as exc:
138
+ raise HTTPException(status_code=500, detail=f"AI Assessment Error: {exc}")
139
+ except Exception as exc:
140
+ raise HTTPException(status_code=500, detail=str(exc))
141
+ finally:
142
+ # Clean up temporary uploaded files to maintain clean storage
143
+ if resume_path.exists():
144
+ try:
145
+ resume_path.unlink()
146
+ except Exception:
147
+ pass
requirements.txt CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  pydantic>=2.5.0
2
  google-genai>=0.1.1
3
  google-generativeai>=0.8.0
 
1
+ fastapi>=0.110.0
2
+ uvicorn>=0.28.0
3
+ python-multipart>=0.0.9
4
  pydantic>=2.5.0
5
  google-genai>=0.1.1
6
  google-generativeai>=0.8.0
static/index.html ADDED
@@ -0,0 +1,727 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AI Resume Scanner Studio — Project 1</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
11
+ <style>
12
+ :root {
13
+ --bg-dark: #0B0F19;
14
+ --bg-card: rgba(18, 24, 38, 0.75);
15
+ --bg-card-hover: rgba(28, 38, 60, 0.85);
16
+ --border-glass: rgba(255, 255, 255, 0.08);
17
+ --accent-cyan: #00F2FE;
18
+ --accent-blue: #4FACFE;
19
+ --accent-purple: #8A2387;
20
+ --text-main: #F1F5F9;
21
+ --text-muted: #94A3B8;
22
+ --success: #10B981;
23
+ --warning: #F59E0B;
24
+ --danger: #EF4444;
25
+ --shadow-glow: 0 0 30px rgba(79, 172, 254, 0.15);
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
30
+ margin: 0;
31
+ padding: 0;
32
+ }
33
+
34
+ body {
35
+ font-family: 'Outfit', -apple-system, sans-serif;
36
+ background: radial-gradient(circle at 15% 15%, #131A2D 0%, #0B0F19 60%);
37
+ color: var(--text-main);
38
+ min-height: 100vh;
39
+ padding-bottom: 4rem;
40
+ overflow-x: hidden;
41
+ }
42
+
43
+ /* Ambient background glow */
44
+ .ambient-glow {
45
+ position: fixed;
46
+ top: -200px;
47
+ right: -200px;
48
+ width: 600px;
49
+ height: 600px;
50
+ background: radial-gradient(circle, rgba(0, 242, 254, 0.08) 0%, transparent 70%);
51
+ z-index: -1;
52
+ pointer-events: none;
53
+ }
54
+
55
+ /* Header */
56
+ header {
57
+ border-bottom: 1px solid var(--border-glass);
58
+ background: rgba(11, 15, 25, 0.8);
59
+ backdrop-filter: blur(16px);
60
+ position: sticky;
61
+ top: 0;
62
+ z-index: 100;
63
+ padding: 1rem 2rem;
64
+ }
65
+
66
+ .header-content {
67
+ max-width: 1300px;
68
+ margin: 0 auto;
69
+ display: flex;
70
+ justify-content: space-between;
71
+ align-items: center;
72
+ }
73
+
74
+ .logo {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 0.75rem;
78
+ font-weight: 700;
79
+ font-size: 1.35rem;
80
+ background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
81
+ -webkit-background-clip: text;
82
+ -webkit-text-fill-color: transparent;
83
+ }
84
+
85
+ .status-pill {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 0.5rem;
89
+ font-size: 0.8rem;
90
+ font-weight: 500;
91
+ padding: 0.35rem 0.85rem;
92
+ border-radius: 9999px;
93
+ background: rgba(16, 185, 129, 0.1);
94
+ border: 1px solid rgba(16, 185, 129, 0.25);
95
+ color: #34D399;
96
+ }
97
+
98
+ .status-dot {
99
+ width: 8px;
100
+ height: 8px;
101
+ border-radius: 50%;
102
+ background: #34D399;
103
+ box-shadow: 0 0 8px #34D399;
104
+ animation: pulse 2s infinite;
105
+ }
106
+
107
+ @keyframes pulse {
108
+ 0%, 100% { transform: scale(1); opacity: 1; }
109
+ 50% { transform: scale(1.3); opacity: 0.6; }
110
+ }
111
+
112
+ /* Main Container */
113
+ .container {
114
+ max-width: 1300px;
115
+ margin: 2.5rem auto;
116
+ padding: 0 1.5rem;
117
+ display: grid;
118
+ grid-template-columns: 460px 1fr;
119
+ gap: 2rem;
120
+ align-items: start;
121
+ }
122
+
123
+ @media (max-width: 1050px) {
124
+ .container {
125
+ grid-template-columns: 1fr;
126
+ }
127
+ }
128
+
129
+ /* Glass Cards */
130
+ .card {
131
+ background: var(--bg-card);
132
+ border: 1px solid var(--border-glass);
133
+ border-radius: 20px;
134
+ padding: 1.75rem;
135
+ backdrop-filter: blur(20px);
136
+ box-shadow: var(--shadow-glow);
137
+ transition: border-color 0.3s ease;
138
+ }
139
+
140
+ .card:hover {
141
+ border-color: rgba(255, 255, 255, 0.15);
142
+ }
143
+
144
+ .card-title {
145
+ font-size: 1.25rem;
146
+ font-weight: 600;
147
+ margin-bottom: 0.35rem;
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 0.5rem;
151
+ }
152
+
153
+ .card-subtitle {
154
+ font-size: 0.88rem;
155
+ color: var(--text-muted);
156
+ margin-bottom: 1.5rem;
157
+ }
158
+
159
+ /* Upload Sections */
160
+ .drop-zone {
161
+ border: 2px dashed rgba(79, 172, 254, 0.35);
162
+ border-radius: 14px;
163
+ padding: 2rem 1.25rem;
164
+ text-align: center;
165
+ cursor: pointer;
166
+ transition: all 0.25s ease;
167
+ background: rgba(79, 172, 254, 0.03);
168
+ position: relative;
169
+ margin-bottom: 1.5rem;
170
+ }
171
+
172
+ .drop-zone:hover, .drop-zone.dragover {
173
+ border-color: var(--accent-cyan);
174
+ background: rgba(0, 242, 254, 0.08);
175
+ transform: translateY(-2px);
176
+ }
177
+
178
+ .drop-icon {
179
+ font-size: 2.25rem;
180
+ color: var(--accent-blue);
181
+ margin-bottom: 0.75rem;
182
+ }
183
+
184
+ .drop-zone h4 {
185
+ font-size: 1rem;
186
+ font-weight: 600;
187
+ margin-bottom: 0.25rem;
188
+ }
189
+
190
+ .drop-zone p {
191
+ font-size: 0.8rem;
192
+ color: var(--text-muted);
193
+ }
194
+
195
+ .file-selected {
196
+ margin-top: 0.75rem;
197
+ padding: 0.5rem 0.85rem;
198
+ background: rgba(16, 185, 129, 0.12);
199
+ border: 1px solid rgba(16, 185, 129, 0.3);
200
+ border-radius: 8px;
201
+ font-size: 0.85rem;
202
+ color: #34D399;
203
+ display: none;
204
+ align-items: center;
205
+ gap: 0.5rem;
206
+ }
207
+
208
+ /* Tabs */
209
+ .tabs {
210
+ display: flex;
211
+ gap: 0.5rem;
212
+ margin-bottom: 1rem;
213
+ background: rgba(255, 255, 255, 0.03);
214
+ padding: 0.35rem;
215
+ border-radius: 10px;
216
+ }
217
+
218
+ .tab-btn {
219
+ flex: 1;
220
+ padding: 0.5rem;
221
+ font-size: 0.85rem;
222
+ font-weight: 500;
223
+ border: none;
224
+ background: transparent;
225
+ color: var(--text-muted);
226
+ border-radius: 8px;
227
+ cursor: pointer;
228
+ transition: all 0.2s ease;
229
+ }
230
+
231
+ .tab-btn.active {
232
+ background: rgba(255, 255, 255, 0.1);
233
+ color: var(--text-main);
234
+ }
235
+
236
+ textarea {
237
+ width: 100%;
238
+ height: 140px;
239
+ background: rgba(0, 0, 0, 0.35);
240
+ border: 1px solid var(--border-glass);
241
+ border-radius: 12px;
242
+ padding: 0.85rem;
243
+ color: var(--text-main);
244
+ font-family: inherit;
245
+ font-size: 0.88rem;
246
+ resize: vertical;
247
+ margin-bottom: 1.5rem;
248
+ }
249
+
250
+ textarea:focus {
251
+ outline: none;
252
+ border-color: var(--accent-blue);
253
+ }
254
+
255
+ /* Buttons */
256
+ .btn-group {
257
+ display: flex;
258
+ gap: 0.75rem;
259
+ }
260
+
261
+ .btn {
262
+ display: inline-flex;
263
+ align-items: center;
264
+ justify-content: center;
265
+ gap: 0.5rem;
266
+ padding: 0.85rem 1.4rem;
267
+ border-radius: 12px;
268
+ font-weight: 600;
269
+ font-size: 0.95rem;
270
+ cursor: pointer;
271
+ transition: all 0.25s ease;
272
+ border: none;
273
+ width: 100%;
274
+ }
275
+
276
+ .btn-primary {
277
+ background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
278
+ color: #0B0F19;
279
+ box-shadow: 0 4px 20px rgba(0, 242, 254, 0.25);
280
+ }
281
+
282
+ .btn-primary:hover {
283
+ transform: translateY(-2px);
284
+ box-shadow: 0 6px 25px rgba(0, 242, 254, 0.4);
285
+ }
286
+
287
+ .btn-secondary {
288
+ background: rgba(255, 255, 255, 0.05);
289
+ color: var(--text-main);
290
+ border: 1px solid var(--border-glass);
291
+ }
292
+
293
+ .btn-secondary:hover {
294
+ background: rgba(255, 255, 255, 0.1);
295
+ }
296
+
297
+ /* Results Panel */
298
+ .result-placeholder {
299
+ display: flex;
300
+ flex-direction: column;
301
+ align-items: center;
302
+ justify-content: center;
303
+ min-height: 480px;
304
+ text-align: center;
305
+ color: var(--text-muted);
306
+ border: 1px dashed var(--border-glass);
307
+ border-radius: 20px;
308
+ background: rgba(18, 24, 38, 0.35);
309
+ padding: 3rem;
310
+ }
311
+
312
+ .placeholder-icon {
313
+ font-size: 3.5rem;
314
+ margin-bottom: 1.25rem;
315
+ color: rgba(79, 172, 254, 0.25);
316
+ }
317
+
318
+ /* Loader */
319
+ .loader-overlay {
320
+ display: none;
321
+ flex-direction: column;
322
+ align-items: center;
323
+ justify-content: center;
324
+ min-height: 480px;
325
+ gap: 1.5rem;
326
+ }
327
+
328
+ .spinner {
329
+ width: 60px;
330
+ height: 60px;
331
+ border: 4px solid rgba(255, 255, 255, 0.08);
332
+ border-top-color: var(--accent-cyan);
333
+ border-right-color: var(--accent-blue);
334
+ border-radius: 50%;
335
+ animation: spin 1s linear infinite;
336
+ }
337
+
338
+ @keyframes spin {
339
+ to { transform: rotate(360deg); }
340
+ }
341
+
342
+ /* Results Card */
343
+ #result-card {
344
+ display: none;
345
+ }
346
+
347
+ .score-banner {
348
+ display: flex;
349
+ align-items: center;
350
+ justify-content: space-between;
351
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(0, 242, 254, 0.08));
352
+ border: 1px solid rgba(16, 185, 129, 0.25);
353
+ border-radius: 16px;
354
+ padding: 1.5rem;
355
+ margin-bottom: 1.75rem;
356
+ }
357
+
358
+ .score-circle {
359
+ width: 90px;
360
+ height: 90px;
361
+ border-radius: 50%;
362
+ background: radial-gradient(circle, #0B0F19 60%, transparent 61%),
363
+ conic-gradient(var(--success) 0%, rgba(255,255,255,0.1) 0%);
364
+ display: flex;
365
+ flex-direction: column;
366
+ align-items: center;
367
+ justify-content: center;
368
+ position: relative;
369
+ font-weight: 700;
370
+ }
371
+
372
+ .score-number {
373
+ font-size: 1.65rem;
374
+ line-height: 1;
375
+ }
376
+
377
+ .score-label {
378
+ font-size: 0.65rem;
379
+ text-transform: uppercase;
380
+ letter-spacing: 1px;
381
+ color: var(--text-muted);
382
+ margin-top: 3px;
383
+ }
384
+
385
+ .rationale-text {
386
+ flex: 1;
387
+ margin-left: 1.75rem;
388
+ font-size: 0.95rem;
389
+ line-height: 1.5;
390
+ color: #E2E8F0;
391
+ }
392
+
393
+ .grid-2 {
394
+ display: grid;
395
+ grid-template-columns: 1fr 1fr;
396
+ gap: 1.5rem;
397
+ margin-bottom: 1.75rem;
398
+ }
399
+
400
+ @media (max-width: 800px) {
401
+ .grid-2 {
402
+ grid-template-columns: 1fr;
403
+ }
404
+ }
405
+
406
+ .section-box {
407
+ background: rgba(0, 0, 0, 0.25);
408
+ border: 1px solid var(--border-glass);
409
+ border-radius: 14px;
410
+ padding: 1.25rem;
411
+ }
412
+
413
+ .section-box h3 {
414
+ font-size: 1rem;
415
+ margin-bottom: 1rem;
416
+ display: flex;
417
+ align-items: center;
418
+ gap: 0.5rem;
419
+ }
420
+
421
+ .list-item {
422
+ display: flex;
423
+ align-items: flex-start;
424
+ gap: 0.6rem;
425
+ font-size: 0.88rem;
426
+ line-height: 1.45;
427
+ margin-bottom: 0.75rem;
428
+ color: #CBD5E1;
429
+ }
430
+
431
+ .list-item i {
432
+ margin-top: 3px;
433
+ }
434
+
435
+ .matched i { color: var(--success); }
436
+ .missing i { color: var(--warning); }
437
+ .suggestion i { color: var(--accent-cyan); }
438
+
439
+ .disclaimer-footer {
440
+ font-size: 0.78rem;
441
+ color: var(--text-muted);
442
+ background: rgba(255, 255, 255, 0.02);
443
+ border-top: 1px solid var(--border-glass);
444
+ padding-top: 1rem;
445
+ margin-top: 1rem;
446
+ line-height: 1.5;
447
+ }
448
+ </style>
449
+ </head>
450
+ <body>
451
+
452
+ <div class="ambient-glow"></div>
453
+
454
+ <header>
455
+ <div class="header-content">
456
+ <div class="logo">
457
+ <i class="fa-solid fa-wand-magic-sparkles"></i>
458
+ AI Resume Scanner Studio
459
+ </div>
460
+ <div class="status-pill">
461
+ <div class="status-dot"></div>
462
+ Project 1 — Track C • Pydantic Validated
463
+ </div>
464
+ </div>
465
+ </header>
466
+
467
+ <div class="container">
468
+ <!-- Input Scanner Form -->
469
+ <div class="card">
470
+ <div class="card-title">
471
+ <i class="fa-solid fa-file-arrow-up" style="color: var(--accent-cyan);"></i>
472
+ Document Scanner
473
+ </div>
474
+ <div class="card-subtitle">Upload candidate resume & job description</div>
475
+
476
+ <!-- Resume Upload Zone -->
477
+ <label style="display: block; font-size: 0.88rem; font-weight: 500; margin-bottom: 0.5rem;">
478
+ Candidate Resume (.pdf or .txt)
479
+ </label>
480
+ <div class="drop-zone" id="resume-dropzone" onclick="document.getElementById('resume-file').click()">
481
+ <i class="fa-solid fa-cloud-arrow-up drop-icon"></i>
482
+ <h4>Drop resume file here or click to browse</h4>
483
+ <p>Supports standard PDF and TXT files up to 10MB</p>
484
+ <input type="file" id="resume-file" accept=".pdf,.txt" style="display: none;">
485
+ <div class="file-selected" id="resume-selected">
486
+ <i class="fa-solid fa-check-circle"></i>
487
+ <span id="resume-name"></span>
488
+ </div>
489
+ </div>
490
+
491
+ <!-- JD Upload Zone -->
492
+ <label style="display: block; font-size: 0.88rem; font-weight: 500; margin-bottom: 0.5rem;">
493
+ Job Description
494
+ </label>
495
+ <div class="tabs">
496
+ <button class="tab-btn active" id="tab-file" onclick="switchTab('file')">Upload File</button>
497
+ <button class="tab-btn" id="tab-text" onclick="switchTab('text')">Paste Text</button>
498
+ </div>
499
+
500
+ <div id="jd-file-section">
501
+ <div class="drop-zone" id="jd-dropzone" onclick="document.getElementById('jd-file').click()">
502
+ <i class="fa-solid fa-briefcase drop-icon"></i>
503
+ <h4>Drop Job Description file here</h4>
504
+ <p>Supports .pdf or .txt</p>
505
+ <input type="file" id="jd-file" accept=".pdf,.txt" style="display: none;">
506
+ <div class="file-selected" id="jd-selected">
507
+ <i class="fa-solid fa-check-circle"></i>
508
+ <span id="jd-name"></span>
509
+ </div>
510
+ </div>
511
+ </div>
512
+
513
+ <div id="jd-text-section" style="display: none;">
514
+ <textarea id="jd-textarea" placeholder="Paste the complete job description text here..."></textarea>
515
+ </div>
516
+
517
+ <!-- Actions -->
518
+ <div class="btn-group">
519
+ <button class="btn btn-secondary" onclick="loadSampleDemo()">
520
+ <i class="fa-solid fa-bolt"></i> 1-Click Demo
521
+ </button>
522
+ <button class="btn btn-primary" onclick="runAssessment()">
523
+ <i class="fa-solid fa-radar"></i> Scan Resume
524
+ </button>
525
+ </div>
526
+ </div>
527
+
528
+ <!-- Results Column -->
529
+ <div>
530
+ <!-- Placeholder -->
531
+ <div class="result-placeholder" id="placeholder">
532
+ <i class="fa-solid fa-chart-pie placeholder-icon"></i>
533
+ <h3>Ready to Scan Candidate</h3>
534
+ <p style="max-width: 320px; margin-top: 0.5rem;">
535
+ Upload a resume and job description or click <strong>1-Click Demo</strong> to see structured AI screening in action.
536
+ </p>
537
+ </div>
538
+
539
+ <!-- Loader -->
540
+ <div class="loader-overlay" id="loader">
541
+ <div class="spinner"></div>
542
+ <h3 style="font-weight: 500;">AI Engine Analyzing Alignment...</h3>
543
+ <p style="color: var(--text-muted); font-size: 0.88rem;">Extracting text & validating against Pydantic schema</p>
544
+ </div>
545
+
546
+ <!-- Results Card -->
547
+ <div class="card" id="result-card">
548
+ <div class="score-banner">
549
+ <div class="score-circle" id="score-meter">
550
+ <span class="score-number" id="score-value">0</span>
551
+ <span class="score-label">MATCH</span>
552
+ </div>
553
+ <div class="rationale-text">
554
+ <div style="font-weight: 600; font-size: 1.05rem; margin-bottom: 0.35rem; color: #FFF;">
555
+ Executive Assessment Rationale
556
+ </div>
557
+ <span id="score-rationale">Loading rationale...</span>
558
+ </div>
559
+ </div>
560
+
561
+ <!-- 2 Column Grid -->
562
+ <div class="grid-2">
563
+ <div class="section-box">
564
+ <h3 style="color: var(--success);">
565
+ <i class="fa-solid fa-circle-check"></i> Matched Requirements
566
+ </h3>
567
+ <div id="matched-list"></div>
568
+ </div>
569
+
570
+ <div class="section-box">
571
+ <h3 style="color: var(--warning);">
572
+ <i class="fa-solid fa-triangle-exclamation"></i> Missing Gaps
573
+ </h3>
574
+ <div id="missing-list"></div>
575
+ </div>
576
+ </div>
577
+
578
+ <!-- Suggestions Section -->
579
+ <div class="section-box" style="margin-bottom: 1rem;">
580
+ <h3 style="color: var(--accent-cyan);">
581
+ <i class="fa-solid fa-lightbulb"></i> Actionable Presentation Suggestions
582
+ </h3>
583
+ <div id="suggestions-list"></div>
584
+ </div>
585
+
586
+ <!-- Disclaimer Footer -->
587
+ <div class="disclaimer-footer">
588
+ <strong>Technical Note & Limitations:</strong> <span id="limitations-text"></span><br>
589
+ <em>Per Project 1 Specification Section 4:</em> Score can vary slightly between runs due to probabilistic LLM weighting. Use the evidence and suggestions as primary guidance.
590
+ </div>
591
+ </div>
592
+ </div>
593
+ </div>
594
+
595
+ <script>
596
+ let currentTab = 'file';
597
+
598
+ function switchTab(tab) {
599
+ currentTab = tab;
600
+ document.getElementById('tab-file').classList.toggle('active', tab === 'file');
601
+ document.getElementById('tab-text').classList.toggle('active', tab === 'text');
602
+ document.getElementById('jd-file-section').style.display = tab === 'file' ? 'block' : 'none';
603
+ document.getElementById('jd-text-section').style.display = tab === 'text' ? 'block' : 'none';
604
+ }
605
+
606
+ // File input change handlers
607
+ document.getElementById('resume-file').addEventListener('change', (e) => {
608
+ if (e.target.files[0]) {
609
+ document.getElementById('resume-name').textContent = e.target.files[0].name;
610
+ document.getElementById('resume-selected').style.display = 'flex';
611
+ }
612
+ });
613
+
614
+ document.getElementById('jd-file').addEventListener('change', (e) => {
615
+ if (e.target.files[0]) {
616
+ document.getElementById('jd-name').textContent = e.target.files[0].name;
617
+ document.getElementById('jd-selected').style.display = 'flex';
618
+ }
619
+ });
620
+
621
+ function showLoading() {
622
+ document.getElementById('placeholder').style.display = 'none';
623
+ document.getElementById('result-card').style.display = 'none';
624
+ document.getElementById('loader').style.display = 'flex';
625
+ }
626
+
627
+ function renderResults(data) {
628
+ document.getElementById('loader').style.display = 'none';
629
+ document.getElementById('result-card').style.display = 'block';
630
+
631
+ // Score animation
632
+ const scoreVal = data.match_score;
633
+ document.getElementById('score-value').textContent = scoreVal;
634
+
635
+ let color = '#10B981';
636
+ if (scoreVal < 75) color = '#F59E0B';
637
+ if (scoreVal < 55) color = '#EF4444';
638
+
639
+ document.getElementById('score-meter').style.background =
640
+ `radial-gradient(circle, #0B0F19 60%, transparent 61%), conic-gradient(${color} ${scoreVal}%, rgba(255,255,255,0.1) 0%)`;
641
+
642
+ document.getElementById('score-rationale').textContent = data.score_rationale;
643
+
644
+ // Render lists
645
+ renderList('matched-list', data.matched_requirements, 'fa-check', 'matched');
646
+ renderList('missing-list', data.missing_requirements, 'fa-exclamation-circle', 'missing');
647
+ renderList('suggestions-list', data.suggestions, 'fa-arrow-right', 'suggestion');
648
+
649
+ const lims = data.limitations && data.limitations.length > 0 ? data.limitations.join(' • ') : 'No document limitations noted.';
650
+ document.getElementById('limitations-text').textContent = lims;
651
+ }
652
+
653
+ function renderList(elementId, items, icon, cls) {
654
+ const container = document.getElementById(elementId);
655
+ container.innerHTML = '';
656
+ if (!items || items.length === 0) {
657
+ container.innerHTML = `<div class="list-item" style="color: var(--text-muted)">None recorded.</div>`;
658
+ return;
659
+ }
660
+ items.forEach(text => {
661
+ const div = document.createElement('div');
662
+ div.className = `list-item ${cls}`;
663
+ div.innerHTML = `<i class="fa-solid ${icon}"></i> <span>${text}</span>`;
664
+ container.appendChild(div);
665
+ });
666
+ }
667
+
668
+ async function loadSampleDemo() {
669
+ showLoading();
670
+ try {
671
+ const res = await fetch('/api/sample');
672
+ const data = await res.json();
673
+ renderResults(data);
674
+ } catch (err) {
675
+ alert('Error loading sample demo: ' + err.message);
676
+ document.getElementById('loader').style.display = 'none';
677
+ document.getElementById('placeholder').style.display = 'flex';
678
+ }
679
+ }
680
+
681
+ async function runAssessment() {
682
+ const resumeFile = document.getElementById('resume-file').files[0];
683
+ if (!resumeFile) {
684
+ alert('Please upload a candidate resume (.pdf or .txt)');
685
+ return;
686
+ }
687
+
688
+ const formData = new FormData();
689
+ formData.append('resume_file', resumeFile);
690
+
691
+ if (currentTab === 'file') {
692
+ const jdFile = document.getElementById('jd-file').files[0];
693
+ if (!jdFile) {
694
+ alert('Please upload a job description file (.pdf or .txt)');
695
+ return;
696
+ }
697
+ formData.append('jd_file', jdFile);
698
+ } else {
699
+ const jdText = document.getElementById('jd-textarea').value.trim();
700
+ if (!jdText) {
701
+ alert('Please paste the job description text.');
702
+ return;
703
+ }
704
+ formData.append('jd_text', jdText);
705
+ }
706
+
707
+ showLoading();
708
+ try {
709
+ const res = await fetch('/api/scan', {
710
+ method: 'POST',
711
+ body: formData
712
+ });
713
+ if (!res.ok) {
714
+ const errData = await res.json();
715
+ throw new Error(errData.detail || 'Server error occurred');
716
+ }
717
+ const data = await res.json();
718
+ renderResults(data);
719
+ } catch (err) {
720
+ alert('Scan Failed: ' + err.message);
721
+ document.getElementById('loader').style.display = 'none';
722
+ document.getElementById('placeholder').style.display = 'flex';
723
+ }
724
+ }
725
+ </script>
726
+ </body>
727
+ </html>