Siddharth Ravikumar commited on
Commit
efaef67
Β·
1 Parent(s): 388a2e0

Deploy Chatbot, Animation, and Full TraceScene Application

Browse files
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ data/
2
+ logs/
3
+ __pycache__/
4
+ *.pyc
5
+ *.db*
app.py ADDED
@@ -0,0 +1,896 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TraceScene β€” Gradio ZeroGPU Application
3
+
4
+ Serves the custom TraceScene frontend + REST API with GPU-accelerated inference.
5
+ Architecture:
6
+ - Gradio demo at / (primary β€” required for ZeroGPU)
7
+ - Custom FastAPI routes added to Gradio's internal app for REST API
8
+ - Custom HTML/CSS/JS frontend served alongside
9
+ - @spaces.GPU wraps inference for dynamic GPU allocation
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import torch
16
+ import gradio as gr
17
+ import spaces
18
+
19
+ from fastapi import FastAPI
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.staticfiles import StaticFiles
22
+ from fastapi.responses import FileResponse
23
+
24
+ # ── Backend Imports ────────────────────────────────────────────────────
25
+ from backend.app.config import settings
26
+ from backend.app.db.database import db
27
+ from backend.app.core.inference import inference_engine, SCENE_ANALYSIS_PROMPT
28
+ from backend.app.core.scene_analyzer import SceneAnalyzer
29
+ from backend.app.core.rule_matcher import RuleMatcher
30
+ from backend.app.core.fault_deducer import FaultDeducer
31
+ from backend.app.core.report_generator import ReportGenerator
32
+ from backend.app.rules.rule_loader import rule_loader
33
+ from backend.app.utils.logger import get_logger
34
+ from backend.app.api.routes import router
35
+
36
+ logger = get_logger("app")
37
+
38
+ scene_analyzer = SceneAnalyzer()
39
+ rule_matcher = RuleMatcher()
40
+ fault_deducer = FaultDeducer()
41
+ report_generator = ReportGenerator()
42
+
43
+ # ── ZeroGPU: Top-level decorated function ──────────────────────────────
44
+ # This MUST be a top-level function wired to a Gradio event handler.
45
+
46
+ _original_run_inference = inference_engine._run_inference # bound method
47
+
48
+
49
+ @spaces.GPU(duration=120)
50
+ def gpu_run_inference(image, prompt):
51
+ """GPU-accelerated inference β€” ZeroGPU allocates GPU for this call."""
52
+ return _original_run_inference(image, prompt)
53
+
54
+
55
+ # Monkey-patch so the entire pipeline uses GPU
56
+ inference_engine._run_inference = gpu_run_inference
57
+
58
+
59
+ # ── Async helpers ──────────────────────────────────────────────────────
60
+
61
+ def run_async(coro):
62
+ """Run async coroutine from sync Gradio context."""
63
+ import asyncio
64
+ try:
65
+ loop = asyncio.get_event_loop()
66
+ if loop.is_running():
67
+ import concurrent.futures
68
+ with concurrent.futures.ThreadPoolExecutor() as pool:
69
+ return pool.submit(asyncio.run, coro).result()
70
+ return loop.run_until_complete(coro)
71
+ except RuntimeError:
72
+ return asyncio.run(coro)
73
+
74
+
75
+ # ── Initialize backend ────────────────────────────────────────────────
76
+
77
+ _initialized = False
78
+
79
+
80
+ async def _ensure_init():
81
+ global _initialized
82
+ if _initialized:
83
+ return
84
+ await db.connect()
85
+ rule_loader.load_rules()
86
+ try:
87
+ inference_engine.load_model()
88
+ except Exception as e:
89
+ logger.error(f"Vision model load failed: {e}")
90
+ _initialized = True
91
+
92
+
93
+ def ensure_init():
94
+ run_async(_ensure_init())
95
+
96
+
97
+ # ── Gradio Handlers ───────────────────────────────────────────────────
98
+
99
+ def gradio_analyze_photo(image):
100
+ """Analyze a single uploaded photo via GPU."""
101
+ if image is None:
102
+ return "Please upload an image."
103
+ from PIL import Image as PILImage
104
+ if not isinstance(image, PILImage.Image):
105
+ image = PILImage.fromarray(image)
106
+
107
+ ensure_init()
108
+ if not inference_engine.is_loaded:
109
+ inference_engine.load_model()
110
+
111
+ result = gpu_run_inference(image, SCENE_ANALYSIS_PROMPT)
112
+ return result
113
+
114
+
115
+ import json
116
+ import hashlib
117
+ import time
118
+ from PIL import Image
119
+
120
+
121
+ def create_case_fn(case_number, officer_name, location, incident_date, notes):
122
+ """Create a new accident case."""
123
+ if not case_number or not case_number.strip():
124
+ return "❌ Case number is required.", list_cases_fn()
125
+ ensure_init()
126
+ try:
127
+ cid = run_async(db.create_case(
128
+ case_number=case_number.strip(),
129
+ officer_name=officer_name.strip() if officer_name else None,
130
+ location=location.strip() if location else None,
131
+ incident_date=incident_date if incident_date else None,
132
+ notes=notes.strip() if notes else None,
133
+ ))
134
+ return f"βœ… Case **{case_number}** created (ID: {cid})", list_cases_fn()
135
+ except Exception as e:
136
+ return f"❌ {e}", list_cases_fn()
137
+
138
+
139
+ def list_cases_fn():
140
+ """List all cases."""
141
+ ensure_init()
142
+ try:
143
+ cases = run_async(db.list_cases())
144
+ if not cases:
145
+ return []
146
+ rows = []
147
+ for c in cases:
148
+ photos = run_async(db.get_photos_by_case(c["id"]))
149
+ rows.append([
150
+ c["id"], c["case_number"],
151
+ c.get("officer_name", "β€”"), c.get("location", "β€”"),
152
+ c.get("incident_date", "β€”"), c["status"], len(photos),
153
+ ])
154
+ return rows
155
+ except Exception:
156
+ return []
157
+
158
+
159
+ def delete_case_fn(case_id):
160
+ """Delete a case."""
161
+ if not case_id:
162
+ return "❌ Enter a Case ID.", list_cases_fn()
163
+ ensure_init()
164
+ try:
165
+ run_async(db.delete_case(int(case_id)))
166
+ return f"βœ… Case {int(case_id)} deleted.", list_cases_fn()
167
+ except Exception as e:
168
+ return f"❌ {e}", list_cases_fn()
169
+
170
+
171
+ def upload_photos_fn(case_id, files):
172
+ """Upload photos to a case."""
173
+ if not case_id:
174
+ return "❌ Enter a Case ID."
175
+ if not files:
176
+ return "❌ Select photos to upload."
177
+ ensure_init()
178
+ try:
179
+ case = run_async(db.get_case(int(case_id)))
180
+ if not case:
181
+ return f"❌ Case {int(case_id)} not found."
182
+
183
+ case_dir = settings.upload_path / f"case_{int(case_id)}"
184
+ case_dir.mkdir(parents=True, exist_ok=True)
185
+
186
+ count = 0
187
+ for fp in files:
188
+ with open(fp, "rb") as f:
189
+ content = f.read()
190
+ filename = Path(fp).name
191
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
192
+ if ext not in settings.allowed_extensions_list:
193
+ continue
194
+ fhash = hashlib.md5(content).hexdigest()[:12]
195
+ dest = case_dir / f"{fhash}_{filename}"
196
+ with open(dest, "wb") as f:
197
+ f.write(content)
198
+ w, h = None, None
199
+ try:
200
+ img = Image.open(dest)
201
+ w, h = img.size
202
+ except Exception:
203
+ pass
204
+ run_async(db.add_photo(
205
+ case_id=int(case_id), filename=filename,
206
+ filepath=str(dest), file_size=len(content),
207
+ width=w, height=h,
208
+ ))
209
+ count += 1
210
+ return f"βœ… Uploaded {count} photo(s) to Case {int(case_id)}."
211
+ except Exception as e:
212
+ return f"❌ {e}"
213
+
214
+
215
+ def get_case_photos_fn(case_id):
216
+ """Get photo gallery for a case."""
217
+ if not case_id:
218
+ return []
219
+ ensure_init()
220
+ try:
221
+ photos = run_async(db.get_photos_by_case(int(case_id)))
222
+ return [(p["filepath"], p["filename"]) for p in photos if Path(p["filepath"]).exists()]
223
+ except Exception:
224
+ return []
225
+
226
+
227
+ def run_analysis_fn(case_id, progress=gr.Progress()):
228
+ """Run the full AI analysis pipeline (GPU-accelerated)."""
229
+ if not case_id:
230
+ return "❌ Enter a Case ID.", "", ""
231
+ ensure_init()
232
+
233
+ try:
234
+ case = run_async(db.get_case(int(case_id)))
235
+ if not case:
236
+ return "❌ Case not found.", "", ""
237
+ photos = run_async(db.get_photos_by_case(int(case_id)))
238
+ if not photos:
239
+ return "❌ No photos uploaded.", "", ""
240
+ except Exception as e:
241
+ return f"❌ {e}", "", ""
242
+
243
+ if not inference_engine.is_loaded:
244
+ inference_engine.load_model()
245
+
246
+ # Step 1: Analyze each photo
247
+ analysis_results = []
248
+ for i, photo in enumerate(photos):
249
+ progress((i + 1) / len(photos) * 0.5, desc=f"Analyzing photo {i+1}/{len(photos)}...")
250
+ try:
251
+ img = Image.open(photo["filepath"])
252
+ start = time.perf_counter()
253
+ raw = gpu_run_inference(img, SCENE_ANALYSIS_PROMPT)
254
+ elapsed_ms = (time.perf_counter() - start) * 1000
255
+ parsed = scene_analyzer._parse_analysis(raw)
256
+ run_async(db.add_scene_analysis(
257
+ photo_id=photo["id"], raw_analysis=raw,
258
+ vehicles_json=json.dumps(parsed.get("vehicles", [])) if parsed.get("vehicles") else None,
259
+ road_conditions_json=json.dumps(parsed.get("road_conditions", {})) if parsed.get("road_conditions") else None,
260
+ evidence_json=json.dumps(parsed.get("evidence", {})) if parsed.get("evidence") else None,
261
+ environmental_json=json.dumps(parsed.get("environmental", {})) if parsed.get("environmental") else None,
262
+ positions_json=json.dumps(parsed.get("positions", {})) if parsed.get("positions") else None,
263
+ model_id=settings.model_id, inference_time_ms=elapsed_ms,
264
+ ))
265
+ analysis_results.append({"filename": photo["filename"], "analysis": raw, "time_ms": round(elapsed_ms)})
266
+ except Exception as e:
267
+ analysis_results.append({"filename": photo["filename"], "analysis": f"Error: {e}", "time_ms": 0})
268
+
269
+ # Identify parties
270
+ progress(0.55, desc="Identifying parties...")
271
+ all_analyses = run_async(db.get_analyses_by_case(int(case_id)))
272
+ parties_data = scene_analyzer._identify_parties(all_analyses)
273
+ run_async(db.clear_parties(int(case_id)))
274
+ for p in parties_data:
275
+ run_async(db.add_party(
276
+ case_id=int(case_id), label=p.get("label", "Unknown"),
277
+ vehicle_type=p.get("vehicle_type"), vehicle_color=p.get("vehicle_color"),
278
+ vehicle_description=p.get("description"),
279
+ ))
280
+
281
+ # Step 2: Rule matching
282
+ progress(0.65, desc="Matching traffic rules...")
283
+ violations = run_async(rule_matcher.match_violations(int(case_id)))
284
+
285
+ # Step 3: Fault deduction
286
+ progress(0.8, desc="Deducing fault...")
287
+ fault_result = run_async(fault_deducer.deduce_fault(int(case_id)))
288
+ run_async(db.update_case_status(int(case_id), "complete"))
289
+
290
+ # Format output
291
+ total_time = sum(r["time_ms"] for r in analysis_results)
292
+ analysis_text = ""
293
+ for r in analysis_results:
294
+ analysis_text += f"### πŸ“· {r['filename']} ({r['time_ms']}ms)\n```\n{r['analysis']}\n```\n---\n\n"
295
+
296
+ violations_text = f"Found {len(violations)} violation(s):\n"
297
+ for v in violations:
298
+ violations_text += f"\nβ€’ **{v.get('rule_title', '?')}** ({v.get('severity', '?')}) β€” {v.get('confidence', 0):.0%}"
299
+ violations_text += f"\n\n### Fault: {fault_result.get('primary_fault_party', 'N/A')}"
300
+ violations_text += f"\nConfidence: {fault_result.get('overall_confidence', 0):.0%}"
301
+ violations_text += f"\n\n{fault_result.get('analysis_summary', '')}"
302
+
303
+ progress(1.0, desc="Complete!")
304
+ return f"βœ… Done! {len(photos)} photos in {total_time/1000:.1f}s", analysis_text, violations_text
305
+
306
+
307
+ def generate_report_fn(case_id):
308
+ """Generate incident report."""
309
+ if not case_id:
310
+ return "❌ Enter a Case ID."
311
+ ensure_init()
312
+ try:
313
+ report = run_async(report_generator.generate_report(int(case_id)))
314
+ except Exception as e:
315
+ return f"❌ {e}"
316
+ if "error" in report:
317
+ return f"❌ {report['error']}"
318
+
319
+ c = report.get("case", {})
320
+ stats = report.get("statistics", {})
321
+ fa = report.get("fault_analysis", {})
322
+ md = f"""# πŸš” TraceScene Report
323
+ > Case: {c.get('case_number', 'β€”')} | Officer: {c.get('officer_name', 'β€”')}
324
+ > Location: {c.get('location', 'β€”')} | Date: {c.get('incident_date', 'β€”')}
325
+
326
+ *{report.get('disclaimer', '')}*
327
+
328
+ | Metric | Value |
329
+ |---|---|
330
+ | Photos | {stats.get('analyzed_photos', 0)} |
331
+ | Violations | {stats.get('total_violations', 0)} |
332
+ | Critical | {stats.get('critical_violations', 0)} |
333
+ | Parties | {stats.get('parties_identified', 0)} |
334
+
335
+ ## Scene Summary
336
+ {report.get('scene_summary', 'N/A')}
337
+
338
+ ## Violations
339
+ """
340
+ for v in report.get("violations", {}).get("list", []):
341
+ md += f"- **{v.get('title', '?')}** [{v.get('severity', '?')}] β€” {v.get('party', '?')} ({v.get('confidence', 0):.0%})\n"
342
+ md += f"\n## Fault Analysis\n"
343
+ if fa.get("determined"):
344
+ md += f"**Primary Fault:** {fa.get('primary_fault_party', '?')}\n"
345
+ md += f"**Confidence:** {fa.get('overall_confidence', 0):.0%}\n"
346
+ md += f"\n{fa.get('probable_cause', '')}\n"
347
+ return md
348
+
349
+
350
+ def get_rules_fn():
351
+ """Get traffic rules."""
352
+ ensure_init()
353
+ data = rule_loader.get_all_rules()
354
+ categories = data.get("categories", [])
355
+ if not categories:
356
+ return "No rules loaded."
357
+ md = "# πŸ“œ Traffic Rules\n\n"
358
+ for cat in categories:
359
+ md += f"## {cat.get('name', '?')} ({cat.get('rule_count', 0)})\n"
360
+ md += "| ID | Title | Severity | Weight |\n|---|---|---|---|\n"
361
+ for r in cat.get("rules", []):
362
+ md += f"| {r.get('id', '')} | {r.get('title', '')} | {r.get('severity', '')} | {r.get('fault_weight', '')} |\n"
363
+ md += "\n"
364
+ return md
365
+
366
+
367
+ # ── JSON API functions (for custom frontend via @gradio/client) ────────
368
+
369
+ def health_fn():
370
+ """Return system health as JSON."""
371
+ ensure_init()
372
+ return json.dumps({
373
+ "status": "ok",
374
+ "model_loaded": inference_engine.is_loaded,
375
+ "model_id": settings.model_id if inference_engine.is_loaded else None,
376
+ "device": inference_engine._device if inference_engine.is_loaded else None,
377
+ "rules_loaded": len(rule_loader.get_all_rules().get("categories", [])),
378
+ })
379
+
380
+
381
+ def list_cases_json():
382
+ """List cases as JSON."""
383
+ ensure_init()
384
+ cases = run_async(db.list_cases())
385
+ for c in cases:
386
+ photos = run_async(db.get_photos_by_case(c["id"]))
387
+ c["photo_count"] = len(photos)
388
+ return json.dumps({"cases": cases})
389
+
390
+
391
+ def get_case_json(case_id):
392
+ """Get full case details as JSON."""
393
+ if not case_id:
394
+ return json.dumps({"error": "No case ID"})
395
+ ensure_init()
396
+ case = run_async(db.get_case(int(case_id)))
397
+ if not case:
398
+ return json.dumps({"error": f"Case {int(case_id)} not found"})
399
+ photos = run_async(db.get_photos_by_case(int(case_id)))
400
+ analyses = run_async(db.get_analyses_by_case(int(case_id)))
401
+ parties = run_async(db.get_parties_by_case(int(case_id)))
402
+ violations = run_async(db.get_violations_by_case(int(case_id)))
403
+ fault = run_async(db.get_fault_analysis(int(case_id)))
404
+ return json.dumps({
405
+ "case": case,
406
+ "photos": photos,
407
+ "analyses": analyses,
408
+ "parties": parties,
409
+ "violations": violations,
410
+ "fault_analysis": fault,
411
+ "stats": {
412
+ "total_photos": len(photos),
413
+ "analyzed_photos": len(analyses),
414
+ "violations_found": len(violations),
415
+ "parties_identified": len(parties),
416
+ },
417
+ })
418
+
419
+
420
+ def get_report_json(case_id):
421
+ """Get report as JSON."""
422
+ if not case_id:
423
+ return json.dumps({"error": "No case ID"})
424
+ ensure_init()
425
+ report = run_async(report_generator.generate_report(int(case_id)))
426
+ return json.dumps(report)
427
+
428
+
429
+ def get_rules_json():
430
+ """Get rules as JSON."""
431
+ ensure_init()
432
+ return json.dumps(rule_loader.get_all_rules())
433
+
434
+
435
+ # ── Build Gradio App ──────────────────────────────────────────────────
436
+
437
+ CUSTOM_CSS = """
438
+ .gradio-container { max-width: 1200px !important; }
439
+ footer { display: none !important; }
440
+ """
441
+
442
+ with gr.Blocks(
443
+ title="TraceScene β€” AI Accident Analysis",
444
+ ) as demo:
445
+ gr.Markdown("""
446
+ # πŸš” TraceScene
447
+ ### AI-Powered Accident Scene Analysis
448
+ *GPU-accelerated inference via ZeroGPU (NVIDIA H200)*
449
+ ---
450
+ """)
451
+
452
+ with gr.Tabs():
453
+ # Tab 1: Quick Analyze (single photo)
454
+ with gr.TabItem("⚑ Quick Analyze"):
455
+ gr.Markdown("Upload a photo for instant GPU-accelerated analysis.")
456
+ with gr.Row():
457
+ with gr.Column():
458
+ input_image = gr.Image(label="Upload Accident Photo", type="pil")
459
+ quick_btn = gr.Button("πŸš€ Analyze with GPU", variant="primary")
460
+ with gr.Column():
461
+ quick_output = gr.Textbox(label="AI Analysis", lines=20)
462
+ quick_btn.click(fn=gradio_analyze_photo, inputs=[input_image], outputs=[quick_output], api_name="analyze_photo")
463
+
464
+ # Tab 2: Cases
465
+ with gr.TabItem("πŸ“‹ Cases"):
466
+ with gr.Row():
467
+ with gr.Column(scale=1):
468
+ gr.Markdown("### Create Case")
469
+ cn = gr.Textbox(label="Case Number *", placeholder="ACC-2026-001")
470
+ on = gr.Textbox(label="Officer Name")
471
+ loc = gr.Textbox(label="Location")
472
+ dt = gr.Textbox(label="Incident Date", placeholder="YYYY-MM-DD")
473
+ nt = gr.Textbox(label="Notes", lines=2)
474
+ create_btn = gr.Button("Create Case", variant="primary")
475
+ create_status = gr.Markdown()
476
+ with gr.Column(scale=2):
477
+ gr.Markdown("### Existing Cases")
478
+ cases_tbl = gr.Dataframe(
479
+ headers=["ID", "Case #", "Officer", "Location", "Date", "Status", "Photos"],
480
+ interactive=False,
481
+ )
482
+ with gr.Row():
483
+ refresh_btn = gr.Button("πŸ”„ Refresh")
484
+ del_id = gr.Number(label="Case ID to Delete", precision=0)
485
+ del_btn = gr.Button("πŸ—‘οΈ Delete", variant="stop")
486
+ del_status = gr.Markdown()
487
+ create_btn.click(create_case_fn, inputs=[cn, on, loc, dt, nt], outputs=[create_status, cases_tbl], api_name="create_case")
488
+ refresh_btn.click(list_cases_fn, outputs=[cases_tbl], api_name="list_cases")
489
+ del_btn.click(delete_case_fn, inputs=[del_id], outputs=[del_status, cases_tbl], api_name="delete_case")
490
+
491
+ # Tab 3: Upload Photos
492
+ with gr.TabItem("πŸ“Έ Photos"):
493
+ with gr.Row():
494
+ with gr.Column(scale=1):
495
+ up_case = gr.Number(label="Case ID", precision=0)
496
+ up_files = gr.File(label="Select Photos", file_count="multiple", file_types=["image"])
497
+ up_btn = gr.Button("Upload", variant="primary")
498
+ up_status = gr.Markdown()
499
+ with gr.Column(scale=2):
500
+ pv_case = gr.Number(label="Preview Case ID", precision=0)
501
+ pv_btn = gr.Button("Load Photos")
502
+ gallery = gr.Gallery(label="Photos", columns=3)
503
+ up_btn.click(upload_photos_fn, inputs=[up_case, up_files], outputs=[up_status], api_name="upload_photos")
504
+ pv_btn.click(get_case_photos_fn, inputs=[pv_case], outputs=[gallery], api_name="get_case_photos")
505
+
506
+ # Tab 4: Run Analysis
507
+ with gr.TabItem("🧠 Analysis"):
508
+ gr.Markdown("""
509
+ ### Full Analysis Pipeline (GPU-accelerated)
510
+ 1. Scene Analysis β†’ 2. Rule Matching β†’ 3. Fault Deduction
511
+ """)
512
+ an_case = gr.Number(label="Case ID", precision=0)
513
+ an_btn = gr.Button("πŸš€ Run Full Analysis", variant="primary", size="lg")
514
+ an_status = gr.Markdown()
515
+ with gr.Accordion("Scene Details", open=False):
516
+ an_detail = gr.Markdown()
517
+ an_violations = gr.Markdown(label="Violations & Fault")
518
+ an_btn.click(run_analysis_fn, inputs=[an_case], outputs=[an_status, an_detail, an_violations], api_name="run_analysis")
519
+
520
+ # Tab 5: Report
521
+ with gr.TabItem("πŸ“„ Report"):
522
+ rp_case = gr.Number(label="Case ID", precision=0)
523
+ rp_btn = gr.Button("Generate Report", variant="primary")
524
+ rp_out = gr.Markdown()
525
+ rp_btn.click(generate_report_fn, inputs=[rp_case], outputs=[rp_out], api_name="generate_report")
526
+
527
+ # Tab 6: Rules
528
+ with gr.TabItem("πŸ“œ Rules"):
529
+ ru_btn = gr.Button("Load Traffic Rules")
530
+ ru_out = gr.Markdown()
531
+ ru_btn.click(get_rules_fn, outputs=[ru_out], api_name="get_rules")
532
+
533
+ # Tab 7: Chat Q&A
534
+ with gr.TabItem("πŸ’¬ Chat"):
535
+ gr.Markdown("### Case Q&A Chatbot\nAsk questions about logged cases, traffic rules, or insurance clauses.")
536
+ with gr.Row():
537
+ chat_case_id = gr.Number(label="Case ID (optional)", precision=0)
538
+ chat_load_btn = gr.Button("πŸ“‚ Load Case Context", variant="secondary")
539
+ chat_context_status = gr.Markdown(value="*No case loaded. You can still ask general traffic/insurance questions.*")
540
+ chatbot = gr.Chatbot(label="Conversation", height=400)
541
+ chat_input = gr.Textbox(label="Your Question", placeholder="e.g. What vehicles were involved? What rules were violated?", lines=2)
542
+ with gr.Row():
543
+ chat_send_btn = gr.Button("πŸ’¬ Send", variant="primary")
544
+ chat_clear_btn = gr.Button("πŸ—‘οΈ Clear")
545
+
546
+ # State for context
547
+ chat_system_ctx = gr.State(value="You are TraceScene AI assistant. You help insurers and investigating officers analyze accident cases, traffic rules, and insurance clauses. Answer concisely and accurately based on the context provided.")
548
+
549
+ def load_chat_context(case_id):
550
+ if not case_id:
551
+ default_ctx = "You are TraceScene AI assistant. You help insurers and investigating officers analyze accident cases, traffic rules, and insurance clauses. Answer concisely and accurately.\n\n"
552
+ # Load traffic rules as general context
553
+ ensure_init()
554
+ rules_data = rule_loader.get_all_rules()
555
+ rules_text = ""
556
+ for cat in rules_data.get("categories", []):
557
+ rules_text += f"\nCategory: {cat.get('name', '')}\n"
558
+ for r in cat.get("rules", []):
559
+ rules_text += f" - {r.get('id', '')}: {r.get('title', '')} (Severity: {r.get('severity', '')})\n"
560
+ ctx = default_ctx + "TRAFFIC RULES:\n" + rules_text
561
+ return ctx, "*General mode: traffic rules loaded. Ask any question!*"
562
+
563
+ ensure_init()
564
+ case = run_async(db.get_case(int(case_id)))
565
+ if not case:
566
+ return "", f"❌ Case {int(case_id)} not found."
567
+
568
+ analyses = run_async(db.get_analyses_by_case(int(case_id)))
569
+ parties = run_async(db.get_parties_by_case(int(case_id)))
570
+ violations = run_async(db.get_violations_by_case(int(case_id)))
571
+ fault = run_async(db.get_fault_analysis(int(case_id)))
572
+ rules_data = rule_loader.get_all_rules()
573
+
574
+ ctx = f"""You are TraceScene AI assistant analyzing Case #{case.get('case_number', '')}.
575
+ Location: {case.get('location', 'Unknown')}
576
+ Date: {case.get('incident_date', 'Unknown')}
577
+ Officer: {case.get('officer_name', 'Unknown')}
578
+ Status: {case.get('status', 'Unknown')}
579
+
580
+ SCENE ANALYSES:\n"""
581
+ for a in analyses:
582
+ ctx += f"\n--- Photo Analysis ---\n{a.get('raw_analysis', '')}\n"
583
+
584
+ if parties:
585
+ ctx += "\nPARTIES IDENTIFIED:\n"
586
+ for p in parties:
587
+ ctx += f" - {p.get('label', '')}: {p.get('vehicle_type', '')} {p.get('vehicle_color', '')} β€” {p.get('vehicle_description', '')}\n"
588
+
589
+ if violations:
590
+ ctx += "\nVIOLATIONS FOUND:\n"
591
+ for v in violations:
592
+ ctx += f" - {v.get('rule_title', '')} (Severity: {v.get('severity', '')}, Confidence: {v.get('confidence', 0):.0%})\n"
593
+
594
+ if fault:
595
+ ctx += f"\nFAULT ANALYSIS:\n Primary Fault: {fault.get('primary_fault_party', 'N/A')}\n Confidence: {fault.get('overall_confidence', 0):.0%}\n Summary: {fault.get('analysis_summary', '')}\n"
596
+
597
+ # Append traffic rules
598
+ rules_text = ""
599
+ for cat in rules_data.get("categories", []):
600
+ rules_text += f"\nCategory: {cat.get('name', '')}\n"
601
+ for r in cat.get("rules", []):
602
+ rules_text += f" - {r.get('id', '')}: {r.get('title', '')} (Severity: {r.get('severity', '')})\n"
603
+ ctx += "\nTRAFFIC RULES:\n" + rules_text
604
+
605
+ return ctx, f"βœ… Case **{case.get('case_number', '')}** loaded with {len(analyses)} analyses, {len(violations)} violations."
606
+
607
+ def chat_respond(user_message, history, system_ctx):
608
+ if not user_message or not user_message.strip():
609
+ return history, "", system_ctx
610
+ ensure_init()
611
+ if not inference_engine.is_loaded:
612
+ inference_engine.load_model()
613
+ try:
614
+ # Use the vision model's text generation capability for chat
615
+ chat_prompt = f"""You are TraceScene AI assistant helping with accident analysis.
616
+
617
+ CONTEXT:
618
+ {system_ctx}
619
+
620
+ USER QUESTION: {user_message.strip()}
621
+
622
+ Provide a concise, helpful answer based on the context above."""
623
+ # Create a small blank image for the vision model
624
+ from PIL import Image as PILImg
625
+ blank = PILImg.new('RGB', (64, 64), color=(0, 0, 0))
626
+ response = gpu_run_inference(blank, chat_prompt)
627
+ except Exception as e:
628
+ response = f"Error: {e}"
629
+ history = history or []
630
+ history.append((user_message.strip(), response))
631
+ return history, "", system_ctx
632
+
633
+ chat_load_btn.click(load_chat_context, inputs=[chat_case_id], outputs=[chat_system_ctx, chat_context_status])
634
+ chat_send_btn.click(chat_respond, inputs=[chat_input, chatbot, chat_system_ctx], outputs=[chatbot, chat_input, chat_system_ctx], api_name="chat")
635
+ chat_input.submit(chat_respond, inputs=[chat_input, chatbot, chat_system_ctx], outputs=[chatbot, chat_input, chat_system_ctx])
636
+ chat_clear_btn.click(lambda: ([], ""), outputs=[chatbot, chat_input])
637
+
638
+ # Tab 8: 2D Animation
639
+ with gr.Tab("Simulation"):
640
+ gr.Markdown("### 2D Accident Simulation\nVisualize the top-down perspective of the incident.")
641
+ anim_case_id = gr.Number(label="Case ID", precision=0)
642
+ anim_btn = gr.Button("Generate Animation", variant="primary")
643
+ anim_output = gr.HTML(label="Animation View")
644
+
645
+ def generate_animation_fn(case_id):
646
+ if not case_id:
647
+ return "<p style='color:red;'>Enter a Case ID.</p>"
648
+ ensure_init()
649
+ analyses = run_async(db.get_analyses_by_case(int(case_id)))
650
+ if not analyses:
651
+ return "<p style='color:red;'>No analyses found. Run analysis first.</p>"
652
+
653
+ # Parse scene details from the first analysis
654
+ raw = analyses[0].get("raw_analysis", "")
655
+
656
+ def extract_field(text, field):
657
+ import re
658
+ pattern = rf"{re.escape(field)}:\s*(.+)"
659
+ m = re.search(pattern, text, re.IGNORECASE)
660
+ return m.group(1).strip() if m else "Unknown"
661
+
662
+ road_type = extract_field(raw, "Road Type")
663
+ num_vehicles = extract_field(raw, "Vehicles Involved")
664
+ v1_pos = extract_field(raw, "Vehicle 1 Position")
665
+ v1_tyre = extract_field(raw, "Vehicle 1 Tyre Direction")
666
+ impact = extract_field(raw, "Area of Impact")
667
+ category = extract_field(raw, "Accident Category")
668
+ v1_make = extract_field(raw, "Vehicle 1 Make/Model")
669
+
670
+ # Check for Vehicle 2
671
+ v2_pos = extract_field(raw, "Vehicle 2 Position")
672
+ v2_tyre = extract_field(raw, "Vehicle 2 Tyre Direction")
673
+ v2_make = extract_field(raw, "Vehicle 2 Make/Model")
674
+ has_v2 = v2_make != "Unknown"
675
+
676
+ # Determine colors from extracted make
677
+ import re as re_mod
678
+ def extract_color(make_str):
679
+ colors = ["Red", "Blue", "White", "Black", "Silver", "Grey", "Green", "Yellow", "Brown", "Orange"]
680
+ for c in colors:
681
+ if c.lower() in make_str.lower():
682
+ return c.lower()
683
+ return "#3b82f6"
684
+
685
+ v1_color = extract_color(v1_make)
686
+ v2_color = extract_color(v2_make) if has_v2 else "#ef4444"
687
+
688
+ # Severity affects animation speed
689
+ speed_map = {"mild": 1.5, "medium": 2.5, "critical": 4.0}
690
+ anim_speed = speed_map.get(category.lower(), 2.5)
691
+
692
+ # Road layout
693
+ road_is_intersection = "intersection" in road_type.lower()
694
+ road_is_highway = "highway" in road_type.lower()
695
+
696
+ num_v = 1
697
+ try:
698
+ num_v = int(num_vehicles)
699
+ except:
700
+ pass
701
+ # Unique ID to force Gradio to re-render on each click (enables replay)
702
+ import random
703
+ uid = random.randint(10000, 99999)
704
+ # Determine animation duration based on severity
705
+ dur = "3s" if category.lower() == "mild" else "2s" if category.lower() == "medium" else "1.5s"
706
+ sev_color = "#22c55e" if category.lower() == "mild" else "#f59e0b" if category.lower() == "medium" else "#ef4444"
707
+
708
+ # Build SVG road
709
+ if road_is_intersection:
710
+ road_svg = '''
711
+ <rect x="0" y="160" width="700" height="100" fill="#555"/>
712
+ <rect x="300" y="0" width="100" height="420" fill="#555"/>
713
+ <line x1="0" y1="210" x2="300" y2="210" stroke="#fbbf24" stroke-width="2" stroke-dasharray="20,15"/>
714
+ <line x1="400" y1="210" x2="700" y2="210" stroke="#fbbf24" stroke-width="2" stroke-dasharray="20,15"/>
715
+ <line x1="350" y1="0" x2="350" y2="160" stroke="#fbbf24" stroke-width="2" stroke-dasharray="20,15"/>
716
+ <line x1="350" y1="260" x2="350" y2="420" stroke="#fbbf24" stroke-width="2" stroke-dasharray="20,15"/>
717
+ '''
718
+ else:
719
+ road_svg = '''
720
+ <rect x="0" y="150" width="700" height="120" fill="#555" rx="2"/>
721
+ <line x1="0" y1="210" x2="700" y2="210" stroke="#fbbf24" stroke-width="2" stroke-dasharray="20,15"/>
722
+ <line x1="0" y1="150" x2="700" y2="150" stroke="white" stroke-width="2"/>
723
+ <line x1="0" y1="270" x2="700" y2="270" stroke="white" stroke-width="2"/>
724
+ '''
725
+
726
+ # Vehicle 2 SVG (if present)
727
+ v2_svg = ""
728
+ if has_v2:
729
+ if road_is_intersection:
730
+ v2_svg = f'''<g>
731
+ <animateTransform attributeName="transform" type="translate" from="0,0" to="0,135" dur="{dur}" fill="freeze"/>
732
+ <rect x="325" y="60" width="50" height="26" rx="5" fill="{v2_color}" stroke="#fff" stroke-width="1"/>
733
+ <text x="350" y="78" fill="white" font-size="10" font-weight="bold" text-anchor="middle">V2</text>
734
+ </g>'''
735
+ else:
736
+ v2_svg = f'''<g>
737
+ <animateTransform attributeName="transform" type="translate" from="0,0" to="-200,0" dur="{dur}" fill="freeze"/>
738
+ <rect x="560" y="215" width="50" height="26" rx="5" fill="{v2_color}" stroke="#fff" stroke-width="1"/>
739
+ <text x="585" y="233" fill="white" font-size="10" font-weight="bold" text-anchor="middle">V2</text>
740
+ </g>'''
741
+
742
+ html = f'''
743
+ <div style="text-align:center; font-family: Inter, Arial, sans-serif;">
744
+ <svg id="anim_{uid}" width="700" height="420" viewBox="0 0 700 420" xmlns="http://www.w3.org/2000/svg" style="border:1px solid #444; border-radius:10px; background:#1a1a2e;">
745
+ <defs>
746
+ <radialGradient id="glow_{uid}" cx="50%" cy="50%" r="50%">
747
+ <stop offset="0%" stop-color="#fbbf24" stop-opacity="0.8"/>
748
+ <stop offset="100%" stop-color="#fbbf24" stop-opacity="0"/>
749
+ </radialGradient>
750
+ </defs>
751
+
752
+ {road_svg}
753
+
754
+ <!-- Vehicle 1 -->
755
+ <g>
756
+ <animateTransform attributeName="transform" type="translate" from="0,0" to="200,0" dur="{dur}" fill="freeze"/>
757
+ <rect x="80" y="190" width="50" height="26" rx="5" fill="{v1_color}" stroke="#fff" stroke-width="1"/>
758
+ <text x="105" y="207" fill="white" font-size="10" font-weight="bold" text-anchor="middle">V1</text>
759
+ </g>
760
+
761
+ {v2_svg}
762
+
763
+ <!-- Impact flash -->
764
+ <circle cx="340" cy="210" r="0" fill="url(#glow_{uid})">
765
+ <animate attributeName="r" values="0;0;0;0;0;0;0;45;55;0" dur="{dur}" fill="freeze"/>
766
+ <animate attributeName="opacity" values="0;0;0;0;0;0;0;1;0.5;0" dur="{dur}" fill="freeze"/>
767
+ </circle>
768
+
769
+ <!-- Debris -->
770
+ <circle cx="340" cy="210" r="3" fill="#fbbf24" opacity="0">
771
+ <animate attributeName="opacity" values="0;0;0;0;0;0;0;1;0" dur="{dur}" fill="freeze"/>
772
+ <animate attributeName="cx" values="340;340;340;340;340;340;340;310;290" dur="{dur}" fill="freeze"/>
773
+ <animate attributeName="cy" values="210;210;210;210;210;210;210;185;170" dur="{dur}" fill="freeze"/>
774
+ </circle>
775
+ <circle cx="340" cy="210" r="2" fill="#ef4444" opacity="0">
776
+ <animate attributeName="opacity" values="0;0;0;0;0;0;0;1;0" dur="{dur}" fill="freeze"/>
777
+ <animate attributeName="cx" values="340;340;340;340;340;340;340;370;395" dur="{dur}" fill="freeze"/>
778
+ <animate attributeName="cy" values="210;210;210;210;210;210;210;190;175" dur="{dur}" fill="freeze"/>
779
+ </circle>
780
+ <circle cx="340" cy="210" r="3" fill="#e2e8f0" opacity="0">
781
+ <animate attributeName="opacity" values="0;0;0;0;0;0;0;1;0" dur="{dur}" fill="freeze"/>
782
+ <animate attributeName="cx" values="340;340;340;340;340;340;340;320;305" dur="{dur}" fill="freeze"/>
783
+ <animate attributeName="cy" values="210;210;210;210;210;210;210;235;255" dur="{dur}" fill="freeze"/>
784
+ </circle>
785
+ <circle cx="340" cy="210" r="2" fill="#f97316" opacity="0">
786
+ <animate attributeName="opacity" values="0;0;0;0;0;0;0;1;0" dur="{dur}" fill="freeze"/>
787
+ <animate attributeName="cx" values="340;340;340;340;340;340;340;365;385" dur="{dur}" fill="freeze"/>
788
+ <animate attributeName="cy" values="210;210;210;210;210;210;210;230;250" dur="{dur}" fill="freeze"/>
789
+ </circle>
790
+
791
+ <!-- Collision label -->
792
+ <text x="350" y="145" fill="#ef4444" font-size="18" font-weight="bold" text-anchor="middle" opacity="0" font-family="Inter, Arial, sans-serif">
793
+ COLLISION
794
+ <animate attributeName="opacity" values="0;0;0;0;0;0;0;1;1" dur="{dur}" fill="freeze"/>
795
+ </text>
796
+
797
+ <!-- HUD -->
798
+ <rect x="10" y="350" width="680" height="60" rx="8" fill="rgba(0,0,0,0.6)"/>
799
+ <text x="20" y="375" fill="#e2e8f0" font-size="12" font-family="Inter, Arial, sans-serif">{v1_make[:35]}</text>
800
+ <text x="20" y="398" fill="#e2e8f0" font-size="12" font-family="Inter, Arial, sans-serif">{"" if not has_v2 else v2_make[:35]}{"Single vehicle accident" if not has_v2 else ""}</text>
801
+ <text x="680" y="375" fill="{sev_color}" font-size="14" font-weight="bold" text-anchor="end" font-family="Inter, Arial, sans-serif">{category.upper()}</text>
802
+ <text x="680" y="398" fill="#94a3b8" font-size="11" text-anchor="end" font-family="Inter, Arial, sans-serif">Impact: {impact} | Road: {road_type}</text>
803
+ </svg>
804
+ <div style="margin-top:8px; color:#94a3b8; font-size:12px;">
805
+ Vehicles: {num_v} | Animation auto-plays on load
806
+ </div>
807
+ </div>
808
+ '''
809
+ return html
810
+
811
+ anim_btn.click(generate_animation_fn, inputs=[anim_case_id], outputs=[anim_output])
812
+
813
+
814
+
815
+ anim_btn.click(generate_animation_fn, inputs=[anim_case_id], outputs=[anim_output])
816
+
817
+ # Hidden API-only endpoints (for @gradio/client from custom frontend)
818
+ with gr.TabItem("πŸ”Œ API", visible=False):
819
+ api_health_btn = gr.Button("health")
820
+ api_health_out = gr.Textbox()
821
+ api_health_btn.click(health_fn, outputs=[api_health_out], api_name="health")
822
+
823
+ api_cases_btn = gr.Button("list_cases_json")
824
+ api_cases_out = gr.Textbox()
825
+ api_cases_btn.click(list_cases_json, outputs=[api_cases_out], api_name="list_cases_json")
826
+
827
+ api_case_id = gr.Number(precision=0)
828
+ api_case_btn = gr.Button("get_case")
829
+ api_case_out = gr.Textbox()
830
+ api_case_btn.click(get_case_json, inputs=[api_case_id], outputs=[api_case_out], api_name="get_case")
831
+
832
+ api_report_id = gr.Number(precision=0)
833
+ api_report_btn = gr.Button("get_report")
834
+ api_report_out = gr.Textbox()
835
+ api_report_btn.click(get_report_json, inputs=[api_report_id], outputs=[api_report_out], api_name="get_report_json")
836
+
837
+ api_rules_btn = gr.Button("get_rules_json")
838
+ api_rules_out = gr.Textbox()
839
+ api_rules_btn.click(get_rules_json, outputs=[api_rules_out], api_name="get_rules_json")
840
+
841
+ gr.Markdown("---\n*TraceScene β€” Built by Siddharth Ravikumar | tracescene@zohomail.ae*")
842
+
843
+
844
+ # ── Create FastAPI App & Mount Gradio ────────────────────────────────
845
+
846
+ app = FastAPI()
847
+
848
+ app.add_middleware(
849
+ CORSMiddleware,
850
+ allow_origins=["*"],
851
+ allow_credentials=True,
852
+ allow_methods=["*"],
853
+ allow_headers=["*"],
854
+ )
855
+
856
+ # Static files (frontend)
857
+ frontend_dir = Path(__file__).resolve().parent / "frontend"
858
+ if frontend_dir.exists():
859
+ app.mount("/static", StaticFiles(directory=str(frontend_dir)), name="static")
860
+
861
+ @app.get("/")
862
+ async def serve_frontend():
863
+ index_file = frontend_dir / "index.html"
864
+ if index_file.exists():
865
+ return FileResponse(str(index_file))
866
+ return {"message": "TraceScene API", "docs": "/docs"}
867
+
868
+ # API Routes
869
+ app.include_router(router, prefix="/api")
870
+
871
+ # Serve uploads folder manually too
872
+ @app.get("/uploads/{path:path}")
873
+ async def serve_upload(path: str):
874
+ filepath = settings.upload_path / path
875
+ if not filepath.exists():
876
+ from fastapi import HTTPException
877
+ raise HTTPException(404, "File not found")
878
+ return FileResponse(str(filepath))
879
+
880
+ # Mount Gradio app at /gradio
881
+ # Note: Since ZeroGPU uses the main app module, we must export `app` itself as the Gradio wrapper. HF Spaces uses app.py
882
+ app = gr.mount_gradio_app(
883
+ app,
884
+ demo,
885
+ path="/gradio"
886
+ )
887
+
888
+ # Startup event wrapper
889
+ @app.on_event("startup")
890
+ async def startup_event():
891
+ logger.info("Starting up FastAPI application...")
892
+ await _ensure_init()
893
+
894
+ if __name__ == "__main__":
895
+ import uvicorn
896
+ uvicorn.run(app, host="0.0.0.0", port=7860)
backend/__init__.py ADDED
File without changes
backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """AI Accident Analysis β€” Backend Application"""
backend/app/api/__init__.py ADDED
File without changes
backend/app/api/routes.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” REST API Routes
3
+
4
+ All endpoints for case management, photo upload, analysis, and reporting.
5
+ """
6
+
7
+ import os
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
14
+ from fastapi.responses import JSONResponse, FileResponse
15
+
16
+ from backend.app.config import settings
17
+ from backend.app.db.database import db
18
+ from backend.app.core.inference import inference_engine
19
+ from backend.app.core.scene_analyzer import scene_analyzer
20
+ from backend.app.core.rule_matcher import rule_matcher
21
+ from backend.app.core.fault_deducer import fault_deducer
22
+ from backend.app.core.report_generator import report_generator
23
+ from backend.app.rules.rule_loader import rule_loader
24
+ from backend.app.utils.logger import get_logger
25
+
26
+ logger = get_logger("api")
27
+
28
+ router = APIRouter(prefix="/api")
29
+
30
+
31
+ # ── Health ─────────────────────────────────────────────────────────────
32
+
33
+ @router.get("/health")
34
+ async def health():
35
+ """Health check with model and rules status."""
36
+ return {
37
+ "status": "ok",
38
+ "model_loaded": inference_engine.is_loaded,
39
+ "model_id": settings.model_id if inference_engine.is_loaded else None,
40
+ "device": inference_engine._device if inference_engine.is_loaded else None,
41
+ "rules_loaded": len(rule_loader.get_all_rules()),
42
+ }
43
+
44
+
45
+ # ── Cases ──────────────────────────────────────────────────────────────
46
+
47
+ @router.post("/cases")
48
+ async def create_case(
49
+ case_number: str = Form(...),
50
+ officer_name: Optional[str] = Form(None),
51
+ location: Optional[str] = Form(None),
52
+ incident_date: Optional[str] = Form(None),
53
+ notes: Optional[str] = Form(None),
54
+ ):
55
+ """Create a new accident case."""
56
+ try:
57
+ case_id = await db.create_case(
58
+ case_number=case_number,
59
+ officer_name=officer_name,
60
+ location=location,
61
+ incident_date=incident_date,
62
+ notes=notes,
63
+ )
64
+ case = await db.get_case(case_id)
65
+ logger.info(f"Created case {case_id}: {case_number}")
66
+ return {"id": case_id, "case": case}
67
+ except Exception as e:
68
+ if "UNIQUE constraint" in str(e):
69
+ raise HTTPException(400, f"Case number '{case_number}' already exists")
70
+ raise HTTPException(500, str(e))
71
+
72
+
73
+ @router.get("/cases")
74
+ async def list_cases():
75
+ """List all cases with photo counts."""
76
+ cases = await db.list_cases()
77
+ # Add photo counts
78
+ for case in cases:
79
+ photos = await db.get_photos_by_case(case["id"])
80
+ case["photo_count"] = len(photos)
81
+ return {"cases": cases}
82
+
83
+
84
+ @router.get("/cases/{case_id}")
85
+ async def get_case(case_id: int):
86
+ """Get case details with photos and analysis status."""
87
+ case = await db.get_case(case_id)
88
+ if not case:
89
+ raise HTTPException(404, "Case not found")
90
+
91
+ photos = await db.get_photos_by_case(case_id)
92
+ analyses = await db.get_analyses_by_case(case_id)
93
+ parties = await db.get_parties_by_case(case_id)
94
+ violations = await db.get_violations_by_case(case_id)
95
+ fault = await db.get_fault_analysis(case_id)
96
+
97
+ return {
98
+ "case": case,
99
+ "photos": photos,
100
+ "analyses": analyses,
101
+ "parties": parties,
102
+ "violations": violations,
103
+ "fault_analysis": fault,
104
+ "stats": {
105
+ "total_photos": len(photos),
106
+ "analyzed_photos": len(analyses),
107
+ "violations_found": len(violations),
108
+ "parties_identified": len(parties),
109
+ },
110
+ }
111
+
112
+
113
+ @router.delete("/cases/{case_id}")
114
+ async def delete_case(case_id: int):
115
+ """Delete a case and all associated data."""
116
+ case = await db.get_case(case_id)
117
+ if not case:
118
+ raise HTTPException(404, "Case not found")
119
+
120
+ # Delete uploaded photos from disk
121
+ photos = await db.get_photos_by_case(case_id)
122
+ for photo in photos:
123
+ try:
124
+ filepath = Path(photo["filepath"])
125
+ if filepath.exists():
126
+ filepath.unlink()
127
+ except Exception as e:
128
+ logger.warning(f"Failed to delete photo file: {e}")
129
+
130
+ await db.delete_case(case_id)
131
+ logger.info(f"Deleted case {case_id}")
132
+ return {"message": f"Case {case_id} deleted"}
133
+
134
+
135
+ @router.put("/cases/{case_id}")
136
+ async def update_case(
137
+ case_id: int,
138
+ officer_name: Optional[str] = Form(None),
139
+ location: Optional[str] = Form(None),
140
+ incident_date: Optional[str] = Form(None),
141
+ notes: Optional[str] = Form(None),
142
+ ):
143
+ """Update case details."""
144
+ case = await db.get_case(case_id)
145
+ if not case:
146
+ raise HTTPException(404, "Case not found")
147
+
148
+ await db.update_case(
149
+ case_id=case_id,
150
+ officer_name=officer_name,
151
+ location=location,
152
+ incident_date=incident_date,
153
+ notes=notes,
154
+ )
155
+ updated_case = await db.get_case(case_id)
156
+ logger.info(f"Updated case {case_id}")
157
+ return {"message": "Case updated", "case": updated_case}
158
+
159
+
160
+ # ── Photos ─────────────────────────────────────────────────────────────
161
+
162
+ @router.post("/cases/{case_id}/photos")
163
+ async def upload_photos(
164
+ case_id: int,
165
+ files: list[UploadFile] = File(...),
166
+ ):
167
+ """Upload one or more photos to a case."""
168
+ case = await db.get_case(case_id)
169
+ if not case:
170
+ raise HTTPException(404, "Case not found")
171
+
172
+ # Check photo limit
173
+ existing = await db.get_photos_by_case(case_id)
174
+ if len(existing) + len(files) > settings.max_photos_per_case:
175
+ raise HTTPException(
176
+ 400,
177
+ f"Maximum {settings.max_photos_per_case} photos per case. "
178
+ f"Already have {len(existing)}."
179
+ )
180
+
181
+ # Create case-specific upload directory
182
+ case_dir = settings.upload_path / f"case_{case_id}"
183
+ case_dir.mkdir(parents=True, exist_ok=True)
184
+
185
+ uploaded = []
186
+ for upload_file in files:
187
+ # Validate extension
188
+ ext = upload_file.filename.rsplit(".", 1)[-1].lower() if "." in upload_file.filename else ""
189
+ if ext not in settings.allowed_extensions_list:
190
+ logger.warning(f"Rejected file: {upload_file.filename} (invalid extension)")
191
+ continue
192
+
193
+ # Read file
194
+ content = await upload_file.read()
195
+ file_size = len(content)
196
+
197
+ # Check size
198
+ if file_size > settings.max_file_size_bytes:
199
+ logger.warning(f"Rejected file: {upload_file.filename} (too large: {file_size})")
200
+ continue
201
+
202
+ # Save file
203
+ file_hash = hashlib.md5(content).hexdigest()[:12]
204
+ safe_name = f"{file_hash}_{upload_file.filename}"
205
+ filepath = case_dir / safe_name
206
+
207
+ with open(filepath, "wb") as f:
208
+ f.write(content)
209
+
210
+ # Get image dimensions
211
+ width, height = None, None
212
+ try:
213
+ from PIL import Image
214
+ img = Image.open(filepath)
215
+ width, height = img.size
216
+ except Exception:
217
+ pass
218
+
219
+ # Store in DB
220
+ photo_id = await db.add_photo(
221
+ case_id=case_id,
222
+ filename=upload_file.filename,
223
+ filepath=str(filepath),
224
+ file_size=file_size,
225
+ width=width,
226
+ height=height,
227
+ )
228
+
229
+ uploaded.append({
230
+ "id": photo_id,
231
+ "filename": upload_file.filename,
232
+ "size": file_size,
233
+ "width": width,
234
+ "height": height,
235
+ })
236
+
237
+ logger.info(f"Uploaded {len(uploaded)} photos to case {case_id}")
238
+ return {"uploaded": uploaded, "count": len(uploaded)}
239
+
240
+
241
+ @router.get("/photos/{photo_id}/image")
242
+ async def get_photo_image(photo_id: int):
243
+ """Serve a photo image file."""
244
+ photo = await db.get_photo(photo_id)
245
+ if not photo:
246
+ raise HTTPException(404, "Photo not found")
247
+
248
+ filepath = Path(photo["filepath"])
249
+ if not filepath.exists():
250
+ raise HTTPException(404, "Photo file not found on disk")
251
+
252
+ return FileResponse(str(filepath), media_type="image/jpeg")
253
+
254
+
255
+ # ── Analysis ───────────────────────────────────────────────────────────
256
+
257
+ @router.post("/cases/{case_id}/analyze")
258
+ async def analyze_case(case_id: int):
259
+ """
260
+ Trigger full analysis pipeline for a case:
261
+ 1. Analyze all unanalyzed photos (scene analysis)
262
+ 2. Match observations against traffic rules
263
+ 3. Deduce fault
264
+ """
265
+ case = await db.get_case(case_id)
266
+ if not case:
267
+ raise HTTPException(404, "Case not found")
268
+
269
+ if not inference_engine.is_loaded:
270
+ raise HTTPException(503, "Model not loaded yet. Please wait for startup to complete.")
271
+
272
+ photos = await db.get_photos_by_case(case_id)
273
+ if not photos:
274
+ raise HTTPException(400, "No photos uploaded for this case")
275
+
276
+ try:
277
+ # Step 1: Scene analysis
278
+ logger.info(f"[Case {case_id}] Step 1: Scene analysis...")
279
+ analysis_result = await scene_analyzer.analyze_case(case_id)
280
+
281
+ # Step 2: Rule matching
282
+ logger.info(f"[Case {case_id}] Step 2: Rule matching...")
283
+ violations = await rule_matcher.match_violations(case_id)
284
+
285
+ # Step 3: Fault deduction
286
+ logger.info(f"[Case {case_id}] Step 3: Fault deduction...")
287
+ fault_result = await fault_deducer.deduce_fault(case_id)
288
+
289
+ # Update case status
290
+ await db.update_case_status(case_id, "complete")
291
+
292
+ return {
293
+ "status": "complete",
294
+ "analysis": analysis_result,
295
+ "violations_found": len(violations),
296
+ "fault_analysis": fault_result,
297
+ }
298
+
299
+ except Exception as e:
300
+ logger.error(f"Analysis failed for case {case_id}: {e}")
301
+ await db.update_case_status(case_id, "error")
302
+ raise HTTPException(500, f"Analysis failed: {str(e)}")
303
+
304
+
305
+ @router.get("/cases/{case_id}/report")
306
+ async def get_report(case_id: int):
307
+ """Get the full generated incident report."""
308
+ case = await db.get_case(case_id)
309
+ if not case:
310
+ raise HTTPException(404, "Case not found")
311
+
312
+ report = await report_generator.generate_report(case_id)
313
+ return report
314
+
315
+
316
+ @router.get("/cases/{case_id}/status")
317
+ async def get_analysis_status(case_id: int):
318
+ """Get analysis progress for a case."""
319
+ case = await db.get_case(case_id)
320
+ if not case:
321
+ raise HTTPException(404, "Case not found")
322
+
323
+ photos = await db.get_photos_by_case(case_id)
324
+ analyses = await db.get_analyses_by_case(case_id)
325
+ violations = await db.get_violations_by_case(case_id)
326
+
327
+ total = len(photos)
328
+ analyzed = len(analyses)
329
+
330
+ return {
331
+ "case_id": case_id,
332
+ "status": case["status"],
333
+ "total_photos": total,
334
+ "analyzed_photos": analyzed,
335
+ "violations_found": len(violations),
336
+ "progress_percent": round(analyzed / max(total, 1) * 100, 1),
337
+ }
338
+
339
+
340
+ # ── Rules ──────────────────────────────────────────────────────────────
341
+
342
+ @router.get("/rules")
343
+ async def get_rules():
344
+ """Get all traffic rules organized by category."""
345
+ return rule_loader.get_rules_summary()
346
+
347
+
348
+ @router.get("/rules/{category_id}")
349
+ async def get_rules_by_category(category_id: str):
350
+ """Get rules for a specific category."""
351
+ rules = rule_loader.get_rules_by_category(category_id.upper())
352
+ if not rules:
353
+ raise HTTPException(404, f"Category '{category_id}' not found")
354
+ return {
355
+ "category": category_id.upper(),
356
+ "rules": [
357
+ {
358
+ "id": r.id,
359
+ "title": r.title,
360
+ "description": r.description,
361
+ "severity": r.severity,
362
+ "visual_indicators": r.visual_indicators,
363
+ "fault_weight": r.fault_weight,
364
+ }
365
+ for r in rules
366
+ ],
367
+ }
backend/app/config.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Application Configuration
3
+
4
+ All settings are loaded from environment variables / .env file.
5
+ Reuses the same pydantic-settings pattern as PhotoSearchApp.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ from pydantic_settings import BaseSettings
13
+ from pydantic import Field
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ """Central configuration β€” all values come from .env or environment."""
18
+
19
+ # --- Model ---
20
+ model_id: str = Field(default="LiquidAI/LFM2-VL-3B", description="HuggingFace model ID for vision")
21
+ chat_model_id: str = Field(default="LiquidAI/LFM2.5-1.2B-Instruct", description="HuggingFace model ID for text chatbot")
22
+ model_torch_dtype: str = Field(default="bfloat16", description="Torch dtype: bfloat16, float16, float32")
23
+ model_max_new_tokens: int = Field(default=1024, description="Max tokens β€” higher for detailed accident analysis")
24
+ model_repetition_penalty: float = Field(default=1.2, description="Penalty for repeating tokens")
25
+ model_temperature: float = Field(default=0.3, description="Low temperature for deterministic analysis")
26
+ model_trust_remote_code: bool = Field(default=True, description="Trust remote code in model repo")
27
+
28
+ # --- Device ---
29
+ device: str = Field(default="auto", description="Device: auto, mps, cuda, cpu")
30
+
31
+ # --- Server ---
32
+ host: str = Field(default="0.0.0.0")
33
+ port: int = Field(default=8001)
34
+ debug: bool = Field(default=True)
35
+ workers: int = Field(default=1)
36
+
37
+ # --- Upload ---
38
+ max_file_size_mb: int = Field(default=15, description="Max single file size in MB")
39
+ max_photos_per_case: int = Field(default=20, description="Max photos per accident case")
40
+ allowed_extensions: str = Field(default="jpg,jpeg,png,webp", description="Comma-separated allowed extensions")
41
+
42
+ # --- Analysis ---
43
+ confidence_threshold: float = Field(default=0.6, description="Min confidence for rule violation match")
44
+ fault_min_violations: int = Field(default=1, description="Min violations needed to assign fault")
45
+
46
+ # --- Security ---
47
+ cors_origins: str = Field(default="http://localhost:8001,http://127.0.0.1:8001")
48
+
49
+ # --- Logging ---
50
+ log_level: str = Field(default="INFO")
51
+ log_file_max_bytes: int = Field(default=10485760) # 10MB
52
+ log_file_backup_count: int = Field(default=5)
53
+
54
+ # --- Paths ---
55
+ upload_dir: str = Field(default="uploads")
56
+ data_dir: str = Field(default="data")
57
+ log_dir: str = Field(default="logs")
58
+ rules_dir: str = Field(default="backend/app/rules")
59
+
60
+ class Config:
61
+ env_file = ".env"
62
+ env_file_encoding = "utf-8"
63
+ case_sensitive = False
64
+ extra = "allow"
65
+
66
+ # --- Derived properties ---
67
+
68
+ @property
69
+ def base_path(self) -> Path:
70
+ """Project root directory."""
71
+ return Path(__file__).resolve().parent.parent.parent
72
+
73
+ @property
74
+ def upload_path(self) -> Path:
75
+ p = self.base_path / self.upload_dir
76
+ p.mkdir(parents=True, exist_ok=True)
77
+ return p
78
+
79
+ @property
80
+ def data_path(self) -> Path:
81
+ p = self.base_path / self.data_dir
82
+ p.mkdir(parents=True, exist_ok=True)
83
+ return p
84
+
85
+ @property
86
+ def log_path(self) -> Path:
87
+ p = self.base_path / self.log_dir
88
+ p.mkdir(parents=True, exist_ok=True)
89
+ return p
90
+
91
+ @property
92
+ def db_path(self) -> Path:
93
+ return self.data_path / "accident_analysis.db"
94
+
95
+ @property
96
+ def rules_path(self) -> Path:
97
+ return self.base_path / self.rules_dir
98
+
99
+ @property
100
+ def allowed_extensions_list(self) -> List[str]:
101
+ return [ext.strip().lower() for ext in self.allowed_extensions.split(",")]
102
+
103
+ @property
104
+ def cors_origins_list(self) -> List[str]:
105
+ return [origin.strip() for origin in self.cors_origins.split(",")]
106
+
107
+ @property
108
+ def max_file_size_bytes(self) -> int:
109
+ return self.max_file_size_mb * 1024 * 1024
110
+
111
+ def resolve_device(self) -> str:
112
+ """Resolve 'auto' device to the best available: MPS (M3) > CUDA > CPU."""
113
+ import torch
114
+ if self.device != "auto":
115
+ return self.device
116
+ if torch.backends.mps.is_available():
117
+ return "mps"
118
+ if torch.cuda.is_available():
119
+ return "cuda"
120
+ return "cpu"
121
+
122
+ def resolve_torch_dtype(self):
123
+ """Convert string dtype to torch dtype object."""
124
+ import torch
125
+ dtype_map = {
126
+ "bfloat16": torch.bfloat16,
127
+ "float16": torch.float16,
128
+ "float32": torch.float32,
129
+ }
130
+ return dtype_map.get(self.model_torch_dtype, torch.bfloat16)
131
+
132
+
133
+ # Singleton settings instance
134
+ settings = Settings()
backend/app/core/__init__.py ADDED
File without changes
backend/app/core/fault_deducer.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Fault Deducer
3
+
4
+ Determines probable cause and fault assignment based on matched
5
+ violations, their severity weights, and physical evidence.
6
+ """
7
+
8
+ import json
9
+ from typing import List, Dict, Optional, Tuple
10
+
11
+ from backend.app.db.database import db
12
+ from backend.app.rules.rule_loader import rule_loader
13
+ from backend.app.utils.logger import get_logger
14
+
15
+ logger = get_logger("fault_deducer")
16
+
17
+ # Severity multipliers for fault scoring
18
+ SEVERITY_MULTIPLIER = {
19
+ "CRITICAL": 3.0,
20
+ "HIGH": 2.0,
21
+ "MEDIUM": 1.0,
22
+ "LOW": 0.5,
23
+ }
24
+
25
+
26
+ class FaultDeducer:
27
+ """
28
+ Determines probable cause and fault assignment.
29
+
30
+ Scoring algorithm:
31
+ fault_score(party) = Ξ£ (violation.confidence Γ— violation.fault_weight Γ— severity_multiplier)
32
+
33
+ The party with the highest fault score is assigned primary fault.
34
+ Fault percentages are computed as relative scores.
35
+ """
36
+
37
+ async def deduce_fault(self, case_id: int) -> Dict:
38
+ """
39
+ Analyze violations and evidence to determine fault.
40
+ Returns fault analysis and saves to database.
41
+ """
42
+ logger.info(f"Starting fault deduction for case {case_id}")
43
+
44
+ violations = await db.get_violations_by_case(case_id)
45
+ parties = await db.get_parties_by_case(case_id)
46
+ analyses = await db.get_analyses_by_case(case_id)
47
+
48
+ if not violations:
49
+ result = self._no_violations_result(case_id, parties)
50
+ await self._save_result(case_id, result)
51
+ return result
52
+
53
+ if not parties:
54
+ result = self._no_parties_result(case_id, violations)
55
+ await self._save_result(case_id, result)
56
+ return result
57
+
58
+ # Calculate fault scores per party
59
+ party_scores = self._calculate_fault_scores(violations, parties)
60
+
61
+ # Determine fault distribution (percentages)
62
+ fault_distribution = self._compute_distribution(party_scores, parties)
63
+
64
+ # Find primary fault party
65
+ primary_party_id, primary_score = max(
66
+ party_scores.items(), key=lambda x: x[1]
67
+ ) if party_scores else (None, 0)
68
+
69
+ # Generate probable cause narrative
70
+ probable_cause = self._generate_probable_cause(
71
+ violations, parties, party_scores, analyses
72
+ )
73
+
74
+ # Generate summary
75
+ analysis_summary = self._generate_summary(
76
+ violations, parties, fault_distribution, primary_party_id
77
+ )
78
+
79
+ # Overall confidence = weighted average of violation confidences
80
+ total_confidence_weight = sum(
81
+ v["confidence"] * SEVERITY_MULTIPLIER.get(v.get("severity", "MEDIUM"), 1.0)
82
+ for v in violations
83
+ )
84
+ total_weight = sum(
85
+ SEVERITY_MULTIPLIER.get(v.get("severity", "MEDIUM"), 1.0)
86
+ for v in violations
87
+ )
88
+ overall_confidence = round(
89
+ total_confidence_weight / max(total_weight, 1), 3
90
+ )
91
+
92
+ result = {
93
+ "case_id": case_id,
94
+ "primary_fault_party_id": primary_party_id,
95
+ "primary_fault_party_label": next(
96
+ (p["label"] for p in parties if p["id"] == primary_party_id), None
97
+ ),
98
+ "fault_distribution": fault_distribution,
99
+ "probable_cause": probable_cause,
100
+ "overall_confidence": overall_confidence,
101
+ "analysis_summary": analysis_summary,
102
+ "violation_count": len(violations),
103
+ "party_scores": {
104
+ next((p["label"] for p in parties if p["id"] == pid), f"Party {pid}"): round(score, 3)
105
+ for pid, score in party_scores.items()
106
+ },
107
+ }
108
+
109
+ await self._save_result(case_id, result)
110
+
111
+ logger.info(
112
+ f"Case {case_id} fault analysis: primary fault = "
113
+ f"{result['primary_fault_party_label']}, "
114
+ f"confidence = {overall_confidence}, "
115
+ f"{len(violations)} violations"
116
+ )
117
+
118
+ return result
119
+
120
+ def _calculate_fault_scores(
121
+ self, violations: List[dict], parties: List[dict]
122
+ ) -> Dict[int, float]:
123
+ """
124
+ Calculate fault scores per party.
125
+ Score = Ξ£ (confidence Γ— fault_weight Γ— severity_multiplier)
126
+ """
127
+ scores = {p["id"]: 0.0 for p in parties}
128
+
129
+ for violation in violations:
130
+ party_id = violation.get("party_id")
131
+ if party_id is None or party_id not in scores:
132
+ continue
133
+
134
+ rule = rule_loader.get_rule_by_id(violation["rule_id"])
135
+ fault_weight = rule.fault_weight if rule else 0.5
136
+ severity = violation.get("severity", "MEDIUM")
137
+ multiplier = SEVERITY_MULTIPLIER.get(severity, 1.0)
138
+
139
+ score = violation["confidence"] * fault_weight * multiplier
140
+ scores[party_id] += score
141
+
142
+ return scores
143
+
144
+ def _compute_distribution(
145
+ self, party_scores: Dict[int, float], parties: List[dict]
146
+ ) -> Dict[str, float]:
147
+ """Convert raw scores to fault percentage distribution."""
148
+ total = sum(party_scores.values())
149
+ if total == 0:
150
+ # Equal distribution
151
+ n = len(parties)
152
+ return {
153
+ p["label"]: round(100.0 / n, 1) for p in parties
154
+ }
155
+
156
+ distribution = {}
157
+ for party in parties:
158
+ score = party_scores.get(party["id"], 0)
159
+ pct = round((score / total) * 100, 1)
160
+ distribution[party["label"]] = pct
161
+
162
+ return distribution
163
+
164
+ def _generate_probable_cause(
165
+ self, violations: List[dict], parties: List[dict],
166
+ party_scores: Dict[int, float], analyses: List[dict]
167
+ ) -> str:
168
+ """Generate a human-readable probable cause narrative."""
169
+ if not violations:
170
+ return "Insufficient evidence to determine probable cause."
171
+
172
+ # Group violations by party
173
+ party_violations = {}
174
+ for v in violations:
175
+ party_id = v.get("party_id")
176
+ party_label = v.get("party_label", "Unknown")
177
+ if party_label not in party_violations:
178
+ party_violations[party_label] = []
179
+ party_violations[party_label].append(v)
180
+
181
+ # Build narrative
182
+ parts = ["Based on the analysis of accident scene photographs:"]
183
+
184
+ for party_label, p_violations in party_violations.items():
185
+ critical = [v for v in p_violations if v.get("severity") == "CRITICAL"]
186
+ high = [v for v in p_violations if v.get("severity") == "HIGH"]
187
+ other = [v for v in p_violations
188
+ if v.get("severity") not in ("CRITICAL", "HIGH")]
189
+
190
+ violation_desc = []
191
+ for v in (critical + high + other)[:5]: # Top 5 violations
192
+ violation_desc.append(
193
+ f"{v['rule_title']} ({v.get('severity', 'MEDIUM')}, "
194
+ f"confidence: {v['confidence']:.0%})"
195
+ )
196
+
197
+ parts.append(
198
+ f"\n{party_label} was found to have the following violations: "
199
+ + "; ".join(violation_desc) + "."
200
+ )
201
+
202
+ # Add conclusion
203
+ if party_scores:
204
+ max_party_id = max(party_scores, key=party_scores.get)
205
+ max_label = next(
206
+ (p["label"] for p in parties if p["id"] == max_party_id),
207
+ "Unknown"
208
+ )
209
+ parts.append(
210
+ f"\nBased on the severity and number of violations, "
211
+ f"{max_label} is assessed as the primary contributing party."
212
+ )
213
+
214
+ return " ".join(parts)
215
+
216
+ def _generate_summary(
217
+ self, violations: List[dict], parties: List[dict],
218
+ distribution: Dict[str, float], primary_party_id: Optional[int]
219
+ ) -> str:
220
+ """Generate a concise summary of the fault analysis."""
221
+ primary_label = next(
222
+ (p["label"] for p in parties if p["id"] == primary_party_id),
223
+ "Unknown"
224
+ )
225
+
226
+ critical_count = sum(1 for v in violations if v.get("severity") == "CRITICAL")
227
+ high_count = sum(1 for v in violations if v.get("severity") == "HIGH")
228
+
229
+ dist_str = ", ".join(
230
+ f"{label}: {pct}%" for label, pct in distribution.items()
231
+ )
232
+
233
+ return (
234
+ f"Analysis identified {len(violations)} traffic violation(s) "
235
+ f"({critical_count} critical, {high_count} high severity). "
236
+ f"Primary fault assigned to {primary_label}. "
237
+ f"Fault distribution: {dist_str}."
238
+ )
239
+
240
+ def _no_violations_result(self, case_id: int, parties: List[dict]) -> Dict:
241
+ """Result when no violations are found."""
242
+ return {
243
+ "case_id": case_id,
244
+ "primary_fault_party_id": None,
245
+ "primary_fault_party_label": None,
246
+ "fault_distribution": {
247
+ p["label"]: round(100.0 / max(len(parties), 1), 1)
248
+ for p in parties
249
+ } if parties else {},
250
+ "probable_cause": (
251
+ "No clear traffic violations were identified from the available "
252
+ "photographs. Additional evidence or investigation may be required."
253
+ ),
254
+ "overall_confidence": 0.0,
255
+ "analysis_summary": "No violations detected. Manual review recommended.",
256
+ "violation_count": 0,
257
+ "party_scores": {},
258
+ }
259
+
260
+ def _no_parties_result(self, case_id: int, violations: List[dict]) -> Dict:
261
+ """Result when no parties are identified."""
262
+ return {
263
+ "case_id": case_id,
264
+ "primary_fault_party_id": None,
265
+ "primary_fault_party_label": None,
266
+ "fault_distribution": {},
267
+ "probable_cause": (
268
+ f"{len(violations)} violation(s) detected but no parties could be "
269
+ "identified from the photographs. Manual party identification required."
270
+ ),
271
+ "overall_confidence": 0.3,
272
+ "analysis_summary": "Violations detected but parties not identifiable.",
273
+ "violation_count": len(violations),
274
+ "party_scores": {},
275
+ }
276
+
277
+ async def _save_result(self, case_id: int, result: Dict):
278
+ """Save fault analysis to database."""
279
+ try:
280
+ await db.save_fault_analysis(
281
+ case_id=case_id,
282
+ primary_fault_party_id=result.get("primary_fault_party_id"),
283
+ fault_distribution_json=json.dumps(result.get("fault_distribution", {})),
284
+ probable_cause=result.get("probable_cause", ""),
285
+ overall_confidence=result.get("overall_confidence", 0.0),
286
+ analysis_summary=result.get("analysis_summary", ""),
287
+ )
288
+ except Exception as e:
289
+ logger.error(f"Failed to save fault analysis for case {case_id}: {e}")
290
+
291
+
292
+ # Singleton
293
+ fault_deducer = FaultDeducer()
backend/app/core/inference.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” LFM Inference Engine
3
+
4
+ Wraps the LiquidAI LFM2-VL model for accident scene analysis.
5
+ Reused from PhotoSearchApp with specialized accident analysis prompts.
6
+ Supports M3/MPS, CUDA, and CPU. Model ID is configurable.
7
+ """
8
+
9
+ import time
10
+ import torch
11
+ from PIL import Image
12
+ from typing import Dict, Any
13
+ from pathlib import Path
14
+
15
+ from backend.app.config import settings
16
+ from backend.app.utils.logger import get_logger
17
+
18
+ logger = get_logger("inference")
19
+
20
+
21
+ # ── Accident Analysis Prompts ──────────────────────────────────────────
22
+
23
+ SCENE_ANALYSIS_PROMPT = """You are an expert AI Accident Investigator. Analyze this accident scene photograph and extract specific details EXACTLY matching the formatting below.
24
+
25
+ DO NOT output a generic "Accident Report" with Date/Time/Location. Do not use markdown headers like "## Accident Report".
26
+ You MUST output EXACTLY these four bracketed sections and nothing else. Output the brackets exactly as shown.
27
+
28
+ [AI Observation]
29
+ Vehicles Involved: <Number>
30
+ Vehicle 1 Make/Model: <Year Make Model (Color BodyType)>
31
+ Vehicle 1 License Plate: <Plate or "Not visible">
32
+ Vehicle 1 Geographic Location: <Place name and Country derived from license plate format/text. E.g. "Maharashtra, India" or "Texas, USA" if possible, else "Unknown">
33
+ Vehicle 1 Owner: <Owner Name or "Not visible">
34
+ ... (Repeat exactly the same 4 fields for Vehicle 2, Vehicle 3, etc. ONLY if they are clearly visible in the image. DO NOT invent vehicles that do not exist.)
35
+ Non-vehicle Parties: <Pedestrians, cyclists, objects if any>
36
+
37
+ [Accident Severity]
38
+ Accident Category: <mild / medium / critical based on structural damage>
39
+ Survival/Injury Estimation: <minor injury / critical injury / life threatening injury based on passenger compartment intrusion and airbag deployment>
40
+
41
+ [Condition Assessment]
42
+ Time of Incident: <Day / Dusk / Night based on lighting>
43
+ Weather: <Clear / Cloudy / Rain / Fog / Snow>
44
+ Road Type: <1-way / 2-way / Intersection / Parking Lot / Highway>
45
+ Road Surface: <Dry / Wet / Icy / Debris-covered>
46
+ Vehicle 1 Position: <Direction facing, location on road>
47
+ Vehicle 1 Tyre Direction: <Straight / Turned Left / Turned Right>
48
+ Vehicle 1 Tyre Condition: <Condition details or "Not visible">
49
+ ... (Repeat exactly the same 3 fields for Vehicle 2, Vehicle 3, etc. ONLY if they are present)
50
+ Area of Impact: <Front / Rear / Side / Corner etc. for the primary collision>
51
+ Visible Debris/Skid Marks: <Description of glass, plastic, fluids, or tire marks on road>
52
+ Airbag/Safety: <Deployed / Not visible>
53
+
54
+ [Damage Analysis & Insurance Assessment]
55
+ - <Specific Part of Vehicle 1>: <Damage description> (Intensity: <Low/Medium/High>). Clause <Guess> -> <SUBROGATION CLAIM / FULLY COVERED / SUBJECT TO DEDUCTIBLE>
56
+ ... (List damage for other visible vehicles only)
57
+
58
+ [Summary]
59
+ <2-3 sentences summarizing the collision, layout, and visible damages>"""
60
+
61
+
62
+ DAMAGE_ASSESSMENT_PROMPT = """Examine this vehicle closely and provide a detailed damage assessment:
63
+
64
+ 1. DAMAGE_LOCATIONS: List every area of damage visible (front bumper, hood, windshield, driver door, rear quarter panel, etc.)
65
+ 2. DAMAGE_SEVERITY: For each location, rate: minor (cosmetic scratches/dents), moderate (structural deformation), severe (crushed/destroyed)
66
+ 3. DAMAGE_TYPE: For each location: impact, scrape, rollover, fire, or combination
67
+ 4. IMPACT_DIRECTION: Based on damage pattern, estimate the direction of force (front, rear, left, right, top)
68
+ 5. SAFETY_INDICATORS: Airbag deployment, seatbelt marks, interior damage visible
69
+
70
+ Only describe what you can see. Be specific about locations on the vehicle."""
71
+
72
+
73
+ class LFMInferenceEngine:
74
+ """
75
+ Singleton wrapper around the LFM vision-language model.
76
+
77
+ Provides specialized methods for accident scene analysis:
78
+ - analyze_scene(): Comprehensive scene analysis from accident photo
79
+ - assess_damage(): Detailed vehicle damage assessment
80
+ """
81
+
82
+ _instance = None
83
+ _model = None
84
+ _processor = None
85
+ _device = None
86
+
87
+ def __new__(cls):
88
+ if cls._instance is None:
89
+ cls._instance = super().__new__(cls)
90
+ return cls._instance
91
+
92
+ @property
93
+ def is_loaded(self) -> bool:
94
+ return self._model is not None and self._processor is not None
95
+
96
+ def load_model(self):
97
+ """Load the model and processor. Called once at app startup."""
98
+ if self.is_loaded:
99
+ logger.info("Model already loaded, skipping")
100
+ return
101
+
102
+ from transformers import AutoProcessor, AutoModelForImageTextToText, AutoModel
103
+
104
+ model_id = settings.model_id
105
+ device = settings.resolve_device()
106
+ dtype = settings.resolve_torch_dtype()
107
+
108
+ logger.info(f"Loading model: {model_id} on device: {device} with dtype: {settings.model_torch_dtype}")
109
+ start = time.perf_counter()
110
+
111
+ def load_fn(m_id, **kwargs):
112
+ try:
113
+ return AutoModelForImageTextToText.from_pretrained(m_id, **kwargs)
114
+ except Exception as e:
115
+ logger.warning(f"AutoModelForImageTextToText failed: {e}. Trying AutoModel...")
116
+ return AutoModel.from_pretrained(m_id, **kwargs)
117
+
118
+ model_args = {
119
+ "torch_dtype": dtype,
120
+ "trust_remote_code": settings.model_trust_remote_code,
121
+ }
122
+
123
+ if device == "mps":
124
+ self._model = load_fn(model_id, **model_args).to("mps")
125
+ elif device == "cuda":
126
+ self._model = load_fn(model_id, device_map="auto", **model_args)
127
+ else:
128
+ self._model = load_fn(model_id, **model_args)
129
+
130
+ self._processor = AutoProcessor.from_pretrained(
131
+ model_id,
132
+ trust_remote_code=settings.model_trust_remote_code,
133
+ )
134
+
135
+ self._device = device
136
+ elapsed = round(time.perf_counter() - start, 2)
137
+ logger.info(f"Model loaded in {elapsed}s on device: {device}")
138
+
139
+ def _get_model_device(self):
140
+ """Get the device the model is on."""
141
+ if self._device == "mps":
142
+ return "mps"
143
+ elif self._device == "cuda":
144
+ return self._model.device
145
+ return "cpu"
146
+
147
+ def _run_inference(self, image: Image.Image, prompt: str) -> str:
148
+ """
149
+ Core inference: run the model on an image with a text prompt.
150
+ Returns the generated text response.
151
+ """
152
+ if not self.is_loaded:
153
+ raise RuntimeError("Model not loaded. Call load_model() first.")
154
+
155
+ # Modern standard VLM pattern (Llama 3.2 Vision, Qwen2-VL)
156
+ conversation = [
157
+ {
158
+ "role": "user",
159
+ "content": [
160
+ {"type": "image"},
161
+ {"type": "text", "text": prompt},
162
+ ],
163
+ },
164
+ ]
165
+
166
+ device = self._get_model_device()
167
+ try:
168
+ text_prompt = self._processor.apply_chat_template(
169
+ conversation,
170
+ add_generation_prompt=True,
171
+ tokenize=False,
172
+ )
173
+ inputs = self._processor(
174
+ text=text_prompt,
175
+ images=image,
176
+ return_tensors="pt",
177
+ ).to(device)
178
+ except Exception as e:
179
+ # Fallback mapping for LiquidAI LFM2-VL
180
+ logger.info(f"Using fallback processor apply_chat_template: {e}")
181
+ conversation[0]["content"][0]["image"] = image
182
+ inputs = self._processor.apply_chat_template(
183
+ conversation,
184
+ add_generation_prompt=True,
185
+ return_tensors="pt",
186
+ return_dict=True,
187
+ tokenize=True,
188
+ ).to(device)
189
+
190
+ with torch.inference_mode():
191
+ outputs = self._model.generate(
192
+ **inputs,
193
+ max_new_tokens=settings.model_max_new_tokens,
194
+ repetition_penalty=settings.model_repetition_penalty,
195
+ temperature=settings.model_temperature,
196
+ do_sample=settings.model_temperature > 0,
197
+ )
198
+
199
+ # Better decoding: slice off the input prompt to get only generated tokens
200
+ prompt_length = inputs["input_ids"].shape[1]
201
+ generated_tokens = outputs[0][prompt_length:]
202
+ response = self._processor.decode(generated_tokens, skip_special_tokens=True)
203
+
204
+ return response.strip()
205
+
206
+ def analyze_scene(self, image: Image.Image) -> Dict[str, Any]:
207
+ """
208
+ Analyze an accident scene photo.
209
+ Returns structured observations about vehicles, damage,
210
+ road conditions, positions, and visible evidence.
211
+ """
212
+ start = time.perf_counter()
213
+ analysis = self._run_inference(image, SCENE_ANALYSIS_PROMPT)
214
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
215
+
216
+ logger.info(f"Scene analyzed in {elapsed_ms}ms ({len(analysis)} chars)")
217
+ return {
218
+ "raw_analysis": analysis,
219
+ "inference_time_ms": elapsed_ms,
220
+ "model_id": settings.model_id,
221
+ }
222
+
223
+ def assess_damage(self, image: Image.Image) -> Dict[str, Any]:
224
+ """
225
+ Detailed damage assessment for a specific vehicle photo.
226
+ Returns damage locations, severity, type, and impact direction.
227
+ """
228
+ start = time.perf_counter()
229
+ assessment = self._run_inference(image, DAMAGE_ASSESSMENT_PROMPT)
230
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
231
+
232
+ logger.info(f"Damage assessed in {elapsed_ms}ms ({len(assessment)} chars)")
233
+ return {
234
+ "raw_assessment": assessment,
235
+ "inference_time_ms": elapsed_ms,
236
+ "model_id": settings.model_id,
237
+ }
238
+
239
+
240
+ # Singleton instance
241
+ inference_engine = LFMInferenceEngine()
242
+
243
+
244
+ # ── Chat Engine (Text-only, LFM2.5-1.2B-Instruct) ────────────────────
245
+
246
+ class ChatEngine:
247
+ """Lightweight text-only chatbot for case Q&A using LFM2.5-1.2B-Instruct."""
248
+
249
+ def __init__(self):
250
+ self._model = None
251
+ self._tokenizer = None
252
+ self._device = None
253
+ self.is_loaded = False
254
+
255
+ def load_model(self):
256
+ """Load the text-only chat model."""
257
+ from transformers import AutoModelForCausalLM, AutoTokenizer
258
+
259
+ model_id = settings.chat_model_id
260
+ logger.info(f"Loading chat model: {model_id}")
261
+
262
+ device = settings.resolve_device()
263
+ dtype = settings.resolve_torch_dtype()
264
+
265
+ self._tokenizer = AutoTokenizer.from_pretrained(
266
+ model_id, trust_remote_code=settings.model_trust_remote_code,
267
+ )
268
+ self._model = AutoModelForCausalLM.from_pretrained(
269
+ model_id, torch_dtype=dtype, trust_remote_code=settings.model_trust_remote_code,
270
+ )
271
+
272
+ if device != "cpu":
273
+ self._model = self._model.to(device)
274
+
275
+ self._device = device
276
+ self.is_loaded = True
277
+ logger.info(f"Chat model loaded on {device}")
278
+
279
+ def chat(self, system_context: str, user_message: str) -> str:
280
+ """
281
+ Generate a response given system context and a user question.
282
+ system_context: case data, traffic rules, etc.
283
+ user_message: the user's question
284
+ """
285
+ if not self.is_loaded:
286
+ raise RuntimeError("Chat model not loaded. Call load_model() first.")
287
+
288
+ messages = [
289
+ {"role": "system", "content": system_context},
290
+ {"role": "user", "content": user_message},
291
+ ]
292
+
293
+ try:
294
+ text_prompt = self._tokenizer.apply_chat_template(
295
+ messages, add_generation_prompt=True, tokenize=False,
296
+ )
297
+ except Exception:
298
+ # Fallback if no chat template
299
+ text_prompt = f"System: {system_context}\n\nUser: {user_message}\n\nAssistant:"
300
+
301
+ inputs = self._tokenizer(text_prompt, return_tensors="pt").to(self._device)
302
+
303
+ with torch.inference_mode():
304
+ outputs = self._model.generate(
305
+ **inputs,
306
+ max_new_tokens=512,
307
+ repetition_penalty=1.2,
308
+ temperature=0.4,
309
+ do_sample=True,
310
+ )
311
+
312
+ prompt_length = inputs["input_ids"].shape[1]
313
+ generated_tokens = outputs[0][prompt_length:]
314
+ response = self._tokenizer.decode(generated_tokens, skip_special_tokens=True)
315
+ return response.strip()
316
+
317
+
318
+ # Singleton instance
319
+ chat_engine = ChatEngine()
backend/app/core/report_generator.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Report Generator
3
+
4
+ Generates structured incident reports from analysis results.
5
+ Assembles all analysis components into a comprehensive report object.
6
+ """
7
+
8
+ import json
9
+ from typing import Dict, Any, List, Optional
10
+
11
+ from backend.app.db.database import db
12
+ from backend.app.utils.logger import get_logger
13
+
14
+ logger = get_logger("report_generator")
15
+
16
+ DISCLAIMER = (
17
+ "ADVISORY NOTICE: This analysis was generated by an AI system powered by "
18
+ "the LFM2-VL vision-language model and is intended as an assistive tool only. "
19
+ "All findings are preliminary and must be reviewed and validated by qualified "
20
+ "law enforcement personnel before use in any official proceedings. "
21
+ "This report does not constitute a legal determination of fault or liability."
22
+ )
23
+
24
+
25
+ class ReportGenerator:
26
+ """Generates structured incident reports from analysis results."""
27
+
28
+ async def generate_report(self, case_id: int) -> Dict[str, Any]:
29
+ """
30
+ Generate a full incident report for a case.
31
+ Assembles case info, photos, analyses, violations, and fault analysis.
32
+ """
33
+ logger.info(f"Generating report for case {case_id}")
34
+
35
+ case = await db.get_case(case_id)
36
+ if not case:
37
+ return {"error": f"Case {case_id} not found"}
38
+
39
+ photos = await db.get_photos_by_case(case_id)
40
+ analyses = await db.get_analyses_by_case(case_id)
41
+ parties = await db.get_parties_by_case(case_id)
42
+ violations = await db.get_violations_by_case(case_id)
43
+ fault = await db.get_fault_analysis(case_id)
44
+
45
+ # Build scene summary from all analyses
46
+ scene_summary = self._build_scene_summary(analyses)
47
+
48
+ # Build violations summary grouped by severity
49
+ violations_summary = self._build_violations_summary(violations)
50
+
51
+ # Build fault summary
52
+ fault_summary = self._build_fault_summary(fault, parties)
53
+
54
+ report = {
55
+ "report_type": "TraceScene Report",
56
+ "disclaimer": DISCLAIMER,
57
+ "case": {
58
+ "id": case["id"],
59
+ "case_number": case["case_number"],
60
+ "officer_name": case.get("officer_name", "N/A"),
61
+ "location": case.get("location", "N/A"),
62
+ "incident_date": case.get("incident_date", "N/A"),
63
+ "notes": case.get("notes", ""),
64
+ "status": case["status"],
65
+ "created_at": case["created_at"],
66
+ },
67
+ "photos": [
68
+ {
69
+ "id": p["id"],
70
+ "filename": p["filename"],
71
+ "photo_type": p.get("photo_type", "general"),
72
+ "filepath": p["filepath"],
73
+ }
74
+ for p in photos
75
+ ],
76
+ "scene_summary": scene_summary,
77
+ "parties": [
78
+ {
79
+ "id": p["id"],
80
+ "label": p["label"],
81
+ "vehicle_type": p.get("vehicle_type"),
82
+ "vehicle_color": p.get("vehicle_color"),
83
+ "description": p.get("vehicle_description", ""),
84
+ }
85
+ for p in parties
86
+ ],
87
+ "analyses": [
88
+ {
89
+ "photo_id": a.get("photo_id"),
90
+ "filename": a.get("filename"),
91
+ "raw_analysis": a.get("raw_analysis", ""),
92
+ "inference_time_ms": a.get("inference_time_ms"),
93
+ }
94
+ for a in analyses
95
+ ],
96
+ "violations": violations_summary,
97
+ "fault_analysis": fault_summary,
98
+ "statistics": {
99
+ "total_photos": len(photos),
100
+ "analyzed_photos": len(analyses),
101
+ "total_violations": len(violations),
102
+ "critical_violations": sum(
103
+ 1 for v in violations if v.get("severity") == "CRITICAL"
104
+ ),
105
+ "high_violations": sum(
106
+ 1 for v in violations if v.get("severity") == "HIGH"
107
+ ),
108
+ "parties_identified": len(parties),
109
+ },
110
+ }
111
+
112
+ logger.info(f"Report generated for case {case_id}")
113
+ return report
114
+
115
+ def _build_scene_summary(self, analyses: List[dict]) -> str:
116
+ """Build a combined scene description from all photo analyses."""
117
+ if not analyses:
118
+ return "No scene analysis available."
119
+
120
+ summaries = []
121
+ for i, analysis in enumerate(analyses, 1):
122
+ raw = analysis.get("raw_analysis", "")
123
+ if raw:
124
+ # Take first 300 chars of each analysis for summary
125
+ snippet = raw[:300].strip()
126
+ if len(raw) > 300:
127
+ snippet += "..."
128
+ summaries.append(f"Photo {i} ({analysis.get('filename', 'unknown')}): {snippet}")
129
+
130
+ return "\n\n".join(summaries) if summaries else "No scene analysis available."
131
+
132
+ def _build_violations_summary(self, violations: List[dict]) -> Dict[str, Any]:
133
+ """Group violations by severity and format for the report."""
134
+ grouped = {
135
+ "CRITICAL": [],
136
+ "HIGH": [],
137
+ "MEDIUM": [],
138
+ "LOW": [],
139
+ }
140
+
141
+ for v in violations:
142
+ severity = v.get("severity", "MEDIUM")
143
+ entry = {
144
+ "rule_id": v["rule_id"],
145
+ "title": v["rule_title"],
146
+ "confidence": v["confidence"],
147
+ "party": v.get("party_label", "Unknown"),
148
+ "evidence": v.get("evidence_summary", ""),
149
+ "category": v.get("rule_category", ""),
150
+ }
151
+ if severity in grouped:
152
+ grouped[severity].append(entry)
153
+ else:
154
+ grouped["MEDIUM"].append(entry)
155
+
156
+ return {
157
+ "by_severity": grouped,
158
+ "total": len(violations),
159
+ "list": [
160
+ {
161
+ "rule_id": v["rule_id"],
162
+ "title": v["rule_title"],
163
+ "severity": v.get("severity", "MEDIUM"),
164
+ "confidence": v["confidence"],
165
+ "party": v.get("party_label", "Unknown"),
166
+ "evidence": v.get("evidence_summary", ""),
167
+ }
168
+ for v in violations
169
+ ],
170
+ }
171
+
172
+ def _build_fault_summary(
173
+ self, fault: Optional[dict], parties: List[dict]
174
+ ) -> Dict[str, Any]:
175
+ """Format fault analysis for the report."""
176
+ if not fault:
177
+ return {
178
+ "determined": False,
179
+ "message": "Fault analysis not yet performed.",
180
+ }
181
+
182
+ distribution = {}
183
+ if fault.get("fault_distribution_json"):
184
+ try:
185
+ distribution = json.loads(fault["fault_distribution_json"])
186
+ except json.JSONDecodeError:
187
+ pass
188
+
189
+ return {
190
+ "determined": True,
191
+ "primary_fault_party": fault.get("fault_party_label", "Unknown"),
192
+ "fault_distribution": distribution,
193
+ "probable_cause": fault.get("probable_cause", ""),
194
+ "overall_confidence": fault.get("overall_confidence", 0),
195
+ "summary": fault.get("analysis_summary", ""),
196
+ }
197
+
198
+
199
+ # Singleton
200
+ report_generator = ReportGenerator()
backend/app/core/rule_matcher.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Rule Matcher
3
+
4
+ Matches scene observations against the traffic ruleset.
5
+ Uses keyword overlap and semantic matching on visual indicators
6
+ to identify which traffic rules were likely violated.
7
+ """
8
+
9
+ import re
10
+ import json
11
+ from typing import List, Dict, Set, Tuple
12
+ from collections import Counter
13
+
14
+ from backend.app.rules.rule_loader import rule_loader, TrafficRule, RuleMatch
15
+ from backend.app.db.database import db
16
+ from backend.app.config import settings
17
+ from backend.app.utils.logger import get_logger
18
+
19
+ logger = get_logger("rule_matcher")
20
+
21
+ # Common stop words to filter out
22
+ STOP_WORDS = {
23
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
24
+ "have", "has", "had", "do", "does", "did", "will", "would", "shall",
25
+ "should", "may", "might", "can", "could", "and", "but", "or", "nor",
26
+ "not", "no", "so", "if", "then", "than", "that", "this", "these",
27
+ "those", "it", "its", "to", "of", "in", "on", "at", "by", "for",
28
+ "with", "from", "up", "out", "off", "into", "over", "about", "as",
29
+ "very", "also", "just", "each", "some", "any", "all", "such",
30
+ }
31
+
32
+
33
+ class RuleMatcher:
34
+ """
35
+ Matches observed evidence from accident scene analysis
36
+ against the traffic ruleset to identify violations.
37
+ """
38
+
39
+ def _tokenize(self, text: str) -> List[str]:
40
+ """Tokenize text: lowercase, split on non-alpha, remove stop words."""
41
+ tokens = re.findall(r'[a-z]+', text.lower())
42
+ return [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
43
+
44
+ def _compute_match_score(
45
+ self, observation_tokens: Set[str], indicator_tokens: Set[str]
46
+ ) -> float:
47
+ """
48
+ Compute overlap score between observation and indicator tokens.
49
+ Returns a value between 0.0 and 1.0.
50
+ """
51
+ if not indicator_tokens:
52
+ return 0.0
53
+
54
+ overlap = observation_tokens & indicator_tokens
55
+ if not overlap:
56
+ return 0.0
57
+
58
+ # Jaccard-like score weighted by indicator coverage
59
+ indicator_coverage = len(overlap) / len(indicator_tokens)
60
+ observation_relevance = len(overlap) / max(len(observation_tokens), 1)
61
+
62
+ # Weight indicator coverage more heavily (we care that the
63
+ # observation matches the rule, not that the rule matches all
64
+ # the observation text)
65
+ score = 0.7 * indicator_coverage + 0.3 * observation_relevance
66
+ return min(score, 1.0)
67
+
68
+ async def match_violations(self, case_id: int) -> List[Dict]:
69
+ """
70
+ Match all scene analyses for a case against traffic rules.
71
+ Returns list of violations found and stores them in the database.
72
+ """
73
+ logger.info(f"Starting rule matching for case {case_id}")
74
+
75
+ analyses = await db.get_analyses_by_case(case_id)
76
+ parties = await db.get_parties_by_case(case_id)
77
+
78
+ if not analyses:
79
+ logger.warning(f"Case {case_id}: no analyses found for rule matching")
80
+ return []
81
+
82
+ # Clear previous violations for re-analysis
83
+ await db.clear_violations(case_id)
84
+
85
+ all_rules = rule_loader.get_all_rules()
86
+ if not all_rules:
87
+ logger.warning("No traffic rules loaded!")
88
+ return []
89
+
90
+ violations = []
91
+ threshold = settings.confidence_threshold
92
+
93
+ for analysis in analyses:
94
+ # Combine all observation text from this photo
95
+ observation_text = self._build_observation_text(analysis)
96
+ observation_tokens = set(self._tokenize(observation_text))
97
+
98
+ if not observation_tokens:
99
+ continue
100
+
101
+ for rule in all_rules:
102
+ match_result = self._match_rule(
103
+ observation_tokens, observation_text, rule
104
+ )
105
+
106
+ if match_result and match_result.confidence >= threshold:
107
+ # Assign to the most likely party
108
+ party_id = self._assign_to_party(
109
+ rule, parties, observation_text
110
+ )
111
+
112
+ violation_id = await db.add_violation(
113
+ case_id=case_id,
114
+ rule_id=rule.id,
115
+ rule_title=rule.title,
116
+ confidence=round(match_result.confidence, 3),
117
+ party_id=party_id,
118
+ rule_category=rule.category_name,
119
+ severity=rule.severity,
120
+ evidence_summary=match_result.evidence_text[:500],
121
+ photo_id=analysis.get("photo_id"),
122
+ )
123
+
124
+ violations.append({
125
+ "id": violation_id,
126
+ "rule_id": rule.id,
127
+ "rule_title": rule.title,
128
+ "severity": rule.severity,
129
+ "confidence": match_result.confidence,
130
+ "party_id": party_id,
131
+ "evidence": match_result.evidence_text[:200],
132
+ })
133
+
134
+ # Deduplicate: if same rule matched across multiple photos,
135
+ # keep only the highest-confidence instance
136
+ violations = self._deduplicate_violations(violations)
137
+
138
+ logger.info(
139
+ f"Case {case_id}: {len(violations)} violations found "
140
+ f"from {len(analyses)} photo analyses"
141
+ )
142
+
143
+ return violations
144
+
145
+ def _build_observation_text(self, analysis: dict) -> str:
146
+ """Combine all analysis fields into a single observation text."""
147
+ parts = [analysis.get("raw_analysis", "")]
148
+
149
+ for field in ["vehicles_json", "road_conditions_json",
150
+ "evidence_json", "environmental_json", "positions_json"]:
151
+ value = analysis.get(field, "")
152
+ if value and value != "null":
153
+ if isinstance(value, str):
154
+ try:
155
+ parsed = json.loads(value)
156
+ parts.append(json.dumps(parsed))
157
+ except json.JSONDecodeError:
158
+ parts.append(value)
159
+ else:
160
+ parts.append(str(value))
161
+
162
+ return " ".join(parts)
163
+
164
+ def _match_rule(
165
+ self, observation_tokens: Set[str], observation_text: str,
166
+ rule: TrafficRule
167
+ ) -> RuleMatch | None:
168
+ """
169
+ Check if a single rule is matched by the observation.
170
+ Uses both token overlap and phrase matching.
171
+ """
172
+ # Score each visual indicator
173
+ best_score = 0.0
174
+ matched_indicators = []
175
+
176
+ for indicator in rule.visual_indicators:
177
+ indicator_tokens = set(self._tokenize(indicator))
178
+
179
+ # Token overlap score
180
+ token_score = self._compute_match_score(
181
+ observation_tokens, indicator_tokens
182
+ )
183
+
184
+ # Phrase/substring match bonus
185
+ phrase_bonus = 0.0
186
+ indicator_lower = indicator.lower()
187
+ if indicator_lower in observation_text.lower():
188
+ phrase_bonus = 0.3 # Exact phrase found
189
+
190
+ combined_score = min(token_score + phrase_bonus, 1.0)
191
+
192
+ if combined_score > 0.3: # Individual indicator threshold
193
+ matched_indicators.append(indicator)
194
+
195
+ best_score = max(best_score, combined_score)
196
+
197
+ if not matched_indicators:
198
+ return None
199
+
200
+ # Final confidence = best indicator score * (matched indicator ratio bonus)
201
+ indicator_ratio = len(matched_indicators) / len(rule.visual_indicators)
202
+ multi_indicator_bonus = min(indicator_ratio * 0.2, 0.2)
203
+ final_confidence = min(best_score + multi_indicator_bonus, 1.0)
204
+
205
+ # Build evidence summary
206
+ evidence = f"Matched indicators: {', '.join(matched_indicators[:3])}"
207
+
208
+ return RuleMatch(
209
+ rule=rule,
210
+ confidence=final_confidence,
211
+ matched_indicators=matched_indicators,
212
+ evidence_text=evidence,
213
+ )
214
+
215
+ def _assign_to_party(
216
+ self, rule: TrafficRule, parties: List[dict],
217
+ observation_text: str
218
+ ) -> int | None:
219
+ """
220
+ Assign a violation to the most likely party.
221
+ Uses simple heuristics based on rule type and observation text.
222
+ """
223
+ if not parties:
224
+ return None
225
+
226
+ obs_lower = observation_text.lower()
227
+
228
+ # Check if observation mentions specific party labels
229
+ for party in parties:
230
+ label = party["label"].lower()
231
+ if label in obs_lower:
232
+ return party["id"]
233
+
234
+ # Also check by color + type
235
+ p_color = (party.get("vehicle_color") or "").lower()
236
+ p_type = (party.get("vehicle_type") or "").lower()
237
+ if p_color and p_color in obs_lower:
238
+ return party["id"]
239
+
240
+ # Default: assign to first party for rear-end rules (following vehicle),
241
+ # otherwise first party
242
+ if "following" in str(rule.applicable_parties):
243
+ return parties[-1]["id"] if len(parties) > 1 else parties[0]["id"]
244
+
245
+ return parties[0]["id"]
246
+
247
+ def _deduplicate_violations(self, violations: List[dict]) -> List[dict]:
248
+ """Keep only the highest-confidence violation per rule_id."""
249
+ best = {}
250
+ for v in violations:
251
+ rule_id = v["rule_id"]
252
+ if rule_id not in best or v["confidence"] > best[rule_id]["confidence"]:
253
+ best[rule_id] = v
254
+ return list(best.values())
255
+
256
+
257
+ # Singleton
258
+ rule_matcher = RuleMatcher()
backend/app/core/scene_analyzer.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Scene Analyzer
3
+
4
+ Orchestrates multi-image accident scene analysis.
5
+ Runs LFM inference on each photo and parses the structured output
6
+ into components (vehicles, road conditions, evidence, etc.).
7
+ """
8
+
9
+ import json
10
+ import re
11
+ import time
12
+ from typing import List, Dict, Any, Optional
13
+ from PIL import Image
14
+ from pathlib import Path
15
+
16
+ from backend.app.core.inference import inference_engine
17
+ from backend.app.db.database import db
18
+ from backend.app.utils.logger import get_logger
19
+
20
+ logger = get_logger("scene_analyzer")
21
+
22
+
23
+ class SceneAnalyzer:
24
+ """
25
+ Analyzes accident scene photos and extracts structured observations.
26
+ Processes each photo individually, then merges observations across images.
27
+ """
28
+
29
+ async def analyze_case(self, case_id: int) -> Dict[str, Any]:
30
+ """
31
+ Analyze all unanalyzed photos in a case.
32
+ Returns analysis results and updates the database.
33
+ """
34
+ logger.info(f"Starting analysis for case {case_id}")
35
+ start = time.perf_counter()
36
+
37
+ # Get unanalyzed photos
38
+ unanalyzed = await db.get_unanalyzed_photos(case_id)
39
+ total = len(unanalyzed)
40
+ analyzed = 0
41
+ errors = 0
42
+
43
+ if total == 0:
44
+ logger.info(f"Case {case_id}: no unanalyzed photos found")
45
+ return {"total": 0, "analyzed": 0, "errors": 0, "elapsed_ms": 0}
46
+
47
+ await db.update_case_status(case_id, "analyzing")
48
+
49
+ all_analyses = []
50
+ for photo in unanalyzed:
51
+ try:
52
+ result = await self._analyze_single_photo(photo)
53
+ if result:
54
+ all_analyses.append(result)
55
+ analyzed += 1
56
+ else:
57
+ errors += 1
58
+ except Exception as e:
59
+ errors += 1
60
+ logger.error(f"Failed to analyze photo {photo['id']}: {e}")
61
+
62
+ # Identify parties from all analyses
63
+ parties = self._identify_parties(all_analyses)
64
+ for party in parties:
65
+ await db.add_party(
66
+ case_id=case_id,
67
+ label=party["label"],
68
+ vehicle_type=party.get("vehicle_type"),
69
+ vehicle_color=party.get("vehicle_color"),
70
+ vehicle_description=party.get("description"),
71
+ )
72
+
73
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
74
+
75
+ logger.info(
76
+ f"Case {case_id} analysis complete: "
77
+ f"{analyzed}/{total} photos analyzed, {errors} errors, "
78
+ f"{len(parties)} parties identified, {elapsed_ms}ms"
79
+ )
80
+
81
+ return {
82
+ "total": total,
83
+ "analyzed": analyzed,
84
+ "errors": errors,
85
+ "parties_found": len(parties),
86
+ "elapsed_ms": elapsed_ms,
87
+ }
88
+
89
+ async def _analyze_single_photo(self, photo: dict) -> Optional[Dict[str, Any]]:
90
+ """Analyze a single photo and store results."""
91
+ photo_id = photo["id"]
92
+ filepath = photo["filepath"]
93
+
94
+ logger.info(f"Analyzing photo {photo_id}: {filepath}")
95
+
96
+ try:
97
+ image = Image.open(filepath).convert("RGB")
98
+ except Exception as e:
99
+ logger.error(f"Cannot open image {filepath}: {e}")
100
+ return None
101
+
102
+ try:
103
+ result = inference_engine.analyze_scene(image)
104
+ raw_analysis = result["raw_analysis"]
105
+
106
+ # Parse structured sections from the model response
107
+ parsed = self._parse_analysis(raw_analysis)
108
+
109
+ await db.add_scene_analysis(
110
+ photo_id=photo_id,
111
+ raw_analysis=raw_analysis,
112
+ vehicles_json=json.dumps(parsed.get("vehicles", [])),
113
+ road_conditions_json=json.dumps(parsed.get("road_conditions", {})),
114
+ evidence_json=json.dumps(parsed.get("evidence", {})),
115
+ environmental_json=json.dumps(parsed.get("environmental", {})),
116
+ positions_json=parsed.get("positions", ""),
117
+ model_id=result["model_id"],
118
+ inference_time_ms=result["inference_time_ms"],
119
+ )
120
+
121
+ logger.info(
122
+ f"Photo {photo_id} analyzed: {len(raw_analysis)} chars, "
123
+ f"{result['inference_time_ms']}ms, "
124
+ f"{len(parsed.get('vehicles', []))} vehicles detected"
125
+ )
126
+
127
+ return {
128
+ "photo_id": photo_id,
129
+ "raw_analysis": raw_analysis,
130
+ "parsed": parsed,
131
+ }
132
+
133
+ except Exception as e:
134
+ logger.error(f"Inference failed for photo {photo_id}: {e}")
135
+ return None
136
+
137
+ def _parse_analysis(self, raw_text: str) -> Dict[str, Any]:
138
+ """
139
+ Parse the structured model response into components based on static UI format.
140
+ """
141
+ result = {
142
+ "vehicles": [],
143
+ "road_conditions": {},
144
+ "evidence": {},
145
+ "environmental": {},
146
+ "positions": "",
147
+ }
148
+
149
+ sections = self._split_sections(raw_text)
150
+
151
+ # Parse vehicles section
152
+ if "vehicles" in sections:
153
+ result["vehicles"] = self._parse_vehicles(sections["vehicles"])
154
+
155
+ # Parse road conditions
156
+ if "road_conditions" in sections:
157
+ result["road_conditions"] = self._parse_key_values(sections["road_conditions"])
158
+
159
+ # Parse evidence
160
+ if "evidence" in sections:
161
+ result["evidence"] = self._parse_key_values(sections["evidence"])
162
+
163
+ # Parse environmental
164
+ if "environmental" in sections:
165
+ result["environmental"] = self._parse_key_values(sections["environmental"])
166
+
167
+ # Parse positions (keep as text)
168
+ if "positions" in sections:
169
+ result["positions"] = sections["positions"].strip()
170
+
171
+ return result
172
+
173
+ def _split_sections(self, text: str) -> Dict[str, str]:
174
+ """Split the model response into named sections based on brackets."""
175
+ sections = {}
176
+ section_patterns = [
177
+ ("vehicles", r"\[AI Observation\]\s*(.+?)(?=\[Condition Assessment\]|$)"),
178
+ ("road_conditions", r"\[Condition Assessment\]\s*(.+?)(?=\[Damage Analysis|\[Summary\]|$)"),
179
+ ("evidence", r"\[Damage Analysis.*?\]\s*(.+?)(?=\[Summary\]|$)"),
180
+ ("positions", r"\[Summary\]\s*(.+?)$"),
181
+ ]
182
+
183
+ for name, pattern in section_patterns:
184
+ match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
185
+ if match:
186
+ sections[name] = match.group(1).strip()
187
+
188
+ return sections
189
+
190
+ def _parse_vehicles(self, text: str) -> List[Dict[str, str]]:
191
+ """Parse vehicle descriptions from the AI Observation section."""
192
+ vehicles = []
193
+
194
+ # Look for "Vehicle 1 Make/Model: Year Make Model (Color Type)"
195
+ pattern = r"Vehicle\s+(\d+)\s+Make/Model:\s*(.+?)\s*(?:\((.+?)\))?"
196
+ matches = re.finditer(pattern, text, re.IGNORECASE)
197
+
198
+ for match in matches:
199
+ label = match.group(1)
200
+ make_model = match.group(2).strip()
201
+ color_type = match.group(3)
202
+
203
+ color = ""
204
+ vtype = make_model
205
+
206
+ if color_type:
207
+ # E.g. "Blue Sedan" -> color="Blue", type="Sedan"
208
+ parts = color_type.strip().split()
209
+ if len(parts) > 1:
210
+ color = parts[0]
211
+ vtype = " ".join(parts[1:])
212
+ else:
213
+ vtype = parts[0]
214
+
215
+ vehicles.append({
216
+ "label": f"Vehicle {label}",
217
+ "description": make_model,
218
+ "color": color,
219
+ "type": vtype
220
+ })
221
+
222
+ return vehicles if vehicles else [{"description": text[:500]}]
223
+
224
+ def _parse_key_values(self, text: str) -> Dict[str, str]:
225
+ """Parse key-value pairs from a section (bullet points or lines)."""
226
+ result = {}
227
+ lines = text.strip().split("\n")
228
+
229
+ for line in lines:
230
+ line = line.strip().lstrip("-*β€’").strip()
231
+ if ":" in line:
232
+ key, _, value = line.partition(":")
233
+ key = key.strip().lower().replace(" ", "_")
234
+ value = value.strip().strip(".,")
235
+ if key and value:
236
+ result[key] = value
237
+ elif line:
238
+ # Store as a numbered or generic entry
239
+ result[f"note_{len(result)}"] = line
240
+
241
+ return result
242
+
243
+ def _identify_parties(self, analyses: List[Dict[str, Any]]) -> List[Dict[str, str]]:
244
+ """
245
+ Identify distinct parties (Vehicle A, Vehicle B, etc.)
246
+ from all photo analyses.
247
+ """
248
+ parties = []
249
+ seen_types = set()
250
+ vehicle_counter = 0
251
+ labels = ["Vehicle A", "Vehicle B", "Vehicle C", "Vehicle D"]
252
+
253
+ for analysis in analyses:
254
+ parsed = analysis.get("parsed", {})
255
+ for vehicle in parsed.get("vehicles", []):
256
+ v_type = vehicle.get("type", "unknown").lower()
257
+ v_color = vehicle.get("color", "").lower()
258
+ v_key = f"{v_color}_{v_type}".strip("_")
259
+
260
+ # Simple dedup β€” don't add if we already have same color+type
261
+ if v_key and v_key in seen_types:
262
+ continue
263
+
264
+ if v_key:
265
+ seen_types.add(v_key)
266
+
267
+ if vehicle_counter < len(labels):
268
+ label = labels[vehicle_counter]
269
+ else:
270
+ label = f"Vehicle {vehicle_counter + 1}"
271
+
272
+ parties.append({
273
+ "label": label,
274
+ "vehicle_type": vehicle.get("type"),
275
+ "vehicle_color": vehicle.get("color"),
276
+ "description": vehicle.get("description", ""),
277
+ })
278
+ vehicle_counter += 1
279
+
280
+ # If no vehicles found at all, add two generic parties
281
+ if not parties:
282
+ parties = [
283
+ {"label": "Vehicle A", "vehicle_type": "unknown",
284
+ "vehicle_color": None, "description": ""},
285
+ {"label": "Vehicle B", "vehicle_type": "unknown",
286
+ "vehicle_color": None, "description": ""},
287
+ ]
288
+
289
+ return parties
290
+
291
+
292
+ # Singleton
293
+ scene_analyzer = SceneAnalyzer()
backend/app/db/__init__.py ADDED
File without changes
backend/app/db/database.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Database Layer
3
+
4
+ SQLite database for accident cases, photos, scene analyses,
5
+ traffic violations, parties, and fault analysis.
6
+ Uses aiosqlite for async compatibility with FastAPI.
7
+ """
8
+
9
+ import aiosqlite
10
+ from pathlib import Path
11
+ from typing import Optional, List, Dict, Any
12
+
13
+ from backend.app.config import settings
14
+ from backend.app.utils.logger import get_logger
15
+
16
+ logger = get_logger("database")
17
+
18
+ SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS cases (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ case_number TEXT UNIQUE NOT NULL,
22
+ officer_name TEXT,
23
+ location TEXT,
24
+ incident_date TEXT,
25
+ notes TEXT,
26
+ status TEXT DEFAULT 'pending',
27
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS photos (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ case_id INTEGER NOT NULL,
33
+ filename TEXT NOT NULL,
34
+ filepath TEXT NOT NULL,
35
+ file_size INTEGER,
36
+ width INTEGER,
37
+ height INTEGER,
38
+ photo_type TEXT DEFAULT 'general',
39
+ uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
40
+ FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS scene_analyses (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ photo_id INTEGER NOT NULL UNIQUE,
46
+ raw_analysis TEXT NOT NULL,
47
+ vehicles_json TEXT,
48
+ road_conditions_json TEXT,
49
+ evidence_json TEXT,
50
+ environmental_json TEXT,
51
+ positions_json TEXT,
52
+ model_id TEXT,
53
+ inference_time_ms REAL,
54
+ analyzed_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS parties (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ case_id INTEGER NOT NULL,
61
+ label TEXT NOT NULL,
62
+ vehicle_type TEXT,
63
+ vehicle_color TEXT,
64
+ vehicle_description TEXT,
65
+ FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS violations (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ case_id INTEGER NOT NULL,
71
+ party_id INTEGER,
72
+ rule_id TEXT NOT NULL,
73
+ rule_title TEXT NOT NULL,
74
+ rule_category TEXT,
75
+ severity TEXT,
76
+ confidence REAL NOT NULL,
77
+ evidence_summary TEXT,
78
+ photo_id INTEGER,
79
+ FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE,
80
+ FOREIGN KEY (party_id) REFERENCES parties(id),
81
+ FOREIGN KEY (photo_id) REFERENCES photos(id)
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS fault_analyses (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ case_id INTEGER NOT NULL UNIQUE,
87
+ primary_fault_party_id INTEGER,
88
+ fault_distribution_json TEXT,
89
+ probable_cause TEXT,
90
+ overall_confidence REAL,
91
+ analysis_summary TEXT,
92
+ generated_at TEXT NOT NULL DEFAULT (datetime('now')),
93
+ FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE,
94
+ FOREIGN KEY (primary_fault_party_id) REFERENCES parties(id)
95
+ );
96
+
97
+ CREATE INDEX IF NOT EXISTS idx_photos_case ON photos(case_id);
98
+ CREATE INDEX IF NOT EXISTS idx_analyses_photo ON scene_analyses(photo_id);
99
+ CREATE INDEX IF NOT EXISTS idx_violations_case ON violations(case_id);
100
+ CREATE INDEX IF NOT EXISTS idx_violations_party ON violations(party_id);
101
+ CREATE INDEX IF NOT EXISTS idx_parties_case ON parties(case_id);
102
+ """
103
+
104
+
105
+ class Database:
106
+ """Async SQLite database wrapper."""
107
+
108
+ def __init__(self):
109
+ self.db_path = str(settings.db_path)
110
+ self._connection: Optional[aiosqlite.Connection] = None
111
+
112
+ async def connect(self):
113
+ """Initialize database connection and create schema."""
114
+ logger.info(f"Connecting to database: {self.db_path}")
115
+ Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
116
+
117
+ self._connection = await aiosqlite.connect(self.db_path)
118
+ self._connection.row_factory = aiosqlite.Row
119
+ await self._connection.execute("PRAGMA journal_mode=WAL")
120
+ await self._connection.execute("PRAGMA foreign_keys=ON")
121
+
122
+ for statement in SCHEMA.strip().split(";"):
123
+ stmt = statement.strip()
124
+ if stmt:
125
+ await self._connection.execute(stmt)
126
+ await self._connection.commit()
127
+ logger.info("Database schema initialized")
128
+
129
+ async def disconnect(self):
130
+ """Close database connection."""
131
+ if self._connection:
132
+ await self._connection.close()
133
+
134
+ @property
135
+ def conn(self) -> aiosqlite.Connection:
136
+ if not self._connection:
137
+ raise RuntimeError("Database not connected. Call connect() first.")
138
+ return self._connection
139
+
140
+ # ── Cases ──────────────────────────────────────────────────────────
141
+
142
+ async def create_case(self, case_number: str, officer_name: str = None,
143
+ location: str = None, incident_date: str = None,
144
+ notes: str = None) -> int:
145
+ cursor = await self.conn.execute(
146
+ """INSERT INTO cases (case_number, officer_name, location, incident_date, notes)
147
+ VALUES (?, ?, ?, ?, ?)""",
148
+ (case_number, officer_name, location, incident_date, notes),
149
+ )
150
+ await self.conn.commit()
151
+ return cursor.lastrowid
152
+
153
+ async def get_case(self, case_id: int) -> Optional[dict]:
154
+ cursor = await self.conn.execute("SELECT * FROM cases WHERE id = ?", (case_id,))
155
+ row = await cursor.fetchone()
156
+ return dict(row) if row else None
157
+
158
+ async def list_cases(self) -> List[dict]:
159
+ cursor = await self.conn.execute(
160
+ "SELECT * FROM cases ORDER BY created_at DESC"
161
+ )
162
+ rows = await cursor.fetchall()
163
+ return [dict(r) for r in rows]
164
+
165
+ async def update_case_status(self, case_id: int, status: str):
166
+ await self.conn.execute(
167
+ "UPDATE cases SET status = ? WHERE id = ?", (status, case_id)
168
+ )
169
+ await self.conn.commit()
170
+
171
+ async def delete_case(self, case_id: int):
172
+ await self.conn.execute("DELETE FROM cases WHERE id = ?", (case_id,))
173
+ await self.conn.commit()
174
+
175
+ async def update_case(self, case_id: int, officer_name: str = None,
176
+ location: str = None, incident_date: str = None,
177
+ notes: str = None) -> bool:
178
+ """Update case details."""
179
+ updates = []
180
+ params = []
181
+ if officer_name is not None:
182
+ updates.append("officer_name = ?")
183
+ params.append(officer_name)
184
+ if location is not None:
185
+ updates.append("location = ?")
186
+ params.append(location)
187
+ if incident_date is not None:
188
+ updates.append("incident_date = ?")
189
+ params.append(incident_date)
190
+ if notes is not None:
191
+ updates.append("notes = ?")
192
+ params.append(notes)
193
+
194
+ if not updates:
195
+ return False
196
+
197
+ params.append(case_id)
198
+ query = f"UPDATE cases SET {', '.join(updates)} WHERE id = ?"
199
+ await self.conn.execute(query, tuple(params))
200
+ await self.conn.commit()
201
+ return True
202
+
203
+ # ── Photos ─────────────────────────────────────────────────────────
204
+
205
+ async def add_photo(self, case_id: int, filename: str, filepath: str,
206
+ file_size: int, width: int = None, height: int = None,
207
+ photo_type: str = "general") -> int:
208
+ cursor = await self.conn.execute(
209
+ """INSERT INTO photos (case_id, filename, filepath, file_size, width, height, photo_type)
210
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
211
+ (case_id, filename, filepath, file_size, width, height, photo_type),
212
+ )
213
+ await self.conn.commit()
214
+ return cursor.lastrowid
215
+
216
+ async def get_photos_by_case(self, case_id: int) -> List[dict]:
217
+ cursor = await self.conn.execute(
218
+ "SELECT * FROM photos WHERE case_id = ? ORDER BY uploaded_at", (case_id,)
219
+ )
220
+ rows = await cursor.fetchall()
221
+ return [dict(r) for r in rows]
222
+
223
+ async def get_photo(self, photo_id: int) -> Optional[dict]:
224
+ cursor = await self.conn.execute("SELECT * FROM photos WHERE id = ?", (photo_id,))
225
+ row = await cursor.fetchone()
226
+ return dict(row) if row else None
227
+
228
+ async def get_unanalyzed_photos(self, case_id: int) -> List[dict]:
229
+ cursor = await self.conn.execute(
230
+ """SELECT p.* FROM photos p
231
+ LEFT JOIN scene_analyses sa ON p.id = sa.photo_id
232
+ WHERE p.case_id = ? AND sa.id IS NULL""",
233
+ (case_id,),
234
+ )
235
+ rows = await cursor.fetchall()
236
+ return [dict(r) for r in rows]
237
+
238
+ # ── Scene Analyses ─────────────────────────────────────────────────
239
+
240
+ async def add_scene_analysis(self, photo_id: int, raw_analysis: str,
241
+ vehicles_json: str = None,
242
+ road_conditions_json: str = None,
243
+ evidence_json: str = None,
244
+ environmental_json: str = None,
245
+ positions_json: str = None,
246
+ model_id: str = None,
247
+ inference_time_ms: float = None) -> int:
248
+ cursor = await self.conn.execute(
249
+ """INSERT INTO scene_analyses
250
+ (photo_id, raw_analysis, vehicles_json, road_conditions_json,
251
+ evidence_json, environmental_json, positions_json, model_id, inference_time_ms)
252
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
253
+ (photo_id, raw_analysis, vehicles_json, road_conditions_json,
254
+ evidence_json, environmental_json, positions_json, model_id, inference_time_ms),
255
+ )
256
+ await self.conn.commit()
257
+ return cursor.lastrowid
258
+
259
+ async def get_analyses_by_case(self, case_id: int) -> List[dict]:
260
+ cursor = await self.conn.execute(
261
+ """SELECT sa.*, p.filename, p.filepath, p.photo_type
262
+ FROM scene_analyses sa
263
+ JOIN photos p ON sa.photo_id = p.id
264
+ WHERE p.case_id = ?""",
265
+ (case_id,),
266
+ )
267
+ rows = await cursor.fetchall()
268
+ return [dict(r) for r in rows]
269
+
270
+ # ── Parties ────────────────────────────────────────────────────────
271
+
272
+ async def add_party(self, case_id: int, label: str,
273
+ vehicle_type: str = None, vehicle_color: str = None,
274
+ vehicle_description: str = None) -> int:
275
+ cursor = await self.conn.execute(
276
+ """INSERT INTO parties (case_id, label, vehicle_type, vehicle_color, vehicle_description)
277
+ VALUES (?, ?, ?, ?, ?)""",
278
+ (case_id, label, vehicle_type, vehicle_color, vehicle_description),
279
+ )
280
+ await self.conn.commit()
281
+ return cursor.lastrowid
282
+
283
+ async def get_parties_by_case(self, case_id: int) -> List[dict]:
284
+ cursor = await self.conn.execute(
285
+ "SELECT * FROM parties WHERE case_id = ?", (case_id,)
286
+ )
287
+ rows = await cursor.fetchall()
288
+ return [dict(r) for r in rows]
289
+
290
+ async def clear_parties(self, case_id: int):
291
+ await self.conn.execute("DELETE FROM parties WHERE case_id = ?", (case_id,))
292
+ await self.conn.commit()
293
+
294
+ # ── Violations ─────────────────────────────────────────────────────
295
+
296
+ async def add_violation(self, case_id: int, rule_id: str, rule_title: str,
297
+ confidence: float, party_id: int = None,
298
+ rule_category: str = None, severity: str = None,
299
+ evidence_summary: str = None,
300
+ photo_id: int = None) -> int:
301
+ cursor = await self.conn.execute(
302
+ """INSERT INTO violations
303
+ (case_id, party_id, rule_id, rule_title, rule_category, severity,
304
+ confidence, evidence_summary, photo_id)
305
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
306
+ (case_id, party_id, rule_id, rule_title, rule_category, severity,
307
+ confidence, evidence_summary, photo_id),
308
+ )
309
+ await self.conn.commit()
310
+ return cursor.lastrowid
311
+
312
+ async def get_violations_by_case(self, case_id: int) -> List[dict]:
313
+ cursor = await self.conn.execute(
314
+ """SELECT v.*, p.label as party_label
315
+ FROM violations v
316
+ LEFT JOIN parties p ON v.party_id = p.id
317
+ WHERE v.case_id = ?
318
+ ORDER BY v.confidence DESC""",
319
+ (case_id,),
320
+ )
321
+ rows = await cursor.fetchall()
322
+ return [dict(r) for r in rows]
323
+
324
+ async def clear_violations(self, case_id: int):
325
+ await self.conn.execute("DELETE FROM violations WHERE case_id = ?", (case_id,))
326
+ await self.conn.commit()
327
+
328
+ # ── Fault Analysis ─────────────────────────────────────────────────
329
+
330
+ async def save_fault_analysis(self, case_id: int,
331
+ primary_fault_party_id: int = None,
332
+ fault_distribution_json: str = None,
333
+ probable_cause: str = None,
334
+ overall_confidence: float = None,
335
+ analysis_summary: str = None) -> int:
336
+ # Upsert β€” replace if already exists
337
+ await self.conn.execute(
338
+ "DELETE FROM fault_analyses WHERE case_id = ?", (case_id,)
339
+ )
340
+ cursor = await self.conn.execute(
341
+ """INSERT INTO fault_analyses
342
+ (case_id, primary_fault_party_id, fault_distribution_json,
343
+ probable_cause, overall_confidence, analysis_summary)
344
+ VALUES (?, ?, ?, ?, ?, ?)""",
345
+ (case_id, primary_fault_party_id, fault_distribution_json,
346
+ probable_cause, overall_confidence, analysis_summary),
347
+ )
348
+ await self.conn.commit()
349
+ return cursor.lastrowid
350
+
351
+ async def get_fault_analysis(self, case_id: int) -> Optional[dict]:
352
+ cursor = await self.conn.execute(
353
+ """SELECT fa.*, p.label as fault_party_label
354
+ FROM fault_analyses fa
355
+ LEFT JOIN parties p ON fa.primary_fault_party_id = p.id
356
+ WHERE fa.case_id = ?""",
357
+ (case_id,),
358
+ )
359
+ row = await cursor.fetchone()
360
+ return dict(row) if row else None
361
+
362
+
363
+ # Singleton database instance
364
+ db = Database()
backend/app/models/__init__.py ADDED
File without changes
backend/app/models/schemas.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Pydantic Request/Response Models
3
+
4
+ Defines the API contract between frontend and backend.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field
8
+ from typing import Optional, List, Dict
9
+ from datetime import datetime
10
+
11
+
12
+ # ── Request Models ─────────────────────────────────────────────────────
13
+
14
+ class CreateCaseRequest(BaseModel):
15
+ case_number: str = Field(..., description="Unique case/incident number")
16
+ officer_name: Optional[str] = Field(None, description="Officer filing the report")
17
+ location: Optional[str] = Field(None, description="Accident location")
18
+ incident_date: Optional[str] = Field(None, description="Date of incident (YYYY-MM-DD)")
19
+ notes: Optional[str] = Field(None, description="Initial officer notes")
20
+
21
+
22
+ class UpdateCaseRequest(BaseModel):
23
+ officer_name: Optional[str] = Field(None, description="Officer filing the report")
24
+ location: Optional[str] = Field(None, description="Accident location")
25
+ incident_date: Optional[str] = Field(None, description="Date of incident (YYYY-MM-DD)")
26
+ notes: Optional[str] = Field(None, description="Initial officer notes")
27
+
28
+
29
+ # ── Response Models ────────────────────────────────────────────────────
30
+
31
+ class CaseResponse(BaseModel):
32
+ id: int
33
+ case_number: str
34
+ officer_name: Optional[str]
35
+ location: Optional[str]
36
+ incident_date: Optional[str]
37
+ notes: Optional[str]
38
+ status: str
39
+ created_at: str
40
+ photo_count: Optional[int] = 0
41
+
42
+
43
+ class PhotoResponse(BaseModel):
44
+ id: int
45
+ case_id: int
46
+ filename: str
47
+ filepath: str
48
+ file_size: Optional[int]
49
+ width: Optional[int]
50
+ height: Optional[int]
51
+ photo_type: str
52
+ uploaded_at: str
53
+
54
+
55
+ class VehicleInfo(BaseModel):
56
+ vehicle_type: Optional[str] = None
57
+ color: Optional[str] = None
58
+ model: Optional[str] = None
59
+ position: Optional[str] = None
60
+ direction: Optional[str] = None
61
+ damage: Optional[str] = None
62
+ damage_severity: Optional[str] = None
63
+
64
+
65
+ class RoadConditions(BaseModel):
66
+ road_type: Optional[str] = None
67
+ surface_condition: Optional[str] = None
68
+ lane_markings: Optional[str] = None
69
+ speed_limit: Optional[str] = None
70
+ traffic_signals: Optional[str] = None
71
+
72
+
73
+ class EvidenceInfo(BaseModel):
74
+ skid_marks: Optional[str] = None
75
+ debris: Optional[str] = None
76
+ fluid_spills: Optional[str] = None
77
+ glass_pattern: Optional[str] = None
78
+ airbag_deployed: Optional[bool] = None
79
+
80
+
81
+ class EnvironmentalInfo(BaseModel):
82
+ time_of_day: Optional[str] = None
83
+ weather: Optional[str] = None
84
+ visibility: Optional[str] = None
85
+
86
+
87
+ class SceneAnalysisResponse(BaseModel):
88
+ photo_id: int
89
+ filename: str
90
+ raw_analysis: str
91
+ vehicles: Optional[List[VehicleInfo]] = []
92
+ road_conditions: Optional[RoadConditions] = None
93
+ evidence: Optional[EvidenceInfo] = None
94
+ environmental: Optional[EnvironmentalInfo] = None
95
+ positions: Optional[str] = None
96
+ inference_time_ms: Optional[float] = None
97
+
98
+
99
+ class PartyResponse(BaseModel):
100
+ id: int
101
+ label: str
102
+ vehicle_type: Optional[str]
103
+ vehicle_color: Optional[str]
104
+ vehicle_description: Optional[str]
105
+
106
+
107
+ class ViolationResponse(BaseModel):
108
+ id: int
109
+ rule_id: str
110
+ rule_title: str
111
+ rule_category: Optional[str]
112
+ severity: Optional[str]
113
+ confidence: float
114
+ evidence_summary: Optional[str]
115
+ party_label: Optional[str]
116
+ photo_id: Optional[int]
117
+
118
+
119
+ class FaultAnalysisResponse(BaseModel):
120
+ primary_fault_party: Optional[str]
121
+ fault_distribution: Optional[Dict[str, float]]
122
+ probable_cause: Optional[str]
123
+ overall_confidence: Optional[float]
124
+ analysis_summary: Optional[str]
125
+
126
+
127
+ class FullReportResponse(BaseModel):
128
+ case: CaseResponse
129
+ photos: List[PhotoResponse]
130
+ analyses: List[SceneAnalysisResponse]
131
+ parties: List[PartyResponse]
132
+ violations: List[ViolationResponse]
133
+ fault_analysis: Optional[FaultAnalysisResponse]
134
+ disclaimer: str = (
135
+ "ADVISORY NOTICE: This analysis was generated by an AI system and is intended "
136
+ "as an assistive tool only. All findings are preliminary and must be reviewed "
137
+ "and validated by qualified law enforcement personnel before use in any "
138
+ "official proceedings. This report does not constitute a legal determination."
139
+ )
140
+
141
+
142
+ class AnalysisStatusResponse(BaseModel):
143
+ case_id: int
144
+ status: str
145
+ total_photos: int
146
+ analyzed_photos: int
147
+ violations_found: int
148
+ progress_percent: float
149
+
150
+
151
+ class HealthResponse(BaseModel):
152
+ status: str
153
+ model_loaded: bool
154
+ model_id: Optional[str]
155
+ device: Optional[str]
156
+ rules_loaded: int
backend/app/rules/__init__.py ADDED
File without changes
backend/app/rules/rule_loader.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Traffic Rules Loader
3
+
4
+ Loads and indexes traffic rules from YAML for fast lookup.
5
+ Rules are loaded once at startup and cached in memory.
6
+ """
7
+
8
+ import yaml
9
+ from pathlib import Path
10
+ from typing import List, Optional, Dict, Any
11
+ from dataclasses import dataclass, field
12
+
13
+ from backend.app.config import settings
14
+ from backend.app.utils.logger import get_logger
15
+
16
+ logger = get_logger("rule_loader")
17
+
18
+
19
+ @dataclass
20
+ class TrafficRule:
21
+ """A single traffic rule with visual indicators."""
22
+ id: str
23
+ title: str
24
+ description: str
25
+ severity: str # CRITICAL, HIGH, MEDIUM, LOW
26
+ visual_indicators: List[str]
27
+ fault_weight: float
28
+ applicable_parties: List[str]
29
+ category_id: str
30
+ category_name: str
31
+
32
+
33
+ @dataclass
34
+ class RuleMatch:
35
+ """A matched rule with confidence score."""
36
+ rule: TrafficRule
37
+ confidence: float
38
+ matched_indicators: List[str]
39
+ evidence_text: str = ""
40
+
41
+
42
+ class RuleLoader:
43
+ """Load and index traffic rules from YAML for fast lookup."""
44
+
45
+ _instance = None
46
+ _rules: List[TrafficRule] = []
47
+ _rules_by_id: Dict[str, TrafficRule] = {}
48
+ _categories: List[Dict[str, Any]] = []
49
+ _all_indicators: List[str] = []
50
+
51
+ def __new__(cls):
52
+ if cls._instance is None:
53
+ cls._instance = super().__new__(cls)
54
+ return cls._instance
55
+
56
+ @property
57
+ def is_loaded(self) -> bool:
58
+ return len(self._rules) > 0
59
+
60
+ def load_rules(self, rules_path: Path = None):
61
+ """Load all rules from YAML files in the rules directory."""
62
+ if rules_path is None:
63
+ rules_path = settings.rules_path
64
+
65
+ yaml_file = rules_path / "traffic_rules.yaml"
66
+ if not yaml_file.exists():
67
+ logger.error(f"Rules file not found: {yaml_file}")
68
+ return
69
+
70
+ logger.info(f"Loading traffic rules from: {yaml_file}")
71
+
72
+ with open(yaml_file, "r") as f:
73
+ data = yaml.safe_load(f)
74
+
75
+ self._rules = []
76
+ self._rules_by_id = {}
77
+ self._categories = []
78
+ self._all_indicators = []
79
+
80
+ for category in data.get("categories", []):
81
+ cat_id = category["id"]
82
+ cat_name = category["name"]
83
+ self._categories.append({"id": cat_id, "name": cat_name})
84
+
85
+ for rule_data in category.get("rules", []):
86
+ rule = TrafficRule(
87
+ id=rule_data["id"],
88
+ title=rule_data["title"],
89
+ description=rule_data["description"],
90
+ severity=rule_data.get("severity", "MEDIUM"),
91
+ visual_indicators=rule_data.get("visual_indicators", []),
92
+ fault_weight=rule_data.get("fault_weight", 0.5),
93
+ applicable_parties=rule_data.get("applicable_parties", []),
94
+ category_id=cat_id,
95
+ category_name=cat_name,
96
+ )
97
+ self._rules.append(rule)
98
+ self._rules_by_id[rule.id] = rule
99
+ self._all_indicators.extend(rule.visual_indicators)
100
+
101
+ logger.info(
102
+ f"Loaded {len(self._rules)} rules across "
103
+ f"{len(self._categories)} categories with "
104
+ f"{len(self._all_indicators)} visual indicators"
105
+ )
106
+
107
+ def get_all_rules(self) -> List[TrafficRule]:
108
+ """Return all loaded rules."""
109
+ return self._rules
110
+
111
+ def get_rule_by_id(self, rule_id: str) -> Optional[TrafficRule]:
112
+ """Look up a rule by its ID."""
113
+ return self._rules_by_id.get(rule_id)
114
+
115
+ def get_rules_by_category(self, category_id: str) -> List[TrafficRule]:
116
+ """Get all rules in a category."""
117
+ return [r for r in self._rules if r.category_id == category_id]
118
+
119
+ def get_categories(self) -> List[Dict[str, Any]]:
120
+ """Get list of all categories."""
121
+ return self._categories
122
+
123
+ def get_all_visual_indicators(self) -> List[str]:
124
+ """Get all visual indicators for prompt construction."""
125
+ return self._all_indicators
126
+
127
+ def get_rules_summary(self) -> Dict[str, Any]:
128
+ """Get a summary of loaded rules for the API."""
129
+ summary = {
130
+ "total_rules": len(self._rules),
131
+ "categories": []
132
+ }
133
+ for cat in self._categories:
134
+ cat_rules = self.get_rules_by_category(cat["id"])
135
+ summary["categories"].append({
136
+ "id": cat["id"],
137
+ "name": cat["name"],
138
+ "rule_count": len(cat_rules),
139
+ "rules": [
140
+ {"id": r.id, "title": r.title, "severity": r.severity}
141
+ for r in cat_rules
142
+ ]
143
+ })
144
+ return summary
145
+
146
+
147
+ # Singleton instance
148
+ rule_loader = RuleLoader()
backend/app/rules/traffic_rules.yaml ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # AI Accident Analysis β€” Traffic Rules Knowledge Base
3
+ # ============================================================================
4
+ # This YAML file contains pre-computed traffic rules that the AI matches
5
+ # against visual observations from accident scene photos.
6
+ #
7
+ # Each rule has:
8
+ # - visual_indicators: what the AI should look for in photos
9
+ # - fault_weight: how strongly this violation indicates fault (0.0-1.0)
10
+ # - severity: CRITICAL, HIGH, MEDIUM, LOW
11
+ # - applicable_parties: who can commit this violation
12
+ # ============================================================================
13
+
14
+ categories:
15
+
16
+ # ── Signal Violations ──────────────────────────────────────────────
17
+ - id: "SIGNAL"
18
+ name: "Traffic Signal Violations"
19
+ rules:
20
+ - id: "SIGNAL-001"
21
+ title: "Red Light Violation"
22
+ description: "Proceeding through a red traffic signal without stopping"
23
+ severity: "CRITICAL"
24
+ visual_indicators:
25
+ - "vehicle past stop line at intersection"
26
+ - "traffic signal showing red"
27
+ - "vehicle in middle of intersection"
28
+ - "collision at signalized intersection"
29
+ fault_weight: 0.95
30
+ applicable_parties: ["moving_vehicle"]
31
+
32
+ - id: "SIGNAL-002"
33
+ title: "Failure to Stop at Yellow Signal"
34
+ description: "Entering intersection on yellow when safe stop was possible"
35
+ severity: "HIGH"
36
+ visual_indicators:
37
+ - "vehicle in intersection"
38
+ - "traffic signal showing yellow"
39
+ - "skid marks before intersection"
40
+ - "late braking evidence"
41
+ fault_weight: 0.70
42
+ applicable_parties: ["moving_vehicle"]
43
+
44
+ - id: "SIGNAL-003"
45
+ title: "Failure to Obey Stop Sign"
46
+ description: "Failing to come to a complete stop at a stop sign"
47
+ severity: "CRITICAL"
48
+ visual_indicators:
49
+ - "stop sign visible"
50
+ - "vehicle past stop line"
51
+ - "no evidence of stopping"
52
+ - "collision near stop sign"
53
+ fault_weight: 0.90
54
+ applicable_parties: ["moving_vehicle"]
55
+
56
+ - id: "SIGNAL-004"
57
+ title: "Failure to Obey Yield Sign"
58
+ description: "Failing to yield right of way at a yield sign"
59
+ severity: "HIGH"
60
+ visual_indicators:
61
+ - "yield sign visible"
62
+ - "vehicle entering without yielding"
63
+ - "merging collision"
64
+ fault_weight: 0.80
65
+ applicable_parties: ["moving_vehicle"]
66
+
67
+ - id: "SIGNAL-005"
68
+ title: "Disobeying Traffic Control Device"
69
+ description: "Ignoring any official traffic control sign or signal"
70
+ severity: "HIGH"
71
+ visual_indicators:
72
+ - "no entry sign visible"
73
+ - "one way sign"
74
+ - "vehicle going wrong direction"
75
+ - "do not enter sign"
76
+ fault_weight: 0.85
77
+ applicable_parties: ["moving_vehicle"]
78
+
79
+ - id: "SIGNAL-006"
80
+ title: "Ignoring Railroad Crossing Signal"
81
+ description: "Proceeding through an active railroad crossing signal"
82
+ severity: "CRITICAL"
83
+ visual_indicators:
84
+ - "railroad crossing"
85
+ - "crossing gates"
86
+ - "flashing lights at crossing"
87
+ fault_weight: 0.95
88
+ applicable_parties: ["moving_vehicle"]
89
+
90
+ # ── Lane Discipline ────────────────────────────────────────────────
91
+ - id: "LANE"
92
+ name: "Lane Discipline Violations"
93
+ rules:
94
+ - id: "LANE-001"
95
+ title: "Wrong-Way Driving"
96
+ description: "Driving against the direction of traffic on a one-way road or divided highway"
97
+ severity: "CRITICAL"
98
+ visual_indicators:
99
+ - "vehicle facing opposite direction"
100
+ - "head-on collision"
101
+ - "vehicle on wrong side of divided highway"
102
+ - "one way sign with vehicle going wrong direction"
103
+ fault_weight: 0.98
104
+ applicable_parties: ["moving_vehicle"]
105
+
106
+ - id: "LANE-002"
107
+ title: "Unsafe Lane Change"
108
+ description: "Changing lanes without signaling or when unsafe to do so"
109
+ severity: "HIGH"
110
+ visual_indicators:
111
+ - "sideswipe damage"
112
+ - "contact marks on sides of vehicles"
113
+ - "vehicles in adjacent lanes"
114
+ - "paint transfer on vehicle sides"
115
+ fault_weight: 0.75
116
+ applicable_parties: ["moving_vehicle"]
117
+
118
+ - id: "LANE-003"
119
+ title: "Crossing Double Yellow Line"
120
+ description: "Crossing a double yellow center line to pass or turn"
121
+ severity: "HIGH"
122
+ visual_indicators:
123
+ - "double yellow line visible"
124
+ - "vehicle across center line"
125
+ - "head-on or angular collision"
126
+ - "vehicle in opposing lane"
127
+ fault_weight: 0.85
128
+ applicable_parties: ["moving_vehicle"]
129
+
130
+ - id: "LANE-004"
131
+ title: "Failure to Maintain Lane"
132
+ description: "Drifting out of designated travel lane"
133
+ severity: "MEDIUM"
134
+ visual_indicators:
135
+ - "vehicle partly in multiple lanes"
136
+ - "tire marks crossing lane lines"
137
+ - "sideswipe on lane boundary"
138
+ - "erratic tire tracks"
139
+ fault_weight: 0.65
140
+ applicable_parties: ["moving_vehicle"]
141
+
142
+ - id: "LANE-005"
143
+ title: "Improper Passing"
144
+ description: "Passing another vehicle illegally or unsafely"
145
+ severity: "HIGH"
146
+ visual_indicators:
147
+ - "passing zone violation"
148
+ - "solid line crossing"
149
+ - "head-on collision in passing zone"
150
+ - "vehicle in oncoming lane"
151
+ fault_weight: 0.80
152
+ applicable_parties: ["moving_vehicle"]
153
+
154
+ - id: "LANE-006"
155
+ title: "Driving on Shoulder"
156
+ description: "Operating a vehicle on the road shoulder"
157
+ severity: "MEDIUM"
158
+ visual_indicators:
159
+ - "vehicle on shoulder"
160
+ - "tire marks on shoulder"
161
+ - "collision on road shoulder"
162
+ - "gravel spray from shoulder"
163
+ fault_weight: 0.60
164
+ applicable_parties: ["moving_vehicle"]
165
+
166
+ - id: "LANE-007"
167
+ title: "Improper U-Turn"
168
+ description: "Making a U-turn where prohibited or unsafe"
169
+ severity: "HIGH"
170
+ visual_indicators:
171
+ - "vehicle turned perpendicular to traffic"
172
+ - "no u-turn sign"
173
+ - "vehicle blocking lanes during turn"
174
+ fault_weight: 0.80
175
+ applicable_parties: ["moving_vehicle"]
176
+
177
+ - id: "LANE-008"
178
+ title: "Failure to Use Turn Signal"
179
+ description: "Turning or changing lanes without activating turn signal"
180
+ severity: "MEDIUM"
181
+ visual_indicators:
182
+ - "no turn signal visible during maneuver"
183
+ - "sudden lane change"
184
+ - "unexpected turn collision"
185
+ fault_weight: 0.55
186
+ applicable_parties: ["moving_vehicle"]
187
+
188
+ # ── Speed Related ──────────────────────────────────────────────────
189
+ - id: "SPEED"
190
+ name: "Speed Related Violations"
191
+ rules:
192
+ - id: "SPEED-001"
193
+ title: "Excessive Speed"
194
+ description: "Traveling at a speed greater than posted limit or unsafe for conditions"
195
+ severity: "CRITICAL"
196
+ visual_indicators:
197
+ - "long skid marks"
198
+ - "severe impact damage"
199
+ - "extensive debris field"
200
+ - "vehicle displacement after impact"
201
+ - "deployed airbags"
202
+ - "significant vehicle deformation"
203
+ fault_weight: 0.85
204
+ applicable_parties: ["moving_vehicle"]
205
+
206
+ - id: "SPEED-002"
207
+ title: "Too Fast for Conditions"
208
+ description: "Driving too fast for weather, road, or traffic conditions"
209
+ severity: "HIGH"
210
+ visual_indicators:
211
+ - "wet road surface"
212
+ - "skid marks on wet road"
213
+ - "reduced visibility conditions"
214
+ - "loss of control evidence"
215
+ - "single vehicle off-road"
216
+ fault_weight: 0.75
217
+ applicable_parties: ["moving_vehicle"]
218
+
219
+ - id: "SPEED-003"
220
+ title: "Speed Too Slow"
221
+ description: "Driving unreasonably slow and impeding traffic"
222
+ severity: "LOW"
223
+ visual_indicators:
224
+ - "rear-end collision"
225
+ - "slow vehicle on highway"
226
+ - "hazard lights on moving vehicle"
227
+ fault_weight: 0.40
228
+ applicable_parties: ["moving_vehicle"]
229
+
230
+ - id: "SPEED-004"
231
+ title: "Racing or Speed Contest"
232
+ description: "Engaging in speed competition on public roads"
233
+ severity: "CRITICAL"
234
+ visual_indicators:
235
+ - "multiple vehicles with severe damage"
236
+ - "very long skid marks"
237
+ - "extreme vehicle deformation"
238
+ - "vehicle rollover"
239
+ fault_weight: 0.90
240
+ applicable_parties: ["moving_vehicle"]
241
+
242
+ - id: "SPEED-005"
243
+ title: "Failure to Reduce Speed in School Zone"
244
+ description: "Not reducing speed in a designated school zone"
245
+ severity: "CRITICAL"
246
+ visual_indicators:
247
+ - "school zone sign"
248
+ - "school crossing"
249
+ - "pedestrian collision near school"
250
+ - "school zone flashing lights"
251
+ fault_weight: 0.90
252
+ applicable_parties: ["moving_vehicle"]
253
+
254
+ # ── Right of Way ───────────────────────────────────────────────────
255
+ - id: "YIELD"
256
+ name: "Right of Way Violations"
257
+ rules:
258
+ - id: "YIELD-001"
259
+ title: "Failure to Yield at Intersection"
260
+ description: "Not yielding right of way to cross traffic at an intersection"
261
+ severity: "HIGH"
262
+ visual_indicators:
263
+ - "T-bone collision at intersection"
264
+ - "side impact at intersection"
265
+ - "vehicle entering intersection from side street"
266
+ - "angular collision"
267
+ fault_weight: 0.85
268
+ applicable_parties: ["moving_vehicle"]
269
+
270
+ - id: "YIELD-002"
271
+ title: "Failure to Yield When Turning Left"
272
+ description: "Not yielding to oncoming traffic when making a left turn"
273
+ severity: "HIGH"
274
+ visual_indicators:
275
+ - "left-turning vehicle struck"
276
+ - "front-corner impact on turning vehicle"
277
+ - "collision in intersection during left turn"
278
+ fault_weight: 0.85
279
+ applicable_parties: ["moving_vehicle"]
280
+
281
+ - id: "YIELD-003"
282
+ title: "Failure to Yield to Pedestrian"
283
+ description: "Not yielding right of way to pedestrian in crosswalk"
284
+ severity: "CRITICAL"
285
+ visual_indicators:
286
+ - "pedestrian in crosswalk"
287
+ - "pedestrian struck"
288
+ - "crosswalk markings visible"
289
+ - "pedestrian crossing sign"
290
+ fault_weight: 0.95
291
+ applicable_parties: ["moving_vehicle"]
292
+
293
+ - id: "YIELD-004"
294
+ title: "Failure to Yield When Merging"
295
+ description: "Not yielding when entering a highway or merging into traffic"
296
+ severity: "HIGH"
297
+ visual_indicators:
298
+ - "merge lane collision"
299
+ - "on-ramp collision"
300
+ - "merging vehicle sideswipe"
301
+ - "acceleration lane impact"
302
+ fault_weight: 0.80
303
+ applicable_parties: ["moving_vehicle"]
304
+
305
+ - id: "YIELD-005"
306
+ title: "Failure to Yield to Emergency Vehicle"
307
+ description: "Not pulling over for an emergency vehicle with lights/sirens"
308
+ severity: "CRITICAL"
309
+ visual_indicators:
310
+ - "emergency vehicle involved"
311
+ - "police car, ambulance, or fire truck"
312
+ - "emergency lights visible"
313
+ fault_weight: 0.90
314
+ applicable_parties: ["moving_vehicle"]
315
+
316
+ - id: "YIELD-006"
317
+ title: "Failure to Yield at Roundabout"
318
+ description: "Not yielding to traffic already in a roundabout"
319
+ severity: "HIGH"
320
+ visual_indicators:
321
+ - "roundabout collision"
322
+ - "circular intersection"
323
+ - "vehicle entering roundabout"
324
+ - "yield sign at roundabout"
325
+ fault_weight: 0.80
326
+ applicable_parties: ["moving_vehicle"]
327
+
328
+ - id: "YIELD-007"
329
+ title: "Right of Way at Uncontrolled Intersection"
330
+ description: "Failure to yield to vehicle on the right at an uncontrolled intersection"
331
+ severity: "HIGH"
332
+ visual_indicators:
333
+ - "intersection without signals or signs"
334
+ - "side impact collision"
335
+ - "residential intersection collision"
336
+ fault_weight: 0.70
337
+ applicable_parties: ["moving_vehicle"]
338
+
339
+ # ── Following Distance ─────────────────────────────────────────────
340
+ - id: "FOLLOW"
341
+ name: "Following Distance Violations"
342
+ rules:
343
+ - id: "FOLLOW-001"
344
+ title: "Following Too Closely (Tailgating)"
345
+ description: "Not maintaining a safe following distance behind another vehicle"
346
+ severity: "HIGH"
347
+ visual_indicators:
348
+ - "rear-end collision"
349
+ - "front damage on following vehicle"
350
+ - "rear damage on lead vehicle"
351
+ - "chain reaction collision"
352
+ - "short skid marks before impact"
353
+ fault_weight: 0.90
354
+ applicable_parties: ["following_vehicle"]
355
+
356
+ - id: "FOLLOW-002"
357
+ title: "Sudden Stop Causing Rear Collision"
358
+ description: "Stopping abruptly without justification causing following vehicle to collide"
359
+ severity: "MEDIUM"
360
+ visual_indicators:
361
+ - "rear-end collision"
362
+ - "no apparent hazard ahead"
363
+ - "brake lights"
364
+ fault_weight: 0.40
365
+ applicable_parties: ["lead_vehicle"]
366
+
367
+ # ── Vehicle Condition ──────────────────────────────────────────────
368
+ - id: "MAINT"
369
+ name: "Vehicle Maintenance Violations"
370
+ rules:
371
+ - id: "MAINT-001"
372
+ title: "Defective Brakes"
373
+ description: "Operating vehicle with defective or inadequate braking system"
374
+ severity: "CRITICAL"
375
+ visual_indicators:
376
+ - "very long skid marks"
377
+ - "no skid marks despite high-speed impact"
378
+ - "brake fluid on road"
379
+ - "brake component debris"
380
+ fault_weight: 0.70
381
+ applicable_parties: ["vehicle_owner"]
382
+
383
+ - id: "MAINT-002"
384
+ title: "Defective Tires"
385
+ description: "Operating vehicle with bald, damaged, or improper tires"
386
+ severity: "HIGH"
387
+ visual_indicators:
388
+ - "tire blowout debris"
389
+ - "bald tires visible"
390
+ - "irregular tire marks"
391
+ - "tire tread separation"
392
+ fault_weight: 0.65
393
+ applicable_parties: ["vehicle_owner"]
394
+
395
+ - id: "MAINT-003"
396
+ title: "Defective Lights"
397
+ description: "Operating vehicle with non-functional headlights, taillights, or signals"
398
+ severity: "MEDIUM"
399
+ visual_indicators:
400
+ - "broken headlight pre-collision"
401
+ - "no tail lights"
402
+ - "nighttime collision with unlit vehicle"
403
+ fault_weight: 0.55
404
+ applicable_parties: ["vehicle_owner"]
405
+
406
+ - id: "MAINT-004"
407
+ title: "Obstructed View"
408
+ description: "Driving with obstructed windshield or mirrors"
409
+ severity: "MEDIUM"
410
+ visual_indicators:
411
+ - "cracked windshield"
412
+ - "obstructed view"
413
+ - "missing mirrors"
414
+ - "tinted windows too dark"
415
+ fault_weight: 0.50
416
+ applicable_parties: ["vehicle_owner"]
417
+
418
+ - id: "MAINT-005"
419
+ title: "Unsecured Load"
420
+ description: "Carrying unsecured cargo that falls onto roadway"
421
+ severity: "HIGH"
422
+ visual_indicators:
423
+ - "cargo on road"
424
+ - "items fallen from vehicle"
425
+ - "truck load spill"
426
+ - "debris from cargo"
427
+ fault_weight: 0.75
428
+ applicable_parties: ["vehicle_owner"]
429
+
430
+ - id: "MAINT-006"
431
+ title: "Vehicle Overloaded"
432
+ description: "Vehicle loaded beyond safe operating capacity"
433
+ severity: "MEDIUM"
434
+ visual_indicators:
435
+ - "visibly overloaded vehicle"
436
+ - "sagging suspension"
437
+ - "items stacked above roof"
438
+ fault_weight: 0.55
439
+ applicable_parties: ["vehicle_owner"]
440
+
441
+ # ── Pedestrian Related ─────────────────────────────────────────────
442
+ - id: "PEDES"
443
+ name: "Pedestrian Related Violations"
444
+ rules:
445
+ - id: "PEDES-001"
446
+ title: "Pedestrian Jaywalking"
447
+ description: "Pedestrian crossing road outside of designated crosswalk"
448
+ severity: "MEDIUM"
449
+ visual_indicators:
450
+ - "pedestrian outside crosswalk"
451
+ - "pedestrian crossing mid-block"
452
+ - "no crosswalk markings at impact location"
453
+ fault_weight: 0.50
454
+ applicable_parties: ["pedestrian"]
455
+
456
+ - id: "PEDES-002"
457
+ title: "Pedestrian Against Signal"
458
+ description: "Pedestrian crossing against a don't walk signal"
459
+ severity: "HIGH"
460
+ visual_indicators:
461
+ - "pedestrian signal showing do not walk"
462
+ - "pedestrian in crosswalk against signal"
463
+ fault_weight: 0.70
464
+ applicable_parties: ["pedestrian"]
465
+
466
+ - id: "PEDES-003"
467
+ title: "Driver Failure to Stop for Pedestrian in Crosswalk"
468
+ description: "Driver not stopping for pedestrian in a marked or unmarked crosswalk"
469
+ severity: "CRITICAL"
470
+ visual_indicators:
471
+ - "pedestrian struck in crosswalk"
472
+ - "crosswalk markings visible"
473
+ - "pedestrian crossing sign"
474
+ fault_weight: 0.95
475
+ applicable_parties: ["moving_vehicle"]
476
+
477
+ - id: "PEDES-004"
478
+ title: "Pedestrian on Highway"
479
+ description: "Pedestrian walking on a limited-access highway"
480
+ severity: "HIGH"
481
+ visual_indicators:
482
+ - "pedestrian on highway"
483
+ - "limited access road"
484
+ - "no pedestrian facilities"
485
+ fault_weight: 0.60
486
+ applicable_parties: ["pedestrian"]
487
+
488
+ - id: "PEDES-005"
489
+ title: "Failure to Stop for School Bus"
490
+ description: "Passing a stopped school bus with flashing lights"
491
+ severity: "CRITICAL"
492
+ visual_indicators:
493
+ - "school bus with stop sign extended"
494
+ - "school bus with flashing lights"
495
+ - "children near school bus"
496
+ fault_weight: 0.95
497
+ applicable_parties: ["moving_vehicle"]
498
+
499
+ # ── Road Condition Related ─────────────────────────────────────────
500
+ - id: "ROAD"
501
+ name: "Road Condition Violations"
502
+ rules:
503
+ - id: "ROAD-001"
504
+ title: "Hazardous Road Condition"
505
+ description: "Road surface or design defect contributing to accident"
506
+ severity: "MEDIUM"
507
+ visual_indicators:
508
+ - "pothole visible"
509
+ - "road surface damage"
510
+ - "missing road markings"
511
+ - "inadequate signage"
512
+ - "construction zone without proper markings"
513
+ fault_weight: 0.30
514
+ applicable_parties: ["road_authority"]
515
+
516
+ - id: "ROAD-002"
517
+ title: "Inadequate Lighting"
518
+ description: "Road segment without adequate lighting contributing to collision"
519
+ severity: "MEDIUM"
520
+ visual_indicators:
521
+ - "dark road"
522
+ - "no street lights"
523
+ - "nighttime collision in unlit area"
524
+ fault_weight: 0.25
525
+ applicable_parties: ["road_authority"]
526
+
527
+ - id: "ROAD-003"
528
+ title: "Missing or Damaged Guard Rail"
529
+ description: "Absent or damaged barrier that could have prevented injury"
530
+ severity: "MEDIUM"
531
+ visual_indicators:
532
+ - "vehicle off road"
533
+ - "no guard rail on curve"
534
+ - "damaged guard rail"
535
+ - "vehicle in ditch or embankment"
536
+ fault_weight: 0.20
537
+ applicable_parties: ["road_authority"]
538
+
539
+ - id: "ROAD-004"
540
+ title: "Work Zone / Construction Hazard"
541
+ description: "Improperly marked or protected construction zone"
542
+ severity: "HIGH"
543
+ visual_indicators:
544
+ - "construction zone"
545
+ - "work zone signs"
546
+ - "cones, barrels, barriers"
547
+ - "lane closure"
548
+ - "construction equipment on road"
549
+ fault_weight: 0.35
550
+ applicable_parties: ["road_authority", "moving_vehicle"]
backend/app/utils/__init__.py ADDED
File without changes
backend/app/utils/logger.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Accident Analysis β€” Logging Utility
3
+
4
+ Provides a consistent logger with file and console output.
5
+ Reuses the same pattern as PhotoSearchApp.
6
+ """
7
+
8
+ import logging
9
+ import functools
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from backend.app.config import settings
14
+
15
+
16
+ def get_logger(name: str) -> logging.Logger:
17
+ """Get a logger with both console and file handlers."""
18
+ logger = logging.getLogger(f"accident_analysis.{name}")
19
+
20
+ if logger.handlers:
21
+ return logger
22
+
23
+ logger.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
24
+
25
+ # Console handler
26
+ console = logging.StreamHandler()
27
+ console.setLevel(logging.DEBUG)
28
+ fmt = logging.Formatter(
29
+ "%(asctime)s | %(name)-30s | %(levelname)-7s | %(message)s",
30
+ datefmt="%H:%M:%S",
31
+ )
32
+ console.setFormatter(fmt)
33
+ logger.addHandler(console)
34
+
35
+ # File handler
36
+ try:
37
+ log_file = settings.log_path / "server.log"
38
+ from logging.handlers import RotatingFileHandler
39
+ file_handler = RotatingFileHandler(
40
+ log_file,
41
+ maxBytes=settings.log_file_max_bytes,
42
+ backupCount=settings.log_file_backup_count,
43
+ )
44
+ file_handler.setLevel(logging.DEBUG)
45
+ file_handler.setFormatter(fmt)
46
+ logger.addHandler(file_handler)
47
+ except Exception:
48
+ pass # Don't crash if log file can't be created
49
+
50
+ return logger
51
+
52
+
53
+ def timed(func):
54
+ """Decorator to log execution time of a function."""
55
+ @functools.wraps(func)
56
+ def wrapper(*args, **kwargs):
57
+ start = time.perf_counter()
58
+ result = func(*args, **kwargs)
59
+ elapsed = round((time.perf_counter() - start) * 1000, 2)
60
+ logger = get_logger("timing")
61
+ logger.info(f"{func.__name__} completed in {elapsed}ms")
62
+ return result
63
+ return wrapper
frontend/css/styles.css ADDED
@@ -0,0 +1,1725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ==========================================================================
2
+ AI Accident Analysis β€” Stylesheet
3
+ Dark theme with police/forensic aesthetic, glassmorphism
4
+ ========================================================================== */
5
+
6
+ /* ── CSS Variables ──────────────────────────────────────────────────── */
7
+ :root {
8
+ /* Primary palette β€” clean white with dark blue accents */
9
+ --bg-primary: #f8fafc;
10
+ --bg-secondary: #ffffff;
11
+ --bg-tertiary: #f1f5f9;
12
+
13
+ /* Dark Blue Panels */
14
+ --surface: rgba(255, 255, 255, 0.85);
15
+ /* deeply saturated dark blue */
16
+ --surface-hover: rgba(240, 244, 248, 0.9);
17
+ --glass: rgba(255, 255, 255, 0.75);
18
+ /* translucent dark blue */
19
+ --glass-border: rgba(30, 58, 138, 0.15);
20
+
21
+ /* Text on light backgrounds (Global) */
22
+ --text-primary: #0f172a;
23
+ --text-secondary: #334155;
24
+ --text-muted: #64748b;
25
+
26
+ /* Accent */
27
+ --accent: #1e3a8a;
28
+ /* deep dark blue */
29
+ --accent-hover: #172554;
30
+ --accent-glow: rgba(30, 58, 138, 0.15);
31
+
32
+ /* Severity colors */
33
+ --critical: #ef4444;
34
+ --critical-bg: rgba(239, 68, 68, 0.12);
35
+ --high: #f97316;
36
+ --high-bg: rgba(249, 115, 22, 0.12);
37
+ --medium: #eab308;
38
+ --medium-bg: rgba(234, 179, 8, 0.12);
39
+ --low: #22c55e;
40
+ --low-bg: rgba(34, 197, 94, 0.12);
41
+
42
+ /* Status */
43
+ --pending: #8b97b0;
44
+ --analyzing: #3b82f6;
45
+ --complete: #22c55e;
46
+ --error: #ef4444;
47
+
48
+ /* Layout */
49
+ --sidebar-width: 240px;
50
+ --radius: 12px;
51
+ --radius-sm: 8px;
52
+ --radius-xs: 6px;
53
+
54
+ /* Fonts */
55
+ --font-heading: 'Outfit', sans-serif;
56
+ --font-body: 'Inter', sans-serif;
57
+
58
+ /* Transitions */
59
+ --transition: 0.2s ease;
60
+ --transition-slow: 0.4s ease;
61
+ }
62
+
63
+ /* ── Reset & Base ──────────────────────────────────────────────────── */
64
+ *,
65
+ *::before,
66
+ *::after {
67
+ box-sizing: border-box;
68
+ margin: 0;
69
+ padding: 0;
70
+ }
71
+
72
+ html,
73
+ body {
74
+ height: 100%;
75
+ font-family: var(--font-body);
76
+ font-size: 14px;
77
+ line-height: 1.6;
78
+ color: var(--text-primary);
79
+ background: var(--bg-primary);
80
+ -webkit-font-smoothing: antialiased;
81
+ overflow: hidden;
82
+ }
83
+
84
+ h1,
85
+ h2,
86
+ h3,
87
+ h4,
88
+ h5 {
89
+ font-family: var(--font-heading);
90
+ font-weight: 600;
91
+ }
92
+
93
+ input,
94
+ select,
95
+ textarea,
96
+ button {
97
+ font-family: var(--font-body);
98
+ font-size: 0.9rem;
99
+ }
100
+
101
+ /* ── App Layout ────────────────────────────────────────────────────── */
102
+ .app-container {
103
+ display: flex;
104
+ height: 100vh;
105
+ background:
106
+ radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.06) 0%, transparent 60%),
107
+ radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.04) 0%, transparent 50%),
108
+ var(--bg-primary);
109
+ }
110
+
111
+ /* ── Sidebar ───────────────────────────────────────────────────────── */
112
+ .sidebar {
113
+ width: var(--sidebar-width);
114
+ min-width: var(--sidebar-width);
115
+ display: flex;
116
+ flex-direction: column;
117
+ padding: 1.5rem 1rem;
118
+ border-right: 1px solid var(--glass-border);
119
+ background: var(--glass);
120
+ backdrop-filter: blur(20px);
121
+ }
122
+
123
+ .brand {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 0.7rem;
127
+ padding: 0 0.5rem;
128
+ margin-bottom: 2rem;
129
+ }
130
+
131
+ .brand i {
132
+ font-size: 1.5rem;
133
+ color: var(--accent);
134
+ text-shadow: 0 0 20px var(--accent-glow);
135
+ }
136
+
137
+ .brand h1 {
138
+ font-size: 1.2rem;
139
+ letter-spacing: -0.5px;
140
+ background: linear-gradient(135deg, var(--text-primary), var(--accent));
141
+ -webkit-background-clip: text;
142
+ background-clip: text;
143
+ -webkit-text-fill-color: transparent;
144
+ }
145
+
146
+ .nav-menu {
147
+ flex: 1;
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: 4px;
151
+ }
152
+
153
+ .nav-item {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 0.7rem;
157
+ padding: 0.65rem 0.8rem;
158
+ border: none;
159
+ background: transparent;
160
+ color: var(--text-secondary);
161
+ border-radius: var(--radius-sm);
162
+ cursor: pointer;
163
+ transition: var(--transition);
164
+ text-align: left;
165
+ font-size: 0.88rem;
166
+ }
167
+
168
+ .nav-item:hover {
169
+ background: var(--surface-hover);
170
+ color: var(--text-primary);
171
+ }
172
+
173
+ .nav-item.active {
174
+ background: var(--accent-glow);
175
+ color: var(--accent);
176
+ font-weight: 500;
177
+ }
178
+
179
+ .nav-section-title {
180
+ font-size: 0.65rem;
181
+ text-transform: uppercase;
182
+ letter-spacing: 0.8px;
183
+ color: var(--text-muted);
184
+ font-weight: 600;
185
+ margin: 1.2rem 0 0.4rem 0.8rem;
186
+ }
187
+
188
+ .nav-divider {
189
+ height: 1px;
190
+ background: var(--glass-border);
191
+ margin: 0.5rem 0;
192
+ }
193
+
194
+ .status-panel {
195
+ padding: 0.8rem;
196
+ background: var(--surface);
197
+ border-radius: var(--radius-sm);
198
+ margin-top: auto;
199
+ }
200
+
201
+ .status-item {
202
+ display: flex;
203
+ justify-content: space-between;
204
+ align-items: center;
205
+ padding: 0.3rem 0;
206
+ }
207
+
208
+ .status-label {
209
+ font-size: 0.75rem;
210
+ color: var(--text-muted);
211
+ text-transform: uppercase;
212
+ letter-spacing: 0.5px;
213
+ }
214
+
215
+ .status-value {
216
+ font-size: 0.78rem;
217
+ color: var(--text-secondary);
218
+ font-weight: 500;
219
+ }
220
+
221
+ .disclaimer-badge {
222
+ display: flex;
223
+ align-items: center;
224
+ gap: 0.5rem;
225
+ padding: 0.6rem 0.8rem;
226
+ margin-top: 0.8rem;
227
+ background: var(--medium-bg);
228
+ border: 1px solid rgba(234, 179, 8, 0.2);
229
+ border-radius: var(--radius-xs);
230
+ color: var(--medium);
231
+ font-size: 0.72rem;
232
+ font-weight: 500;
233
+ letter-spacing: 0.3px;
234
+ }
235
+
236
+ /* ── Main Content ──────────────────────────────────────────────────── */
237
+ .main-content {
238
+ flex: 1;
239
+ overflow-y: auto;
240
+ padding: 1.5rem 2rem;
241
+ position: relative;
242
+ }
243
+
244
+ .view {
245
+ display: none;
246
+ animation: fadeIn 0.3s ease;
247
+ }
248
+
249
+ .view.active {
250
+ display: block;
251
+ }
252
+
253
+ .hidden {
254
+ display: none !important;
255
+ }
256
+
257
+ @keyframes fadeIn {
258
+ from {
259
+ opacity: 0;
260
+ transform: translateY(8px);
261
+ }
262
+
263
+ to {
264
+ opacity: 1;
265
+ transform: translateY(0);
266
+ }
267
+ }
268
+
269
+ .view-header {
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 1rem;
273
+ margin-bottom: 1.5rem;
274
+ }
275
+
276
+ .view-header h2 {
277
+ font-size: 1.3rem;
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 0.6rem;
281
+ }
282
+
283
+ .view-header h2 i {
284
+ color: var(--accent);
285
+ font-size: 1.1rem;
286
+ }
287
+
288
+ .header-actions {
289
+ margin-left: auto;
290
+ display: flex;
291
+ gap: 0.6rem;
292
+ }
293
+
294
+ /* ── Buttons ───────────────────────────────────────────────────────── */
295
+ .btn-primary {
296
+ background: linear-gradient(135deg, var(--accent), var(--accent-hover));
297
+ color: white;
298
+ border: none;
299
+ padding: 0.6rem 1.2rem;
300
+ border-radius: var(--radius-sm);
301
+ cursor: pointer;
302
+ font-weight: 500;
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 0.4rem;
306
+ transition: var(--transition);
307
+ box-shadow: 0 2px 10px var(--accent-glow);
308
+ }
309
+
310
+ .btn-primary:hover {
311
+ transform: translateY(-1px);
312
+ box-shadow: 0 4px 15px var(--accent-glow);
313
+ }
314
+
315
+ .btn-primary:disabled {
316
+ opacity: 0.5;
317
+ cursor: not-allowed;
318
+ transform: none;
319
+ }
320
+
321
+ .btn-secondary {
322
+ background: var(--surface);
323
+ color: var(--text-secondary);
324
+ border: 1px solid var(--glass-border);
325
+ padding: 0.6rem 1rem;
326
+ border-radius: var(--radius-sm);
327
+ cursor: pointer;
328
+ display: flex;
329
+ align-items: center;
330
+ gap: 0.4rem;
331
+ transition: var(--transition);
332
+ }
333
+
334
+ .btn-secondary:hover {
335
+ background: var(--surface-hover);
336
+ color: var(--text-primary);
337
+ }
338
+
339
+ .btn-secondary:disabled {
340
+ opacity: 0.5;
341
+ cursor: not-allowed;
342
+ }
343
+
344
+ .btn-large {
345
+ padding: 0.8rem 2rem;
346
+ font-size: 1rem;
347
+ }
348
+
349
+ .btn-back {
350
+ background: transparent;
351
+ border: none;
352
+ color: var(--text-secondary);
353
+ cursor: pointer;
354
+ font-size: 1.1rem;
355
+ padding: 0.4rem;
356
+ transition: var(--transition);
357
+ }
358
+
359
+ .btn-back:hover {
360
+ color: var(--accent);
361
+ }
362
+
363
+ /* ── Glass Panel ───────────────────────────────────────────────────── */
364
+ .glass {
365
+ background: var(--glass);
366
+ backdrop-filter: blur(20px);
367
+ border: 2px solid var(--accent);
368
+ /* thick dark blue border */
369
+ border-radius: var(--radius);
370
+ }
371
+
372
+ /* ── Cases Grid (Dashboard) ────────────────────────────────────────── */
373
+ .cases-grid {
374
+ display: grid;
375
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
376
+ gap: 1rem;
377
+ }
378
+
379
+ .case-card {
380
+ background: var(--surface);
381
+ border: 2px solid var(--accent);
382
+ /* thick dark blue border */
383
+ border-radius: var(--radius);
384
+ padding: 1.2rem;
385
+ cursor: pointer;
386
+ transition: var(--transition);
387
+ position: relative;
388
+ overflow: hidden;
389
+ }
390
+
391
+ .case-card::before {
392
+ content: '';
393
+ position: absolute;
394
+ top: 0;
395
+ left: 0;
396
+ width: 100%;
397
+ height: 3px;
398
+ background: var(--accent);
399
+ opacity: 0;
400
+ transition: var(--transition);
401
+ }
402
+
403
+ .case-card:hover {
404
+ border-color: var(--accent);
405
+ transform: translateY(-2px);
406
+ }
407
+
408
+ .case-card:hover::before {
409
+ opacity: 1;
410
+ }
411
+
412
+ .case-card .card-header {
413
+ display: flex;
414
+ justify-content: space-between;
415
+ align-items: center;
416
+ margin-bottom: 0.6rem;
417
+ }
418
+
419
+ .case-card .case-number {
420
+ font-weight: 600;
421
+ font-size: 1rem;
422
+ color: var(--text-primary);
423
+ }
424
+
425
+ .case-card .case-meta {
426
+ display: flex;
427
+ flex-direction: column;
428
+ gap: 0.25rem;
429
+ font-size: 0.82rem;
430
+ color: var(--text-secondary);
431
+ }
432
+
433
+ .case-card .case-meta span {
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 0.4rem;
437
+ }
438
+
439
+ .case-card .case-meta i {
440
+ width: 14px;
441
+ text-align: center;
442
+ color: var(--text-muted);
443
+ font-size: 0.78rem;
444
+ }
445
+
446
+ .case-card .card-footer {
447
+ display: flex;
448
+ justify-content: space-between;
449
+ align-items: center;
450
+ margin-top: 0.8rem;
451
+ padding-top: 0.8rem;
452
+ border-top: 1px solid var(--glass-border);
453
+ }
454
+
455
+ .case-card .photo-count {
456
+ font-size: 0.78rem;
457
+ color: var(--text-muted);
458
+ display: flex;
459
+ align-items: center;
460
+ gap: 0.3rem;
461
+ }
462
+
463
+ /* Status badge */
464
+ .status-badge {
465
+ display: inline-flex;
466
+ align-items: center;
467
+ gap: 0.3rem;
468
+ padding: 0.2rem 0.6rem;
469
+ border-radius: 999px;
470
+ font-size: 0.7rem;
471
+ font-weight: 600;
472
+ text-transform: uppercase;
473
+ letter-spacing: 0.5px;
474
+ }
475
+
476
+ .status-badge.pending {
477
+ background: rgba(139, 151, 176, 0.15);
478
+ color: var(--pending);
479
+ }
480
+
481
+ .status-badge.analyzing {
482
+ background: rgba(59, 130, 246, 0.15);
483
+ color: var(--analyzing);
484
+ }
485
+
486
+ .status-badge.complete {
487
+ background: rgba(34, 197, 94, 0.15);
488
+ color: var(--complete);
489
+ }
490
+
491
+ .status-badge.error {
492
+ background: rgba(239, 68, 68, 0.15);
493
+ color: var(--error);
494
+ }
495
+
496
+ .status-badge i {
497
+ font-size: 0.5rem;
498
+ }
499
+
500
+ /* ── Empty State ───────────────────────────────────────────────────── */
501
+ .empty-state {
502
+ display: flex;
503
+ flex-direction: column;
504
+ align-items: center;
505
+ justify-content: center;
506
+ padding: 4rem 2rem;
507
+ text-align: center;
508
+ }
509
+
510
+ .empty-state i {
511
+ font-size: 3rem;
512
+ color: var(--text-muted);
513
+ margin-bottom: 1rem;
514
+ opacity: 0.4;
515
+ }
516
+
517
+ .empty-state h3 {
518
+ font-size: 1.2rem;
519
+ color: var(--text-secondary);
520
+ margin-bottom: 0.5rem;
521
+ }
522
+
523
+ .empty-state p {
524
+ color: var(--text-muted);
525
+ margin-bottom: 1.5rem;
526
+ }
527
+
528
+ /* ── New Case Form ─────────────────────────────────────────────────── */
529
+ .form-container {
530
+ padding: 1.5rem;
531
+ }
532
+
533
+ .form-grid {
534
+ display: grid;
535
+ grid-template-columns: 1fr 1fr;
536
+ gap: 1rem;
537
+ margin-bottom: 1.5rem;
538
+ }
539
+
540
+ .form-group {
541
+ display: flex;
542
+ flex-direction: column;
543
+ gap: 0.3rem;
544
+ }
545
+
546
+ .form-group.full-width {
547
+ grid-column: 1 / -1;
548
+ }
549
+
550
+ .form-group label {
551
+ font-size: 0.78rem;
552
+ font-weight: 500;
553
+ color: var(--text-secondary);
554
+ text-transform: uppercase;
555
+ letter-spacing: 0.5px;
556
+ }
557
+
558
+ .form-group input,
559
+ .form-group textarea,
560
+ .form-group select {
561
+ background: var(--bg-secondary);
562
+ border: 1px solid var(--glass-border);
563
+ border-radius: var(--radius-xs);
564
+ padding: 0.6rem 0.8rem;
565
+ color: var(--text-primary);
566
+ /* explicitly dark text for white inputs */
567
+ transition: var(--transition);
568
+ }
569
+
570
+ .form-group input:focus,
571
+ .form-group textarea:focus {
572
+ outline: none;
573
+ border-color: var(--accent);
574
+ box-shadow: 0 0 0 3px var(--accent-glow);
575
+ }
576
+
577
+ .form-group textarea {
578
+ resize: vertical;
579
+ }
580
+
581
+ .form-actions {
582
+ display: flex;
583
+ justify-content: center;
584
+ margin-top: 1rem;
585
+ }
586
+
587
+ /* ── Drop Zone ─────────────────────────────────────────────────────── */
588
+ .drop-zone {
589
+ display: flex;
590
+ flex-direction: column;
591
+ align-items: center;
592
+ justify-content: center;
593
+ padding: 2.5rem 1.5rem;
594
+ border: 2px dashed var(--glass-border);
595
+ border-radius: var(--radius);
596
+ cursor: pointer;
597
+ transition: var(--transition);
598
+ text-align: center;
599
+ }
600
+
601
+ .drop-zone:hover,
602
+ .drop-zone.drag-over {
603
+ border-color: var(--accent);
604
+ background: var(--accent-glow);
605
+ }
606
+
607
+ .drop-zone i {
608
+ font-size: 2.2rem;
609
+ color: var(--accent);
610
+ margin-bottom: 0.8rem;
611
+ opacity: 0.7;
612
+ }
613
+
614
+ .drop-zone p {
615
+ color: var(--text-secondary);
616
+ font-size: 0.95rem;
617
+ margin-bottom: 0.3rem;
618
+ }
619
+
620
+ .drop-zone .sub-text {
621
+ font-size: 0.78rem;
622
+ color: var(--text-muted);
623
+ }
624
+
625
+ /* ── Photo Preview ─────────────────────────────────────────────────── */
626
+ .photo-preview {
627
+ margin-top: 1rem;
628
+ }
629
+
630
+ .photo-thumbnails {
631
+ display: flex;
632
+ flex-wrap: wrap;
633
+ gap: 0.5rem;
634
+ margin-bottom: 0.5rem;
635
+ }
636
+
637
+ .photo-thumbnail {
638
+ width: 70px;
639
+ height: 70px;
640
+ border-radius: var(--radius-xs);
641
+ overflow: hidden;
642
+ border: 2px solid var(--glass-border);
643
+ }
644
+
645
+ .photo-thumbnail img {
646
+ width: 100%;
647
+ height: 100%;
648
+ object-fit: cover;
649
+ }
650
+
651
+ .photo-count {
652
+ font-size: 0.82rem;
653
+ color: var(--text-secondary);
654
+ }
655
+
656
+ /* ── Progress ────────────────────────────────────────────��─────────── */
657
+ .progress-section {
658
+ margin-top: 1rem;
659
+ }
660
+
661
+ .progress-bar {
662
+ width: 100%;
663
+ height: 6px;
664
+ background: var(--bg-secondary);
665
+ border-radius: 999px;
666
+ overflow: hidden;
667
+ margin-bottom: 0.5rem;
668
+ }
669
+
670
+ .progress-fill {
671
+ height: 100%;
672
+ background: linear-gradient(90deg, var(--accent), #818cf8);
673
+ border-radius: 999px;
674
+ width: 0%;
675
+ transition: width 0.5s ease;
676
+ }
677
+
678
+ #upload-status-text {
679
+ font-size: 0.82rem;
680
+ color: var(--text-secondary);
681
+ }
682
+
683
+ /* ── Case Detail ───────────────────────────────────────────────────── */
684
+ .case-info-bar {
685
+ display: flex;
686
+ flex-wrap: wrap;
687
+ gap: 0.8rem;
688
+ padding: 0.8rem 1rem;
689
+ margin-bottom: 1.5rem;
690
+ }
691
+
692
+ .info-chip {
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 0.4rem;
696
+ font-size: 0.82rem;
697
+ color: var(--text-secondary);
698
+ padding: 0.3rem 0.7rem;
699
+ background: var(--surface);
700
+ border-radius: 999px;
701
+ }
702
+
703
+ .info-chip i {
704
+ color: var(--accent);
705
+ font-size: 0.75rem;
706
+ }
707
+
708
+ .status-chip.pending i {
709
+ color: var(--pending);
710
+ }
711
+
712
+ .status-chip.analyzing i {
713
+ color: var(--analyzing);
714
+ }
715
+
716
+ .status-chip.complete i {
717
+ color: var(--complete);
718
+ }
719
+
720
+ .status-chip.error i {
721
+ color: var(--error);
722
+ }
723
+
724
+ /* ── Analysis Grid ─────────────────────────────────────────────────── */
725
+ .analysis-grid {
726
+ display: grid;
727
+ grid-template-columns: 1fr 1fr;
728
+ gap: 1rem;
729
+ }
730
+
731
+ .panel {
732
+ padding: 1rem;
733
+ min-height: 200px;
734
+ max-height: 500px;
735
+ overflow-y: auto;
736
+ }
737
+
738
+ .panel-header {
739
+ display: flex;
740
+ justify-content: space-between;
741
+ align-items: center;
742
+ margin-bottom: 0.8rem;
743
+ padding-bottom: 0.6rem;
744
+ border-bottom: 1px solid var(--glass-border);
745
+ }
746
+
747
+ .panel-header h3 {
748
+ font-size: 0.9rem;
749
+ display: flex;
750
+ align-items: center;
751
+ gap: 0.5rem;
752
+ color: var(--text-primary);
753
+ }
754
+
755
+ .panel-header h3 i {
756
+ color: var(--accent);
757
+ font-size: 0.85rem;
758
+ }
759
+
760
+ .badge {
761
+ background: var(--surface);
762
+ color: var(--text-secondary);
763
+ padding: 0.15rem 0.5rem;
764
+ border-radius: 999px;
765
+ font-size: 0.72rem;
766
+ font-weight: 600;
767
+ }
768
+
769
+ .badge.danger {
770
+ background: var(--critical-bg);
771
+ color: var(--critical);
772
+ }
773
+
774
+ .placeholder-text {
775
+ color: var(--text-muted);
776
+ font-size: 0.85rem;
777
+ font-style: italic;
778
+ }
779
+
780
+ /* ── Detail Photos Grid ────────────────────────────────────────────── */
781
+ .detail-photos-grid {
782
+ display: grid;
783
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
784
+ gap: 0.5rem;
785
+ }
786
+
787
+ .detail-photo {
788
+ aspect-ratio: 4/3;
789
+ border-radius: var(--radius-xs);
790
+ overflow: hidden;
791
+ cursor: pointer;
792
+ border: 2px solid transparent;
793
+ transition: var(--transition);
794
+ }
795
+
796
+ .detail-photo:hover {
797
+ border-color: var(--accent);
798
+ transform: scale(1.02);
799
+ }
800
+
801
+ .detail-photo img {
802
+ width: 100%;
803
+ height: 100%;
804
+ object-fit: cover;
805
+ }
806
+
807
+ /* ── Analysis Content ──────────────────────────────────────────────── */
808
+ .analysis-content {
809
+ font-size: 0.85rem;
810
+ line-height: 1.7;
811
+ color: var(--text-secondary);
812
+ }
813
+
814
+ .analysis-content pre {
815
+ white-space: pre-wrap;
816
+ word-break: break-word;
817
+ font-family: var(--font-body);
818
+ }
819
+
820
+ .analysis-photo-section {
821
+ margin-bottom: 1rem;
822
+ }
823
+
824
+ .analysis-photo-label {
825
+ font-weight: 600;
826
+ color: var(--accent);
827
+ margin-bottom: 0.3rem;
828
+ font-size: 0.82rem;
829
+ }
830
+
831
+ /* ── Violations List ───────────────────────────────────────────────── */
832
+ .violation-card {
833
+ background: var(--surface);
834
+ border: 2px solid var(--accent);
835
+ /* thick dark blue border */
836
+ border-radius: var(--radius-sm);
837
+ padding: 0.8rem;
838
+ margin-bottom: 0.5rem;
839
+ border-left: 6px solid var(--text-muted);
840
+ transition: var(--transition);
841
+ }
842
+
843
+ .violation-card:hover {
844
+ background: var(--surface-hover);
845
+ }
846
+
847
+ .violation-card.CRITICAL {
848
+ border-left-color: var(--critical);
849
+ }
850
+
851
+ .violation-card.HIGH {
852
+ border-left-color: var(--high);
853
+ }
854
+
855
+ .violation-card.MEDIUM {
856
+ border-left-color: var(--medium);
857
+ }
858
+
859
+ .violation-card.LOW {
860
+ border-left-color: var(--low);
861
+ }
862
+
863
+ .violation-header {
864
+ display: flex;
865
+ justify-content: space-between;
866
+ align-items: center;
867
+ margin-bottom: 0.3rem;
868
+ }
869
+
870
+ .violation-title {
871
+ font-weight: 600;
872
+ font-size: 0.85rem;
873
+ color: var(--text-primary);
874
+ }
875
+
876
+ .severity-tag {
877
+ font-size: 0.65rem;
878
+ font-weight: 700;
879
+ padding: 0.15rem 0.5rem;
880
+ border-radius: 999px;
881
+ text-transform: uppercase;
882
+ letter-spacing: 0.5px;
883
+ }
884
+
885
+ .severity-tag.CRITICAL {
886
+ background: var(--critical-bg);
887
+ color: var(--critical);
888
+ }
889
+
890
+ .severity-tag.HIGH {
891
+ background: var(--high-bg);
892
+ color: var(--high);
893
+ }
894
+
895
+ .severity-tag.MEDIUM {
896
+ background: var(--medium-bg);
897
+ color: var(--medium);
898
+ }
899
+
900
+ .severity-tag.LOW {
901
+ background: var(--low-bg);
902
+ color: var(--low);
903
+ }
904
+
905
+ .violation-meta {
906
+ font-size: 0.78rem;
907
+ color: var(--text-muted);
908
+ display: flex;
909
+ gap: 1rem;
910
+ }
911
+
912
+ .violation-meta span {
913
+ display: flex;
914
+ align-items: center;
915
+ gap: 0.3rem;
916
+ }
917
+
918
+ .violation-evidence {
919
+ font-size: 0.78rem;
920
+ color: var(--text-secondary);
921
+ margin-top: 0.3rem;
922
+ font-style: italic;
923
+ }
924
+
925
+ /* ── Fault Content ─────────────────────────────────────────────────── */
926
+ .fault-content {
927
+ font-size: 0.85rem;
928
+ }
929
+
930
+ .fault-party-bars {
931
+ margin-bottom: 1rem;
932
+ }
933
+
934
+ .party-bar {
935
+ margin-bottom: 0.6rem;
936
+ }
937
+
938
+ .party-bar-label {
939
+ display: flex;
940
+ justify-content: space-between;
941
+ margin-bottom: 0.25rem;
942
+ font-size: 0.82rem;
943
+ }
944
+
945
+ .party-bar-label .party-name {
946
+ font-weight: 600;
947
+ color: var(--text-primary);
948
+ }
949
+
950
+ .party-bar-label .party-pct {
951
+ color: var(--accent);
952
+ font-weight: 600;
953
+ }
954
+
955
+ .party-bar-track {
956
+ height: 8px;
957
+ background: var(--bg-secondary);
958
+ border-radius: 999px;
959
+ overflow: hidden;
960
+ }
961
+
962
+ .party-bar-fill {
963
+ height: 100%;
964
+ border-radius: 999px;
965
+ transition: width 1s ease;
966
+ }
967
+
968
+ .party-bar-fill.primary {
969
+ background: linear-gradient(90deg, var(--critical), var(--high));
970
+ }
971
+
972
+ .party-bar-fill.secondary {
973
+ background: linear-gradient(90deg, var(--medium), var(--low));
974
+ }
975
+
976
+ .fault-cause {
977
+ background: var(--surface);
978
+ border-radius: var(--radius-sm);
979
+ padding: 0.8rem;
980
+ margin-top: 0.8rem;
981
+ }
982
+
983
+ .fault-cause h4 {
984
+ font-size: 0.82rem;
985
+ color: var(--accent);
986
+ margin-bottom: 0.4rem;
987
+ }
988
+
989
+ .fault-cause p {
990
+ font-size: 0.82rem;
991
+ color: var(--text-secondary);
992
+ line-height: 1.6;
993
+ }
994
+
995
+ .confidence-meter {
996
+ display: flex;
997
+ align-items: center;
998
+ gap: 0.5rem;
999
+ margin-top: 0.8rem;
1000
+ font-size: 0.82rem;
1001
+ color: var(--text-muted);
1002
+ }
1003
+
1004
+ .confidence-meter .meter-bar {
1005
+ flex: 1;
1006
+ height: 4px;
1007
+ background: var(--bg-secondary);
1008
+ border-radius: 999px;
1009
+ overflow: hidden;
1010
+ }
1011
+
1012
+ .confidence-meter .meter-fill {
1013
+ height: 100%;
1014
+ background: var(--accent);
1015
+ border-radius: 999px;
1016
+ }
1017
+
1018
+ /* ── Analysis Overlay ──────────────────────────────────────────────── */
1019
+ .analysis-overlay {
1020
+ position: absolute;
1021
+ top: 0;
1022
+ left: 0;
1023
+ right: 0;
1024
+ bottom: 0;
1025
+ background: rgba(10, 14, 26, 0.85);
1026
+ display: flex;
1027
+ align-items: center;
1028
+ justify-content: center;
1029
+ z-index: 100;
1030
+ backdrop-filter: blur(5px);
1031
+ }
1032
+
1033
+ .analysis-overlay.hidden {
1034
+ display: none;
1035
+ }
1036
+
1037
+ .overlay-content {
1038
+ text-align: center;
1039
+ padding: 2.5rem 3rem;
1040
+ border-radius: var(--radius);
1041
+ }
1042
+
1043
+ .overlay-content h3 {
1044
+ margin: 1rem 0 0.5rem;
1045
+ color: var(--text-primary);
1046
+ }
1047
+
1048
+ .overlay-content p {
1049
+ color: var(--text-secondary);
1050
+ font-size: 0.88rem;
1051
+ }
1052
+
1053
+ /* ── Spinner ───────────────────────────────────────────────────────── */
1054
+ .spinner-large {
1055
+ width: 48px;
1056
+ height: 48px;
1057
+ border: 4px solid var(--glass-border);
1058
+ border-top-color: var(--accent);
1059
+ border-radius: 50%;
1060
+ margin: 0 auto;
1061
+ animation: spin 1s linear infinite;
1062
+ }
1063
+
1064
+ @keyframes spin {
1065
+ to {
1066
+ transform: rotate(360deg);
1067
+ }
1068
+ }
1069
+
1070
+ /* ── Report Content ────────────────────────────────────────────────── */
1071
+ .report-content {
1072
+ padding: 2rem;
1073
+ max-width: 900px;
1074
+ margin: 0 auto;
1075
+ line-height: 1.8;
1076
+ }
1077
+
1078
+ .report-content h3 {
1079
+ font-size: 1.1rem;
1080
+ color: var(--accent);
1081
+ margin: 1.5rem 0 0.5rem;
1082
+ padding-bottom: 0.3rem;
1083
+ border-bottom: 1px solid var(--glass-border);
1084
+ }
1085
+
1086
+ .report-content h4 {
1087
+ font-size: 0.95rem;
1088
+ color: var(--text-primary);
1089
+ margin: 1rem 0 0.3rem;
1090
+ }
1091
+
1092
+ .report-content p {
1093
+ font-size: 0.88rem;
1094
+ color: var(--text-secondary);
1095
+ margin-bottom: 0.5rem;
1096
+ }
1097
+
1098
+ .report-header {
1099
+ text-align: center;
1100
+ margin-bottom: 2rem;
1101
+ padding-bottom: 1.5rem;
1102
+ border-bottom: 2px solid var(--accent);
1103
+ }
1104
+
1105
+ .report-header h2 {
1106
+ font-size: 1.4rem;
1107
+ margin-bottom: 0.3rem;
1108
+ }
1109
+
1110
+ .report-header .report-subtitle {
1111
+ color: var(--text-muted);
1112
+ font-size: 0.88rem;
1113
+ }
1114
+
1115
+ .report-disclaimer {
1116
+ background: var(--medium-bg);
1117
+ border: 1px solid rgba(234, 179, 8, 0.2);
1118
+ border-radius: var(--radius-sm);
1119
+ padding: 0.8rem 1rem;
1120
+ font-size: 0.78rem;
1121
+ color: var(--medium);
1122
+ line-height: 1.6;
1123
+ margin-bottom: 1.5rem;
1124
+ }
1125
+
1126
+ .report-stat-grid {
1127
+ display: grid;
1128
+ grid-template-columns: repeat(4, 1fr);
1129
+ gap: 0.8rem;
1130
+ margin-bottom: 1.5rem;
1131
+ }
1132
+
1133
+ .report-stat {
1134
+ text-align: center;
1135
+ background: var(--surface);
1136
+ border-radius: var(--radius-sm);
1137
+ padding: 0.8rem;
1138
+ }
1139
+
1140
+ .report-stat .stat-number {
1141
+ font-size: 1.8rem;
1142
+ font-weight: 700;
1143
+ color: var(--accent);
1144
+ }
1145
+
1146
+ .report-stat .stat-label {
1147
+ font-size: 0.72rem;
1148
+ color: var(--text-muted);
1149
+ text-transform: uppercase;
1150
+ letter-spacing: 0.5px;
1151
+ }
1152
+
1153
+ /* ── Rules View ────────────────────────────────────────────────────── */
1154
+ .rules-content {
1155
+ display: flex;
1156
+ flex-direction: column;
1157
+ gap: 1.5rem;
1158
+ }
1159
+
1160
+ .rule-category {
1161
+ margin-bottom: 1.5rem;
1162
+ }
1163
+
1164
+ .rule-category-header {
1165
+ font-size: 1rem;
1166
+ color: var(--text-primary);
1167
+ padding: 0.6rem 1rem;
1168
+ background: var(--surface);
1169
+ border-radius: var(--radius-sm);
1170
+ margin-bottom: 0.5rem;
1171
+ display: flex;
1172
+ align-items: center;
1173
+ gap: 0.5rem;
1174
+ cursor: pointer;
1175
+ transition: var(--transition);
1176
+ }
1177
+
1178
+ .rule-category-header:hover {
1179
+ background: var(--surface-hover);
1180
+ }
1181
+
1182
+ .rule-category-header .rule-count {
1183
+ margin-left: auto;
1184
+ color: var(--text-muted);
1185
+ font-size: 0.82rem;
1186
+ }
1187
+
1188
+ .rule-list {
1189
+ padding-left: 0.5rem;
1190
+ }
1191
+
1192
+ .rule-item {
1193
+ padding: 0.4rem 0.8rem;
1194
+ margin-bottom: 0.25rem;
1195
+ font-size: 0.82rem;
1196
+ color: var(--text-secondary);
1197
+ display: flex;
1198
+ align-items: center;
1199
+ gap: 0.5rem;
1200
+ }
1201
+
1202
+ .rule-item .rule-id {
1203
+ color: var(--text-muted);
1204
+ font-family: monospace;
1205
+ font-size: 0.75rem;
1206
+ min-width: 90px;
1207
+ }
1208
+
1209
+ /* ── Toast ─────────────────────────────────────────────────────────── */
1210
+ #toast-container {
1211
+ position: fixed;
1212
+ top: 1rem;
1213
+ right: 1rem;
1214
+ z-index: 1000;
1215
+ display: flex;
1216
+ flex-direction: column;
1217
+ gap: 0.5rem;
1218
+ }
1219
+
1220
+ .toast {
1221
+ padding: 0.7rem 1.2rem;
1222
+ border-radius: var(--radius-sm);
1223
+ font-size: 0.85rem;
1224
+ animation: slideIn 0.3s ease, fadeOut 0.5s ease 3.5s forwards;
1225
+ max-width: 350px;
1226
+ backdrop-filter: blur(10px);
1227
+ }
1228
+
1229
+ .toast.success {
1230
+ background: rgba(34, 197, 94, 0.9);
1231
+ color: white;
1232
+ }
1233
+
1234
+ .toast.error {
1235
+ background: rgba(239, 68, 68, 0.9);
1236
+ color: white;
1237
+ }
1238
+
1239
+ .toast.info {
1240
+ background: rgba(59, 130, 246, 0.9);
1241
+ color: white;
1242
+ }
1243
+
1244
+ @keyframes slideIn {
1245
+ from {
1246
+ transform: translateX(100%);
1247
+ opacity: 0;
1248
+ }
1249
+
1250
+ to {
1251
+ transform: translateX(0);
1252
+ opacity: 1;
1253
+ }
1254
+ }
1255
+
1256
+ @keyframes fadeOut {
1257
+ to {
1258
+ opacity: 0;
1259
+ }
1260
+ }
1261
+
1262
+ /* ── Responsive ────────────────────────────────────────────────────── */
1263
+ @media (max-width: 768px) {
1264
+ .sidebar {
1265
+ display: none;
1266
+ }
1267
+
1268
+ .main-content {
1269
+ padding: 1rem;
1270
+ }
1271
+
1272
+ .form-grid {
1273
+ grid-template-columns: 1fr;
1274
+ }
1275
+
1276
+ .analysis-grid {
1277
+ grid-template-columns: 1fr;
1278
+ }
1279
+
1280
+ .case-info-bar {
1281
+ flex-direction: column;
1282
+ }
1283
+
1284
+ .report-stat-grid {
1285
+ grid-template-columns: repeat(2, 1fr);
1286
+ }
1287
+ }
1288
+
1289
+ /* ── Scrollbar ─────────────────────────────────────────────────────── */
1290
+ ::-webkit-scrollbar {
1291
+ width: 6px;
1292
+ }
1293
+
1294
+ ::-webkit-scrollbar-track {
1295
+ background: transparent;
1296
+ }
1297
+
1298
+ ::-webkit-scrollbar-thumb {
1299
+ background: var(--glass-border);
1300
+ border-radius: 999px;
1301
+ }
1302
+
1303
+ ::-webkit-scrollbar-thumb:hover {
1304
+ background: var(--text-muted);
1305
+ }
1306
+
1307
+ /* ── Modals ────────────────────────────────────────────────────────── */
1308
+ .modal {
1309
+ position: fixed;
1310
+ top: 0;
1311
+ left: 0;
1312
+ width: 100%;
1313
+ height: 100%;
1314
+ background: rgba(0, 0, 0, 0.6);
1315
+ display: flex;
1316
+ align-items: center;
1317
+ justify-content: center;
1318
+ z-index: 1000;
1319
+ backdrop-filter: blur(4px);
1320
+ transition: opacity 0.3s ease;
1321
+ }
1322
+
1323
+ .modal.hidden {
1324
+ display: none;
1325
+ opacity: 0;
1326
+ pointer-events: none;
1327
+ }
1328
+
1329
+ .modal-content {
1330
+ width: 90%;
1331
+ max-width: 600px;
1332
+ max-height: 90vh;
1333
+ overflow-y: auto;
1334
+ padding: 2rem;
1335
+ position: relative;
1336
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
1337
+ }
1338
+
1339
+ .modal-header {
1340
+ display: flex;
1341
+ justify-content: space-between;
1342
+ align-items: center;
1343
+ margin-bottom: 1.5rem;
1344
+ }
1345
+
1346
+ .modal-header h2 {
1347
+ font-size: 1.3rem;
1348
+ margin: 0;
1349
+ }
1350
+
1351
+ .btn-close {
1352
+ background: transparent;
1353
+ border: none;
1354
+ color: var(--text-secondary);
1355
+ font-size: 1.8rem;
1356
+ cursor: pointer;
1357
+ line-height: 1;
1358
+ }
1359
+
1360
+ .modal-footer {
1361
+ display: flex;
1362
+ justify-content: flex-end;
1363
+ gap: 1rem;
1364
+ margin-top: 2rem;
1365
+ padding-top: 1rem;
1366
+ border-top: 1px solid var(--glass-border);
1367
+ }
1368
+
1369
+ /* ── UI Enhancements ──────────────────────────────────────────────── */
1370
+ .panel-actions {
1371
+ display: flex;
1372
+ align-items: center;
1373
+ gap: 0.8rem;
1374
+ }
1375
+
1376
+ .btn-icon-sm {
1377
+ background: var(--surface);
1378
+ border: 1px solid var(--glass-border);
1379
+ color: var(--text-secondary);
1380
+ width: 28px;
1381
+ height: 28px;
1382
+ border-radius: 999px;
1383
+ display: flex;
1384
+ align-items: center;
1385
+ justify-content: center;
1386
+ cursor: pointer;
1387
+ transition: var(--transition);
1388
+ }
1389
+
1390
+ .btn-icon-sm:hover {
1391
+ color: var(--accent);
1392
+ border-color: var(--accent);
1393
+ background: var(--accent-glow);
1394
+ }
1395
+
1396
+ /* Delete Icon in Card */
1397
+ .btn-delete-card {
1398
+ position: absolute;
1399
+ top: 0.8rem;
1400
+ right: 0.8rem;
1401
+ background: rgba(239, 68, 68, 0.1);
1402
+ color: var(--critical);
1403
+ border: 1px solid rgba(239, 68, 68, 0.2);
1404
+ width: 24px;
1405
+ height: 24px;
1406
+ border-radius: 4px;
1407
+ display: flex;
1408
+ align-items: center;
1409
+ justify-content: center;
1410
+ cursor: pointer;
1411
+ transition: var(--transition);
1412
+ z-index: 10;
1413
+ opacity: 0.4;
1414
+ }
1415
+
1416
+ .case-card:hover .btn-delete-card {
1417
+ opacity: 1;
1418
+ }
1419
+
1420
+ .btn-delete-card:hover {
1421
+ background: var(--critical);
1422
+ color: white;
1423
+ border-color: var(--critical);
1424
+ }
1425
+
1426
+ /* ── Landing Page ─────────────────────────────────────────────────── */
1427
+ #view-landing {
1428
+ display: flex;
1429
+ flex-direction: column;
1430
+ align-items: center;
1431
+ max-width: 1000px;
1432
+ margin: 0 auto;
1433
+ padding: 2rem 1rem;
1434
+ animation: fadeIn 0.8s ease-out;
1435
+ }
1436
+
1437
+ @keyframes fadeIn {
1438
+ from {
1439
+ opacity: 0;
1440
+ transform: translateY(10px);
1441
+ }
1442
+
1443
+ to {
1444
+ opacity: 1;
1445
+ transform: translateY(0);
1446
+ }
1447
+ }
1448
+
1449
+ .landing-hero {
1450
+ text-align: center;
1451
+ margin-bottom: 4rem;
1452
+ }
1453
+
1454
+ .landing-title {
1455
+ font-size: 4.5rem;
1456
+ font-weight: 800;
1457
+ color: var(--accent);
1458
+ margin-bottom: 1rem;
1459
+ letter-spacing: -1px;
1460
+ background: linear-gradient(135deg, var(--accent) 0%, #3b82f6 100%);
1461
+ -webkit-background-clip: text;
1462
+ background-clip: text;
1463
+ -webkit-text-fill-color: transparent;
1464
+ }
1465
+
1466
+ .landing-subtitle {
1467
+ font-size: 1.6rem;
1468
+ color: var(--text-primary);
1469
+ font-weight: 600;
1470
+ margin-bottom: 1.5rem;
1471
+ line-height: 1.3;
1472
+ }
1473
+
1474
+ .landing-description {
1475
+ font-size: 1.15rem;
1476
+ color: var(--text-secondary);
1477
+ max-width: 800px;
1478
+ margin: 0 auto;
1479
+ line-height: 1.6;
1480
+ }
1481
+
1482
+ .landing-use-cases {
1483
+ width: 100%;
1484
+ }
1485
+
1486
+ .use-cases-title {
1487
+ font-size: 1.8rem;
1488
+ color: var(--text-primary);
1489
+ margin-bottom: 2rem;
1490
+ text-align: center;
1491
+ font-weight: 700;
1492
+ }
1493
+
1494
+ /* ── Modern Accordion ─────────────────────────────────────────────── */
1495
+ .accordion-container {
1496
+ display: flex;
1497
+ flex-direction: column;
1498
+ gap: 1rem;
1499
+ }
1500
+
1501
+ .accordion-item {
1502
+ border-radius: var(--radius);
1503
+ overflow: hidden;
1504
+ transition: box-shadow 0.3s ease, border-color 0.3s ease;
1505
+ }
1506
+
1507
+ .accordion-item:hover {
1508
+ box-shadow: 0 4px 20px var(--accent-glow);
1509
+ border-color: var(--accent);
1510
+ }
1511
+
1512
+ .accordion-header {
1513
+ width: 100%;
1514
+ display: flex;
1515
+ justify-content: space-between;
1516
+ align-items: center;
1517
+ padding: 1.2rem 1.5rem;
1518
+ background: transparent;
1519
+ border: none;
1520
+ color: var(--text-primary);
1521
+ font-size: 1.2rem;
1522
+ font-weight: 600;
1523
+ cursor: pointer;
1524
+ text-align: left;
1525
+ transition: color 0.3s ease;
1526
+ }
1527
+
1528
+ .accordion-header i:first-child {
1529
+ margin-right: 0.8rem;
1530
+ color: var(--accent);
1531
+ width: 24px;
1532
+ text-align: center;
1533
+ }
1534
+
1535
+ .accordion-icon {
1536
+ transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
1537
+ color: var(--text-secondary);
1538
+ font-size: 1rem;
1539
+ }
1540
+
1541
+ .accordion-header:hover {
1542
+ color: var(--accent);
1543
+ }
1544
+
1545
+ /* Modern smooth height transition using Grid */
1546
+ .accordion-body {
1547
+ display: grid;
1548
+ grid-template-rows: 0fr;
1549
+ transition: grid-template-rows 0.4s cubic-bezier(0.4, 0, 0.2, 1);
1550
+ background: rgba(255, 255, 255, 0.4);
1551
+ }
1552
+
1553
+ .accordion-content {
1554
+ overflow: hidden;
1555
+ }
1556
+
1557
+ .accordion-content p {
1558
+ padding: 0 1.5rem 1.2rem 3.5rem;
1559
+ margin: 0;
1560
+ color: var(--text-secondary);
1561
+ line-height: 1.6;
1562
+ }
1563
+
1564
+ .accordion-content p strong {
1565
+ color: var(--text-primary);
1566
+ }
1567
+
1568
+ .accordion-content p:first-child {
1569
+ padding-top: 0.5rem;
1570
+ }
1571
+
1572
+ /* Active State */
1573
+ .accordion-item.active .accordion-body {
1574
+ grid-template-rows: 1fr;
1575
+ }
1576
+
1577
+ .accordion-item.active .accordion-icon {
1578
+ transform: rotate(180deg);
1579
+ color: var(--accent);
1580
+ }
1581
+
1582
+ .accordion-item.active .accordion-header {
1583
+ color: var(--accent);
1584
+ }
1585
+
1586
+ /* ── Sidebar Collapsible Widgets ──────────────────────────────────── */
1587
+ .sidebar-collapsible {
1588
+ margin-bottom: 0.4rem;
1589
+ }
1590
+
1591
+ .sidebar-collapse-btn {
1592
+ width: 100%;
1593
+ display: flex;
1594
+ justify-content: space-between;
1595
+ align-items: center;
1596
+ padding: 0.55rem 0.8rem;
1597
+ background: transparent;
1598
+ border: none;
1599
+ color: var(--text-secondary);
1600
+ font-size: 0.82rem;
1601
+ font-weight: 500;
1602
+ cursor: pointer;
1603
+ border-radius: var(--radius-sm);
1604
+ transition: var(--transition);
1605
+ text-align: left;
1606
+ }
1607
+
1608
+ .sidebar-collapse-btn span {
1609
+ display: flex;
1610
+ align-items: center;
1611
+ gap: 0.55rem;
1612
+ }
1613
+
1614
+ .sidebar-collapse-btn span i {
1615
+ color: var(--accent);
1616
+ font-size: 0.85rem;
1617
+ width: 16px;
1618
+ text-align: center;
1619
+ }
1620
+
1621
+ .sidebar-collapse-btn:hover {
1622
+ background: var(--surface-hover);
1623
+ color: var(--text-primary);
1624
+ }
1625
+
1626
+ .sidebar-collapse-icon {
1627
+ font-size: 0.65rem;
1628
+ color: var(--text-muted);
1629
+ transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
1630
+ }
1631
+
1632
+ .sidebar-collapsible.open .sidebar-collapse-icon {
1633
+ transform: rotate(180deg);
1634
+ color: var(--accent);
1635
+ }
1636
+
1637
+ .sidebar-collapsible.open .sidebar-collapse-btn {
1638
+ color: var(--accent);
1639
+ background: var(--accent-glow);
1640
+ }
1641
+
1642
+ /* Smooth height animation via CSS Grid */
1643
+ .sidebar-collapse-body {
1644
+ display: grid;
1645
+ grid-template-rows: 0fr;
1646
+ transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1);
1647
+ }
1648
+
1649
+ .sidebar-collapsible.open .sidebar-collapse-body {
1650
+ grid-template-rows: 1fr;
1651
+ }
1652
+
1653
+ .sidebar-collapse-content {
1654
+ overflow: hidden;
1655
+ padding: 0 0.8rem;
1656
+ }
1657
+
1658
+ .sidebar-collapsible.open .sidebar-collapse-content {
1659
+ padding: 0.4rem 0.8rem 0.6rem;
1660
+ }
1661
+
1662
+ /* Founder details in sidebar */
1663
+ .sidebar-founder-role {
1664
+ font-size: 0.75rem;
1665
+ color: var(--text-muted);
1666
+ line-height: 1.5;
1667
+ margin-bottom: 0.5rem;
1668
+ }
1669
+
1670
+ .sidebar-linkedin {
1671
+ display: inline-flex;
1672
+ align-items: center;
1673
+ gap: 0.4rem;
1674
+ padding: 0.3rem 0.7rem;
1675
+ background: #0a66c2;
1676
+ color: #ffffff;
1677
+ border-radius: var(--radius-xs);
1678
+ text-decoration: none;
1679
+ font-size: 0.75rem;
1680
+ font-weight: 500;
1681
+ transition: var(--transition);
1682
+ }
1683
+
1684
+ .sidebar-linkedin:hover {
1685
+ background: #004182;
1686
+ transform: translateY(-1px);
1687
+ }
1688
+
1689
+ .sidebar-linkedin i {
1690
+ font-size: 0.85rem;
1691
+ }
1692
+
1693
+ /* Contact details in sidebar */
1694
+ .sidebar-contact-link {
1695
+ display: flex;
1696
+ align-items: center;
1697
+ gap: 0.4rem;
1698
+ color: var(--accent);
1699
+ text-decoration: none;
1700
+ font-size: 0.78rem;
1701
+ font-weight: 500;
1702
+ margin-bottom: 0.4rem;
1703
+ transition: var(--transition);
1704
+ }
1705
+
1706
+ .sidebar-contact-link:hover {
1707
+ color: var(--accent-hover);
1708
+ }
1709
+
1710
+ .sidebar-contact-link i,
1711
+ .sidebar-contact-phone i {
1712
+ color: var(--accent);
1713
+ font-size: 0.78rem;
1714
+ width: 14px;
1715
+ text-align: center;
1716
+ }
1717
+
1718
+ .sidebar-contact-phone {
1719
+ display: flex;
1720
+ align-items: center;
1721
+ gap: 0.4rem;
1722
+ color: var(--text-muted);
1723
+ font-size: 0.75rem;
1724
+ font-style: italic;
1725
+ }
frontend/index.html ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>TraceScene β€” Visual Intelligence for Law Enforcement</title>
8
+ <meta name="description"
9
+ content="AI-powered accident scene analysis: upload photos, detect traffic violations, determine fault.">
10
+
11
+ <!-- Fonts -->
12
+ <link rel="preconnect" href="https://fonts.googleapis.com">
13
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
14
+ <link
15
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700&display=swap"
16
+ rel="stylesheet">
17
+
18
+ <!-- Icons -->
19
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
20
+
21
+ <!-- Styles -->
22
+ <link rel="stylesheet" href="/static/css/styles.css">
23
+ </head>
24
+
25
+ <body>
26
+ <div class="app-container">
27
+ <!-- Sidebar -->
28
+ <aside class="sidebar glass">
29
+ <div class="brand">
30
+ <i class="fa-solid fa-shield-halved"></i>
31
+ <h1>TraceScene</h1>
32
+ </div>
33
+
34
+ <nav class="nav-menu">
35
+ <button class="nav-item active" data-view="landing">
36
+ <i class="fa-solid fa-home"></i> Home
37
+ </button>
38
+
39
+ <div class="nav-section-title">Law Enforcement</div>
40
+ <button class="nav-item" data-view="le-dashboard">
41
+ <i class="fa-solid fa-chart-line"></i> Dashboard
42
+ </button>
43
+ <button class="nav-item" data-view="le-new-case">
44
+ <i class="fa-solid fa-shield-halved"></i> New Case
45
+ </button>
46
+
47
+ <div class="nav-section-title">Insurance</div>
48
+ <button class="nav-item" data-view="ins-dashboard">
49
+ <i class="fa-solid fa-chart-line"></i> Dashboard
50
+ </button>
51
+ <button class="nav-item" data-view="ins-new-case">
52
+ <i class="fa-solid fa-file-invoice-dollar"></i> New Case
53
+ </button>
54
+
55
+ <div class="nav-divider"></div>
56
+
57
+ <button class="nav-item" data-view="rules">
58
+ <i class="fa-solid fa-book-open"></i> Traffic Rules
59
+ </button>
60
+ <button class="nav-item" data-view="chat">
61
+ <i class="fa-solid fa-message"></i> Chat Agent
62
+ </button>
63
+ <button class="nav-item" data-view="simulation">
64
+ <i class="fa-solid fa-video"></i> Simulation
65
+ </button>
66
+ </nav>
67
+
68
+ <!-- Sidebar: Founder (collapsible) -->
69
+ <div class="sidebar-collapsible">
70
+ <button class="sidebar-collapse-btn" aria-expanded="false"
71
+ onclick="this.parentElement.classList.toggle('open'); this.setAttribute('aria-expanded', this.parentElement.classList.contains('open'))">
72
+ <span><i class="fa-solid fa-user-tie"></i> Built by</span>
73
+ <i class="fa-solid fa-chevron-down sidebar-collapse-icon"></i>
74
+ </button>
75
+ <div class="sidebar-collapse-body">
76
+ <div class="sidebar-collapse-content">
77
+ <a href="https://www.linkedin.com/in/siddharth-ravikumar-17262a50/" target="_blank"
78
+ rel="noopener noreferrer" class="sidebar-linkedin">
79
+ <i class="fa-brands fa-linkedin"></i> LinkedIn
80
+ </a>
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <!-- Sidebar: Contact (collapsible) -->
86
+ <div class="sidebar-collapsible">
87
+ <button class="sidebar-collapse-btn" aria-expanded="false"
88
+ onclick="this.parentElement.classList.toggle('open'); this.setAttribute('aria-expanded', this.parentElement.classList.contains('open'))">
89
+ <span><i class="fa-solid fa-envelope"></i> Request Demo</span>
90
+ <i class="fa-solid fa-chevron-down sidebar-collapse-icon"></i>
91
+ </button>
92
+ <div class="sidebar-collapse-body">
93
+ <div class="sidebar-collapse-content">
94
+ <a href="mailto:tracescene@zohomail.ae" class="sidebar-contact-link">
95
+ <i class="fa-solid fa-at"></i> tracescene@zohomail.ae
96
+ </a>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="status-panel">
102
+ <div class="status-item">
103
+ <span class="status-label">Model</span>
104
+ <span class="status-value" id="status-model">Loading...</span>
105
+ </div>
106
+ <div class="status-item">
107
+ <span class="status-label">Device</span>
108
+ <span class="status-value" id="status-device">β€”</span>
109
+ </div>
110
+ <div class="status-item">
111
+ <span class="status-label">Rules</span>
112
+ <span class="status-value" id="status-rules">β€”</span>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="disclaimer-badge">
117
+ <i class="fa-solid fa-triangle-exclamation"></i>
118
+ <span>AI Advisory Only</span>
119
+ </div>
120
+ </aside>
121
+
122
+ <!-- Main Content -->
123
+ <main class="main-content">
124
+
125
+ <!-- Landing Page View -->
126
+ <section id="view-landing" class="view active">
127
+ <div class="landing-hero">
128
+ <h1 class="landing-title">TraceScene</h1>
129
+ <h2 class="landing-subtitle">Edge AI for Accident Intelligence β€” Fully Offline. Fully Private.</h2>
130
+ <p class="landing-description">
131
+ Edge-based AI enables powerful reasoning, and real-time physical world analysis directly on edge
132
+ devices custom-trained in specialized physical conditions for accurate accident analysis and
133
+ Insurance Verificationβ€”all fully offline and privacy-preserving, with no data leaving the device
134
+ </p>
135
+ </div>
136
+
137
+ <div class="landing-use-cases">
138
+ <h3 class="use-cases-title">Cross-Vertical Use Cases: From Intelligence to Implementation</h3>
139
+
140
+ <div class="accordion-container">
141
+ <!-- Law Enforcement -->
142
+ <div class="accordion-item glass">
143
+ <button class="accordion-header" aria-expanded="false">
144
+ <span><i class="fa-solid fa-shield-halved"></i> Law Enforcement and Public Safety</span>
145
+ <i class="fa-solid fa-chevron-down accordion-icon"></i>
146
+ </button>
147
+ <div class="accordion-body">
148
+ <div class="accordion-content">
149
+ <p><strong>Automated Incident Reporting:</strong> Field officers capture a 30-second
150
+ scene walkthrough. The engine determines causality (e.g., "Vehicle A rear-ended
151
+ Vehicle B due to wet road conditions") and
152
+ auto-generates a structured First Information Report (FIR) instantly.</p>
153
+ <p><strong>Rule-Book Verification:</strong> Insights are matched against the UAE
154
+ Federal Traffic rulebook to identify violations like Article 18 tailgating or
155
+ signal jumping.</p>
156
+ <p><strong>Forensic Reconstructions:</strong> Generates "frozen-in-time" 3D digital
157
+ twins and evidence maps locally on mobile NPUs.</p>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ <!-- Insurance -->
163
+ <div class="accordion-item glass">
164
+ <button class="accordion-header" aria-expanded="false">
165
+ <span><i class="fa-solid fa-file-invoice-dollar"></i> Insurance and Claims
166
+ Management</span>
167
+ <i class="fa-solid fa-chevron-down accordion-icon"></i>
168
+ </button>
169
+ <div class="accordion-body">
170
+ <div class="accordion-content">
171
+ <p><strong>Verified Local Law Mapping:</strong> Automatically cross-references
172
+ visual evidence
173
+ against the UAE Federal Traffic Law (e.g., Article 18 for tailgating or Article
174
+ 12 for accident assistance) to determine fault based on objective legal
175
+ standards rather than human estimation.</p>
176
+ <p><strong>Real-Time Reasoning vs. Manual Verification:</strong> Shifts the
177
+ adjuster's
178
+ workflow from "Collect-then-Verify" to an Analyze-while-Collecting model.
179
+ Real-time physical-AI analyzes impact trajectories and debris patterns to
180
+ justify or flag claims instantly.</p>
181
+ <p><strong>Auto-Filled Insurance Pointers:</strong> Uses document intelligence to
182
+ instantly extract data from Emirates IDs, Mulkiya cards, and police reports,
183
+ converting them into structured JSON to auto-populate mandatory claim fields.
184
+ </p>
185
+ <p><strong>Rapid Triage and settlement:</strong> Reduces the average UAE claims
186
+ lifecycle from 15 days to minutes by automating the intake and verification of
187
+ accident photos.</p>
188
+ <p><strong>Fraud Shielding and CBUAE 2026 Compliance:</strong> Identifies suspicious
189
+ patterns that contradict the reported scene context. Every reasoning deduction
190
+ is
191
+ linked to an auditable Evidence Map (linking frame coordinates to specific law
192
+ articles) as mandated by 2026 Central Bank guidelines on AI transparency.</p>
193
+ </div>
194
+ </div>
195
+ </div>
196
+
197
+ <!-- Real Estate & Legal Removed for Alternative UI -->
198
+ </div>
199
+ </div>
200
+ </section>
201
+
202
+ <!-- LE Dashboard View -->
203
+ <section id="view-le-dashboard" class="view hidden">
204
+ <div class="view-header">
205
+ <h2><i class="fa-solid fa-chart-line"></i> Active Cases</h2>
206
+ <button class="btn-secondary" id="btn-refresh-cases">
207
+ <i class="fa-solid fa-rotate-right"></i>
208
+ </button>
209
+ </div>
210
+
211
+ <div id="le-cases-grid" class="cases-grid">
212
+ <!-- Cases injected here -->
213
+ </div>
214
+
215
+ <div id="le-empty-state-dashboard" class="empty-state">
216
+ <i class="fa-solid fa-folder-open"></i>
217
+ <h3>No Cases Yet</h3>
218
+ <p>Create a new case to begin accident analysis.</p>
219
+ <button class="btn-primary" id="btn-le-empty-new-case">
220
+ <i class="fa-solid fa-plus"></i> Create First Case
221
+ </button>
222
+ </div>
223
+ </section>
224
+
225
+ <!-- LE New Case View -->
226
+ <section id="view-le-new-case" class="view hidden">
227
+ <div class="view-header">
228
+ <h2><i class="fa-solid fa-plus-circle"></i> New Accident Case</h2>
229
+ </div>
230
+
231
+ <div class="form-container glass">
232
+ <div class="form-grid">
233
+ <div class="form-group">
234
+ <label for="case-number">Case / Incident Number *</label>
235
+ <input type="text" id="case-number" placeholder="e.g., ACC-2026-001">
236
+ </div>
237
+ <div class="form-group">
238
+ <label for="officer-name">Officer Name</label>
239
+ <input type="text" id="officer-name" placeholder="e.g., Officer Smith">
240
+ </div>
241
+ <div class="form-group">
242
+ <label for="incident-location">Location</label>
243
+ <input type="text" id="incident-location" placeholder="e.g., Main St & 5th Ave">
244
+ </div>
245
+ <div class="form-group">
246
+ <label for="incident-date">Incident Date</label>
247
+ <input type="date" id="incident-date">
248
+ </div>
249
+ <div class="form-group full-width">
250
+ <label for="officer-notes">Officer Notes</label>
251
+ <textarea id="officer-notes" rows="3"
252
+ placeholder="Initial observations, weather conditions, etc."></textarea>
253
+ </div>
254
+ </div>
255
+
256
+ <div class="drop-zone" id="drop-zone">
257
+ <i class="fa-solid fa-camera"></i>
258
+ <p>Drag & drop accident scene photos here</p>
259
+ <span class="sub-text">or click to browse β€’ Max 20 photos β€’ JPG, PNG, WebP</span>
260
+ <input type="file" id="file-input" multiple accept="image/*" capture="environment" hidden>
261
+ </div>
262
+
263
+ <div id="photo-preview" class="photo-preview hidden">
264
+ <div id="photo-thumbnails" class="photo-thumbnails"></div>
265
+ <span id="photo-count" class="photo-count">0 photos selected</span>
266
+ </div>
267
+
268
+ <div class="form-actions">
269
+ <button class="btn-primary btn-large" id="btn-create-case" disabled>
270
+ <i class="fa-solid fa-shield-halved"></i> Create Case & Upload Photos
271
+ </button>
272
+ </div>
273
+
274
+ <div id="upload-progress" class="progress-section hidden">
275
+ <div class="progress-bar">
276
+ <div class="progress-fill" id="upload-progress-fill"></div>
277
+ </div>
278
+ <span id="upload-status-text">Uploading photos...</span>
279
+ </div>
280
+ </div>
281
+ </section>
282
+
283
+ <!-- INS Dashboard View -->
284
+ <section id="view-ins-dashboard" class="view hidden">
285
+ <div class="view-header">
286
+ <h2><i class="fa-solid fa-chart-line"></i> Active Cases</h2>
287
+ <button class="btn-secondary" id="btn-refresh-cases-ins">
288
+ <i class="fa-solid fa-rotate-right"></i>
289
+ </button>
290
+ </div>
291
+
292
+ <div id="ins-cases-grid" class="cases-grid">
293
+ <!-- Cases injected here -->
294
+ </div>
295
+
296
+ <div id="ins-empty-state-dashboard" class="empty-state">
297
+ <i class="fa-solid fa-folder-open"></i>
298
+ <h3>No Cases Yet</h3>
299
+ <p>Create a new case to begin accident analysis.</p>
300
+ <button class="btn-primary" id="btn-ins-empty-new-case">
301
+ <i class="fa-solid fa-plus"></i> Create First Case
302
+ </button>
303
+ </div>
304
+ </section>
305
+
306
+ <!-- INS New Case View -->
307
+ <section id="view-ins-new-case" class="view hidden">
308
+ <div class="view-header">
309
+ <h2><i class="fa-solid fa-plus-circle"></i> New Accident Case</h2>
310
+ </div>
311
+
312
+ <div class="form-container glass">
313
+ <div class="form-grid">
314
+ <div class="form-group">
315
+ <label for="ins-case-number">Case / Incident Number *</label>
316
+ <input type="text" id="ins-case-number" placeholder="e.g., ACC-2026-001">
317
+ </div>
318
+ <div class="form-group">
319
+ <label for="ins-officer-name">Adjuster Name</label>
320
+ <input type="text" id="ins-officer-name" placeholder="e.g., Adjuster Smith">
321
+ </div>
322
+ <div class="form-group">
323
+ <label for="ins-incident-location">Location</label>
324
+ <input type="text" id="ins-incident-location" placeholder="e.g., Main St & 5th Ave">
325
+ </div>
326
+ <div class="form-group">
327
+ <label for="ins-incident-date">Incident Date</label>
328
+ <input type="date" id="ins-incident-date">
329
+ </div>
330
+ <div class="form-group full-width">
331
+ <label for="ins-officer-notes">Adjuster Notes</label>
332
+ <textarea id="ins-officer-notes" rows="3"
333
+ placeholder="Initial observations, weather conditions, etc."></textarea>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="drop-zone" id="ins-drop-zone">
338
+ <i class="fa-solid fa-camera"></i>
339
+ <p>Drag & drop accident scene photos here</p>
340
+ <span class="sub-text">or click to browse β€’ Max 20 photos β€’ JPG, PNG, WebP</span>
341
+ <input type="file" id="ins-file-input" multiple accept="image/*" capture="environment" hidden>
342
+ </div>
343
+
344
+ <div id="ins-photo-preview" class="photo-preview hidden">
345
+ <div id="ins-photo-thumbnails" class="photo-thumbnails"></div>
346
+ <span id="ins-photo-count" class="photo-count">0 photos selected</span>
347
+ </div>
348
+
349
+ <div class="form-actions">
350
+ <button class="btn-primary btn-large" id="btn-ins-create-case" disabled>
351
+ <i class="fa-solid fa-file-invoice-dollar"></i> Create Case & Upload Photos
352
+ </button>
353
+ </div>
354
+
355
+ <div id="ins-upload-progress" class="progress-section hidden">
356
+ <div class="progress-bar">
357
+ <div class="progress-fill" id="ins-upload-progress-fill"></div>
358
+ </div>
359
+ <span id="ins-upload-status-text">Uploading photos...</span>
360
+ </div>
361
+ </div>
362
+ </section>
363
+
364
+ <!-- Case Detail View -->
365
+ <section id="view-case-detail" class="view hidden">
366
+ <div class="view-header">
367
+ <button class="btn-back" id="btn-back-dashboard">
368
+ <i class="fa-solid fa-arrow-left"></i>
369
+ </button>
370
+ <h2 id="case-detail-title">Case Details</h2>
371
+ <div class="header-actions">
372
+ <button class="btn-primary" id="btn-run-analysis" disabled>
373
+ <i class="fa-solid fa-brain"></i> Run AI Analysis
374
+ </button>
375
+ <button class="btn-secondary" id="btn-edit-case">
376
+ <i class="fa-solid fa-pen-to-square"></i> Edit
377
+ </button>
378
+ <button class="btn-secondary" id="btn-view-report" disabled>
379
+ <i class="fa-solid fa-file-lines"></i> View Report
380
+ </button>
381
+ </div>
382
+ </div>
383
+
384
+ <!-- Case Info Bar -->
385
+ <div class="case-info-bar glass">
386
+ <div class="info-chip">
387
+ <i class="fa-solid fa-hashtag"></i>
388
+ <span id="detail-case-number">β€”</span>
389
+ </div>
390
+ <div class="info-chip">
391
+ <i class="fa-solid fa-user-shield"></i>
392
+ <span id="detail-officer">β€”</span>
393
+ </div>
394
+ <div class="info-chip">
395
+ <i class="fa-solid fa-location-dot"></i>
396
+ <span id="detail-location">β€”</span>
397
+ </div>
398
+ <div class="info-chip">
399
+ <i class="fa-solid fa-calendar"></i>
400
+ <span id="detail-date">β€”</span>
401
+ </div>
402
+ <div class="info-chip status-chip" id="detail-status-chip">
403
+ <i class="fa-solid fa-circle"></i>
404
+ <span id="detail-status">β€”</span>
405
+ </div>
406
+ </div>
407
+
408
+ <!-- Analysis Panels -->
409
+ <div class="analysis-grid">
410
+ <!-- Photo Gallery Panel -->
411
+ <div class="panel glass" id="panel-photos">
412
+ <div class="panel-header">
413
+ <h3><i class="fa-solid fa-images"></i> Scene Photos</h3>
414
+ <div class="panel-actions">
415
+ <span class="badge" id="photo-badge">0</span>
416
+ <button class="btn-icon-sm" id="btn-add-photos-inline" title="Add Photos">
417
+ <i class="fa-solid fa-plus"></i>
418
+ </button>
419
+ </div>
420
+ </div>
421
+ <div id="detail-photos-grid" class="detail-photos-grid">
422
+ <!-- Photo thumbnails injected here -->
423
+ </div>
424
+ </div>
425
+
426
+ <!-- Scene Analysis Panel -->
427
+ <div class="panel glass" id="panel-analysis">
428
+ <div class="panel-header">
429
+ <h3><i class="fa-solid fa-eye"></i> Scene Analysis</h3>
430
+ </div>
431
+ <div id="analysis-content" class="analysis-content">
432
+ <p class="placeholder-text">Run analysis to see AI observations.</p>
433
+ </div>
434
+ </div>
435
+
436
+ <!-- Violations Panel -->
437
+ <div class="panel glass" id="panel-violations">
438
+ <div class="panel-header">
439
+ <h3><i class="fa-solid fa-gavel"></i> Violations Detected</h3>
440
+ <span class="badge danger" id="violation-badge">0</span>
441
+ </div>
442
+ <div id="violations-list" class="violations-list">
443
+ <p class="placeholder-text">No violations detected yet.</p>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- Fault Analysis Panel -->
448
+ <div class="panel glass" id="panel-fault">
449
+ <div class="panel-header">
450
+ <h3><i class="fa-solid fa-scale-balanced"></i> Fault Analysis</h3>
451
+ </div>
452
+ <div id="fault-content" class="fault-content">
453
+ <p class="placeholder-text">Run analysis to determine fault.</p>
454
+ </div>
455
+ </div>
456
+ </div>
457
+
458
+ <!-- Analysis Progress Overlay -->
459
+ <div id="analysis-overlay" class="analysis-overlay hidden">
460
+ <div class="overlay-content glass">
461
+ <div class="spinner-large"></div>
462
+ <h3 id="analysis-step">Analyzing scene photos...</h3>
463
+ <p id="analysis-detail">This may take a few minutes depending on the number of photos.</p>
464
+ </div>
465
+ </div>
466
+ </section>
467
+
468
+ <!-- Report View -->
469
+ <section id="view-report" class="view hidden">
470
+ <div class="view-header">
471
+ <button class="btn-back" id="btn-back-case">
472
+ <i class="fa-solid fa-arrow-left"></i>
473
+ </button>
474
+ <h2><i class="fa-solid fa-file-lines"></i> Incident Report</h2>
475
+ </div>
476
+ <div id="report-content" class="report-content glass">
477
+ <!-- Report injected here -->
478
+ </div>
479
+ </section>
480
+
481
+ <!-- Rules View -->
482
+ <section id="view-rules" class="view hidden">
483
+ <div class="view-header">
484
+ <h2><i class="fa-solid fa-book-open"></i> Traffic Rules Reference</h2>
485
+ </div>
486
+ <div id="rules-content" class="rules-content">
487
+ <!-- Rules injected here -->
488
+ </div>
489
+ </section>
490
+
491
+ <!-- Chat View -->
492
+ <section id="view-chat" class="view hidden">
493
+ <div class="view-header">
494
+ <h2><i class="fa-solid fa-message"></i> Interactive Chat Agent</h2>
495
+ </div>
496
+ <div class="form-container glass" style="display:flex; flex-direction:column; height: 75vh;">
497
+ <div style="flex:1; overflow-y:auto; padding:1rem; border-bottom:1px solid rgba(255,255,255,0.1);"
498
+ id="chat-messages">
499
+ <div class="chat-message assistant">
500
+ <i class="fa-solid fa-robot" style="margin-right:10px;"></i>
501
+ <span>Hello! I am the TraceScene AI Assistant. You can ask me about traffic rules, insurance
502
+ clauses, or a specific case if you load it.</span>
503
+ </div>
504
+ </div>
505
+ <div style="padding:1rem; display:flex; gap:10px;">
506
+ <input type="text" id="chat-input" placeholder="Type your message..." style="flex:1;">
507
+ <button class="btn-primary" id="btn-chat-send"><i class="fa-solid fa-paper-plane"></i>
508
+ Send</button>
509
+ </div>
510
+ </div>
511
+ </section>
512
+
513
+ <!-- Simulation View -->
514
+ <section id="view-simulation" class="view hidden">
515
+ <div class="view-header">
516
+ <h2><i class="fa-solid fa-video"></i> 2D Accident Simulation</h2>
517
+ </div>
518
+ <div class="form-container glass" style="text-align:center;">
519
+ <p style="margin-bottom:1rem; color:var(--text-secondary);">Enter a Case ID to generate a
520
+ physics-based 2D simulation of the accident trajectory from AI observations.</p>
521
+ <div style="display:flex; justify-content:center; gap:10px; margin-bottom: 2rem;">
522
+ <input type="number" id="sim-case-id" placeholder="Case ID" style="max-width: 200px;">
523
+ <button class="btn-primary" id="btn-sim-generate"><i class="fa-solid fa-play"></i>
524
+ Generate</button>
525
+ </div>
526
+ <div id="simulation-content"
527
+ style="min-height:300px; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2); border-radius:8px;">
528
+ <p class="placeholder-text">Simulation output will appear here.</p>
529
+ </div>
530
+ </div>
531
+ </section>
532
+
533
+ </main>
534
+ </div>
535
+
536
+ <!-- Toast Container -->
537
+ <div id="toast-container"></div>
538
+
539
+ <!-- Edit Case Modal -->
540
+ <div id="modal-edit-case" class="modal hidden">
541
+ <div class="modal-content glass">
542
+ <div class="modal-header">
543
+ <h2><i class="fa-solid fa-pen-to-square"></i> Edit Case Details</h2>
544
+ <button class="btn-close" id="btn-close-edit-modal">&times;</button>
545
+ </div>
546
+ <div class="modal-body">
547
+ <div class="form-grid">
548
+ <div class="form-group">
549
+ <label for="edit-officer-name">Officer Name</label>
550
+ <input type="text" id="edit-officer-name" placeholder="e.g., Officer Smith">
551
+ </div>
552
+ <div class="form-group">
553
+ <label for="edit-incident-location">Location</label>
554
+ <input type="text" id="edit-incident-location" placeholder="e.g., Main St & 5th Ave">
555
+ </div>
556
+ <div class="form-group">
557
+ <label for="edit-incident-date">Incident Date</label>
558
+ <input type="date" id="edit-incident-date">
559
+ </div>
560
+ <div class="form-group full-width">
561
+ <label for="edit-officer-notes">Officer Notes</label>
562
+ <textarea id="edit-officer-notes" rows="4" placeholder="Update observations..."></textarea>
563
+ </div>
564
+ </div>
565
+ </div>
566
+ <div class="modal-footer">
567
+ <button class="btn-secondary" id="btn-cancel-edit">Cancel</button>
568
+ <button class="btn-primary" id="btn-save-case">Save Changes</button>
569
+ </div>
570
+ </div>
571
+ </div>
572
+
573
+ <!-- Add Photos Modal/Overlay -->
574
+ <div id="modal-add-photos" class="modal hidden">
575
+ <div class="modal-content glass">
576
+ <div class="modal-header">
577
+ <h2><i class="fa-solid fa-images"></i> Add More Photos</h2>
578
+ <button class="btn-close" id="btn-close-photos-modal">&times;</button>
579
+ </div>
580
+ <div class="modal-body">
581
+ <div class="drop-zone" id="edit-drop-zone">
582
+ <i class="fa-solid fa-camera"></i>
583
+ <p>Drag & drop additional photos here</p>
584
+ <span class="sub-text">or click to browse</span>
585
+ <input type="file" id="edit-file-input" multiple accept="image/*" capture="environment" hidden>
586
+ </div>
587
+ <div id="edit-photo-preview" class="photo-preview hidden">
588
+ <div id="edit-photo-thumbnails" class="photo-thumbnails"></div>
589
+ <span id="edit-photo-count" class="photo-count">0 photos selected</span>
590
+ </div>
591
+ <div id="edit-upload-progress" class="progress-section hidden">
592
+ <div class="progress-bar">
593
+ <div class="progress-fill" id="edit-upload-progress-fill"></div>
594
+ </div>
595
+ <span id="edit-upload-status-text">Uploading photos...</span>
596
+ </div>
597
+ </div>
598
+ <div class="modal-footer">
599
+ <button class="btn-secondary" id="btn-cancel-photos">Cancel</button>
600
+ <button class="btn-primary" id="btn-upload-more" disabled>Upload Photos</button>
601
+ </div>
602
+ </div>
603
+ </div>
604
+
605
+ <script src="/static/js/alt_app.js"></script>
606
+ </body>
607
+
608
+ </html>
frontend/js/alt_app.js ADDED
@@ -0,0 +1,975 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TraceScene β€” Frontend Application
3
+ *
4
+ * Single-page application for accident case management,
5
+ * photo upload, AI analysis, and report viewing.
6
+ */
7
+
8
+ const API_BASE = '/api';
9
+
10
+ // ── State ─────────────────────────────────────────────────────────────
11
+
12
+ let currentView = 'landing';
13
+ let currentCaseId = null;
14
+ let currentCaseData = null;
15
+ let currentVertical = 'le'; // Store full case data for editing
16
+ let selectedFiles = [];
17
+ let additionalFiles = []; // For add photos modal
18
+
19
+ // Gradio API Helper for ZeroGPU Event Streams
20
+ async function callGradioApi(apiName, dataArr) {
21
+ const res = await fetch('/gradio/call/' + apiName, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify({ data: dataArr })
25
+ });
26
+
27
+ if (!res.ok) {
28
+ throw new Error('Gradio API Request Failed');
29
+ }
30
+
31
+ const eventObj = await res.json();
32
+ const eventId = eventObj.event_id;
33
+
34
+ return new Promise((resolve, reject) => {
35
+ const source = new EventSource('/gradio/call/' + apiName + '/' + eventId);
36
+ source.onmessage = (e) => {
37
+ const msg = JSON.parse(e.data);
38
+ if (msg.msg === 'process_completed') {
39
+ source.close();
40
+ if (msg.success) {
41
+ resolve(msg.output.data);
42
+ } else {
43
+ reject(new Error(msg.output.error || 'Server Error'));
44
+ }
45
+ }
46
+ };
47
+ source.onerror = (e) => {
48
+ source.close();
49
+ reject(new Error('Gradio SSE Connection Failed'));
50
+ };
51
+ });
52
+ }
53
+
54
+ // ── Init ──────────────────────────────────────────────────────────────
55
+
56
+ document.addEventListener('DOMContentLoaded', () => {
57
+ initNavigation();
58
+ initDropZone();
59
+ initButtons();
60
+ initAccordions();
61
+ loadHealth();
62
+ loadCases();
63
+ });
64
+
65
+ // ── Accordions ────────────────────────────────────────────────────────
66
+
67
+ function initAccordions() {
68
+ document.querySelectorAll('.accordion-header').forEach(btn => {
69
+ btn.addEventListener('click', () => {
70
+ const item = btn.closest('.accordion-item');
71
+ const isActive = item.classList.contains('active');
72
+
73
+ // Close all other accordions
74
+ document.querySelectorAll('.accordion-item').forEach(i => {
75
+ i.classList.remove('active');
76
+ i.querySelector('.accordion-header').setAttribute('aria-expanded', 'false');
77
+ });
78
+
79
+ // Toggle current
80
+ if (!isActive) {
81
+ item.classList.add('active');
82
+ btn.setAttribute('aria-expanded', 'true');
83
+ }
84
+ });
85
+ });
86
+ }
87
+
88
+ // ── Navigation ────────────────────────────────────────────────────────
89
+
90
+ function initNavigation() {
91
+ document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
92
+ btn.addEventListener('click', () => {
93
+ const view = btn.dataset.view;
94
+ switchView(view);
95
+ document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
96
+ btn.classList.add('active');
97
+ });
98
+ });
99
+ }
100
+
101
+ function switchView(viewName) {
102
+ document.querySelectorAll('.view').forEach(v => {
103
+ v.classList.remove('active');
104
+ v.classList.add('hidden');
105
+ });
106
+ const target = document.getElementById(`view-${viewName}`);
107
+ if (target) {
108
+ target.classList.remove('hidden');
109
+ target.classList.add('active');
110
+ }
111
+ currentView = viewName;
112
+
113
+ if (viewName === 'le-dashboard') loadCases('le');
114
+ if (viewName === 'ins-dashboard') loadCases('ins');
115
+ if (viewName === 'rules') loadRules();
116
+ }
117
+
118
+ // ── Buttons ───────────────────────────────────────────────────────────
119
+
120
+ function initButtons() {
121
+ // LE Dashboard
122
+ document.getElementById('btn-refresh-cases')?.addEventListener('click', () => loadCases('le'));
123
+ document.getElementById('btn-le-empty-new-case')?.addEventListener('click', () => {
124
+ switchView('le-new-case');
125
+ document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
126
+ document.querySelector('[data-view="le-new-case"]')?.classList.add('active');
127
+ });
128
+
129
+ // INS Dashboard
130
+ document.getElementById('btn-refresh-cases-ins')?.addEventListener('click', () => loadCases('ins'));
131
+ document.getElementById('btn-ins-empty-new-case')?.addEventListener('click', () => {
132
+ switchView('ins-new-case');
133
+ document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
134
+ document.querySelector('[data-view="ins-new-case"]')?.classList.add('active');
135
+ });
136
+
137
+ // New Case Forms
138
+ document.getElementById('btn-create-case')?.addEventListener('click', () => createCase('le'));
139
+ document.getElementById('btn-ins-create-case')?.addEventListener('click', () => createCase('ins'));
140
+
141
+ // Case Detail
142
+ document.getElementById('btn-back-dashboard')?.addEventListener('click', () => switchView(currentVertical === 'ins' ? 'ins-dashboard' : 'le-dashboard'));
143
+ document.getElementById('btn-run-analysis')?.addEventListener('click', runAnalysis);
144
+ document.getElementById('btn-view-report')?.addEventListener('click', viewReport);
145
+ document.getElementById('btn-edit-case')?.addEventListener('click', openEditModal);
146
+ document.getElementById('btn-add-photos-inline')?.addEventListener('click', openAddPhotosModal);
147
+
148
+ // Edit Modal
149
+ document.getElementById('btn-close-edit-modal')?.addEventListener('click', closeEditModal);
150
+ document.getElementById('btn-cancel-edit')?.addEventListener('click', closeEditModal);
151
+ document.getElementById('btn-save-case')?.addEventListener('click', saveCaseChanges);
152
+
153
+ // Photos Modal
154
+ document.getElementById('btn-close-photos-modal')?.addEventListener('click', closePhotosModal);
155
+ document.getElementById('btn-cancel-photos')?.addEventListener('click', closePhotosModal);
156
+ document.getElementById('btn-upload-more')?.addEventListener('click', uploadMorePhotos);
157
+
158
+ // Chat and Simulation Logic
159
+ document.getElementById('btn-chat-send')?.addEventListener('click', sendChatMessage);
160
+ document.getElementById('chat-input')?.addEventListener('keypress', (e) => {
161
+ if (e.key === 'Enter') sendChatMessage();
162
+ });
163
+ document.getElementById('btn-sim-generate')?.addEventListener('click', generateSimulation);
164
+
165
+ // Form logic
166
+ document.getElementById('case-number')?.addEventListener('input', () => validateForm('le'));
167
+ document.getElementById('ins-case-number')?.addEventListener('input', () => validateForm('ins'));
168
+ initEditDropZone();
169
+ }
170
+
171
+ function initEditDropZone() {
172
+ const dropZone = document.getElementById('edit-drop-zone');
173
+ const fileInput = document.getElementById('edit-file-input');
174
+ if (!dropZone || !fileInput) return;
175
+
176
+ dropZone.addEventListener('click', () => fileInput.click());
177
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
178
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
179
+ dropZone.addEventListener('drop', e => {
180
+ e.preventDefault();
181
+ dropZone.classList.remove('drag-over');
182
+ handleAdditionalFiles(Array.from(e.dataTransfer.files));
183
+ });
184
+ fileInput.addEventListener('change', () => {
185
+ handleAdditionalFiles(Array.from(fileInput.files));
186
+ });
187
+ }
188
+
189
+ function validateForm(vertical = 'le') {
190
+ const cid = vertical === 'le' ? 'case-number' : 'ins-case-number';
191
+ const bid = vertical === 'le' ? 'btn-create-case' : 'btn-ins-create-case';
192
+ const caseNum = document.getElementById(cid)?.value.trim();
193
+ const btn = document.getElementById(bid);
194
+ if (btn) btn.disabled = !caseNum;
195
+ }
196
+
197
+ // ── Health ─────────────────────────────────────────────────────────────
198
+
199
+ async function loadHealth() {
200
+ try {
201
+ const resp = await fetch(`${API_BASE}/health`);
202
+ const data = await resp.json();
203
+ document.getElementById('status-model').textContent = data.model_loaded ? 'βœ“ Ready' : 'βœ— Not loaded';
204
+ document.getElementById('status-model').style.color = data.model_loaded ? '#22c55e' : '#ef4444';
205
+ document.getElementById('status-device').textContent = data.device || 'β€”';
206
+ document.getElementById('status-rules').textContent = `${data.rules_loaded} rules`;
207
+ } catch {
208
+ document.getElementById('status-model').textContent = 'βœ— Offline';
209
+ document.getElementById('status-model').style.color = '#ef4444';
210
+ }
211
+ }
212
+
213
+ // ── Cases ─────────────────────────────────────────────────────────────
214
+
215
+ async function loadCases(vertical = 'le') {
216
+ currentVertical = vertical;
217
+ try {
218
+ const resp = await fetch(`${API_BASE}/cases`);
219
+ const data = await resp.json();
220
+ renderCases(data.cases || [], vertical);
221
+ } catch (e) {
222
+ showToast('Failed to load cases', 'error');
223
+ }
224
+ }
225
+
226
+ function renderCases(cases, vertical) {
227
+ const grid = document.getElementById(vertical === 'le' ? 'le-cases-grid' : 'ins-cases-grid');
228
+ const empty = document.getElementById(vertical === 'le' ? 'le-empty-state-dashboard' : 'ins-empty-state-dashboard');
229
+
230
+ if (!cases.length) {
231
+ grid.innerHTML = '';
232
+ grid.style.display = 'none';
233
+ empty.style.display = 'flex';
234
+ return;
235
+ }
236
+
237
+ grid.style.display = 'grid';
238
+ empty.style.display = 'none';
239
+
240
+ grid.innerHTML = cases.map(c => `
241
+ <div class="case-card" onclick="openCase(${c.id})">
242
+ <button class="btn-delete-card" onclick="event.stopPropagation(); deleteCase(${c.id}, '${escHtml(c.case_number)}', currentVertical)" title="Delete Case">
243
+ <i class="fa-solid fa-trash-can"></i>
244
+ </button>
245
+ <div class="card-header">
246
+ <span class="case-number">${escHtml(c.case_number)}</span>
247
+ <span class="status-badge ${c.status}">
248
+ <i class="fa-solid fa-circle"></i> ${c.status}
249
+ </span>
250
+ </div>
251
+ <div class="case-meta">
252
+ ${c.officer_name ? `<span><i class="fa-solid fa-user-shield"></i> ${escHtml(c.officer_name)}</span>` : ''}
253
+ ${c.location ? `<span><i class="fa-solid fa-location-dot"></i> ${escHtml(c.location)}</span>` : ''}
254
+ ${c.incident_date ? `<span><i class="fa-solid fa-calendar"></i> ${c.incident_date}</span>` : ''}
255
+ </div>
256
+ <div class="card-footer">
257
+ <span class="photo-count"><i class="fa-solid fa-camera"></i> ${c.photo_count || 0} photos</span>
258
+ <span style="font-size:0.72rem;color:var(--text-muted)">${formatDate(c.created_at)}</span>
259
+ </div>
260
+ </div>
261
+ `).join('');
262
+ }
263
+
264
+ async function deleteCase(id, caseNum, vertical = 'le') {
265
+ if (!confirm(`Are you sure you want to delete Case ${caseNum}? This will permanently remove all photos and analysis data.`)) {
266
+ return;
267
+ }
268
+
269
+ try {
270
+ const resp = await fetch(`${API_BASE}/cases/${id}`, { method: 'DELETE' });
271
+ if (!resp.ok) throw new Error('Delete failed');
272
+ showToast(`Case ${caseNum} deleted`, 'success');
273
+ loadCases();
274
+ } catch (e) {
275
+ showToast(e.message, 'error');
276
+ }
277
+ }
278
+
279
+ // ── Create Case ───────────────────────────────────────────────────────
280
+
281
+ async function createCase(vertical = 'le') {
282
+ const cid = vertical === 'le' ? 'case-number' : 'ins-case-number';
283
+ const oid = vertical === 'le' ? 'officer-name' : 'ins-officer-name';
284
+ const lid = vertical === 'le' ? 'incident-location' : 'ins-incident-location';
285
+ const did = vertical === 'le' ? 'incident-date' : 'ins-incident-date';
286
+ const nid = vertical === 'le' ? 'officer-notes' : 'ins-officer-notes';
287
+
288
+ const caseNumber = document.getElementById(cid).value.trim();
289
+ if (!caseNumber) return showToast('Case number is required', 'error');
290
+
291
+ const formData = new FormData();
292
+ formData.append('case_number', caseNumber);
293
+ formData.append('officer_name', document.getElementById(oid).value.trim());
294
+ formData.append('location', document.getElementById(lid).value.trim());
295
+ formData.append('incident_date', document.getElementById(did).value);
296
+ formData.append('notes', document.getElementById(nid).value.trim());
297
+
298
+ try {
299
+ const resp = await fetch(`${API_BASE}/cases`, { method: 'POST', body: formData });
300
+ if (!resp.ok) {
301
+ const err = await resp.json();
302
+ throw new Error(err.detail || 'Failed to create case');
303
+ }
304
+ const data = await resp.json();
305
+ const caseId = data.id;
306
+
307
+ showToast('Case created', 'success');
308
+
309
+ // Upload photos if selected
310
+ if (selectedFiles.length > 0) {
311
+ await uploadPhotos(caseId, vertical);
312
+ }
313
+
314
+ // Clear form
315
+ document.getElementById(cid).value = '';
316
+ document.getElementById(oid).value = '';
317
+ document.getElementById(lid).value = '';
318
+ document.getElementById(did).value = '';
319
+ document.getElementById(nid).value = '';
320
+ selectedFiles = [];
321
+ document.getElementById(vertical === 'le' ? 'photo-preview' : 'ins-photo-preview').classList.add('hidden');
322
+
323
+ // Open the case detail
324
+ openCase(caseId);
325
+
326
+ } catch (e) {
327
+ showToast(e.message, 'error');
328
+ }
329
+ }
330
+
331
+ async function uploadPhotos(caseId, vertical = 'le') {
332
+ const pfx = vertical === 'le' ? '' : 'ins-';
333
+ const progressSection = document.getElementById(`${pfx}upload-progress`);
334
+ const progressFill = document.getElementById(`${pfx}upload-progress-fill`);
335
+ const statusText = document.getElementById(`${pfx}upload-status-text`);
336
+
337
+ progressSection.classList.remove('hidden');
338
+ statusText.textContent = `Uploading ${selectedFiles.length} photos...`;
339
+
340
+ const formData = new FormData();
341
+ selectedFiles.forEach(f => formData.append('files', f));
342
+
343
+ try {
344
+ progressFill.style.width = '50%';
345
+ const resp = await fetch(`${API_BASE}/cases/${caseId}/photos`, {
346
+ method: 'POST',
347
+ body: formData,
348
+ });
349
+
350
+ if (!resp.ok) throw new Error('Upload failed');
351
+
352
+ const data = await resp.json();
353
+ progressFill.style.width = '100%';
354
+ statusText.textContent = `${data.count} photos uploaded βœ“`;
355
+ showToast(`${data.count} photos uploaded`, 'success');
356
+ return data;
357
+ } catch (e) {
358
+ statusText.textContent = 'Upload failed';
359
+ showToast('Photo upload failed', 'error');
360
+ throw e;
361
+ }
362
+ }
363
+
364
+ // ── Drop Zone ─────────────────────────────────────────────────────────
365
+
366
+ function initDropZone() {
367
+ setupZone('drop-zone', 'file-input', 'photo-preview', 'photo-thumbnails', 'photo-count');
368
+ setupZone('ins-drop-zone', 'ins-file-input', 'ins-photo-preview', 'ins-photo-thumbnails', 'ins-photo-count');
369
+ }
370
+
371
+ function setupZone(dzId, inId, prvId, tnId, ctId) {
372
+ const dropZone = document.getElementById(dzId);
373
+ const fileInput = document.getElementById(inId);
374
+ if (!dropZone || !fileInput) return;
375
+
376
+ dropZone.addEventListener('click', () => fileInput.click());
377
+
378
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
379
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
380
+ dropZone.addEventListener('drop', e => {
381
+ e.preventDefault();
382
+ dropZone.classList.remove('drag-over');
383
+ handleFiles(Array.from(e.dataTransfer.files), prvId, tnId, ctId);
384
+ });
385
+
386
+ fileInput.addEventListener('change', () => {
387
+ handleFiles(Array.from(fileInput.files), prvId, tnId, ctId, prefix);
388
+ });
389
+ }
390
+
391
+ function handleFiles(files, prvId = 'photo-preview', tnId = 'photo-thumbnails', ctId = 'photo-count', prefix = 'le') {
392
+ const imageFiles = files.filter(f => f.type.startsWith('image/'));
393
+ if (!imageFiles.length) return showToast('No image files found', 'error');
394
+
395
+ selectedFiles = imageFiles.slice(0, 20); // Max 20
396
+
397
+ // Show previews
398
+ const preview = document.getElementById(prvId);
399
+ const thumbs = document.getElementById(tnId);
400
+ const count = document.getElementById(ctId);
401
+
402
+ preview.classList.remove('hidden');
403
+ count.textContent = `${selectedFiles.length} photo${selectedFiles.length > 1 ? 's' : ''} selected`;
404
+
405
+ thumbs.innerHTML = '';
406
+ selectedFiles.forEach(file => {
407
+ const div = document.createElement('div');
408
+ div.className = 'photo-thumbnail';
409
+ const img = document.createElement('img');
410
+ img.src = URL.createObjectURL(file);
411
+ div.appendChild(img);
412
+ thumbs.appendChild(div);
413
+ });
414
+
415
+ validateForm(prefix);
416
+ }
417
+
418
+ // ── Open Case Detail ──────────────────────────────────────────────────
419
+
420
+ async function openCase(caseId) {
421
+ currentCaseId = caseId;
422
+ switchView('case-detail');
423
+
424
+ try {
425
+ const resp = await fetch(`${API_BASE}/cases/${caseId}`);
426
+ const data = await resp.json();
427
+ currentCaseData = data.case;
428
+ renderCaseDetail(data);
429
+ } catch (e) {
430
+ showToast('Failed to load case', 'error');
431
+ }
432
+ }
433
+
434
+ // ── Edit Case ─────────────────────────────────────────────────────────
435
+
436
+ function openEditModal() {
437
+ if (!currentCaseData) return;
438
+ document.getElementById('edit-officer-name').value = currentCaseData.officer_name || '';
439
+ document.getElementById('edit-incident-location').value = currentCaseData.location || '';
440
+ document.getElementById('edit-incident-date').value = currentCaseData.incident_date || '';
441
+ document.getElementById('edit-officer-notes').value = currentCaseData.notes || '';
442
+ document.getElementById('modal-edit-case').classList.remove('hidden');
443
+ }
444
+
445
+ function closeEditModal() {
446
+ document.getElementById('modal-edit-case').classList.add('hidden');
447
+ }
448
+
449
+ async function saveCaseChanges() {
450
+ if (!currentCaseId) return;
451
+
452
+ const formData = new FormData();
453
+ formData.append('officer_name', document.getElementById('edit-officer-name').value.trim());
454
+ formData.append('location', document.getElementById('edit-incident-location').value.trim());
455
+ formData.append('incident_date', document.getElementById('edit-incident-date').value);
456
+ formData.append('notes', document.getElementById('edit-officer-notes').value.trim());
457
+
458
+ try {
459
+ const resp = await fetch(`${API_BASE}/cases/${currentCaseId}`, {
460
+ method: 'PUT',
461
+ body: formData
462
+ });
463
+
464
+ if (!resp.ok) throw new Error('Failed to update case');
465
+
466
+ showToast('Case updated', 'success');
467
+ closeEditModal();
468
+ openCase(currentCaseId); // Refresh
469
+ } catch (e) {
470
+ showToast(e.message, 'error');
471
+ }
472
+ }
473
+
474
+ // ── Add Photos ─────────────────────────────��──────────────────────────
475
+
476
+ function openAddPhotosModal() {
477
+ additionalFiles = [];
478
+ document.getElementById('edit-photo-preview').classList.add('hidden');
479
+ document.getElementById('edit-upload-progress').classList.add('hidden');
480
+ document.getElementById('btn-upload-more').disabled = true;
481
+ document.getElementById('modal-add-photos').classList.remove('hidden');
482
+ }
483
+
484
+ function closePhotosModal() {
485
+ document.getElementById('modal-add-photos').classList.add('hidden');
486
+ }
487
+
488
+ function handleAdditionalFiles(files) {
489
+ const imageFiles = files.filter(f => f.type.startsWith('image/'));
490
+ if (!imageFiles.length) return showToast('No image files found', 'error');
491
+
492
+ additionalFiles = imageFiles.slice(0, 20);
493
+
494
+ const preview = document.getElementById('edit-photo-preview');
495
+ const thumbs = document.getElementById('edit-photo-thumbnails');
496
+ const count = document.getElementById('edit-photo-count');
497
+
498
+ preview.classList.remove('hidden');
499
+ count.textContent = `${additionalFiles.length} photo${additionalFiles.length > 1 ? 's' : ''} selected`;
500
+
501
+ thumbs.innerHTML = '';
502
+ additionalFiles.forEach(file => {
503
+ const div = document.createElement('div');
504
+ div.className = 'photo-thumbnail';
505
+ const img = document.createElement('img');
506
+ img.src = URL.createObjectURL(file);
507
+ div.appendChild(img);
508
+ thumbs.appendChild(div);
509
+ });
510
+
511
+ document.getElementById('btn-upload-more').disabled = additionalFiles.length === 0;
512
+ }
513
+
514
+ async function uploadMorePhotos() {
515
+ if (!currentCaseId || !additionalFiles.length) return;
516
+
517
+ const progressSection = document.getElementById('edit-upload-progress');
518
+ const progressFill = document.getElementById('edit-upload-progress-fill');
519
+ const statusText = document.getElementById('edit-upload-status-text');
520
+
521
+ progressSection.classList.remove('hidden');
522
+ document.getElementById('btn-upload-more').disabled = true;
523
+
524
+ const formData = new FormData();
525
+ additionalFiles.forEach(f => formData.append('files', f));
526
+
527
+ try {
528
+ progressFill.style.width = '50%';
529
+ const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/photos`, {
530
+ method: 'POST',
531
+ body: formData,
532
+ });
533
+
534
+ if (!resp.ok) throw new Error('Upload failed');
535
+
536
+ progressFill.style.width = '100%';
537
+ statusText.textContent = `Upload complete βœ“`;
538
+ showToast('Photos added successfully', 'success');
539
+
540
+ setTimeout(() => {
541
+ closePhotosModal();
542
+ openCase(currentCaseId); // Refresh
543
+ }, 1000);
544
+ } catch (e) {
545
+ statusText.textContent = 'Upload failed';
546
+ showToast('Photo upload failed', 'error');
547
+ document.getElementById('btn-upload-more').disabled = false;
548
+ }
549
+ }
550
+
551
+ function renderCaseDetail(data) {
552
+ const c = data.case;
553
+
554
+ // Header
555
+ document.getElementById('case-detail-title').textContent = `Case: ${c.case_number}`;
556
+
557
+ // Info bar
558
+ document.getElementById('detail-case-number').textContent = c.case_number;
559
+ document.getElementById('detail-officer').textContent = c.officer_name || 'N/A';
560
+ document.getElementById('detail-location').textContent = c.location || 'N/A';
561
+ document.getElementById('detail-date').textContent = c.incident_date || 'N/A';
562
+ document.getElementById('detail-status').textContent = c.status;
563
+
564
+ const statusChip = document.getElementById('detail-status-chip');
565
+ statusChip.className = `info-chip status-chip ${c.status}`;
566
+
567
+ // Enable/disable buttons
568
+ const btnAnalysis = document.getElementById('btn-run-analysis');
569
+ const btnReport = document.getElementById('btn-view-report');
570
+ btnAnalysis.disabled = !data.photos?.length;
571
+ btnReport.disabled = c.status !== 'complete';
572
+
573
+ // Photos
574
+ const photosGrid = document.getElementById('detail-photos-grid');
575
+ document.getElementById('photo-badge').textContent = data.photos?.length || 0;
576
+
577
+ if (data.photos?.length) {
578
+ photosGrid.innerHTML = data.photos.map(p => `
579
+ <div class="detail-photo">
580
+ <img src="/${p.filepath}" alt="${escHtml(p.filename)}" onerror="this.onerror=null; this.src='/static/placeholder.jpg';" loading="lazy">
581
+ </div>
582
+ `).join('');
583
+ } else {
584
+ photosGrid.innerHTML = '<p class="placeholder-text">No photos uploaded yet.</p>';
585
+ }
586
+
587
+ // Analyses
588
+ const analysisContent = document.getElementById('analysis-content');
589
+ if (data.analyses?.length) {
590
+ analysisContent.innerHTML = data.analyses.map(a => `
591
+ <div class="analysis-photo-section">
592
+ <div class="analysis-photo-label">πŸ“· ${escHtml(a.filename || 'Photo')}</div>
593
+ <pre>${escHtml(a.raw_analysis || '')}</pre>
594
+ </div>
595
+ `).join('');
596
+ } else {
597
+ analysisContent.innerHTML = '<p class="placeholder-text">Run analysis to see AI observations.</p>';
598
+ }
599
+
600
+ // Violations
601
+ const violationsList = document.getElementById('violations-list');
602
+ const violationBadge = document.getElementById('violation-badge');
603
+ violationBadge.textContent = data.violations?.length || 0;
604
+
605
+ if (data.violations?.length) {
606
+ violationsList.innerHTML = data.violations.map(v => `
607
+ <div class="violation-card ${v.severity || 'MEDIUM'}">
608
+ <div class="violation-header">
609
+ <span class="violation-title">${escHtml(v.rule_title)}</span>
610
+ <span class="severity-tag ${v.severity || 'MEDIUM'}">${v.severity || 'MEDIUM'}</span>
611
+ </div>
612
+ <div class="violation-meta">
613
+ <span><i class="fa-solid fa-hashtag"></i> ${v.rule_id}</span>
614
+ <span><i class="fa-solid fa-percent"></i> ${Math.round(v.confidence * 100)}% confidence</span>
615
+ ${v.party_label ? `<span><i class="fa-solid fa-car"></i> ${escHtml(v.party_label)}</span>` : ''}
616
+ </div>
617
+ ${v.evidence_summary ? `<div class="violation-evidence">${escHtml(v.evidence_summary)}</div>` : ''}
618
+ </div>
619
+ `).join('');
620
+ } else {
621
+ violationsList.innerHTML = '<p class="placeholder-text">No violations detected yet.</p>';
622
+ }
623
+
624
+ // Fault Analysis
625
+ renderFaultAnalysis(data.fault_analysis, data.parties);
626
+ }
627
+
628
+ function renderFaultAnalysis(fault, parties) {
629
+ const content = document.getElementById('fault-content');
630
+
631
+ if (!fault) {
632
+ content.innerHTML = '<p class="placeholder-text">Run analysis to determine fault.</p>';
633
+ return;
634
+ }
635
+
636
+ let html = '';
637
+
638
+ // Fault distribution bars
639
+ if (fault.fault_distribution_json) {
640
+ let dist = {};
641
+ try {
642
+ dist = typeof fault.fault_distribution_json === 'string'
643
+ ? JSON.parse(fault.fault_distribution_json)
644
+ : fault.fault_distribution_json;
645
+ } catch { dist = {}; }
646
+
647
+ const entries = Object.entries(dist);
648
+ if (entries.length) {
649
+ html += '<div class="fault-party-bars">';
650
+ entries.forEach(([label, pct], i) => {
651
+ html += `
652
+ <div class="party-bar">
653
+ <div class="party-bar-label">
654
+ <span class="party-name">${escHtml(label)}</span>
655
+ <span class="party-pct">${pct}%</span>
656
+ </div>
657
+ <div class="party-bar-track">
658
+ <div class="party-bar-fill ${i === 0 ? 'primary' : 'secondary'}"
659
+ style="width: ${pct}%"></div>
660
+ </div>
661
+ </div>
662
+ `;
663
+ });
664
+ html += '</div>';
665
+ }
666
+ }
667
+
668
+ // Probable cause
669
+ if (fault.probable_cause) {
670
+ html += `
671
+ <div class="fault-cause">
672
+ <h4><i class="fa-solid fa-magnifying-glass-chart"></i> Probable Cause</h4>
673
+ <p>${escHtml(fault.probable_cause)}</p>
674
+ </div>
675
+ `;
676
+ }
677
+
678
+ // Confidence
679
+ if (fault.overall_confidence != null) {
680
+ const pct = Math.round(fault.overall_confidence * 100);
681
+ html += `
682
+ <div class="confidence-meter">
683
+ <span>Confidence:</span>
684
+ <div class="meter-bar">
685
+ <div class="meter-fill" style="width: ${pct}%"></div>
686
+ </div>
687
+ <span>${pct}%</span>
688
+ </div>
689
+ `;
690
+ }
691
+
692
+ // Summary
693
+ if (fault.analysis_summary) {
694
+ html += `<p style="margin-top:0.8rem;font-size:0.82rem;color:var(--text-secondary)">
695
+ ${escHtml(fault.analysis_summary)}</p>`;
696
+ }
697
+
698
+ content.innerHTML = html || '<p class="placeholder-text">No fault analysis available.</p>';
699
+ }
700
+
701
+ // ── Run Analysis ──────────────────────────────────────────────────────
702
+
703
+ async function runAnalysis() {
704
+ if (!currentCaseId) return;
705
+
706
+ const overlay = document.getElementById('analysis-overlay');
707
+ const stepEl = document.getElementById('analysis-step');
708
+ const detailEl = document.getElementById('analysis-detail');
709
+
710
+ overlay.classList.remove('hidden');
711
+ stepEl.textContent = 'Analyzing accident scene photos...';
712
+ detailEl.textContent = 'Running AI vision analysis on each photo. This may take several minutes.';
713
+
714
+ try {
715
+ // Use Gradio API to trigger ZeroGPU explicitly
716
+ const data = await callGradioApi('run_analysis', [currentCaseId]);
717
+
718
+ overlay.classList.add('hidden');
719
+ showToast(`Analysis complete! Status: ${data[0]}`, 'success');
720
+
721
+ // Reload case detail
722
+ openCase(currentCaseId);
723
+
724
+ } catch (e) {
725
+ overlay.classList.add('hidden');
726
+ showToast(`Analysis failed: ${e.message}`, 'error');
727
+ }
728
+ }
729
+
730
+ // ── Chat and Simulation ────────────────────��──────────────────────────
731
+
732
+ let chatHistory = [];
733
+ let currentSystemCtx = "You are TraceScene AI assistant. Answer concisely and accurately based on context provided.";
734
+
735
+ async function sendChatMessage() {
736
+ const input = document.getElementById('chat-input');
737
+ const msg = input.value.trim();
738
+ if (!msg) return;
739
+
740
+ input.value = '';
741
+ const chatContainer = document.getElementById('chat-messages');
742
+
743
+ // Add user message
744
+ chatContainer.innerHTML += `
745
+ <div class="chat-message user" style="text-align:right; margin:10px 0;">
746
+ <span style="background:var(--primary); color:white; padding:8px 12px; border-radius:15px; display:inline-block;">${escHtml(msg)}</span>
747
+ </div>
748
+ `;
749
+ chatContainer.scrollTop = chatContainer.scrollHeight;
750
+
751
+ // Call Gradio Chat
752
+ try {
753
+ const responseData = await callGradioApi('chat', [msg, chatHistory, currentSystemCtx]);
754
+ // chat output expects: [updated_history, clean_input, currentSystemCtx]
755
+ const updatedHistory = responseData[0];
756
+ const lastBotMsg = updatedHistory[updatedHistory.length - 1][1];
757
+ chatHistory = updatedHistory;
758
+
759
+ chatContainer.innerHTML += `
760
+ <div class="chat-message assistant" style="text-align:left; margin:10px 0; max-width:80%;">
761
+ <i class="fa-solid fa-robot" style="margin-right:10px;"></i>
762
+ <span style="background:rgba(255,255,255,0.1); padding:8px 12px; border-radius:15px; display:inline-block;">${escHtml(lastBotMsg)}</span>
763
+ </div>
764
+ `;
765
+ chatContainer.scrollTop = chatContainer.scrollHeight;
766
+
767
+ } catch (err) {
768
+ showToast('Chat failed: ' + err.message, 'error');
769
+ }
770
+ }
771
+
772
+ async function generateSimulation() {
773
+ const caseIdInput = document.getElementById('sim-case-id').value;
774
+ if (!caseIdInput) {
775
+ showToast('Please enter a Case ID', 'error');
776
+ return;
777
+ }
778
+
779
+ const contentDiv = document.getElementById('simulation-content');
780
+ contentDiv.innerHTML = '<div class="spinner-large"></div>';
781
+
782
+ try {
783
+ const simData = await callGradioApi('animation', [parseInt(caseIdInput)]);
784
+ // The animation out is index 0
785
+ contentDiv.innerHTML = simData[0];
786
+ } catch (err) {
787
+ contentDiv.innerHTML = `<p class="placeholder-text" style="color:red;">Error: ${err.message}</p>`;
788
+ }
789
+ }
790
+
791
+ // ── Report ────────────────────────────────────────────────────────────
792
+
793
+ async function viewReport() {
794
+ if (!currentCaseId) return;
795
+
796
+ switchView('report');
797
+
798
+ try {
799
+ const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/report`);
800
+ const report = await resp.json();
801
+ renderReport(report);
802
+ } catch (e) {
803
+ showToast('Failed to load report', 'error');
804
+ }
805
+ }
806
+
807
+ function renderReport(report) {
808
+ const content = document.getElementById('report-content');
809
+
810
+ let html = `
811
+ <div class="report-header">
812
+ <h2><i class="fa-solid fa-shield-halved"></i> ${report.report_type || 'Incident Report'}</h2>
813
+ <p class="report-subtitle">Case ${escHtml(report.case?.case_number || '')} β€’ Generated by AI</p>
814
+ </div>
815
+
816
+ <div class="report-disclaimer">
817
+ <i class="fa-solid fa-triangle-exclamation"></i> ${escHtml(report.disclaimer || '')}
818
+ </div>
819
+ `;
820
+
821
+ // Stats
822
+ const stats = report.statistics || {};
823
+ html += `
824
+ <div class="report-stat-grid">
825
+ <div class="report-stat">
826
+ <div class="stat-number">${stats.total_photos || 0}</div>
827
+ <div class="stat-label">Photos</div>
828
+ </div>
829
+ <div class="report-stat">
830
+ <div class="stat-number">${stats.total_violations || 0}</div>
831
+ <div class="stat-label">Violations</div>
832
+ </div>
833
+ <div class="report-stat">
834
+ <div class="stat-number">${stats.critical_violations || 0}</div>
835
+ <div class="stat-label">Critical</div>
836
+ </div>
837
+ <div class="report-stat">
838
+ <div class="stat-number">${stats.parties_identified || 0}</div>
839
+ <div class="stat-label">Parties</div>
840
+ </div>
841
+ </div>
842
+ `;
843
+
844
+ // Case Info
845
+ html += `<h3>Case Information</h3>`;
846
+ html += `<p><strong>Case Number:</strong> ${escHtml(report.case?.case_number || 'N/A')}</p>`;
847
+ html += `<p><strong>Officer:</strong> ${escHtml(report.case?.officer_name || 'N/A')}</p>`;
848
+ html += `<p><strong>Location:</strong> ${escHtml(report.case?.location || 'N/A')}</p>`;
849
+ html += `<p><strong>Date:</strong> ${report.case?.incident_date || 'N/A'}</p>`;
850
+ if (report.case?.notes) html += `<p><strong>Notes:</strong> ${escHtml(report.case.notes)}</p>`;
851
+
852
+ // Scene Summary
853
+ if (report.scene_summary) {
854
+ html += `<h3>Scene Analysis</h3>`;
855
+ html += `<p style="white-space:pre-wrap">${escHtml(report.scene_summary)}</p>`;
856
+ }
857
+
858
+ // Parties
859
+ if (report.parties?.length) {
860
+ html += `<h3>Parties Involved</h3>`;
861
+ report.parties.forEach(p => {
862
+ html += `<h4>${escHtml(p.label)}</h4>`;
863
+ html += `<p>Type: ${p.vehicle_type || 'Unknown'} β€’ Color: ${p.vehicle_color || 'Unknown'}</p>`;
864
+ });
865
+ }
866
+
867
+ // Violations
868
+ if (report.violations?.list?.length) {
869
+ html += `<h3>Traffic Violations Detected</h3>`;
870
+ report.violations.list.forEach(v => {
871
+ html += `
872
+ <div class="violation-card ${v.severity}" style="margin-bottom:0.5rem">
873
+ <div class="violation-header">
874
+ <span class="violation-title">${escHtml(v.title)}</span>
875
+ <span class="severity-tag ${v.severity}">${v.severity}</span>
876
+ </div>
877
+ <p style="font-size:0.82rem;color:var(--text-secondary);margin-top:0.3rem">
878
+ Party: ${escHtml(v.party)} β€’ Confidence: ${Math.round(v.confidence * 100)}%
879
+ </p>
880
+ ${v.evidence ? `<p style="font-size:0.78rem;color:var(--text-muted);font-style:italic">${escHtml(v.evidence)}</p>` : ''}
881
+ </div>
882
+ `;
883
+ });
884
+ }
885
+
886
+ // Fault Analysis
887
+ if (report.fault_analysis?.determined) {
888
+ html += `<h3>Fault Analysis</h3>`;
889
+ html += `<p><strong>Primary Fault:</strong> ${escHtml(report.fault_analysis.primary_fault_party || 'Undetermined')}</p>`;
890
+
891
+ if (report.fault_analysis.fault_distribution) {
892
+ html += `<p><strong>Distribution:</strong> `;
893
+ html += Object.entries(report.fault_analysis.fault_distribution)
894
+ .map(([k, v]) => `${k}: ${v}%`).join(' β€’ ');
895
+ html += `</p>`;
896
+ }
897
+
898
+ html += `<p><strong>Confidence:</strong> ${Math.round((report.fault_analysis.overall_confidence || 0) * 100)}%</p>`;
899
+
900
+ if (report.fault_analysis.probable_cause) {
901
+ html += `<h4>Probable Cause</h4>`;
902
+ html += `<p>${escHtml(report.fault_analysis.probable_cause)}</p>`;
903
+ }
904
+ }
905
+
906
+ content.innerHTML = html;
907
+ }
908
+
909
+ // ── Rules ─────────────────────────────────────────────────────────────
910
+
911
+ async function loadRules() {
912
+ try {
913
+ const resp = await fetch(`${API_BASE}/rules`);
914
+ const data = await resp.json();
915
+ renderRules(data);
916
+ } catch (e) {
917
+ showToast('Failed to load rules', 'error');
918
+ }
919
+ }
920
+
921
+ function renderRules(data) {
922
+ const content = document.getElementById('rules-content');
923
+
924
+ if (!data.categories?.length) {
925
+ content.innerHTML = '<p class="placeholder-text">No rules loaded.</p>';
926
+ return;
927
+ }
928
+
929
+ content.innerHTML = data.categories.map(cat => `
930
+ <div class="rule-category">
931
+ <div class="rule-category-header">
932
+ <i class="fa-solid fa-gavel"></i>
933
+ ${escHtml(cat.name)}
934
+ <span class="rule-count">${cat.rule_count} rules</span>
935
+ </div>
936
+ <div class="rule-list">
937
+ ${cat.rules.map(r => `
938
+ <div class="rule-item">
939
+ <span class="rule-id">${r.id}</span>
940
+ <span>${escHtml(r.title)}</span>
941
+ <span class="severity-tag ${r.severity}" style="margin-left:auto">${r.severity}</span>
942
+ </div>
943
+ `).join('')}
944
+ </div>
945
+ </div>
946
+ `).join('');
947
+ }
948
+
949
+ // ── Toast ─────────────────────────────────────────────────────────────
950
+
951
+ function showToast(message, type = 'info') {
952
+ const container = document.getElementById('toast-container');
953
+ const toast = document.createElement('div');
954
+ toast.className = `toast ${type}`;
955
+ toast.textContent = message;
956
+ container.appendChild(toast);
957
+ setTimeout(() => toast.remove(), 4000);
958
+ }
959
+
960
+ // ── Helpers ───────────────────────────────────────────────────────────
961
+
962
+ function escHtml(str) {
963
+ if (!str) return '';
964
+ const div = document.createElement('div');
965
+ div.textContent = str;
966
+ return div.innerHTML;
967
+ }
968
+
969
+ function formatDate(dateStr) {
970
+ if (!dateStr) return '';
971
+ try {
972
+ const d = new Date(dateStr);
973
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
974
+ } catch { return dateStr; }
975
+ }
frontend/js/app.js ADDED
@@ -0,0 +1,852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TraceScene β€” Frontend Application
3
+ *
4
+ * Single-page application for accident case management,
5
+ * photo upload, AI analysis, and report viewing.
6
+ */
7
+
8
+ const API_BASE = '/api';
9
+
10
+ // ── State ─────────────────────────────────────────────────────────────
11
+
12
+ let currentView = 'landing';
13
+ let currentCaseId = null;
14
+ let currentCaseData = null; // Store full case data for editing
15
+ let selectedFiles = [];
16
+ let additionalFiles = []; // For add photos modal
17
+
18
+ // ── Init ──────────────────────────────────────────────────────────────
19
+
20
+ document.addEventListener('DOMContentLoaded', () => {
21
+ initNavigation();
22
+ initDropZone();
23
+ initButtons();
24
+ initAccordions();
25
+ loadHealth();
26
+ loadCases();
27
+ });
28
+
29
+ // ── Accordions ────────────────────────────────────────────────────────
30
+
31
+ function initAccordions() {
32
+ document.querySelectorAll('.accordion-header').forEach(btn => {
33
+ btn.addEventListener('click', () => {
34
+ const item = btn.closest('.accordion-item');
35
+ const isActive = item.classList.contains('active');
36
+
37
+ // Close all other accordions
38
+ document.querySelectorAll('.accordion-item').forEach(i => {
39
+ i.classList.remove('active');
40
+ i.querySelector('.accordion-header').setAttribute('aria-expanded', 'false');
41
+ });
42
+
43
+ // Toggle current
44
+ if (!isActive) {
45
+ item.classList.add('active');
46
+ btn.setAttribute('aria-expanded', 'true');
47
+ }
48
+ });
49
+ });
50
+ }
51
+
52
+ // ── Navigation ────────────────────────────────────────────────────────
53
+
54
+ function initNavigation() {
55
+ document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
56
+ btn.addEventListener('click', () => {
57
+ const view = btn.dataset.view;
58
+ switchView(view);
59
+ document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
60
+ btn.classList.add('active');
61
+ });
62
+ });
63
+ }
64
+
65
+ function switchView(viewName) {
66
+ document.querySelectorAll('.view').forEach(v => {
67
+ v.classList.remove('active');
68
+ v.classList.add('hidden');
69
+ });
70
+ const target = document.getElementById(`view-${viewName}`);
71
+ if (target) {
72
+ target.classList.remove('hidden');
73
+ target.classList.add('active');
74
+ }
75
+ currentView = viewName;
76
+
77
+ if (viewName === 'dashboard') loadCases();
78
+ if (viewName === 'rules') loadRules();
79
+ }
80
+
81
+ // ── Buttons ───────────────────────────────────────────────────────────
82
+
83
+ function initButtons() {
84
+ // Dashboard
85
+ document.getElementById('btn-refresh-cases')?.addEventListener('click', loadCases);
86
+ document.getElementById('btn-empty-new-case')?.addEventListener('click', () => {
87
+ switchView('new-case');
88
+ document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
89
+ document.querySelector('[data-view="new-case"]')?.classList.add('active');
90
+ });
91
+
92
+ // New Case
93
+ document.getElementById('btn-create-case')?.addEventListener('click', createCase);
94
+
95
+ // Case Detail
96
+ document.getElementById('btn-back-dashboard')?.addEventListener('click', () => switchView('dashboard'));
97
+ document.getElementById('btn-run-analysis')?.addEventListener('click', runAnalysis);
98
+ document.getElementById('btn-view-report')?.addEventListener('click', viewReport);
99
+ document.getElementById('btn-edit-case')?.addEventListener('click', openEditModal);
100
+ document.getElementById('btn-add-photos-inline')?.addEventListener('click', openAddPhotosModal);
101
+
102
+ // Edit Modal
103
+ document.getElementById('btn-close-edit-modal')?.addEventListener('click', closeEditModal);
104
+ document.getElementById('btn-cancel-edit')?.addEventListener('click', closeEditModal);
105
+ document.getElementById('btn-save-case')?.addEventListener('click', saveCaseChanges);
106
+
107
+ // Photos Modal
108
+ document.getElementById('btn-close-photos-modal')?.addEventListener('click', closePhotosModal);
109
+ document.getElementById('btn-cancel-photos')?.addEventListener('click', closePhotosModal);
110
+ document.getElementById('btn-upload-more')?.addEventListener('click', uploadMorePhotos);
111
+
112
+ // Form logic
113
+ document.getElementById('case-number')?.addEventListener('input', validateForm);
114
+ initEditDropZone();
115
+ }
116
+
117
+ function initEditDropZone() {
118
+ const dropZone = document.getElementById('edit-drop-zone');
119
+ const fileInput = document.getElementById('edit-file-input');
120
+ if (!dropZone || !fileInput) return;
121
+
122
+ dropZone.addEventListener('click', () => fileInput.click());
123
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
124
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
125
+ dropZone.addEventListener('drop', e => {
126
+ e.preventDefault();
127
+ dropZone.classList.remove('drag-over');
128
+ handleAdditionalFiles(Array.from(e.dataTransfer.files));
129
+ });
130
+ fileInput.addEventListener('change', () => {
131
+ handleAdditionalFiles(Array.from(fileInput.files));
132
+ });
133
+ }
134
+
135
+ function validateForm() {
136
+ const caseNum = document.getElementById('case-number')?.value.trim();
137
+ const btn = document.getElementById('btn-create-case');
138
+ if (btn) btn.disabled = !caseNum;
139
+ }
140
+
141
+ // ── Health ─────────────────────────────────────────────────────────────
142
+
143
+ async function loadHealth() {
144
+ try {
145
+ const resp = await fetch(`${API_BASE}/health`);
146
+ const data = await resp.json();
147
+ document.getElementById('status-model').textContent = data.model_loaded ? 'βœ“ Ready' : 'βœ— Not loaded';
148
+ document.getElementById('status-model').style.color = data.model_loaded ? '#22c55e' : '#ef4444';
149
+ document.getElementById('status-device').textContent = data.device || 'β€”';
150
+ document.getElementById('status-rules').textContent = `${data.rules_loaded} rules`;
151
+ } catch {
152
+ document.getElementById('status-model').textContent = 'βœ— Offline';
153
+ document.getElementById('status-model').style.color = '#ef4444';
154
+ }
155
+ }
156
+
157
+ // ── Cases ─────────────────────────────────────────────────────────────
158
+
159
+ async function loadCases() {
160
+ try {
161
+ const resp = await fetch(`${API_BASE}/cases`);
162
+ const data = await resp.json();
163
+ renderCases(data.cases || []);
164
+ } catch (e) {
165
+ showToast('Failed to load cases', 'error');
166
+ }
167
+ }
168
+
169
+ function renderCases(cases) {
170
+ const grid = document.getElementById('cases-grid');
171
+ const empty = document.getElementById('empty-state-dashboard');
172
+
173
+ if (!cases.length) {
174
+ grid.innerHTML = '';
175
+ grid.style.display = 'none';
176
+ empty.style.display = 'flex';
177
+ return;
178
+ }
179
+
180
+ grid.style.display = 'grid';
181
+ empty.style.display = 'none';
182
+
183
+ grid.innerHTML = cases.map(c => `
184
+ <div class="case-card" onclick="openCase(${c.id})">
185
+ <button class="btn-delete-card" onclick="event.stopPropagation(); deleteCase(${c.id}, '${escHtml(c.case_number)}')" title="Delete Case">
186
+ <i class="fa-solid fa-trash-can"></i>
187
+ </button>
188
+ <div class="card-header">
189
+ <span class="case-number">${escHtml(c.case_number)}</span>
190
+ <span class="status-badge ${c.status}">
191
+ <i class="fa-solid fa-circle"></i> ${c.status}
192
+ </span>
193
+ </div>
194
+ <div class="case-meta">
195
+ ${c.officer_name ? `<span><i class="fa-solid fa-user-shield"></i> ${escHtml(c.officer_name)}</span>` : ''}
196
+ ${c.location ? `<span><i class="fa-solid fa-location-dot"></i> ${escHtml(c.location)}</span>` : ''}
197
+ ${c.incident_date ? `<span><i class="fa-solid fa-calendar"></i> ${c.incident_date}</span>` : ''}
198
+ </div>
199
+ <div class="card-footer">
200
+ <span class="photo-count"><i class="fa-solid fa-camera"></i> ${c.photo_count || 0} photos</span>
201
+ <span style="font-size:0.72rem;color:var(--text-muted)">${formatDate(c.created_at)}</span>
202
+ </div>
203
+ </div>
204
+ `).join('');
205
+ }
206
+
207
+ async function deleteCase(id, caseNum) {
208
+ if (!confirm(`Are you sure you want to delete Case ${caseNum}? This will permanently remove all photos and analysis data.`)) {
209
+ return;
210
+ }
211
+
212
+ try {
213
+ const resp = await fetch(`${API_BASE}/cases/${id}`, { method: 'DELETE' });
214
+ if (!resp.ok) throw new Error('Delete failed');
215
+ showToast(`Case ${caseNum} deleted`, 'success');
216
+ loadCases();
217
+ } catch (e) {
218
+ showToast(e.message, 'error');
219
+ }
220
+ }
221
+
222
+ // ── Create Case ───────────────────────────────────────────────────────
223
+
224
+ async function createCase() {
225
+ const caseNumber = document.getElementById('case-number').value.trim();
226
+ if (!caseNumber) return showToast('Case number is required', 'error');
227
+
228
+ const formData = new FormData();
229
+ formData.append('case_number', caseNumber);
230
+ formData.append('officer_name', document.getElementById('officer-name').value.trim());
231
+ formData.append('location', document.getElementById('incident-location').value.trim());
232
+ formData.append('incident_date', document.getElementById('incident-date').value);
233
+ formData.append('notes', document.getElementById('officer-notes').value.trim());
234
+
235
+ try {
236
+ const resp = await fetch(`${API_BASE}/cases`, { method: 'POST', body: formData });
237
+ if (!resp.ok) {
238
+ const err = await resp.json();
239
+ throw new Error(err.detail || 'Failed to create case');
240
+ }
241
+ const data = await resp.json();
242
+ const caseId = data.id;
243
+
244
+ showToast('Case created', 'success');
245
+
246
+ // Upload photos if selected
247
+ if (selectedFiles.length > 0) {
248
+ await uploadPhotos(caseId);
249
+ }
250
+
251
+ // Clear form
252
+ document.getElementById('case-number').value = '';
253
+ document.getElementById('officer-name').value = '';
254
+ document.getElementById('incident-location').value = '';
255
+ document.getElementById('incident-date').value = '';
256
+ document.getElementById('officer-notes').value = '';
257
+ selectedFiles = [];
258
+ document.getElementById('photo-preview').classList.add('hidden');
259
+
260
+ // Open the case detail
261
+ openCase(caseId);
262
+
263
+ } catch (e) {
264
+ showToast(e.message, 'error');
265
+ }
266
+ }
267
+
268
+ async function uploadPhotos(caseId) {
269
+ const progressSection = document.getElementById('upload-progress');
270
+ const progressFill = document.getElementById('upload-progress-fill');
271
+ const statusText = document.getElementById('upload-status-text');
272
+
273
+ progressSection.classList.remove('hidden');
274
+ statusText.textContent = `Uploading ${selectedFiles.length} photos...`;
275
+
276
+ const formData = new FormData();
277
+ selectedFiles.forEach(f => formData.append('files', f));
278
+
279
+ try {
280
+ progressFill.style.width = '50%';
281
+ const resp = await fetch(`${API_BASE}/cases/${caseId}/photos`, {
282
+ method: 'POST',
283
+ body: formData,
284
+ });
285
+
286
+ if (!resp.ok) throw new Error('Upload failed');
287
+
288
+ const data = await resp.json();
289
+ progressFill.style.width = '100%';
290
+ statusText.textContent = `${data.count} photos uploaded βœ“`;
291
+ showToast(`${data.count} photos uploaded`, 'success');
292
+ return data;
293
+ } catch (e) {
294
+ statusText.textContent = 'Upload failed';
295
+ showToast('Photo upload failed', 'error');
296
+ throw e;
297
+ }
298
+ }
299
+
300
+ // ── Drop Zone ─────────────────────────────────────────────────────────
301
+
302
+ function initDropZone() {
303
+ const dropZone = document.getElementById('drop-zone');
304
+ const fileInput = document.getElementById('file-input');
305
+ if (!dropZone || !fileInput) return;
306
+
307
+ dropZone.addEventListener('click', () => fileInput.click());
308
+
309
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
310
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
311
+ dropZone.addEventListener('drop', e => {
312
+ e.preventDefault();
313
+ dropZone.classList.remove('drag-over');
314
+ handleFiles(Array.from(e.dataTransfer.files));
315
+ });
316
+
317
+ fileInput.addEventListener('change', () => {
318
+ handleFiles(Array.from(fileInput.files));
319
+ });
320
+ }
321
+
322
+ function handleFiles(files) {
323
+ const imageFiles = files.filter(f => f.type.startsWith('image/'));
324
+ if (!imageFiles.length) return showToast('No image files found', 'error');
325
+
326
+ selectedFiles = imageFiles.slice(0, 20); // Max 20
327
+
328
+ // Show previews
329
+ const preview = document.getElementById('photo-preview');
330
+ const thumbs = document.getElementById('photo-thumbnails');
331
+ const count = document.getElementById('photo-count');
332
+
333
+ preview.classList.remove('hidden');
334
+ count.textContent = `${selectedFiles.length} photo${selectedFiles.length > 1 ? 's' : ''} selected`;
335
+
336
+ thumbs.innerHTML = '';
337
+ selectedFiles.forEach(file => {
338
+ const div = document.createElement('div');
339
+ div.className = 'photo-thumbnail';
340
+ const img = document.createElement('img');
341
+ img.src = URL.createObjectURL(file);
342
+ div.appendChild(img);
343
+ thumbs.appendChild(div);
344
+ });
345
+
346
+ validateForm();
347
+ }
348
+
349
+ // ── Open Case Detail ──────────────────────────────────────────────────
350
+
351
+ async function openCase(caseId) {
352
+ currentCaseId = caseId;
353
+ switchView('case-detail');
354
+
355
+ try {
356
+ const resp = await fetch(`${API_BASE}/cases/${caseId}`);
357
+ const data = await resp.json();
358
+ currentCaseData = data.case;
359
+ renderCaseDetail(data);
360
+ } catch (e) {
361
+ showToast('Failed to load case', 'error');
362
+ }
363
+ }
364
+
365
+ // ── Edit Case ─────────────────────────────────────────────────────────
366
+
367
+ function openEditModal() {
368
+ if (!currentCaseData) return;
369
+ document.getElementById('edit-officer-name').value = currentCaseData.officer_name || '';
370
+ document.getElementById('edit-incident-location').value = currentCaseData.location || '';
371
+ document.getElementById('edit-incident-date').value = currentCaseData.incident_date || '';
372
+ document.getElementById('edit-officer-notes').value = currentCaseData.notes || '';
373
+ document.getElementById('modal-edit-case').classList.remove('hidden');
374
+ }
375
+
376
+ function closeEditModal() {
377
+ document.getElementById('modal-edit-case').classList.add('hidden');
378
+ }
379
+
380
+ async function saveCaseChanges() {
381
+ if (!currentCaseId) return;
382
+
383
+ const formData = new FormData();
384
+ formData.append('officer_name', document.getElementById('edit-officer-name').value.trim());
385
+ formData.append('location', document.getElementById('edit-incident-location').value.trim());
386
+ formData.append('incident_date', document.getElementById('edit-incident-date').value);
387
+ formData.append('notes', document.getElementById('edit-officer-notes').value.trim());
388
+
389
+ try {
390
+ const resp = await fetch(`${API_BASE}/cases/${currentCaseId}`, {
391
+ method: 'PUT',
392
+ body: formData
393
+ });
394
+
395
+ if (!resp.ok) throw new Error('Failed to update case');
396
+
397
+ showToast('Case updated', 'success');
398
+ closeEditModal();
399
+ openCase(currentCaseId); // Refresh
400
+ } catch (e) {
401
+ showToast(e.message, 'error');
402
+ }
403
+ }
404
+
405
+ // ── Add Photos ────────────────────────────────────────────────────────
406
+
407
+ function openAddPhotosModal() {
408
+ additionalFiles = [];
409
+ document.getElementById('edit-photo-preview').classList.add('hidden');
410
+ document.getElementById('edit-upload-progress').classList.add('hidden');
411
+ document.getElementById('btn-upload-more').disabled = true;
412
+ document.getElementById('modal-add-photos').classList.remove('hidden');
413
+ }
414
+
415
+ function closePhotosModal() {
416
+ document.getElementById('modal-add-photos').classList.add('hidden');
417
+ }
418
+
419
+ function handleAdditionalFiles(files) {
420
+ const imageFiles = files.filter(f => f.type.startsWith('image/'));
421
+ if (!imageFiles.length) return showToast('No image files found', 'error');
422
+
423
+ additionalFiles = imageFiles.slice(0, 20);
424
+
425
+ const preview = document.getElementById('edit-photo-preview');
426
+ const thumbs = document.getElementById('edit-photo-thumbnails');
427
+ const count = document.getElementById('edit-photo-count');
428
+
429
+ preview.classList.remove('hidden');
430
+ count.textContent = `${additionalFiles.length} photo${additionalFiles.length > 1 ? 's' : ''} selected`;
431
+
432
+ thumbs.innerHTML = '';
433
+ additionalFiles.forEach(file => {
434
+ const div = document.createElement('div');
435
+ div.className = 'photo-thumbnail';
436
+ const img = document.createElement('img');
437
+ img.src = URL.createObjectURL(file);
438
+ div.appendChild(img);
439
+ thumbs.appendChild(div);
440
+ });
441
+
442
+ document.getElementById('btn-upload-more').disabled = additionalFiles.length === 0;
443
+ }
444
+
445
+ async function uploadMorePhotos() {
446
+ if (!currentCaseId || !additionalFiles.length) return;
447
+
448
+ const progressSection = document.getElementById('edit-upload-progress');
449
+ const progressFill = document.getElementById('edit-upload-progress-fill');
450
+ const statusText = document.getElementById('edit-upload-status-text');
451
+
452
+ progressSection.classList.remove('hidden');
453
+ document.getElementById('btn-upload-more').disabled = true;
454
+
455
+ const formData = new FormData();
456
+ additionalFiles.forEach(f => formData.append('files', f));
457
+
458
+ try {
459
+ progressFill.style.width = '50%';
460
+ const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/photos`, {
461
+ method: 'POST',
462
+ body: formData,
463
+ });
464
+
465
+ if (!resp.ok) throw new Error('Upload failed');
466
+
467
+ progressFill.style.width = '100%';
468
+ statusText.textContent = `Upload complete βœ“`;
469
+ showToast('Photos added successfully', 'success');
470
+
471
+ setTimeout(() => {
472
+ closePhotosModal();
473
+ openCase(currentCaseId); // Refresh
474
+ }, 1000);
475
+ } catch (e) {
476
+ statusText.textContent = 'Upload failed';
477
+ showToast('Photo upload failed', 'error');
478
+ document.getElementById('btn-upload-more').disabled = false;
479
+ }
480
+ }
481
+
482
+ function renderCaseDetail(data) {
483
+ const c = data.case;
484
+
485
+ // Header
486
+ document.getElementById('case-detail-title').textContent = `Case: ${c.case_number}`;
487
+
488
+ // Info bar
489
+ document.getElementById('detail-case-number').textContent = c.case_number;
490
+ document.getElementById('detail-officer').textContent = c.officer_name || 'N/A';
491
+ document.getElementById('detail-location').textContent = c.location || 'N/A';
492
+ document.getElementById('detail-date').textContent = c.incident_date || 'N/A';
493
+ document.getElementById('detail-status').textContent = c.status;
494
+
495
+ const statusChip = document.getElementById('detail-status-chip');
496
+ statusChip.className = `info-chip status-chip ${c.status}`;
497
+
498
+ // Enable/disable buttons
499
+ const btnAnalysis = document.getElementById('btn-run-analysis');
500
+ const btnReport = document.getElementById('btn-view-report');
501
+ btnAnalysis.disabled = !data.photos?.length;
502
+ btnReport.disabled = c.status !== 'complete';
503
+
504
+ // Photos
505
+ const photosGrid = document.getElementById('detail-photos-grid');
506
+ document.getElementById('photo-badge').textContent = data.photos?.length || 0;
507
+
508
+ if (data.photos?.length) {
509
+ photosGrid.innerHTML = data.photos.map(p => `
510
+ <div class="detail-photo">
511
+ <img src="${API_BASE.replace('/api', '')}/api/photos/${p.id}/image" alt="${escHtml(p.filename)}" loading="lazy">
512
+ </div>
513
+ `).join('');
514
+ } else {
515
+ photosGrid.innerHTML = '<p class="placeholder-text">No photos uploaded yet.</p>';
516
+ }
517
+
518
+ // Analyses
519
+ const analysisContent = document.getElementById('analysis-content');
520
+ if (data.analyses?.length) {
521
+ analysisContent.innerHTML = data.analyses.map(a => `
522
+ <div class="analysis-photo-section">
523
+ <div class="analysis-photo-label">πŸ“· ${escHtml(a.filename || 'Photo')}</div>
524
+ <pre>${escHtml(a.raw_analysis || '')}</pre>
525
+ </div>
526
+ `).join('');
527
+ } else {
528
+ analysisContent.innerHTML = '<p class="placeholder-text">Run analysis to see AI observations.</p>';
529
+ }
530
+
531
+ // Violations
532
+ const violationsList = document.getElementById('violations-list');
533
+ const violationBadge = document.getElementById('violation-badge');
534
+ violationBadge.textContent = data.violations?.length || 0;
535
+
536
+ if (data.violations?.length) {
537
+ violationsList.innerHTML = data.violations.map(v => `
538
+ <div class="violation-card ${v.severity || 'MEDIUM'}">
539
+ <div class="violation-header">
540
+ <span class="violation-title">${escHtml(v.rule_title)}</span>
541
+ <span class="severity-tag ${v.severity || 'MEDIUM'}">${v.severity || 'MEDIUM'}</span>
542
+ </div>
543
+ <div class="violation-meta">
544
+ <span><i class="fa-solid fa-hashtag"></i> ${v.rule_id}</span>
545
+ <span><i class="fa-solid fa-percent"></i> ${Math.round(v.confidence * 100)}% confidence</span>
546
+ ${v.party_label ? `<span><i class="fa-solid fa-car"></i> ${escHtml(v.party_label)}</span>` : ''}
547
+ </div>
548
+ ${v.evidence_summary ? `<div class="violation-evidence">${escHtml(v.evidence_summary)}</div>` : ''}
549
+ </div>
550
+ `).join('');
551
+ } else {
552
+ violationsList.innerHTML = '<p class="placeholder-text">No violations detected yet.</p>';
553
+ }
554
+
555
+ // Fault Analysis
556
+ renderFaultAnalysis(data.fault_analysis, data.parties);
557
+ }
558
+
559
+ function renderFaultAnalysis(fault, parties) {
560
+ const content = document.getElementById('fault-content');
561
+
562
+ if (!fault) {
563
+ content.innerHTML = '<p class="placeholder-text">Run analysis to determine fault.</p>';
564
+ return;
565
+ }
566
+
567
+ let html = '';
568
+
569
+ // Fault distribution bars
570
+ if (fault.fault_distribution_json) {
571
+ let dist = {};
572
+ try {
573
+ dist = typeof fault.fault_distribution_json === 'string'
574
+ ? JSON.parse(fault.fault_distribution_json)
575
+ : fault.fault_distribution_json;
576
+ } catch { dist = {}; }
577
+
578
+ const entries = Object.entries(dist);
579
+ if (entries.length) {
580
+ html += '<div class="fault-party-bars">';
581
+ entries.forEach(([label, pct], i) => {
582
+ html += `
583
+ <div class="party-bar">
584
+ <div class="party-bar-label">
585
+ <span class="party-name">${escHtml(label)}</span>
586
+ <span class="party-pct">${pct}%</span>
587
+ </div>
588
+ <div class="party-bar-track">
589
+ <div class="party-bar-fill ${i === 0 ? 'primary' : 'secondary'}"
590
+ style="width: ${pct}%"></div>
591
+ </div>
592
+ </div>
593
+ `;
594
+ });
595
+ html += '</div>';
596
+ }
597
+ }
598
+
599
+ // Probable cause
600
+ if (fault.probable_cause) {
601
+ html += `
602
+ <div class="fault-cause">
603
+ <h4><i class="fa-solid fa-magnifying-glass-chart"></i> Probable Cause</h4>
604
+ <p>${escHtml(fault.probable_cause)}</p>
605
+ </div>
606
+ `;
607
+ }
608
+
609
+ // Confidence
610
+ if (fault.overall_confidence != null) {
611
+ const pct = Math.round(fault.overall_confidence * 100);
612
+ html += `
613
+ <div class="confidence-meter">
614
+ <span>Confidence:</span>
615
+ <div class="meter-bar">
616
+ <div class="meter-fill" style="width: ${pct}%"></div>
617
+ </div>
618
+ <span>${pct}%</span>
619
+ </div>
620
+ `;
621
+ }
622
+
623
+ // Summary
624
+ if (fault.analysis_summary) {
625
+ html += `<p style="margin-top:0.8rem;font-size:0.82rem;color:var(--text-secondary)">
626
+ ${escHtml(fault.analysis_summary)}</p>`;
627
+ }
628
+
629
+ content.innerHTML = html || '<p class="placeholder-text">No fault analysis available.</p>';
630
+ }
631
+
632
+ // ── Run Analysis ──────────────────────────────────────────────────────
633
+
634
+ async function runAnalysis() {
635
+ if (!currentCaseId) return;
636
+
637
+ const overlay = document.getElementById('analysis-overlay');
638
+ const stepEl = document.getElementById('analysis-step');
639
+ const detailEl = document.getElementById('analysis-detail');
640
+
641
+ overlay.classList.remove('hidden');
642
+ stepEl.textContent = 'Analyzing accident scene photos...';
643
+ detailEl.textContent = 'Running AI vision analysis on each photo. This may take several minutes.';
644
+
645
+ try {
646
+ const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/analyze`, {
647
+ method: 'POST',
648
+ });
649
+
650
+ if (!resp.ok) {
651
+ const err = await resp.json();
652
+ throw new Error(err.detail || 'Analysis failed');
653
+ }
654
+
655
+ const data = await resp.json();
656
+ overlay.classList.add('hidden');
657
+ showToast(`Analysis complete: ${data.violations_found} violations found`, 'success');
658
+
659
+ // Reload case detail
660
+ openCase(currentCaseId);
661
+
662
+ } catch (e) {
663
+ overlay.classList.add('hidden');
664
+ showToast(`Analysis failed: ${e.message}`, 'error');
665
+ }
666
+ }
667
+
668
+ // ── Report ────────────────────────────────────────────────────────────
669
+
670
+ async function viewReport() {
671
+ if (!currentCaseId) return;
672
+
673
+ switchView('report');
674
+
675
+ try {
676
+ const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/report`);
677
+ const report = await resp.json();
678
+ renderReport(report);
679
+ } catch (e) {
680
+ showToast('Failed to load report', 'error');
681
+ }
682
+ }
683
+
684
+ function renderReport(report) {
685
+ const content = document.getElementById('report-content');
686
+
687
+ let html = `
688
+ <div class="report-header">
689
+ <h2><i class="fa-solid fa-shield-halved"></i> ${report.report_type || 'Incident Report'}</h2>
690
+ <p class="report-subtitle">Case ${escHtml(report.case?.case_number || '')} β€’ Generated by AI</p>
691
+ </div>
692
+
693
+ <div class="report-disclaimer">
694
+ <i class="fa-solid fa-triangle-exclamation"></i> ${escHtml(report.disclaimer || '')}
695
+ </div>
696
+ `;
697
+
698
+ // Stats
699
+ const stats = report.statistics || {};
700
+ html += `
701
+ <div class="report-stat-grid">
702
+ <div class="report-stat">
703
+ <div class="stat-number">${stats.total_photos || 0}</div>
704
+ <div class="stat-label">Photos</div>
705
+ </div>
706
+ <div class="report-stat">
707
+ <div class="stat-number">${stats.total_violations || 0}</div>
708
+ <div class="stat-label">Violations</div>
709
+ </div>
710
+ <div class="report-stat">
711
+ <div class="stat-number">${stats.critical_violations || 0}</div>
712
+ <div class="stat-label">Critical</div>
713
+ </div>
714
+ <div class="report-stat">
715
+ <div class="stat-number">${stats.parties_identified || 0}</div>
716
+ <div class="stat-label">Parties</div>
717
+ </div>
718
+ </div>
719
+ `;
720
+
721
+ // Case Info
722
+ html += `<h3>Case Information</h3>`;
723
+ html += `<p><strong>Case Number:</strong> ${escHtml(report.case?.case_number || 'N/A')}</p>`;
724
+ html += `<p><strong>Officer:</strong> ${escHtml(report.case?.officer_name || 'N/A')}</p>`;
725
+ html += `<p><strong>Location:</strong> ${escHtml(report.case?.location || 'N/A')}</p>`;
726
+ html += `<p><strong>Date:</strong> ${report.case?.incident_date || 'N/A'}</p>`;
727
+ if (report.case?.notes) html += `<p><strong>Notes:</strong> ${escHtml(report.case.notes)}</p>`;
728
+
729
+ // Scene Summary
730
+ if (report.scene_summary) {
731
+ html += `<h3>Scene Analysis</h3>`;
732
+ html += `<p style="white-space:pre-wrap">${escHtml(report.scene_summary)}</p>`;
733
+ }
734
+
735
+ // Parties
736
+ if (report.parties?.length) {
737
+ html += `<h3>Parties Involved</h3>`;
738
+ report.parties.forEach(p => {
739
+ html += `<h4>${escHtml(p.label)}</h4>`;
740
+ html += `<p>Type: ${p.vehicle_type || 'Unknown'} β€’ Color: ${p.vehicle_color || 'Unknown'}</p>`;
741
+ });
742
+ }
743
+
744
+ // Violations
745
+ if (report.violations?.list?.length) {
746
+ html += `<h3>Traffic Violations Detected</h3>`;
747
+ report.violations.list.forEach(v => {
748
+ html += `
749
+ <div class="violation-card ${v.severity}" style="margin-bottom:0.5rem">
750
+ <div class="violation-header">
751
+ <span class="violation-title">${escHtml(v.title)}</span>
752
+ <span class="severity-tag ${v.severity}">${v.severity}</span>
753
+ </div>
754
+ <p style="font-size:0.82rem;color:var(--text-secondary);margin-top:0.3rem">
755
+ Party: ${escHtml(v.party)} β€’ Confidence: ${Math.round(v.confidence * 100)}%
756
+ </p>
757
+ ${v.evidence ? `<p style="font-size:0.78rem;color:var(--text-muted);font-style:italic">${escHtml(v.evidence)}</p>` : ''}
758
+ </div>
759
+ `;
760
+ });
761
+ }
762
+
763
+ // Fault Analysis
764
+ if (report.fault_analysis?.determined) {
765
+ html += `<h3>Fault Analysis</h3>`;
766
+ html += `<p><strong>Primary Fault:</strong> ${escHtml(report.fault_analysis.primary_fault_party || 'Undetermined')}</p>`;
767
+
768
+ if (report.fault_analysis.fault_distribution) {
769
+ html += `<p><strong>Distribution:</strong> `;
770
+ html += Object.entries(report.fault_analysis.fault_distribution)
771
+ .map(([k, v]) => `${k}: ${v}%`).join(' β€’ ');
772
+ html += `</p>`;
773
+ }
774
+
775
+ html += `<p><strong>Confidence:</strong> ${Math.round((report.fault_analysis.overall_confidence || 0) * 100)}%</p>`;
776
+
777
+ if (report.fault_analysis.probable_cause) {
778
+ html += `<h4>Probable Cause</h4>`;
779
+ html += `<p>${escHtml(report.fault_analysis.probable_cause)}</p>`;
780
+ }
781
+ }
782
+
783
+ content.innerHTML = html;
784
+ }
785
+
786
+ // ── Rules ─────────────────────────────────────────────────────────────
787
+
788
+ async function loadRules() {
789
+ try {
790
+ const resp = await fetch(`${API_BASE}/rules`);
791
+ const data = await resp.json();
792
+ renderRules(data);
793
+ } catch (e) {
794
+ showToast('Failed to load rules', 'error');
795
+ }
796
+ }
797
+
798
+ function renderRules(data) {
799
+ const content = document.getElementById('rules-content');
800
+
801
+ if (!data.categories?.length) {
802
+ content.innerHTML = '<p class="placeholder-text">No rules loaded.</p>';
803
+ return;
804
+ }
805
+
806
+ content.innerHTML = data.categories.map(cat => `
807
+ <div class="rule-category">
808
+ <div class="rule-category-header">
809
+ <i class="fa-solid fa-gavel"></i>
810
+ ${escHtml(cat.name)}
811
+ <span class="rule-count">${cat.rule_count} rules</span>
812
+ </div>
813
+ <div class="rule-list">
814
+ ${cat.rules.map(r => `
815
+ <div class="rule-item">
816
+ <span class="rule-id">${r.id}</span>
817
+ <span>${escHtml(r.title)}</span>
818
+ <span class="severity-tag ${r.severity}" style="margin-left:auto">${r.severity}</span>
819
+ </div>
820
+ `).join('')}
821
+ </div>
822
+ </div>
823
+ `).join('');
824
+ }
825
+
826
+ // ── Toast ─────────────────────────────────────────────────────────────
827
+
828
+ function showToast(message, type = 'info') {
829
+ const container = document.getElementById('toast-container');
830
+ const toast = document.createElement('div');
831
+ toast.className = `toast ${type}`;
832
+ toast.textContent = message;
833
+ container.appendChild(toast);
834
+ setTimeout(() => toast.remove(), 4000);
835
+ }
836
+
837
+ // ── Helpers ───────────────────────────────────────────────────────────
838
+
839
+ function escHtml(str) {
840
+ if (!str) return '';
841
+ const div = document.createElement('div');
842
+ div.textContent = str;
843
+ return div.innerHTML;
844
+ }
845
+
846
+ function formatDate(dateStr) {
847
+ if (!dateStr) return '';
848
+ try {
849
+ const d = new Date(dateStr);
850
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
851
+ } catch { return dateStr; }
852
+ }
frontend/static/placeholder.jpg ADDED