ar07xd commited on
Commit
bc6669a
·
verified ·
1 Parent(s): c9b7b2b

Sync from GitHub via hub-sync

Browse files
api/router.py CHANGED
@@ -1,6 +1,6 @@
1
  from fastapi import APIRouter
2
 
3
- from api.v1 import analyze, auth, health, history, report
4
 
5
  api_router = APIRouter(prefix="/api/v1")
6
  api_router.include_router(health.router)
@@ -9,3 +9,4 @@ api_router.include_router(analyze.jobs_router) # Phase 19.3
9
  api_router.include_router(report.router)
10
  api_router.include_router(auth.router)
11
  api_router.include_router(history.router)
 
 
1
  from fastapi import APIRouter
2
 
3
+ from api.v1 import analyze, auth, health, history, report, stats
4
 
5
  api_router = APIRouter(prefix="/api/v1")
6
  api_router.include_router(health.router)
 
9
  api_router.include_router(report.router)
10
  api_router.include_router(auth.router)
11
  api_router.include_router(history.router)
12
+ api_router.include_router(stats.router)
api/v1/analyze.py CHANGED
@@ -259,9 +259,7 @@ async def analyze_image(
259
  media_type="image",
260
  verdict=label,
261
  authenticity_score=float(score),
262
- result_json=json.dumps(resp.model_dump(
263
- exclude={"explainability": {"heatmap_base64", "ela_base64", "boxes_base64"}}
264
- )),
265
  media_hash=media_hash,
266
  media_path=media_path,
267
  thumbnail_url=thumbnail_url,
@@ -526,8 +524,13 @@ async def analyze_text_endpoint(
526
  # lower confidence, but should not give a high floor when classifier is very fake.
527
  manip_penalty = min(len(manip) * 5, 30)
528
  raw_score = (1.0 - effective_fake_prob) * 100.0
529
- heuristic_score = max(0, 100 - sens.score) * 0.60 + max(0, 100 - manip_penalty) * 0.40
530
- weighted = raw_score * 0.90 + heuristic_score * 0.10
 
 
 
 
 
531
  score = int(round(max(0.0, min(100.0, weighted))))
532
  label, severity = get_verdict_label(score)
533
  duration_ms = int((time.perf_counter() - start) * 1000)
@@ -685,12 +688,18 @@ async def analyze_screenshot_endpoint(
685
  manip_penalty = min(len(manip) * 5, 30)
686
  layout_penalty = min(len(layout) * 5, 15)
687
  raw_score = (1.0 - effective_fake_prob) * 100.0
688
- heuristic_score = (
689
- max(0, 100 - sens.score) * 0.45
690
- + max(0, 100 - manip_penalty) * 0.35
691
- + max(0, 100 - layout_penalty) * 0.20
692
- )
693
- weighted = raw_score * 0.90 + heuristic_score * 0.10
 
 
 
 
 
 
694
  if not full_text.strip():
695
  weighted = 50
696
  score = int(round(max(0.0, min(100.0, weighted))))
 
259
  media_type="image",
260
  verdict=label,
261
  authenticity_score=float(score),
262
+ result_json=json.dumps(resp.model_dump()),
 
 
263
  media_hash=media_hash,
264
  media_path=media_path,
265
  thumbnail_url=thumbnail_url,
 
524
  # lower confidence, but should not give a high floor when classifier is very fake.
525
  manip_penalty = min(len(manip) * 5, 30)
526
  raw_score = (1.0 - effective_fake_prob) * 100.0
527
+
528
+ if lang == "en":
529
+ heuristic_score = max(0, 100 - sens.score) * 0.60 + max(0, 100 - manip_penalty) * 0.40
530
+ weighted = raw_score * 0.90 + heuristic_score * 0.10
531
+ else:
532
+ weighted = raw_score
533
+
534
  score = int(round(max(0.0, min(100.0, weighted))))
535
  label, severity = get_verdict_label(score)
536
  duration_ms = int((time.perf_counter() - start) * 1000)
 
688
  manip_penalty = min(len(manip) * 5, 30)
689
  layout_penalty = min(len(layout) * 5, 15)
690
  raw_score = (1.0 - effective_fake_prob) * 100.0
691
+
692
+ if lang == "en":
693
+ heuristic_score = (
694
+ max(0, 100 - sens.score) * 0.45
695
+ + max(0, 100 - manip_penalty) * 0.35
696
+ + max(0, 100 - layout_penalty) * 0.20
697
+ )
698
+ weighted = raw_score * 0.90 + heuristic_score * 0.10
699
+ else:
700
+ layout_heuristic = max(0, 100 - layout_penalty)
701
+ weighted = raw_score * 0.90 + layout_heuristic * 0.10
702
+
703
  if not full_text.strip():
704
  weighted = 50
705
  score = int(round(max(0.0, min(100.0, weighted))))
api/v1/report.py CHANGED
@@ -16,13 +16,21 @@ from services.report_service import cleanup_expired, create_report_row, generate
16
  router = APIRouter(prefix="/report", tags=["report"])
17
 
18
 
19
- def _assert_record_access(record: AnalysisRecord, user: User | None) -> None:
20
  """Phase 15.1 — allow access if the requester owns the record, or if the record
21
- is anonymous (user_id is None). Everything else is 403."""
22
- if record.user_id is None:
23
- return
24
  if user is not None and record.user_id == user.id:
25
  return
 
 
 
 
 
 
 
 
 
 
26
  raise HTTPException(status.HTTP_403_FORBIDDEN, "You do not own this analysis")
27
 
28
 
@@ -32,6 +40,7 @@ def _assert_record_access(record: AnalysisRecord, user: User | None) -> None:
32
  def generate(
33
  request: Request,
34
  analysis_id: int,
 
35
  db: Session = Depends(get_db),
36
  user: User | None = Depends(optional_current_user),
37
  ):
@@ -39,7 +48,7 @@ def generate(
39
  if not record:
40
  raise HTTPException(status_code=404, detail="analysis not found")
41
 
42
- _assert_record_access(record, user)
43
 
44
  existing = db.query(Report).filter(Report.analysis_id == analysis_id).first()
45
  if existing and Path(existing.file_path).exists():
@@ -70,13 +79,14 @@ def generate(
70
  def download(
71
  request: Request,
72
  analysis_id: int,
 
73
  db: Session = Depends(get_db),
74
  user: User | None = Depends(optional_current_user),
75
  ):
76
  record = db.query(AnalysisRecord).filter(AnalysisRecord.id == analysis_id).first()
77
  if not record:
78
  raise HTTPException(status_code=404, detail="analysis not found")
79
- _assert_record_access(record, user)
80
 
81
  row = db.query(Report).filter(Report.analysis_id == analysis_id).first()
82
  if not row:
 
16
  router = APIRouter(prefix="/report", tags=["report"])
17
 
18
 
19
+ def _assert_record_access(record: AnalysisRecord, user: User | None, token: str | None = None) -> None:
20
  """Phase 15.1 — allow access if the requester owns the record, or if the record
21
+ is anonymous (user_id is None) AND they provide the correct UUID token. Everything else is 403."""
 
 
22
  if user is not None and record.user_id == user.id:
23
  return
24
+ if record.user_id is None:
25
+ if not token:
26
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Anonymous reports require a token")
27
+ try:
28
+ import json
29
+ data = json.loads(record.result_json)
30
+ if data.get("analysis_id") == token:
31
+ return
32
+ except Exception:
33
+ pass
34
  raise HTTPException(status.HTTP_403_FORBIDDEN, "You do not own this analysis")
35
 
36
 
 
40
  def generate(
41
  request: Request,
42
  analysis_id: int,
43
+ token: str | None = Query(None),
44
  db: Session = Depends(get_db),
45
  user: User | None = Depends(optional_current_user),
46
  ):
 
48
  if not record:
49
  raise HTTPException(status_code=404, detail="analysis not found")
50
 
51
+ _assert_record_access(record, user, token)
52
 
53
  existing = db.query(Report).filter(Report.analysis_id == analysis_id).first()
54
  if existing and Path(existing.file_path).exists():
 
79
  def download(
80
  request: Request,
81
  analysis_id: int,
82
+ token: str | None = Query(None),
83
  db: Session = Depends(get_db),
84
  user: User | None = Depends(optional_current_user),
85
  ):
86
  record = db.query(AnalysisRecord).filter(AnalysisRecord.id == analysis_id).first()
87
  if not record:
88
  raise HTTPException(status_code=404, detail="analysis not found")
89
+ _assert_record_access(record, user, token)
90
 
91
  row = db.query(Report).filter(Report.analysis_id == analysis_id).first()
92
  if not row:
api/v1/stats.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from fastapi import APIRouter, Depends
3
+ from sqlalchemy.orm import Session
4
+ from sqlalchemy import func
5
+
6
+ from db.database import get_db
7
+ from db.models import AnalysisRecord
8
+
9
+ router = APIRouter(prefix="/stats", tags=["stats"])
10
+
11
+ @router.get("/recent")
12
+ def get_recent_stats(db: Session = Depends(get_db)):
13
+ """Phase 20.4 — Live Engagement Counter."""
14
+ twenty_four_hours_ago = datetime.utcnow() - timedelta(hours=24)
15
+ count = db.query(func.count(AnalysisRecord.id)).filter(AnalysisRecord.created_at >= twenty_four_hours_ago).scalar()
16
+ return {"count_24h": count or 0}
requirements.txt CHANGED
@@ -66,4 +66,4 @@ ffmpeg-python==0.2.0 # Python wrapper for ffmpeg subprocess (audio extraction)
66
 
67
  asyncpg
68
  psycopg2-binary
69
- alembic
 
66
 
67
  asyncpg
68
  psycopg2-binary
69
+ alembicslowapi==0.1.9
services/report_service.py CHANGED
@@ -76,9 +76,10 @@ def _extract_llm_summary(analysis_json: dict) -> dict | None:
76
 
77
 
78
  def render_html(analysis_json: dict) -> str:
79
- score = analysis_json.get("verdict", {}).get("authenticity_score", 50)
80
- sc = _score_class(score)
81
- donut_b64 = _make_donut_chart(score, sc)
 
82
  llm_summary = _extract_llm_summary(analysis_json)
83
  expl: dict[str, Any] = analysis_json.get("explainability") or {}
84
 
@@ -96,6 +97,7 @@ def render_html(analysis_json: dict) -> str:
96
  "AI-based analysis may not be 100% accurate.",
97
  ),
98
  score_class=sc,
 
99
  generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
100
  donut_b64=donut_b64,
101
  llm_summary=llm_summary,
@@ -134,11 +136,11 @@ def _fallback_pdf(record: AnalysisRecord, analysis_json: dict, out_path: Path) -
134
  Paragraph("DeepShield Analysis Report", styles["Title"]),
135
  Paragraph(f"Record #{record.id} · {analysis_json.get('media_type', record.media_type)}", styles["Normal"]),
136
  Spacer(1, 8),
137
- Paragraph("Verdict", styles["Heading2"]),
138
  Table(
139
  [
140
  ["Label", verdict.get("label", record.verdict)],
141
- ["Authenticity score", f"{verdict.get('authenticity_score', record.authenticity_score)}/100"],
142
  ["Model label", verdict.get("model_label", "")],
143
  ["Model confidence", f"{float(verdict.get('model_confidence', 0.0)):.3f}"],
144
  ],
@@ -147,6 +149,14 @@ def _fallback_pdf(record: AnalysisRecord, analysis_json: dict, out_path: Path) -
147
  Spacer(1, 8),
148
  ]
149
 
 
 
 
 
 
 
 
 
150
  exif = expl.get("exif") or {}
151
  if exif:
152
  story.extend([
 
76
 
77
 
78
  def render_html(analysis_json: dict) -> str:
79
+ auth_score = analysis_json.get("verdict", {}).get("authenticity_score", 50)
80
+ fake_score = 100 - auth_score
81
+ sc = _score_class(auth_score)
82
+ donut_b64 = _make_donut_chart(fake_score, sc)
83
  llm_summary = _extract_llm_summary(analysis_json)
84
  expl: dict[str, Any] = analysis_json.get("explainability") or {}
85
 
 
97
  "AI-based analysis may not be 100% accurate.",
98
  ),
99
  score_class=sc,
100
+ fake_score=fake_score,
101
  generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
102
  donut_b64=donut_b64,
103
  llm_summary=llm_summary,
 
136
  Paragraph("DeepShield Analysis Report", styles["Title"]),
137
  Paragraph(f"Record #{record.id} · {analysis_json.get('media_type', record.media_type)}", styles["Normal"]),
138
  Spacer(1, 8),
139
+ Paragraph("Deepfake Probability", styles["Heading2"]),
140
  Table(
141
  [
142
  ["Label", verdict.get("label", record.verdict)],
143
+ ["Deepfake probability", f"{100 - verdict.get('authenticity_score', record.authenticity_score)}/100"],
144
  ["Model label", verdict.get("model_label", "")],
145
  ["Model confidence", f"{float(verdict.get('model_confidence', 0.0)):.3f}"],
146
  ],
 
149
  Spacer(1, 8),
150
  ]
151
 
152
+ llm_summary = _extract_llm_summary(analysis_json)
153
+ if llm_summary and llm_summary.get("paragraph"):
154
+ story.extend([
155
+ Paragraph("AI Explanation", styles["Heading2"]),
156
+ Paragraph(llm_summary["paragraph"], styles["Normal"]),
157
+ Spacer(1, 8),
158
+ ])
159
+
160
  exif = expl.get("exif") or {}
161
  if exif:
162
  story.extend([
templates/report.html CHANGED
@@ -84,11 +84,11 @@
84
  </table>
85
 
86
  {# ── Verdict ── #}
87
- <h2>Verdict</h2>
88
  <table class="verdict-table">
89
  <tr>
90
  <td class="verdict-score-cell">
91
- <div class="score-num score {{ score_class }}">{{ verdict.authenticity_score }}</div>
92
  <div class="score-denom">/ 100</div>
93
  </td>
94
  <td class="verdict-detail-cell">
@@ -206,7 +206,7 @@
206
  <td><b>{{ sc2 }}</b>/100</td>
207
  <td>
208
  <span class="vlm-score-bar-wrap">
209
- <span class="vlm-score-bar {{ bar_cls }}" style="width:{{ sc2 }}%;display:block;"></span>
210
  </span>
211
  </td>
212
  <td class="muted">{{ comp.notes if comp else '' }}</td>
 
84
  </table>
85
 
86
  {# ── Verdict ── #}
87
+ <h2>Deepfake Probability</h2>
88
  <table class="verdict-table">
89
  <tr>
90
  <td class="verdict-score-cell">
91
+ <div class="score-num score {{ score_class }}">{{ fake_score }}</div>
92
  <div class="score-denom">/ 100</div>
93
  </td>
94
  <td class="verdict-detail-cell">
 
206
  <td><b>{{ sc2 }}</b>/100</td>
207
  <td>
208
  <span class="vlm-score-bar-wrap">
209
+ <span class="vlm-score-bar {{ bar_cls }}" style="display: block; width: {{ sc2 }}%;"></span>
210
  </span>
211
  </td>
212
  <td class="muted">{{ comp.notes if comp else '' }}</td>