Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View 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 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",
]

View 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ă 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 poată funcționa).
Pe video lung sample sparse ca 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

View 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Ă 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

View 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ță" 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