|
|
""" |
|
|
FastAPI Application - Thin HTTP translation layer |
|
|
Stateless server that delegates all business logic to the orchestrator. |
|
|
|
|
|
Design principles: |
|
|
- No business logic in this file |
|
|
- Orchestrator handles all operations |
|
|
- Clean error handling with typed exceptions |
|
|
- Graceful degradation for optional features (OCR, AI) |
|
|
""" |
|
|
|
|
|
from fastapi import FastAPI, File, UploadFile, HTTPException, Form |
|
|
from fastapi.responses import JSONResponse |
|
|
from fastapi.middleware.cors import CORSMiddleware |
|
|
from pydantic import BaseModel, Field |
|
|
from typing import Optional |
|
|
import logging |
|
|
from contextlib import asynccontextmanager |
|
|
import os |
|
|
|
|
|
|
|
|
from dotenv import load_dotenv |
|
|
load_dotenv() |
|
|
|
|
|
from core.orchestrator import Orchestrator |
|
|
from core.errors import ( |
|
|
ProofSystemError, |
|
|
ValidationError, |
|
|
ProofNotFoundError, |
|
|
OCRError |
|
|
) |
|
|
from config.settings import settings |
|
|
|
|
|
|
|
|
logging.basicConfig( |
|
|
level=logging.INFO, |
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' |
|
|
) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
orchestrator: Optional[Orchestrator] = None |
|
|
|
|
|
|
|
|
@asynccontextmanager |
|
|
async def lifespan(app: FastAPI): |
|
|
""" |
|
|
Application lifespan handler. |
|
|
Initialize orchestrator at startup, cleanup at shutdown. |
|
|
""" |
|
|
global orchestrator |
|
|
|
|
|
logger.info("Starting Proof-of-Existence API") |
|
|
logger.info(f"OCR Enabled: {settings.OCR_ENABLED}") |
|
|
logger.info(f"AI Enabled: {settings.AI_ENABLED}") |
|
|
|
|
|
|
|
|
try: |
|
|
orchestrator = Orchestrator() |
|
|
logger.info("Orchestrator initialized successfully") |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to initialize orchestrator: {str(e)}") |
|
|
raise |
|
|
|
|
|
yield |
|
|
|
|
|
|
|
|
logger.info("Shutting down Proof-of-Existence API") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI( |
|
|
title="Proof-of-Existence API", |
|
|
description="Deterministic proof generation and verification with optional OCR and AI assistance", |
|
|
version="0.4.0", |
|
|
lifespan=lifespan |
|
|
) |
|
|
|
|
|
|
|
|
app.add_middleware( |
|
|
CORSMiddleware, |
|
|
allow_origins=["*"], |
|
|
allow_credentials=True, |
|
|
allow_methods=["*"], |
|
|
allow_headers=["*"], |
|
|
expose_headers=["*"], |
|
|
max_age=3600, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TextProofRequest(BaseModel): |
|
|
"""Request model for creating proof from text content.""" |
|
|
content: str = Field(..., description="Text content to create proof for") |
|
|
metadata: Optional[dict] = Field(None, description="Optional metadata") |
|
|
|
|
|
|
|
|
class VerifyProofRequest(BaseModel): |
|
|
"""Request model for verifying a proof.""" |
|
|
proof_id: str = Field(..., description="Unique proof identifier") |
|
|
content: str = Field(..., description="Content to verify against proof") |
|
|
|
|
|
|
|
|
class AssistantRequest(BaseModel): |
|
|
"""Request model for AI assistant queries.""" |
|
|
question: str = Field(..., description="Question to ask the AI assistant") |
|
|
proof_id: Optional[str] = Field(None, description="Optional proof ID for context") |
|
|
|
|
|
|
|
|
class ProofResponse(BaseModel): |
|
|
"""Standard proof creation response.""" |
|
|
success: bool |
|
|
proof_id: Optional[str] = None |
|
|
hash: Optional[str] = None |
|
|
timestamp: Optional[str] = None |
|
|
message: str |
|
|
assistant: Optional[dict] = None |
|
|
|
|
|
|
|
|
class VerificationResponse(BaseModel): |
|
|
"""Standard verification response.""" |
|
|
success: bool |
|
|
is_valid: Optional[bool] = None |
|
|
message: str |
|
|
assistant: Optional[dict] = None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.options("/{rest_of_path:path}") |
|
|
async def preflight_handler(rest_of_path: str): |
|
|
"""Handle CORS preflight requests.""" |
|
|
return JSONResponse( |
|
|
content={"message": "OK"}, |
|
|
headers={ |
|
|
"Access-Control-Allow-Origin": "*", |
|
|
"Access-Control-Allow-Methods": "*", |
|
|
"Access-Control-Allow-Headers": "*", |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(ValidationError) |
|
|
async def validation_error_handler(request, exc: ValidationError): |
|
|
"""Handle validation errors with 400 Bad Request.""" |
|
|
return JSONResponse( |
|
|
status_code=400, |
|
|
content={ |
|
|
"success": False, |
|
|
"error": "validation_error", |
|
|
"message": str(exc) |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
@app.exception_handler(ProofNotFoundError) |
|
|
async def proof_not_found_handler(request, exc: ProofNotFoundError): |
|
|
"""Handle proof not found with 404.""" |
|
|
return JSONResponse( |
|
|
status_code=404, |
|
|
content={ |
|
|
"success": False, |
|
|
"error": "proof_not_found", |
|
|
"message": str(exc) |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
@app.exception_handler(ProofSystemError) |
|
|
async def proof_system_error_handler(request, exc: ProofSystemError): |
|
|
"""Handle general proof system errors with 500.""" |
|
|
logger.error(f"Proof system error: {str(exc)}") |
|
|
return JSONResponse( |
|
|
status_code=500, |
|
|
content={ |
|
|
"success": False, |
|
|
"error": "system_error", |
|
|
"message": str(exc) |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
@app.exception_handler(Exception) |
|
|
async def general_exception_handler(request, exc: Exception): |
|
|
"""Handle unexpected errors with 500.""" |
|
|
logger.error(f"Unexpected error: {str(exc)}", exc_info=True) |
|
|
return JSONResponse( |
|
|
status_code=500, |
|
|
content={ |
|
|
"success": False, |
|
|
"error": "internal_error", |
|
|
"message": "An unexpected error occurred" |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/health") |
|
|
async def health_check(): |
|
|
""" |
|
|
Health check endpoint. |
|
|
Returns system status and feature availability. |
|
|
""" |
|
|
return { |
|
|
"status": "healthy", |
|
|
"version": "0.4.0", |
|
|
"features": { |
|
|
"ocr": settings.OCR_ENABLED, |
|
|
"ai_assistant": settings.AI_ENABLED and orchestrator.ai_sidecar.enabled if orchestrator else False |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/proof/create/text", response_model=ProofResponse) |
|
|
async def create_proof_from_text(request: TextProofRequest): |
|
|
""" |
|
|
Create a cryptographic proof from text content. |
|
|
|
|
|
This endpoint: |
|
|
1. Validates text input |
|
|
2. Generates SHA-256 hash |
|
|
3. Creates proof with timestamp |
|
|
4. Stores proof in database |
|
|
5. Optionally provides AI explanation |
|
|
|
|
|
Returns: |
|
|
Proof ID, hash, timestamp, and optional AI explanation |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
logger.info(f"Creating proof from text ({len(request.content)} chars)") |
|
|
|
|
|
result = orchestrator.create_proof({ |
|
|
"type": "text", |
|
|
"content": request.content |
|
|
}) |
|
|
|
|
|
if not result["success"]: |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail=result.get("message", "Failed to create proof") |
|
|
) |
|
|
|
|
|
proof = result["proof"] |
|
|
|
|
|
return ProofResponse( |
|
|
success=True, |
|
|
proof_id=proof.proof_id, |
|
|
hash=proof.content_hash, |
|
|
timestamp=proof.timestamp, |
|
|
message="Proof created successfully", |
|
|
assistant=result.get("assistant") |
|
|
) |
|
|
|
|
|
|
|
|
@app.post("/proof/create/file") |
|
|
async def create_proof_from_file( |
|
|
file: UploadFile = File(...), |
|
|
metadata: Optional[str] = Form(None) |
|
|
): |
|
|
""" |
|
|
Create a cryptographic proof from uploaded file. |
|
|
|
|
|
Supports: |
|
|
- Text files |
|
|
- Images (with optional OCR) |
|
|
- PDFs |
|
|
- Binary files |
|
|
|
|
|
Returns: |
|
|
Proof ID, hash, timestamp, OCR results (if applicable), and optional AI explanation |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
logger.info(f"Creating proof from file: {file.filename} ({file.content_type})") |
|
|
|
|
|
|
|
|
content = await file.read() |
|
|
|
|
|
if len(content) == 0: |
|
|
raise ValidationError("File is empty") |
|
|
|
|
|
result = orchestrator.create_proof({ |
|
|
"type": "file", |
|
|
"content": content, |
|
|
"filename": file.filename |
|
|
}) |
|
|
|
|
|
if not result["success"]: |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail=result.get("message", "Failed to create proof") |
|
|
) |
|
|
|
|
|
proof = result["proof"] |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"proof_id": proof.proof_id, |
|
|
"hash": proof.content_hash, |
|
|
"timestamp": proof.timestamp, |
|
|
"content_type": proof.content_type, |
|
|
"size": proof.content_size, |
|
|
"ocr_status": proof.ocr_status, |
|
|
"extracted_text": proof.extracted_text[:200] + "..." if proof.extracted_text and len(proof.extracted_text) > 200 else proof.extracted_text, |
|
|
"message": "Proof created successfully", |
|
|
"assistant": result.get("assistant") |
|
|
} |
|
|
|
|
|
|
|
|
@app.get("/proof/{proof_id}") |
|
|
async def get_proof(proof_id: str): |
|
|
""" |
|
|
Retrieve a stored proof by ID. |
|
|
|
|
|
Args: |
|
|
proof_id: Unique proof identifier |
|
|
|
|
|
Returns: |
|
|
Complete proof object with all metadata |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
logger.info(f"Retrieving proof: {proof_id}") |
|
|
|
|
|
result = orchestrator.get_proof(proof_id) |
|
|
|
|
|
if not result["success"]: |
|
|
raise ProofNotFoundError(f"Proof not found: {proof_id}") |
|
|
|
|
|
proof = result["proof"] |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"proof": proof.to_dict(), |
|
|
"message": "Proof retrieved successfully" |
|
|
} |
|
|
|
|
|
|
|
|
@app.post("/proof/verify", response_model=VerificationResponse) |
|
|
async def verify_proof(request: VerifyProofRequest): |
|
|
""" |
|
|
Verify a proof against original content. |
|
|
|
|
|
This endpoint: |
|
|
1. Retrieves original proof |
|
|
2. Recomputes hash from provided content |
|
|
3. Compares hashes |
|
|
4. Returns verification result |
|
|
5. Optionally provides AI explanation |
|
|
|
|
|
Returns: |
|
|
Verification status (valid/invalid) with explanation |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
logger.info(f"Verifying proof: {request.proof_id}") |
|
|
|
|
|
|
|
|
content_bytes = request.content.encode('utf-8') |
|
|
|
|
|
result = orchestrator.verify_proof(request.proof_id, content_bytes) |
|
|
|
|
|
if not result["success"]: |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail=result.get("message", "Verification failed") |
|
|
) |
|
|
|
|
|
verification_result = result["verification_result"] |
|
|
|
|
|
return VerificationResponse( |
|
|
success=True, |
|
|
is_valid=verification_result.is_valid, |
|
|
message=verification_result.message, |
|
|
assistant=result.get("assistant") |
|
|
) |
|
|
|
|
|
|
|
|
@app.post("/proof/verify/file") |
|
|
async def verify_proof_with_file( |
|
|
proof_id: str = Form(...), |
|
|
file: UploadFile = File(...) |
|
|
): |
|
|
""" |
|
|
Verify a proof by uploading the original file. |
|
|
|
|
|
This endpoint: |
|
|
1. Accepts proof ID + file upload |
|
|
2. Retrieves original proof from storage |
|
|
3. Recomputes hash from uploaded file |
|
|
4. Compares hashes |
|
|
5. Returns verification result with original timestamp |
|
|
|
|
|
Args: |
|
|
proof_id: Unique proof identifier |
|
|
file: Original file to verify |
|
|
|
|
|
Returns: |
|
|
- is_valid: True if hashes match, False if tampered |
|
|
- original_hash: Hash from original proof |
|
|
- computed_hash: Hash from uploaded file |
|
|
- original_timestamp: When proof was created |
|
|
- message: Human-readable result |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
logger.info(f"Verifying proof {proof_id} with file: {file.filename}") |
|
|
|
|
|
|
|
|
content = await file.read() |
|
|
|
|
|
if len(content) == 0: |
|
|
raise ValidationError("Uploaded file is empty") |
|
|
|
|
|
|
|
|
result = orchestrator.verify_proof(proof_id, content) |
|
|
|
|
|
if not result["success"]: |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail=result.get("message", "Verification failed") |
|
|
) |
|
|
|
|
|
verification_result = result["verification_result"] |
|
|
|
|
|
|
|
|
proof_result = orchestrator.get_proof(proof_id) |
|
|
original_proof = proof_result.get("proof") if proof_result["success"] else None |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"is_valid": verification_result.is_valid, |
|
|
"proof_id": proof_id, |
|
|
"original_hash": verification_result.original_hash, |
|
|
"computed_hash": verification_result.computed_hash, |
|
|
"original_timestamp": original_proof.timestamp if original_proof else None, |
|
|
"original_filename": original_proof.metadata.get("filename") if original_proof else None, |
|
|
"file_size": len(content), |
|
|
"message": verification_result.message, |
|
|
"assistant": result.get("assistant") |
|
|
} |
|
|
|
|
|
|
|
|
@app.get("/proof/{proof_id}/details") |
|
|
async def get_proof_details(proof_id: str): |
|
|
""" |
|
|
Get detailed information about a proof by its ID. |
|
|
Shows everything: hash, timestamp, file info, OCR results. |
|
|
|
|
|
Args: |
|
|
proof_id: Unique proof identifier |
|
|
|
|
|
Returns: |
|
|
Complete proof details including: |
|
|
- Hash |
|
|
- Original timestamp |
|
|
- File name and size |
|
|
- OCR results (if applicable) |
|
|
- Content type |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
logger.info(f"Getting details for proof: {proof_id}") |
|
|
|
|
|
result = orchestrator.get_proof(proof_id) |
|
|
|
|
|
if not result["success"]: |
|
|
raise ProofNotFoundError(f"Proof not found: {proof_id}") |
|
|
|
|
|
proof = result["proof"] |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"proof_id": proof.proof_id, |
|
|
"hash": proof.content_hash, |
|
|
"hash_algorithm": proof.hash_algorithm, |
|
|
"timestamp": proof.timestamp, |
|
|
"content_type": proof.content_type, |
|
|
"file_size": proof.content_size, |
|
|
"filename": proof.metadata.get("filename"), |
|
|
"ocr_status": proof.ocr_status, |
|
|
"extracted_text": proof.extracted_text, |
|
|
"message": "Proof details retrieved successfully" |
|
|
} |
|
|
|
|
|
|
|
|
@app.post("/proof/verify/hash") |
|
|
async def verify_by_hash( |
|
|
proof_id: str = Form(...), |
|
|
hash_to_verify: str = Form(...) |
|
|
): |
|
|
""" |
|
|
Verify a proof by comparing hashes directly. |
|
|
Useful when you've computed the hash separately. |
|
|
|
|
|
Args: |
|
|
proof_id: Unique proof identifier |
|
|
hash_to_verify: SHA-256 hash to compare |
|
|
|
|
|
Returns: |
|
|
Verification result with timestamp info |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
logger.info(f"Verifying proof {proof_id} with hash: {hash_to_verify[:16]}...") |
|
|
|
|
|
|
|
|
result = orchestrator.get_proof(proof_id) |
|
|
|
|
|
if not result["success"]: |
|
|
raise ProofNotFoundError(f"Proof not found: {proof_id}") |
|
|
|
|
|
proof = result["proof"] |
|
|
|
|
|
|
|
|
is_valid = proof.content_hash.lower() == hash_to_verify.lower() |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"is_valid": is_valid, |
|
|
"proof_id": proof_id, |
|
|
"original_hash": proof.content_hash, |
|
|
"provided_hash": hash_to_verify, |
|
|
"original_timestamp": proof.timestamp, |
|
|
"original_filename": proof.metadata.get("filename"), |
|
|
"message": "Hash match: proof is valid" if is_valid else "Hash mismatch: content has been modified" |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/assistant/ask") |
|
|
async def ask_assistant(request: AssistantRequest): |
|
|
""" |
|
|
Ask the AI assistant a question about proofs. |
|
|
|
|
|
This is an OPTIONAL feature that provides explanations and guidance. |
|
|
The assistant is non-authoritative and never affects proof validity. |
|
|
|
|
|
Returns: |
|
|
AI-generated explanation (clearly marked as non-authoritative) |
|
|
""" |
|
|
if not orchestrator: |
|
|
raise HTTPException(status_code=503, detail="Service not initialized") |
|
|
|
|
|
if not settings.AI_ENABLED or not orchestrator.ai_sidecar.enabled: |
|
|
return { |
|
|
"success": False, |
|
|
"message": "AI assistant is not enabled. Set AI_ENABLED=true and configure GEMINI_API_KEY." |
|
|
} |
|
|
|
|
|
logger.info(f"AI assistant query: {request.question[:50]}...") |
|
|
|
|
|
result = orchestrator.ask_assistant(request.question, request.proof_id) |
|
|
|
|
|
return result |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/") |
|
|
async def root(): |
|
|
""" |
|
|
API root with basic information and links. |
|
|
""" |
|
|
return { |
|
|
"name": "Proof-of-Existence API", |
|
|
"version": "0.4.0", |
|
|
"description": "Deterministic cryptographic proof generation and verification", |
|
|
"docs": "/docs", |
|
|
"health": "/health", |
|
|
"endpoints": { |
|
|
"create_text_proof": "POST /proof/create/text", |
|
|
"create_file_proof": "POST /proof/create/file", |
|
|
"get_proof": "GET /proof/{proof_id}", |
|
|
"get_proof_details": "GET /proof/{proof_id}/details", |
|
|
"verify_proof": "POST /proof/verify", |
|
|
"verify_proof_file": "POST /proof/verify/file", |
|
|
"verify_proof_hash": "POST /proof/verify/hash", |
|
|
"ask_assistant": "POST /assistant/ask (optional)" |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
|
|
|
|
|
|
uvicorn.run( |
|
|
"app:app", |
|
|
host="0.0.0.0", |
|
|
port=7860, |
|
|
reload=True, |
|
|
log_level="info" |
|
|
) |