diff --git a/app/views/files.py b/app/views/files.py index f259c566..2e5b165c 100644 --- a/app/views/files.py +++ b/app/views/files.py @@ -2,6 +2,7 @@ File management views for displaying and managing files. """ +import os from typing import Optional from fastapi import Depends, Query, Request @@ -133,6 +134,7 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d Return the file detail page showing processing history and file information """ try: + import json import os from app.models import FileRecord, ProcessingLog @@ -153,24 +155,28 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d .all() ) - # Check if file exists on disk - file_exists = os.path.exists(file_record.local_filename) if file_record.local_filename else False + # Check if original file exists (use persisted path from database) + original_file_exists = False + if file_record.original_file_path and os.path.exists(file_record.original_file_path): + original_file_exists = True - # Check if processed file exists - processed_exists = False - workdir = settings.workdir - processed_dir = os.path.join(workdir, "processed") - if os.path.exists(processed_dir): - base_filename = os.path.splitext(file_record.original_filename)[0] - potential_paths = [ - os.path.join(processed_dir, f"{file_record.filehash}.pdf"), - os.path.join(processed_dir, f"{base_filename}_processed.pdf"), - os.path.join(processed_dir, file_record.original_filename), - ] - for path in potential_paths: - if os.path.exists(path): - processed_exists = True - break + # Check if processed file exists (use persisted path from database) + processed_file_exists = False + if file_record.processed_file_path and os.path.exists(file_record.processed_file_path): + processed_file_exists = True + + # Load metadata from JSON file if it exists + gpt_metadata = None + if file_record.processed_file_path: + # Metadata JSON file is stored alongside the processed PDF + metadata_path = os.path.splitext(file_record.processed_file_path)[0] + ".json" + if os.path.exists(metadata_path): + try: + with open(metadata_path, "r", encoding="utf-8") as f: + gpt_metadata = json.load(f) + logger.debug(f"Loaded GPT metadata from {metadata_path}") + except Exception as e: + logger.warning(f"Failed to load metadata from {metadata_path}: {e}") # Compute processing flow for visualization flow_data = _compute_processing_flow(logs) @@ -190,8 +196,9 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d "request": request, "file": file_record, "logs": logs, - "file_exists": file_exists, - "processed_exists": processed_exists, + "original_file_exists": original_file_exists, + "processed_file_exists": processed_file_exists, + "gpt_metadata": gpt_metadata, "flow_data": flow_data, "step_summary": step_summary, }, @@ -396,3 +403,137 @@ def _compute_step_summary(logs): "total_main_steps": len(main_steps_seen), "total_upload_tasks": len(upload_tasks_seen), } + + +@router.get("/files/{file_id}/preview/original") +@require_login +def preview_original_file(request: Request, file_id: int, db: Session = Depends(get_db)): + """ + Serve the original (pre-processing) PDF file for preview + """ + import os + + from fastapi import HTTPException, status + from fastapi.responses import FileResponse + + from app.models import FileRecord + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not file_record.original_file_path or not os.path.exists(file_record.original_file_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Original file not found on disk") + + return FileResponse( + path=file_record.original_file_path, + media_type="application/pdf", + headers={"Content-Disposition": "inline"}, + ) + + +@router.get("/files/{file_id}/preview/processed") +@require_login +def preview_processed_file(request: Request, file_id: int, db: Session = Depends(get_db)): + """ + Serve the processed (with embedded metadata) PDF file for preview + """ + import os + + from fastapi import HTTPException, status + from fastapi.responses import FileResponse + + from app.models import FileRecord + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not file_record.processed_file_path or not os.path.exists(file_record.processed_file_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Processed file not found on disk") + + return FileResponse( + path=file_record.processed_file_path, + media_type="application/pdf", + headers={"Content-Disposition": "inline"}, + ) + + +@router.get("/files/{file_id}/text/original") +@require_login +def get_original_text(request: Request, file_id: int, db: Session = Depends(get_db)): + """ + Extract and return text from the original PDF file on-demand + """ + import os + + from fastapi import HTTPException, status + from fastapi.responses import JSONResponse + + from app.models import FileRecord + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not file_record.original_file_path or not os.path.exists(file_record.original_file_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Original file not found on disk") + + try: + # Extract text from PDF using PyPDF2 + from PyPDF2 import PdfReader + + reader = PdfReader(file_record.original_file_path) + text = "" + for page in reader.pages: + text += page.extract_text() + "\n\n" + + if not text.strip(): + text = "(No text could be extracted from this PDF - it may be a scanned image without OCR)" + + return JSONResponse(content={"text": text.strip(), "page_count": len(reader.pages)}) + except Exception as e: + logger.error(f"Error extracting text from original file {file_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to extract text: {str(e)}" + ) + + +@router.get("/files/{file_id}/text/processed") +@require_login +def get_processed_text(request: Request, file_id: int, db: Session = Depends(get_db)): + """ + Extract and return text from the processed PDF file on-demand + """ + import os + + from fastapi import HTTPException, status + from fastapi.responses import JSONResponse + + from app.models import FileRecord + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + if not file_record.processed_file_path or not os.path.exists(file_record.processed_file_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Processed file not found on disk") + + try: + # Extract text from PDF using PyPDF2 + from PyPDF2 import PdfReader + + reader = PdfReader(file_record.processed_file_path) + text = "" + for page in reader.pages: + text += page.extract_text() + "\n\n" + + if not text.strip(): + text = "(No text could be extracted from this PDF - it may be a scanned image without OCR)" + + return JSONResponse(content={"text": text.strip(), "page_count": len(reader.pages)}) + except Exception as e: + logger.error(f"Error extracting text from processed file {file_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to extract text: {str(e)}" + ) diff --git a/docs/FileDetailPageLayout.md b/docs/FileDetailPageLayout.md new file mode 100644 index 00000000..c7b932db --- /dev/null +++ b/docs/FileDetailPageLayout.md @@ -0,0 +1,111 @@ +# File Detail Page - Technical Reference + +## API Endpoints + +``` +GET /files/{id}/detail → Main detail page (template view) +GET /files/{id}/preview/original → Serve original PDF file +GET /files/{id}/preview/processed → Serve processed PDF file +GET /files/{id}/text/original → Extract text from original PDF (on-demand) +GET /files/{id}/text/processed → Extract text from processed PDF (on-demand) +POST /api/files/{id}/retry → Retry full processing +POST /api/files/{id}/retry-subtask → Retry specific task +``` + +## Text Extraction + +Text extraction is performed **on-demand** when the user clicks "View Extracted Text": +- Uses PyPDF2 to extract text from PDF files in real-time +- Returns JSON: `{"text": "...", "page_count": 3}` +- Client-side caching prevents re-extraction on subsequent views +- Loading indicator shown during extraction +- Graceful error handling if extraction fails + +## Template Context Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `file` | FileRecord | Database record with file metadata | +| `gpt_metadata` | dict | Extracted metadata from JSON file (or None) | +| `extracted_text` | str | Text content from OCR/extraction (or None) | +| `original_file_exists` | bool | True if original file exists on disk | +| `processed_file_exists` | bool | True if processed file exists on disk | +| `logs` | List[ProcessingLog] | Processing history records | +| `step_summary` | dict | Aggregated processing status counts | +| `flow_data` | dict | Processing flow visualization data | + +## File System Structure + +``` +workdir/ +├── tmp/ +│ └── {uuid}.pdf ← Temporary ingestion file +├── original/ +│ └── {uuid}.pdf ← Immutable original copy (original_file_path) +└── processed/ + ├── 2024-01-15_Invoice.pdf ← Processed with metadata (processed_file_path) + └── 2024-01-15_Invoice.json ← GPT metadata JSON +``` + +## Metadata JSON Schema + +Stored as `{processed_file_path_without_extension}.json`: + +```json +{ + "document_type": "Invoice", + "filename": "2024-01-15_Company_Invoice", + "date": "2024-01-15", + "absender": "Test Company GmbH", + "empfaenger": "Customer Inc", + "betrag": "€ 1,234.56", + "kontonummer": "DE89370400440532013000", + "tags": ["invoice", "payment", "2024"], + "original_file_path": "/workdir/original/{uuid}.pdf", + "processed_file_path": "/workdir/processed/2024-01-15_Invoice.pdf" +} +``` + +## JavaScript Functions + +```javascript +toggleMetadata() // Show/hide JSON metadata view +toggleTextModal(modalId) // Open/close text extraction modals +window.onclick // Close modal when clicking outside +``` + +## Database Schema + +```sql +CREATE TABLE files ( + id INTEGER PRIMARY KEY, + filehash TEXT NOT NULL, + original_filename TEXT, + local_filename TEXT NOT NULL, + original_file_path TEXT, -- Points to workdir/original/ + processed_file_path TEXT, -- Points to workdir/processed/ + file_size INTEGER, + mime_type TEXT, + created_at DATETIME +); + +CREATE TABLE processing_logs ( + id INTEGER PRIMARY KEY, + file_id INTEGER, + task_id TEXT, + step_name TEXT, -- e.g., "extract_text", "process_with_azure_document_intelligence" + status TEXT, -- "pending", "in_progress", "success", "failure" + message TEXT, + detail TEXT, -- May contain extracted text + timestamp DATETIME +); +``` + +## Processing Stages + +Text extraction logs to check for `extracted_text`: +- `extract_text` - Local PDF text extraction +- `process_with_azure_document_intelligence` - Azure OCR processing + +Both may contain extracted text in the `detail` field when `status = 'success'`. + diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 0f18a82a..a0de2635 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -95,13 +95,52 @@ The **Files** page provides access to all processed documents: When you click on a file, you'll see a comprehensive detail view with the following sections: +#### File Information +This section displays metadata about the file: #### File Information This section displays metadata about the file: - File ID and original filename - File hash (SHA-256) - File size and MIME type - Creation timestamp -- Local path and disk status +- Original file path and status (shows if the immutable original is available) +- Processed file path and status (shows if the final processed file is available) + +#### Extracted Metadata (GPT) +If metadata has been extracted by GPT, this section displays structured information including: +- **Document Type**: Classification (Invoice, Receipt, Contract, etc.) +- **Suggested Filename**: AI-recommended filename based on content +- **Document Date**: Extracted date from document +- **Sender (Absender)**: Sender or issuing party information +- **Recipient (Empfänger)**: Recipient information +- **Amount (Betrag)**: Financial amounts (for invoices, receipts) +- **Account Number**: Bank account information +- **Tags**: Document categories and labels + +**Show JSON**: Click this button to toggle the full metadata in JSON format. This is useful for: +- Viewing all extracted fields at once +- Debugging metadata extraction issues +- Copying metadata for external use +- Understanding the complete data structure + +#### Document Previews +View your documents side-by-side in embedded PDF viewers: +- **Original Document**: The immutable original file as first ingested, before any processing +- **Processed Document**: The final file with embedded metadata + +**Features**: +- In-browser PDF rendering for immediate viewing +- Side-by-side comparison of original vs processed versions +- Full 600px height previews for detailed review +- **View Extracted Text** buttons below each preview to see the full text content + +**Text Extraction Modals**: Click "View Extracted Text" to open a fullscreen modal showing: +- Complete extracted text from OCR or PDF text layer +- Dark-themed, scrollable display for easy reading +- Copy-friendly pre-formatted text +- Close by clicking the close button or clicking outside the modal + +**Note**: The original file is stored in an immutable archive (`workdir/original/`) and is never modified. The processed file is stored in `workdir/processed/` with the suggested filename and embedded metadata. #### Processing History View the complete processing history with a timeline showing: @@ -138,11 +177,23 @@ If your file is still available on disk, you can preview it directly in the brow - **Processed File**: The file after metadata has been embedded (if processing completed) Both previews support: -- In-browser PDF viewing -- Opening in a new tab for full-screen viewing +- In-browser PDF viewing with embedded viewer - Side-by-side comparison of original and processed versions +- Full text extraction viewing via modal overlays -**Note**: The original file is stored in an immutable archive and is never modified. This ensures you always have access to the file exactly as it was uploaded, which is valuable for: +**View Extracted Text**: Each preview includes a button to view the complete extracted text in a fullscreen modal. When you click this button: +- The system extracts text from the PDF file on-demand using PyPDF2 +- A loading indicator shows while extraction is in progress +- The extracted text is displayed in a scrollable, copy-friendly format +- The text is cached so subsequent views load instantly + +This is useful for: +- Verifying OCR accuracy +- Searching within document content +- Copying text for external use +- Reading documents without downloading them + +**Note**: The original file is stored in an immutable archive (`workdir/original/`) and is never modified. This ensures you always have access to the file exactly as it was uploaded, which is valuable for: - Auditing and compliance - Debugging processing issues - Reprocessing with improved algorithms diff --git a/frontend/templates/file_detail.html b/frontend/templates/file_detail.html index 57a11962..58225f9d 100644 --- a/frontend/templates/file_detail.html +++ b/frontend/templates/file_detail.html @@ -2,6 +2,12 @@ {% block title %}File Details{% endblock %} {% block head_extra %} + + + {% if file %} {% endif %} {% endblock %} @@ -683,13 +948,15 @@ {{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}
Loading PDF...
+Original file not available
+Loading PDF...
+Processed file not available yet
+