Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
33
ai_platform/modules/forensic_features/forensic/__init__.py
Normal file
33
ai_platform/modules/forensic_features/forensic/__init__.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""
|
||||
forensic/ — Glue layer pentru consumul modulelor m25-m29 într-un context LLM.
|
||||
|
||||
Componente:
|
||||
|
||||
- scoring.py Fuziunea celor N scoruri individuale într-un verdict
|
||||
unificat. Pure Python, FĂRĂ dependențe pesante (cv2,
|
||||
mediapipe). Importabil safe peste tot.
|
||||
|
||||
- prompt_builder.py Format text + imagini base64 pentru prompt LLM.
|
||||
Doar stdlib + os/base64. Importabil safe.
|
||||
|
||||
- orchestrator.py Apelează modulele forensice. Importă preprocessing.py
|
||||
care depinde de cv2 → IMPORT LAZY. Nu e exportat din
|
||||
__init__ ca să nu propage cv2 dependency la utilizatorii
|
||||
care vor doar scoring/prompt_builder.
|
||||
|
||||
Convenție import recomandată:
|
||||
|
||||
from forensic.scoring import fuse_scores # pur, fără cv2
|
||||
from forensic.prompt_builder import build_evidence_block # pur, fără cv2
|
||||
from forensic.orchestrator import run_forensic_pipeline # cere cv2 + mediapipe
|
||||
"""
|
||||
|
||||
# Exportăm DOAR componente fără dependențe pesante. Orchestrator importat
|
||||
# explicit de cine-l folosește.
|
||||
from .scoring import fuse_scores, fusion_label, explain_fusion, DEFAULT_WEIGHTS
|
||||
from .prompt_builder import build_evidence_block, encode_image_b64, format_for_chat_completion
|
||||
|
||||
__all__ = [
|
||||
"fuse_scores", "fusion_label", "explain_fusion", "DEFAULT_WEIGHTS",
|
||||
"build_evidence_block", "encode_image_b64", "format_for_chat_completion",
|
||||
]
|
||||
333
ai_platform/modules/forensic_features/forensic/orchestrator.py
Normal file
333
ai_platform/modules/forensic_features/forensic/orchestrator.py
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
"""
|
||||
forensic/orchestrator.py — Apel uniform pentru modulele m25-m29.
|
||||
|
||||
OBIECTIV:
|
||||
Modulele forensice noi au input_type diferit: m25, m27, m28, m29 cer
|
||||
o listă de frame_paths; m26 cere video_path direct (audio extraction).
|
||||
Orchestratorul rezolvă această diferență prin extragerea frame-urilor
|
||||
o singură dată și trimiterea la fiecare modul în formatul corect.
|
||||
|
||||
EXECUȚIE:
|
||||
Modulele rulează SECVENȚIAL (default) pentru predictibilitate, dar pot
|
||||
rula în parallel via ProcessPoolExecutor (use_parallel=True). Atenție:
|
||||
MediaPipe e thread-safe la inferență dar nu și la load (FaceLandmarker
|
||||
se inițializează per process), iar m26 ține audio buffer mare în RAM.
|
||||
Pe mașini cu <8GB RAM, paralelizarea poate cauza OOM.
|
||||
|
||||
EROARE HANDLING:
|
||||
Dacă un modul aruncă excepție, NU oprește orchestratorul. Înregistrează
|
||||
eroarea în câmpul "errors" al rezultatului acelui modul și continuă.
|
||||
Asta ține contractul: toate modulele întotdeauna întorc o intrare
|
||||
în results, chiar și când eșuează.
|
||||
|
||||
CALL EXEMPLU (sync):
|
||||
>>> from forensic.orchestrator import run_forensic_pipeline
|
||||
>>> results = run_forensic_pipeline(
|
||||
... video_path="/tmp/clip.mp4",
|
||||
... results_dir="/tmp/forensic_out",
|
||||
... modules=["m25", "m26", "m27", "m28", "m29"],
|
||||
... )
|
||||
>>> results["modules"]["m25"]["summary"]["primary_label"]
|
||||
'FAKE'
|
||||
>>> results["fusion"]["score"]
|
||||
0.78
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if BASE_DIR not in sys.path:
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
|
||||
import preprocessing # noqa: E402
|
||||
from tools._contract import empty_response # noqa: E402
|
||||
from forensic.scoring import fuse_scores, explain_fusion # noqa: E402
|
||||
|
||||
# ── Catalog module disponibile ──────────────────────────────────────────────
|
||||
# Map: module_id → (import_path, function_name, input_type)
|
||||
# input_type: "video_path" sau "overview_frames" (lista frame paths)
|
||||
AVAILABLE_MODULES: dict[str, tuple[str, str, str]] = {
|
||||
"m25": ("tools.m25_physiology.physiology", "run", "overview_frames"),
|
||||
"m26": ("tools.m26_audio.audio", "run", "video_path"),
|
||||
"m27": ("tools.m27_ai_detector.ai_detector", "run", "overview_frames"),
|
||||
"m28": ("tools.m28_forgery_heatmap.forgery_heatmap", "run", "overview_frames"),
|
||||
"m29": ("tools.m29_lighting.lighting", "run", "overview_frames"),
|
||||
}
|
||||
|
||||
DEFAULT_MODULES = ["m25", "m26", "m27", "m28", "m29"]
|
||||
|
||||
|
||||
def _load_module_function(module_id: str):
|
||||
"""Importă lazy modulul și întoarce funcția run."""
|
||||
if module_id not in AVAILABLE_MODULES:
|
||||
raise ValueError(f"Unknown module: {module_id}")
|
||||
import_path, fn_name, input_type = AVAILABLE_MODULES[module_id]
|
||||
mod = importlib.import_module(import_path)
|
||||
return getattr(mod, fn_name), input_type, getattr(mod, "TOOL_NAME", module_id), getattr(mod, "VERSION", "?")
|
||||
|
||||
|
||||
def _run_single_module(
|
||||
module_id: str,
|
||||
video_path: str,
|
||||
frame_paths: list[str] | None,
|
||||
module_results_dir: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""
|
||||
Rulează un singur modul, capturează excepții, întoarce (id, result).
|
||||
|
||||
Garantează că result e un dict cu schema CONTRACT.md, chiar și pe eroare.
|
||||
"""
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
fn, input_type, tool_name, version = _load_module_function(module_id)
|
||||
except Exception as e:
|
||||
return module_id, empty_response(
|
||||
tool_id=module_id, tool_name=module_id, version="?",
|
||||
input_type="overview_frames",
|
||||
reason=f"Module load failed: {e}",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
try:
|
||||
if input_type == "video_path":
|
||||
result = fn(video_path, results_dir=module_results_dir)
|
||||
else:
|
||||
if frame_paths is None or not frame_paths:
|
||||
return module_id, empty_response(
|
||||
tool_id=module_id, tool_name=tool_name, version=version,
|
||||
input_type=input_type,
|
||||
reason="Frame extraction failed sau frame paths empty",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
result = fn(frame_paths, results_dir=module_results_dir)
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc(limit=5)
|
||||
return module_id, empty_response(
|
||||
tool_id=module_id, tool_name=tool_name, version=version,
|
||||
input_type=input_type,
|
||||
reason=f"Module execution failed: {e}\n{tb}",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# Verifică schema minimă: orice modul TREBUIE să întoarcă schema CONTRACT.md.
|
||||
# Dacă cineva a uitat să folosească make_response, hotfix la rulare.
|
||||
if not isinstance(result, dict) or "summary" not in result or "tool" not in result:
|
||||
return module_id, empty_response(
|
||||
tool_id=module_id, tool_name=tool_name, version=version,
|
||||
input_type=input_type,
|
||||
reason="Schema invalidă — modulul nu folosește make_response",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
return module_id, result
|
||||
|
||||
|
||||
def _adaptive_every_n_frames(video_path: str) -> int:
|
||||
"""
|
||||
Calculează pasul de extracție frame-uri în funcție de durata video.
|
||||
Pe video scurt vrem TOATE cadrele (ca m25 rPPG să poată funcționa).
|
||||
Pe video lung — sample sparse ca să nu producem 1000+ frame-uri.
|
||||
|
||||
Heuristic empiric:
|
||||
< 5s → every_n=1 (toate cadrele)
|
||||
< 30s → every_n=3 (~10 fps efectiv)
|
||||
< 120s → every_n=10 (~3 fps efectiv)
|
||||
else → every_n=30 (~1 fps efectiv)
|
||||
"""
|
||||
try:
|
||||
import cv2 # local import (preprocessing îl are deja)
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
|
||||
n = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
|
||||
cap.release()
|
||||
duration = float(n) / fps if fps > 0 else 0.0
|
||||
if duration <= 0:
|
||||
return 30
|
||||
if duration < 5:
|
||||
return 1
|
||||
if duration < 30:
|
||||
return 3
|
||||
if duration < 120:
|
||||
return 10
|
||||
return 30
|
||||
except Exception:
|
||||
return 30
|
||||
|
||||
|
||||
def _probe_has_audio(video_path: str) -> bool:
|
||||
"""Verifică rapid cu ffprobe dacă video-ul are pistă audio."""
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_type",
|
||||
"-of", "default=nw=1:nk=1", video_path],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
return "audio" in result.stdout.lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def run_forensic_pipeline(
|
||||
video_path: str,
|
||||
results_dir: str,
|
||||
modules: list[str] | None = None,
|
||||
every_n_frames: int | None = None,
|
||||
use_parallel: bool = False,
|
||||
max_workers: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Rulează modulele forensice m25-m29 (sau alt set) pe un video.
|
||||
|
||||
Args:
|
||||
video_path: cale către fișierul video (.mp4, .mov, etc.)
|
||||
results_dir: director unde se salvează frame-urile extrase și
|
||||
artefactele PNG (un sub-folder per modul). Se creează dacă lipsește.
|
||||
modules: lista module_ids de rulat. Default: DEFAULT_MODULES (toate cele 5).
|
||||
every_n_frames: pas extracție frame-uri (1 = toate; 30 = ~1/sec @ 30fps).
|
||||
Pentru analiza forensică e suficient 1 frame/sec → economie disk.
|
||||
use_parallel: dacă True, rulează modulele concurent (ThreadPoolExecutor).
|
||||
Notă: MediaPipe nu e thread-safe la load — module care folosesc
|
||||
MediaPipe cer un proces per modul (vezi limitări în docstring top).
|
||||
Default False = sequential, mai lent dar safe.
|
||||
max_workers: număr thread-uri pentru paralelizare (ignorat dacă
|
||||
use_parallel=False).
|
||||
|
||||
Returns:
|
||||
dict cu:
|
||||
"video_path": str
|
||||
"frame_paths": list[str] (pentru debug)
|
||||
"n_frames_extracted": int
|
||||
"modules": dict {module_id: result_CONTRACT_schema}
|
||||
"fusion": dict (rezultatul fuse_scores)
|
||||
"explanation": list[str] (1-3 propoziții human-readable)
|
||||
"execution_time_ms": float (durată totală orchestrator)
|
||||
"errors": list[str] (erori globale, NU per-modul)
|
||||
"""
|
||||
t_start = time.perf_counter()
|
||||
if modules is None:
|
||||
modules = list(DEFAULT_MODULES)
|
||||
|
||||
# Adaptive every_n_frames dacă nu e specificat — important pentru m25/m26
|
||||
# care au nevoie de extracție densă pe video scurt (rPPG, audio analysis).
|
||||
if every_n_frames is None:
|
||||
every_n_frames = _adaptive_every_n_frames(video_path)
|
||||
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
frames_dir = os.path.join(results_dir, "_frames")
|
||||
errors: list[str] = []
|
||||
auto_skipped: list[str] = []
|
||||
|
||||
# ── Auto-skip module unde input lipsește ──
|
||||
# m26 (audio) are nevoie de pistă audio. Dacă lipsește, skip ca să nu
|
||||
# consumăm 30s+ pe ffmpeg degeaba.
|
||||
if "m26" in modules and not _probe_has_audio(video_path):
|
||||
modules = [m for m in modules if m != "m26"]
|
||||
auto_skipped.append("m26 (no audio track)")
|
||||
|
||||
# ── Extragere frame-uri o singură dată ──
|
||||
frame_paths: list[str] = []
|
||||
if any(AVAILABLE_MODULES[m][2] == "overview_frames" for m in modules
|
||||
if m in AVAILABLE_MODULES):
|
||||
try:
|
||||
frame_paths = preprocessing.extract_frames(
|
||||
video_path, frames_dir, every_n=every_n_frames
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"Frame extraction failed: {e}")
|
||||
frame_paths = []
|
||||
|
||||
# ── Scriem metadata care modulele pot citi (fps real, every_n) ──
|
||||
# Asta evită ghicirea hațardă în m25 a fps-ului real.
|
||||
try:
|
||||
import cv2 as _cv2
|
||||
cap = _cv2.VideoCapture(video_path)
|
||||
original_fps = float(cap.get(_cv2.CAP_PROP_FPS) or 24.0)
|
||||
cap.release()
|
||||
except Exception:
|
||||
original_fps = 24.0
|
||||
effective_fps = original_fps / max(1, every_n_frames)
|
||||
meta = {
|
||||
"video_path": os.path.abspath(video_path),
|
||||
"original_fps": original_fps,
|
||||
"every_n_frames": every_n_frames,
|
||||
"effective_fps": effective_fps,
|
||||
"n_frames_extracted": len(frame_paths),
|
||||
}
|
||||
try:
|
||||
import json as _json
|
||||
with open(os.path.join(results_dir, "_meta.json"), "w") as f:
|
||||
_json.dump(meta, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Rulare module ──
|
||||
module_results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Toate modulele scriu PNG-uri în results_dir/images/ — numele sunt
|
||||
# prefixate unic (m25_*, m26_*, ...) deci nu există coliziuni.
|
||||
# Folosim același results_dir pentru toate.
|
||||
|
||||
if use_parallel and len(modules) > 1:
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
||||
futures = {
|
||||
ex.submit(_run_single_module, mid, video_path,
|
||||
frame_paths, results_dir): mid
|
||||
for mid in modules
|
||||
}
|
||||
for fut in as_completed(futures):
|
||||
mid, result = fut.result()
|
||||
module_results[mid] = result
|
||||
else:
|
||||
for mid in modules:
|
||||
mid_returned, result = _run_single_module(
|
||||
mid, video_path, frame_paths, results_dir
|
||||
)
|
||||
module_results[mid_returned] = result
|
||||
|
||||
# ── Fuziunea scorurilor ──
|
||||
fusion = fuse_scores(module_results)
|
||||
explanation = explain_fusion(fusion, module_results)
|
||||
|
||||
return {
|
||||
"video_path": video_path,
|
||||
"frame_paths": frame_paths,
|
||||
"n_frames_extracted": len(frame_paths),
|
||||
"every_n_frames_used": every_n_frames,
|
||||
"auto_skipped": auto_skipped,
|
||||
"modules": module_results,
|
||||
"fusion": fusion,
|
||||
"explanation": explanation,
|
||||
"execution_time_ms": round((time.perf_counter() - t_start) * 1000, 2),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def list_module_artifacts(orchestrator_result: dict[str, Any],
|
||||
results_dir: str) -> dict[str, list[str]]:
|
||||
"""
|
||||
Helper: returnează dict {module_id: [absolute_paths]} cu toate PNG-urile
|
||||
salvate de fiecare modul. Util pentru a le mâna la prompt_builder.
|
||||
Toate PNG-urile sunt în results_dir/images/ (nume prefixate unic).
|
||||
"""
|
||||
artifacts: dict[str, list[str]] = {}
|
||||
images_dir = os.path.join(results_dir, "images")
|
||||
for mid, result in orchestrator_result.get("modules", {}).items():
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
names = (result.get("artifacts") or {}).get("images", []) or []
|
||||
artifacts[mid] = [
|
||||
os.path.join(images_dir, n)
|
||||
for n in names
|
||||
if os.path.exists(os.path.join(images_dir, n))
|
||||
]
|
||||
return artifacts
|
||||
295
ai_platform/modules/forensic_features/forensic/prompt_builder.py
Normal file
295
ai_platform/modules/forensic_features/forensic/prompt_builder.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
"""
|
||||
forensic/prompt_builder.py — Construcție evidence block pregătit pentru LLM.
|
||||
|
||||
OBIECTIV:
|
||||
Aplicația din spate are deja un LLM care face vision/OCR/typology pe
|
||||
imagini. Acest builder produce un BLOC TEXT + LISTA DE IMAGINI base64
|
||||
care se inserează în prompt-ul existent FĂRĂ să modifice promptul curent
|
||||
al userului. LLM-ul primește astfel:
|
||||
(1) imaginile lui obișnuite + tipologii
|
||||
(2) PLUS un bloc cu măsurători forensice obiective pe care nu le
|
||||
poate calcula singur
|
||||
|
||||
REGULI DE FORMATARE:
|
||||
- Tot textul în engleză (compat cu prompt-uri existente bilingve)
|
||||
- Numerele cu unități clare ("BPM", "Hz", "ms", "deg")
|
||||
- Comparații implicite cu range-uri reale ("real: 60-100 BPM")
|
||||
- Bullet-uri scurte (max ~80 chars per linie)
|
||||
- Imaginile referite cu nume scurt în text, apoi atașate ca data URLs
|
||||
|
||||
SCHEMA DE OUTPUT:
|
||||
{
|
||||
"evidence_text": str, # bloc text de inserat în prompt
|
||||
"images": [ # listă de imagini formatate
|
||||
{
|
||||
"name": "m25_pulse_signal.png",
|
||||
"tool_id": "m25",
|
||||
"data_url": "data:image/png;base64,...", # opțional
|
||||
"abs_path": "/abs/path/file.png",
|
||||
"size_bytes": 12345,
|
||||
}
|
||||
],
|
||||
"summary": { # info structurat pentru reasoning
|
||||
"overall_score": 0.78,
|
||||
"overall_label": "FAKE",
|
||||
"overall_confidence": 0.65,
|
||||
"n_modules_run": 5,
|
||||
"n_modules_signal": 4,
|
||||
},
|
||||
"instruction_for_llm": str, # text fix care explică LLM-ului
|
||||
# cum să folosească evidence-ul
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# ── Instrucțiunea fixă pe care o injectăm la final pentru a ghida LLM-ul ──
|
||||
LLM_INSTRUCTION = """\
|
||||
HOW TO USE FORENSIC EVIDENCE ABOVE:
|
||||
- These are objective numerical measurements that you CANNOT recompute from
|
||||
images alone. They are produced by classical signal-processing detectors
|
||||
(PRNU, FFT, optical flow, gradient analysis, etc.) plus pre-trained
|
||||
black-box scorers.
|
||||
- For each detector that flags FAKE, search the keyframes for the visual
|
||||
artifact that explains the score. If you can confirm visually, the verdict
|
||||
is strong; if you see nothing, treat it as a numerical anomaly that may
|
||||
not transfer (codec artifact, lighting noise, etc.).
|
||||
- A single detector at FAKE does NOT mean fake — it means "investigate this
|
||||
channel". Multiple detectors agreeing across independent channels (audio,
|
||||
video, frequency, lighting) is the strong signal.
|
||||
- The OVERALL score is a weighted fusion that already accounts for confidence
|
||||
and disagreement between detectors. Use it as a baseline; your visual
|
||||
analysis can override it if you have a concrete reason.
|
||||
- When forensic says NO_SIGNAL or low confidence on a detector, IGNORE that
|
||||
detector and rely on others + your visual analysis.\
|
||||
"""
|
||||
|
||||
|
||||
def encode_image_b64(path: str) -> str | None:
|
||||
"""Citește imaginea de pe disk și returnează data URL base64 (sau None)."""
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
ext = os.path.splitext(path)[1].lower().lstrip(".")
|
||||
mime = "image/png" if ext == "png" else f"image/{ext or 'jpeg'}"
|
||||
return f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _format_module_block(
|
||||
module_id: str,
|
||||
result: dict[str, Any],
|
||||
fusion_contribution: float | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format compact pentru un singur modul. Exemple de ieșire:
|
||||
|
||||
[m25 Physiology] score=0.83 (FAKE) confidence=0.71 contrib=0.27
|
||||
- Pulse: 0 BPM (real: 60-100 BPM)
|
||||
- Eye blink count over 5s: 0
|
||||
- Blink L/R asymmetry: 0ms (typical: 30-80ms)
|
||||
Visuals: m25_pulse_signal.png, m25_blink_timeline.png
|
||||
"""
|
||||
tool = result.get("tool", {})
|
||||
summary = result.get("summary", {})
|
||||
name = tool.get("name", module_id)
|
||||
score = summary.get("primary_score")
|
||||
label = summary.get("primary_label", "?")
|
||||
conf = summary.get("confidence", 0.0)
|
||||
evidence = summary.get("evidence", []) or []
|
||||
artifacts = (result.get("artifacts") or {}).get("images", []) or []
|
||||
|
||||
if score is None:
|
||||
header = f"[{module_id} {name}] NO_SIGNAL — {(evidence or ['no signal'])[0]}"
|
||||
return header
|
||||
|
||||
contrib_str = ""
|
||||
if fusion_contribution is not None:
|
||||
contrib_str = f" contrib={fusion_contribution:+.3f}"
|
||||
header = (
|
||||
f"[{module_id} {name}] score={score:.2f} ({label}) "
|
||||
f"confidence={conf:.2f}{contrib_str}"
|
||||
)
|
||||
|
||||
bullets = []
|
||||
for ev in evidence[:5]:
|
||||
bullets.append(f" - {ev}")
|
||||
|
||||
visuals = ""
|
||||
if artifacts:
|
||||
visuals = f" Visuals: {', '.join(artifacts)}"
|
||||
|
||||
parts = [header] + bullets
|
||||
if visuals:
|
||||
parts.append(visuals)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_evidence_block(
|
||||
module_results: dict[str, dict[str, Any]],
|
||||
fusion: dict[str, Any] | None = None,
|
||||
images_dir: str | None = None,
|
||||
encode_images: bool = True,
|
||||
include_instruction: bool = True,
|
||||
max_images: int = 12,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Construiește evidence block pentru LLM.
|
||||
|
||||
Args:
|
||||
module_results: dict {module_id: result_dict CONTRACT.md}
|
||||
fusion: rezultatul fuse_scores() (opțional; dacă None, se omite
|
||||
secțiunea OVERALL VERDICT)
|
||||
images_dir: director unde sunt salvate PNG-urile artifact (necesar
|
||||
pentru base64 encoding). Dacă None, încercăm orchestrator.results_dir.
|
||||
encode_images: dacă True, atașează data URL base64 pentru fiecare
|
||||
imagine. Dacă False, doar paths absolute.
|
||||
include_instruction: dacă True, append LLM_INSTRUCTION la text.
|
||||
max_images: limită cap maxim imagini (LLM-urile au limite de tokeni
|
||||
pentru imagini multimodale).
|
||||
|
||||
Returns:
|
||||
dict cu evidence_text, images, summary, instruction_for_llm.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# ── Header secțiune forensică ──
|
||||
lines.append("=" * 70)
|
||||
lines.append("FORENSIC EVIDENCE (objective measurements you cannot recompute)")
|
||||
lines.append("=" * 70)
|
||||
lines.append("")
|
||||
|
||||
# ── OVERALL VERDICT (dacă fusion e furnizat) ──
|
||||
summary_obj: dict[str, Any] = {}
|
||||
if fusion is not None:
|
||||
if fusion.get("score") is None:
|
||||
lines.append("OVERALL: NO_SIGNAL — niciun detector n-a returnat semnal valid.")
|
||||
lines.append(
|
||||
f" Modules with no signal: {fusion.get('n_no_signal', 0)} "
|
||||
f"of {fusion.get('n_no_signal', 0) + fusion.get('n_contributing', 0)}"
|
||||
)
|
||||
summary_obj = {
|
||||
"overall_score": None,
|
||||
"overall_label": "NO_SIGNAL",
|
||||
"overall_confidence": 0.0,
|
||||
"n_modules_run": len(module_results),
|
||||
"n_modules_signal": 0,
|
||||
"disagreement": 0.0,
|
||||
}
|
||||
else:
|
||||
lines.append(
|
||||
f"OVERALL VERDICT: {fusion['label']} "
|
||||
f"(score={fusion['score']:.2f}, confidence={fusion['confidence']:.2f})"
|
||||
)
|
||||
lines.append(
|
||||
f" Detectors active: {fusion['n_contributing']}, "
|
||||
f"no signal: {fusion['n_no_signal']}, "
|
||||
f"disagreement: {fusion['disagreement']:.3f}"
|
||||
)
|
||||
summary_obj = {
|
||||
"overall_score": fusion["score"],
|
||||
"overall_label": fusion["label"],
|
||||
"overall_confidence": fusion["confidence"],
|
||||
"n_modules_run": len(module_results),
|
||||
"n_modules_signal": fusion["n_contributing"],
|
||||
"disagreement": fusion["disagreement"],
|
||||
}
|
||||
lines.append("")
|
||||
|
||||
# ── PER-MODULE BLOCKS ──
|
||||
lines.append("Individual detectors:")
|
||||
lines.append("-" * 70)
|
||||
|
||||
contributions = (fusion or {}).get("contributions", {}) or {}
|
||||
for module_id in sorted(module_results.keys()):
|
||||
result = module_results[module_id]
|
||||
block = _format_module_block(
|
||||
module_id, result, fusion_contribution=contributions.get(module_id)
|
||||
)
|
||||
lines.append(block)
|
||||
lines.append("")
|
||||
|
||||
# ── INSTRUCȚIUNEA PENTRU LLM ──
|
||||
if include_instruction:
|
||||
lines.append("=" * 70)
|
||||
lines.append(LLM_INSTRUCTION)
|
||||
lines.append("=" * 70)
|
||||
|
||||
evidence_text = "\n".join(lines)
|
||||
|
||||
# ── COLECTARE ȘI ENCODE IMAGINI ──
|
||||
images_list: list[dict[str, Any]] = []
|
||||
|
||||
for module_id, result in module_results.items():
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
artifacts = (result.get("artifacts") or {}).get("images", []) or []
|
||||
for img_name in artifacts:
|
||||
if len(images_list) >= max_images:
|
||||
break
|
||||
abs_path = None
|
||||
if images_dir:
|
||||
abs_path = os.path.join(images_dir, img_name)
|
||||
if not os.path.exists(abs_path):
|
||||
abs_path = None
|
||||
entry: dict[str, Any] = {
|
||||
"name": img_name,
|
||||
"tool_id": module_id,
|
||||
"abs_path": abs_path,
|
||||
"size_bytes": (os.path.getsize(abs_path)
|
||||
if abs_path and os.path.exists(abs_path) else 0),
|
||||
}
|
||||
if encode_images and abs_path:
|
||||
data_url = encode_image_b64(abs_path)
|
||||
if data_url:
|
||||
entry["data_url"] = data_url
|
||||
images_list.append(entry)
|
||||
if len(images_list) >= max_images:
|
||||
break
|
||||
|
||||
return {
|
||||
"evidence_text": evidence_text,
|
||||
"images": images_list,
|
||||
"summary": summary_obj,
|
||||
"instruction_for_llm": LLM_INSTRUCTION if include_instruction else "",
|
||||
}
|
||||
|
||||
|
||||
def format_for_chat_completion(
|
||||
evidence: dict[str, Any],
|
||||
user_prompt_prefix: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Helper: împachetează evidence block ca message content pentru un
|
||||
LLM ChatCompletions multimodal (OpenAI/Anthropic/Qwen format).
|
||||
|
||||
Returnează lista pentru content (text + image_url-uri), ready de pus
|
||||
direct în messages: [{"role": "user", "content": <ăsta>}].
|
||||
|
||||
Pentru pipeline-ul tău existent: combină acest content cu textul tău
|
||||
de prompt dinainte (typologii, instrucțiuni custom), inserează imaginile
|
||||
tale + imaginile noastre, trimite ca un singur mesaj user.
|
||||
"""
|
||||
content: list[dict[str, Any]] = []
|
||||
|
||||
# Text user prefix (existing prompt) + evidence block
|
||||
full_text = (user_prompt_prefix + "\n\n" if user_prompt_prefix else "") + \
|
||||
evidence["evidence_text"]
|
||||
content.append({"type": "text", "text": full_text})
|
||||
|
||||
# Adaugă imaginile noastre forensice
|
||||
for img in evidence.get("images", []):
|
||||
if "data_url" in img:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": img["data_url"]},
|
||||
})
|
||||
|
||||
return content
|
||||
235
ai_platform/modules/forensic_features/forensic/scoring.py
Normal file
235
ai_platform/modules/forensic_features/forensic/scoring.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""
|
||||
forensic/scoring.py — Fuziunea scorurilor individuale în verdict unificat.
|
||||
|
||||
PROBLEMA:
|
||||
Fiecare modul m25-m29 returnează un primary_score în [0,1] cu propria
|
||||
confidence. Cum combinăm 5 scoruri independente (cu surse de zgomot
|
||||
diferite) într-un verdict unic? Mediere naivă pierde semnal când un
|
||||
detector strigă tare pe canal specific.
|
||||
|
||||
ABORDARE:
|
||||
Folosim fuziunea Dempster-Shafer-inspired: fiecare modul produce o
|
||||
"credință" că videoul e fake. Credința unui modul e
|
||||
primary_score * confidence (modulul cu confidence 0.1 contribuie
|
||||
aproape nimic, modulul cu confidence 0.9 contribuie aproape integral).
|
||||
|
||||
Score final:
|
||||
weighted_score = sum(score_i * confidence_i * weight_i) / sum(confidence_i * weight_i)
|
||||
|
||||
Weight-urile sunt empiric: m25 (physiology) și m28 (forgery heatmap)
|
||||
sunt cele mai discriminante; m27 (AI detector) e generalist; m26
|
||||
(audio) e specific pe talking-head; m29 (lighting) e niche pe scene
|
||||
cu lumină distinctă.
|
||||
|
||||
CALIBRARE:
|
||||
Pragurile (0.35, 0.65) sunt aceleași ca în CONTRACT.md per-modul,
|
||||
pentru consistență. Dacă vrei threshold-uri agresive sau conservatoare,
|
||||
suprascrie via parametru.
|
||||
|
||||
NU FACE:
|
||||
- Nu antrenează un meta-classifier (ar trebui ground truth);
|
||||
- Nu filtrează detectoare cu NO_SIGNAL (le elimină din pondere);
|
||||
- Nu impune "majority vote" — un singur detector cu confidence mare
|
||||
poate dicta verdictul, ceea ce e corect statistic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Ponderi empirice per modul. Suma nu trebuie 1.0; e renormalizată.
|
||||
# Bazate pe discriminative power observat în literatură + auditul nostru:
|
||||
# - m28 (forgery heatmap) — directly localizes blending = signal puternic
|
||||
# - m25 (physiology) — pulse + blink absent = signal puternic specific
|
||||
# - m27 (AI detector) — generalist, util pe orice imagine
|
||||
# - m26 (audio) — specific talking-head, niche dar discriminant când prinde
|
||||
# - m29 (lighting) — niche pe scene cu lumină distinctă, dar greu de
|
||||
# falsificat când există semnal
|
||||
DEFAULT_WEIGHTS = {
|
||||
"m25": 1.2,
|
||||
"m26": 0.9,
|
||||
"m27": 1.0,
|
||||
"m28": 1.3,
|
||||
"m29": 0.8,
|
||||
}
|
||||
|
||||
# Pragurile pentru label final, identice cu CONTRACT.md
|
||||
THRESHOLD_FAKE = 0.65
|
||||
THRESHOLD_REAL = 0.35
|
||||
|
||||
|
||||
def fusion_label(score: float | None) -> str:
|
||||
"""Mapează scor fuzionat în label final."""
|
||||
if score is None:
|
||||
return "NO_SIGNAL"
|
||||
if score >= THRESHOLD_FAKE:
|
||||
return "FAKE"
|
||||
if score <= THRESHOLD_REAL:
|
||||
return "REAL"
|
||||
return "INCERT"
|
||||
|
||||
|
||||
def fuse_scores(
|
||||
module_results: dict[str, dict[str, Any]],
|
||||
weights: dict[str, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Fuzionează rezultatele a N module într-un verdict unificat.
|
||||
|
||||
Args:
|
||||
module_results: dict {module_id: result_dict} unde result_dict e
|
||||
schema CONTRACT.md (cu summary.primary_score, summary.confidence).
|
||||
Doar modulele cu primary_score != None contribuie la fuzionare.
|
||||
|
||||
weights: dict {module_id: float} — ponderi opționale. Default:
|
||||
DEFAULT_WEIGHTS. Module nelistate primesc weight 1.0.
|
||||
|
||||
Returns:
|
||||
dict cu:
|
||||
score: float | None (None dacă niciun modul n-a contribuit)
|
||||
label: str ("FAKE" | "REAL" | "INCERT" | "NO_SIGNAL")
|
||||
confidence: float — agregarea confidence individuale
|
||||
n_contributing: int — câte module au contribuit
|
||||
n_no_signal: int — câte module au returnat NO_SIGNAL
|
||||
contributions: dict {module_id: weighted_contribution}
|
||||
(pentru debug — contribuția ponderată per modul)
|
||||
disagreement: float — varianța scorurilor (>0.15 = detectoare
|
||||
contradictorii, redu confidence final)
|
||||
"""
|
||||
if weights is None:
|
||||
weights = DEFAULT_WEIGHTS
|
||||
|
||||
contributing: list[tuple[str, float, float, float]] = [] # (id, score, conf, weight)
|
||||
no_signal_count = 0
|
||||
|
||||
for mid, result in module_results.items():
|
||||
if not isinstance(result, dict) or "summary" not in result:
|
||||
continue
|
||||
summary = result["summary"]
|
||||
score = summary.get("primary_score")
|
||||
if score is None:
|
||||
no_signal_count += 1
|
||||
continue
|
||||
conf = float(summary.get("confidence", 0.5) or 0.5)
|
||||
w = float(weights.get(mid, 1.0))
|
||||
contributing.append((mid, float(score), conf, w))
|
||||
|
||||
if not contributing:
|
||||
return {
|
||||
"score": None,
|
||||
"label": "NO_SIGNAL",
|
||||
"confidence": 0.0,
|
||||
"n_contributing": 0,
|
||||
"n_no_signal": no_signal_count,
|
||||
"contributions": {},
|
||||
"disagreement": 0.0,
|
||||
}
|
||||
|
||||
# Weight efectiv per modul = confidence * weight
|
||||
total_eff_weight = sum(c * w for _, _, c, w in contributing)
|
||||
if total_eff_weight < 1e-9:
|
||||
# Toate confidence-urile sunt zero — fallback la mediană simplă
|
||||
scores_only = [s for _, s, _, _ in contributing]
|
||||
score_final = float(sum(scores_only) / len(scores_only))
|
||||
confidence_final = 0.1
|
||||
contributions = {mid: 1.0 / len(contributing) for mid, _, _, _ in contributing}
|
||||
else:
|
||||
weighted_sum = sum(s * c * w for _, s, c, w in contributing)
|
||||
score_final = float(weighted_sum / total_eff_weight)
|
||||
# Confidence finală: media ponderată a confidence-urilor, dar
|
||||
# diminuată dacă detectoarele nu sunt de acord.
|
||||
scores_only = [s for _, s, _, _ in contributing]
|
||||
if len(scores_only) > 1:
|
||||
mean_s = sum(scores_only) / len(scores_only)
|
||||
disagreement = sum((s - mean_s) ** 2 for s in scores_only) / len(scores_only)
|
||||
else:
|
||||
disagreement = 0.0
|
||||
# Disagreement penalty: 0 → factor 1.0; 0.25 → factor 0.5
|
||||
disagreement_factor = max(0.3, 1.0 - 2.0 * disagreement)
|
||||
avg_conf = sum(c for _, _, c, _ in contributing) / len(contributing)
|
||||
confidence_final = float(avg_conf * disagreement_factor)
|
||||
# Contribuții normalizate per modul (cât a influențat scorul final)
|
||||
contributions = {
|
||||
mid: round((s * c * w) / total_eff_weight, 4)
|
||||
for mid, s, c, w in contributing
|
||||
}
|
||||
|
||||
# Penalizare confidence dacă au contribuit puține module
|
||||
if len(contributing) <= 1:
|
||||
confidence_final *= 0.5
|
||||
elif len(contributing) == 2:
|
||||
confidence_final *= 0.8
|
||||
|
||||
# Penalizare suplimentară când multe module au returnat NO_SIGNAL
|
||||
if no_signal_count >= 3:
|
||||
confidence_final *= 0.7
|
||||
|
||||
confidence_final = max(0.0, min(1.0, confidence_final))
|
||||
|
||||
# Disagreement raw (pre-factor) — util pentru debug
|
||||
if len(contributing) > 1:
|
||||
scores_only = [s for _, s, _, _ in contributing]
|
||||
mean_s = sum(scores_only) / len(scores_only)
|
||||
disagreement_raw = sum((s - mean_s) ** 2 for s in scores_only) / len(scores_only)
|
||||
else:
|
||||
disagreement_raw = 0.0
|
||||
|
||||
return {
|
||||
"score": round(score_final, 4),
|
||||
"label": fusion_label(score_final),
|
||||
"confidence": round(confidence_final, 4),
|
||||
"n_contributing": len(contributing),
|
||||
"n_no_signal": no_signal_count,
|
||||
"contributions": contributions,
|
||||
"disagreement": round(disagreement_raw, 4),
|
||||
}
|
||||
|
||||
|
||||
def explain_fusion(fusion: dict[str, Any],
|
||||
module_results: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""
|
||||
Generează 1-3 propoziții human-readable care explică verdictul fuzionat.
|
||||
Util pentru a injecta în prompt LLM ca "executive summary".
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
if fusion["score"] is None:
|
||||
lines.append("Niciun detector forensic n-a putut produce semnal valid.")
|
||||
return lines
|
||||
|
||||
label = fusion["label"]
|
||||
score = fusion["score"]
|
||||
n = fusion["n_contributing"]
|
||||
n_zero = fusion["n_no_signal"]
|
||||
|
||||
# Linia principală
|
||||
lines.append(
|
||||
f"Verdict forensic: {label} (score={score:.2f}, confidence={fusion['confidence']:.2f}, "
|
||||
f"din {n} detectoare active, {n_zero} fără semnal)"
|
||||
)
|
||||
|
||||
# Contributors top-3
|
||||
contribs = fusion.get("contributions", {})
|
||||
if contribs:
|
||||
sorted_c = sorted(contribs.items(), key=lambda x: abs(x[1]), reverse=True)[:3]
|
||||
names = []
|
||||
for mid, c in sorted_c:
|
||||
mname = ""
|
||||
if mid in module_results:
|
||||
mname = module_results[mid].get("tool", {}).get("name", mid)
|
||||
label_per_module = (
|
||||
module_results.get(mid, {}).get("summary", {}).get("primary_label", "")
|
||||
)
|
||||
names.append(f"{mid} {label_per_module}".strip())
|
||||
if names:
|
||||
lines.append(f"Top contributors: {', '.join(names)}")
|
||||
|
||||
# Disagreement warning
|
||||
disagreement = fusion.get("disagreement", 0.0)
|
||||
if disagreement > 0.15:
|
||||
lines.append(
|
||||
f"Atenție: detectoarele NU sunt de acord (disagreement={disagreement:.2f}); "
|
||||
f"verdictul are confidence redusă."
|
||||
)
|
||||
|
||||
return lines
|
||||
Loading…
Add table
Add a link
Reference in a new issue