Spaces:
Running
Running
Sync from GitHub via hub-sync
Browse files- api/router.py +2 -1
- api/v1/analyze.py +20 -11
- api/v1/report.py +16 -6
- api/v1/stats.py +16 -0
- requirements.txt +1 -1
- services/report_service.py +15 -5
- templates/report.html +3 -3
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 |
-
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
| 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("
|
| 138 |
Table(
|
| 139 |
[
|
| 140 |
["Label", verdict.get("label", record.verdict)],
|
| 141 |
-
["
|
| 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>
|
| 88 |
<table class="verdict-table">
|
| 89 |
<tr>
|
| 90 |
<td class="verdict-score-cell">
|
| 91 |
-
<div class="score-num score {{ score_class }}">{{
|
| 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 }}%;
|
| 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>
|