bitcheck-document / app /services /report_builder.py
AI Assistant
Update Bitcheck Document Service and test.html
37a1755
Raw
History Blame Contribute Delete
7.59 kB
import os
from pathlib import Path
from typing import Any
from app.schemas.document_verification import (
ContentRiskAnalysis,
DeepSeekAnalysis,
DocumentVerificationReport,
FieldExtractionAnalysis,
FileValidationResult,
ForensicAnalysis,
ImageAnalysis,
MetadataAnalysis,
PdfAnalysis,
QRAnalysis,
TextConsistencyAnalysis,
TextExtractionAnalysis,
VerificationInput,
)
from app.services.trust_scorer import TrustScorer
LIMITATIONS = [
"BitCheck provides a risk-based estimate, not legal proof of forgery or authenticity.",
"Missing metadata does not prove a document is fake.",
"Editing software metadata does not automatically prove manipulation.",
"OCR may be inaccurate on low-quality scans.",
"QR code detection does not mean the linked source is authentic unless externally verified.",
"QR URLs are analyzed structurally but are not opened or browsed.",
"Forensic visual analysis is not court-grade evidence.",
"DeepSeek analysis does not perform live web or issuer database verification.",
"High-stakes documents should be manually verified with the issuing authority.",
]
class ReportBuilder:
def build(
self,
*,
verification_id: str,
status: str,
processing_time_ms: int,
verification_input: VerificationInput,
file_validation: FileValidationResult,
pdf_analysis: PdfAnalysis | None,
image_analysis: ImageAnalysis | None,
metadata: MetadataAnalysis,
text_extraction: TextExtractionAnalysis,
text_consistency: TextConsistencyAnalysis,
fields: FieldExtractionAnalysis,
content_risk: ContentRiskAnalysis,
deepseek_analysis: DeepSeekAnalysis,
qr_analysis: QRAnalysis,
forensics: ForensicAnalysis | None,
warnings: list[str],
) -> DocumentVerificationReport:
sanitized_file_validation = self._sanitize_model(file_validation)
sanitized_pdf = self._sanitize_model(pdf_analysis) if pdf_analysis else None
sanitized_image = self._sanitize_model(image_analysis) if image_analysis else None
sanitized_forensics = self._sanitize_model(forensics) if forensics else None
trust = TrustScorer().score(
{
"metadata": metadata,
"pdf_analysis": pdf_analysis,
"text_extraction": text_extraction,
"text_consistency": text_consistency,
"qr_analysis": qr_analysis,
"forensics": forensics,
"fields": fields,
"content_risk": content_risk,
"deepseek_analysis": deepseek_analysis,
}
)
risk_flags = self._risk_flags(metadata, pdf_analysis, text_consistency, qr_analysis, forensics, fields, content_risk, trust)
all_warnings = self._dedupe(warnings)
return DocumentVerificationReport(
verification_id=verification_id,
service="BitCheck",
file_type="document",
status=status,
processing_time_ms=processing_time_ms,
input=verification_input,
file_validation=sanitized_file_validation,
pdf_analysis=sanitized_pdf,
image_analysis=sanitized_image,
metadata=metadata,
text_extraction=self._redact_text_extraction(text_extraction),
text_consistency=text_consistency,
fields=fields,
content_risk=content_risk,
deepseek_analysis=deepseek_analysis,
qr_analysis=qr_analysis,
forensics=sanitized_forensics,
trust=trust,
risk_flags=risk_flags,
recommended_actions=self._recommended_actions(trust, risk_flags, deepseek_analysis),
limitations=LIMITATIONS,
warnings=all_warnings,
)
def _risk_flags(
self,
metadata: MetadataAnalysis,
pdf_analysis: PdfAnalysis | None,
text_consistency: TextConsistencyAnalysis,
qr_analysis: QRAnalysis,
forensics: ForensicAnalysis | None,
fields: FieldExtractionAnalysis,
content_risk: ContentRiskAnalysis,
trust,
) -> list[str]:
flags: list[str] = []
flags.extend(metadata.flags)
if pdf_analysis:
flags.extend(pdf_analysis.flags)
flags.extend(text_consistency.flags)
flags.extend(qr_analysis.flags)
if forensics:
flags.extend(forensics.flags)
flags.extend(fields.field_flags)
flags.extend(content_risk.signals)
flags.extend(trust.applied_overrides)
return self._dedupe(flags)
def _recommended_actions(self, trust, risk_flags: list[str], deepseek_analysis: DeepSeekAnalysis) -> list[str]:
actions: list[str] = []
if trust.decision == "approve":
actions.append("Proceed only after routine human review of the document details.")
elif trust.risk_level == "Suspicious":
actions.append("Manually review the document before accepting it.")
else:
actions.append("Do not approve automatically; escalate for manual verification.")
if deepseek_analysis.external_verification_required:
actions.append("Verify the document directly with the issuing authority or official portal.")
if any("QR" in flag or flag.startswith("shortened_url") or flag.startswith("suspicious_url") for flag in risk_flags):
actions.append("Check QR destination ownership and compare it with official issuer channels.")
if any("metadata" in flag.lower() for flag in risk_flags):
actions.append("Review metadata in context rather than treating it as standalone proof.")
if any("visual" in flag.lower() or "tampering" in flag.lower() for flag in risk_flags):
actions.append("Inspect highlighted visual regions manually.")
return self._dedupe(actions)
def _redact_text_extraction(self, text_extraction: TextExtractionAnalysis) -> TextExtractionAnalysis:
return text_extraction.model_copy(
update={
"page_texts": [self._truncate(text, 240) for text in text_extraction.page_texts],
"combined_text_excerpt": self._truncate(text_extraction.combined_text_excerpt, 500),
}
)
def _sanitize_model(self, model):
data = model.model_dump()
sanitized = self._sanitize_paths(data)
return model.__class__.model_validate(sanitized)
def _sanitize_paths(self, value: Any) -> Any:
if isinstance(value, dict):
return {key: self._sanitize_paths(item) for key, item in value.items()}
if isinstance(value, list):
return [self._sanitize_paths(item) for item in value]
if isinstance(value, str):
return self._relative_if_path(value)
return value
def _relative_if_path(self, value: str) -> str:
try:
path = Path(value)
except (TypeError, ValueError):
return value
if not path.is_absolute():
return value
try:
return os.path.relpath(path, Path.cwd())
except ValueError:
return value
def _truncate(self, text: str, max_chars: int) -> str:
if len(text) <= max_chars:
return text
return text[: max_chars - 3].rstrip() + "..."
def _dedupe(self, values: list[str]) -> list[str]:
return [value for value in dict.fromkeys(values) if value]