"""API routes for report generation FastAPI router with all report-related endpoints: - POST /api/rooms/{room_id}/reports/generate - Trigger report generation - GET /api/rooms/{room_id}/reports - List reports for a room - GET /api/rooms/{room_id}/reports/{report_id} - Get report status/metadata - GET /api/rooms/{room_id}/reports/{report_id}/download - Download report .docx """ import logging from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from typing import Optional import io from app.core.database import get_db from app.core.config import get_settings from app.core.minio_client import get_minio_client from app.modules.auth import get_current_user from app.modules.report_generation import schemas from app.modules.report_generation.models import GeneratedReport, ReportStatus from app.modules.report_generation.dependencies import require_room_member from app.modules.report_generation.services.report_data_service import ReportDataService from app.modules.report_generation.services.dify_client import ( dify_service, DifyAPIError, DifyJSONParseError, DifyValidationError, ) from app.modules.report_generation.services.docx_service import docx_service from app.modules.report_generation.prompts import build_report_prompt from app.modules.realtime.websocket_manager import manager as ws_manager settings = get_settings() logger = logging.getLogger(__name__) async def _broadcast_report_progress( room_id: str, report_id: str, status: str, message: str, error: str = None, ): """Broadcast report generation progress via WebSocket Args: room_id: Room ID to broadcast to report_id: Report ID status: Current status (pending, collecting_data, generating_content, etc.) message: Human-readable progress message error: Error message if failed """ payload = { "type": "report_progress", "report_id": report_id, "room_id": room_id, "status": status, "message": message, } if error: payload["error"] = error await ws_manager.broadcast_to_room(room_id, payload) router = APIRouter(prefix="/api/rooms/{room_id}/reports", tags=["Report Generation"]) health_router = APIRouter(prefix="/api/reports", tags=["Report Generation"]) @health_router.get("/health", response_model=schemas.HealthCheckResponse) async def check_dify_health(): """Check DIFY AI service connection status Returns: Health check result with status and message """ if not settings.DIFY_API_KEY: return schemas.HealthCheckResponse( status="error", message="DIFY_API_KEY 未設定,請聯繫系統管理員" ) try: result = await dify_service.test_connection() return schemas.HealthCheckResponse( status=result["status"], message=result["message"] ) except DifyAPIError as e: return schemas.HealthCheckResponse( status="error", message=str(e) ) @router.post("/generate", response_model=schemas.ReportGenerateResponse, status_code=status.HTTP_202_ACCEPTED) async def generate_report( room_id: str, request: schemas.ReportGenerateRequest = None, background_tasks: BackgroundTasks = None, db: Session = Depends(get_db), room_context: dict = Depends(require_room_member), ): """Trigger AI report generation for a room This endpoint starts an async report generation process: 1. Collects room data (messages, members, files) 2. Sends data to DIFY AI for content generation 3. Assembles .docx document with AI content and images 4. Stores report in MinIO Args: room_id: Room ID to generate report for request: Optional generation parameters Returns: Report ID and initial status """ if request is None: request = schemas.ReportGenerateRequest() user_email = room_context["user_email"] # Create report record with pending status report = GeneratedReport( room_id=room_id, generated_by=user_email, status=ReportStatus.PENDING.value, ) db.add(report) db.commit() db.refresh(report) # Start background generation task background_tasks.add_task( _generate_report_task, report_id=report.report_id, room_id=room_id, include_images=request.include_images, include_file_list=request.include_file_list, ) return schemas.ReportGenerateResponse( report_id=report.report_id, status=schemas.ReportStatus.PENDING, message="Report generation started", ) @router.get("", response_model=schemas.ReportListResponse) async def list_reports( room_id: str, db: Session = Depends(get_db), room_context: dict = Depends(require_room_member), ): """List all reports for a room Args: room_id: Room ID to list reports for Returns: List of report summaries sorted by generation time (newest first) """ reports = ( db.query(GeneratedReport) .filter(GeneratedReport.room_id == room_id) .order_by(GeneratedReport.generated_at.desc()) .all() ) items = [ schemas.ReportListItem( report_id=r.report_id, generated_at=r.generated_at, generated_by=r.generated_by, status=schemas.ReportStatus(r.status), report_title=r.report_title, ) for r in reports ] return schemas.ReportListResponse( reports=items, total=len(items), ) @router.get("/{report_id}", response_model=schemas.ReportStatusResponse) async def get_report_status( room_id: str, report_id: str, db: Session = Depends(get_db), room_context: dict = Depends(require_room_member), ): """Get report status and metadata Args: room_id: Room ID report_id: Report ID to get status for Returns: Report metadata including status, token usage, etc. """ report = ( db.query(GeneratedReport) .filter( GeneratedReport.report_id == report_id, GeneratedReport.room_id == room_id, ) .first() ) if not report: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Report not found", ) return schemas.ReportStatusResponse( report_id=report.report_id, room_id=report.room_id, generated_by=report.generated_by, generated_at=report.generated_at, status=schemas.ReportStatus(report.status), error_message=report.error_message, report_title=report.report_title, prompt_tokens=report.prompt_tokens, completion_tokens=report.completion_tokens, ) @router.get("/{report_id}/markdown", response_model=schemas.ReportMarkdownResponse) async def get_report_markdown( room_id: str, report_id: str, db: Session = Depends(get_db), room_context: dict = Depends(require_room_member), ): """Get report content as Markdown for in-page preview Args: room_id: Room ID report_id: Report ID to get markdown for Returns: Markdown formatted report content """ report = ( db.query(GeneratedReport) .filter( GeneratedReport.report_id == report_id, GeneratedReport.room_id == room_id, ) .first() ) if not report: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Report not found", ) if report.status != ReportStatus.COMPLETED.value: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Report is not ready. Status: {report.status}", ) if not report.report_json: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Report content not found", ) # Collect room data for markdown generation data_service = ReportDataService(db) room_data = data_service.collect_room_data(room_id) if not room_data: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Room not found", ) prompt_data = data_service.to_prompt_dict(room_data) # Generate markdown markdown_content = docx_service.to_markdown( room_data=prompt_data["room_data"], ai_content=report.report_json, files=prompt_data["files"], ) return schemas.ReportMarkdownResponse( report_id=report.report_id, report_title=report.report_title, markdown=markdown_content, ) @router.get("/{report_id}/download") async def download_report( room_id: str, report_id: str, db: Session = Depends(get_db), room_context: dict = Depends(require_room_member), ): """Download generated report as .docx Args: room_id: Room ID report_id: Report ID to download Returns: StreamingResponse with .docx file """ report = ( db.query(GeneratedReport) .filter( GeneratedReport.report_id == report_id, GeneratedReport.room_id == room_id, ) .first() ) if not report: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Report not found", ) if report.status != ReportStatus.COMPLETED.value: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Report is not ready for download. Status: {report.status}", ) if not report.docx_storage_path: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Report file not found in storage", ) # Download from MinIO try: minio_client = get_minio_client() response = minio_client.get_object( settings.MINIO_BUCKET, report.docx_storage_path, ) # Read file content content = response.read() response.close() response.release_conn() # Create filename from report title or ID filename = report.report_title or f"report_{report.report_id[:8]}" filename = f"{filename}.docx" # Use RFC 5987 format for non-ASCII filenames # Provide ASCII fallback for older clients + UTF-8 encoded version ascii_filename = f"report_{report.report_id[:8]}.docx" encoded_filename = quote(filename) return StreamingResponse( io.BytesIO(content), media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={ "Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}", }, ) except Exception as e: logger.error(f"Failed to download report from MinIO: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve report file", ) async def _generate_report_task( report_id: str, room_id: str, include_images: bool = True, include_file_list: bool = True, ): """Background task to generate report This task: 1. Updates status to COLLECTING_DATA + WebSocket notification 2. Collects room data 3. Updates status to GENERATING_CONTENT + WebSocket notification 4. Calls DIFY AI for content 5. Updates status to ASSEMBLING_DOCUMENT + WebSocket notification 6. Creates .docx document 7. Uploads to MinIO 8. Updates status to COMPLETED (or FAILED) + WebSocket notification """ from app.core.database import SessionLocal db = SessionLocal() try: report = db.query(GeneratedReport).filter( GeneratedReport.report_id == report_id ).first() if not report: logger.error(f"Report not found: {report_id}") return # Phase 1: Collecting Data report.status = ReportStatus.COLLECTING_DATA.value db.commit() await _broadcast_report_progress( room_id, report_id, "collecting_data", "正在收集聊天室資料..." ) data_service = ReportDataService(db) room_data = data_service.collect_room_data(room_id) if not room_data: report.status = ReportStatus.FAILED.value report.error_message = "Room not found" db.commit() await _broadcast_report_progress( room_id, report_id, "failed", "報告生成失敗", error="找不到聊天室" ) return # Convert to dict for prompt builder prompt_data = data_service.to_prompt_dict(room_data) # Phase 2: Generating Content report.status = ReportStatus.GENERATING_CONTENT.value db.commit() await _broadcast_report_progress( room_id, report_id, "generating_content", "AI 正在分析並生成報告內容..." ) # Build prompt and call DIFY prompt = build_report_prompt( room_data=prompt_data["room_data"], messages=prompt_data["messages"], members=prompt_data["members"], files=prompt_data["files"], ) try: dify_response = await dify_service.generate_report(prompt, room_id) except (DifyAPIError, DifyJSONParseError, DifyValidationError) as e: report.status = ReportStatus.FAILED.value report.error_message = str(e) db.commit() await _broadcast_report_progress( room_id, report_id, "failed", "AI 生成失敗", error=str(e) ) logger.error(f"DIFY error for report {report_id}: {e}") return # Save AI response data report.dify_message_id = dify_response.message_id report.dify_conversation_id = dify_response.conversation_id report.prompt_tokens = dify_response.prompt_tokens report.completion_tokens = dify_response.completion_tokens report.report_json = dify_response.parsed_json # Extract title from summary ai_content = dify_response.parsed_json if ai_content and "summary" in ai_content: summary_content = ai_content["summary"].get("content", "") # Use first 50 chars of summary as title report.report_title = summary_content[:50] + "..." if len(summary_content) > 50 else summary_content db.commit() # Phase 3: Assembling Document report.status = ReportStatus.ASSEMBLING_DOCUMENT.value db.commit() await _broadcast_report_progress( room_id, report_id, "assembling_document", "正在組裝報告文件..." ) try: docx_data = docx_service.create_report( room_data=prompt_data["room_data"], ai_content=ai_content, files=prompt_data["files"], include_images=include_images, include_file_list=include_file_list, ) except Exception as e: report.status = ReportStatus.FAILED.value report.error_message = f"Document assembly failed: {str(e)}" db.commit() await _broadcast_report_progress( room_id, report_id, "failed", "文件組裝失敗", error=str(e) ) logger.error(f"Document assembly error for report {report_id}: {e}") return # Upload to MinIO storage_path = docx_service.upload_report( report_data=docx_data, room_id=room_id, report_id=report_id, ) if not storage_path: report.status = ReportStatus.FAILED.value report.error_message = "Failed to upload report to storage" db.commit() await _broadcast_report_progress( room_id, report_id, "failed", "報告上傳失敗", error="無法上傳到儲存空間" ) return # Phase 4: Completed report.docx_storage_path = storage_path report.status = ReportStatus.COMPLETED.value db.commit() await _broadcast_report_progress( room_id, report_id, "completed", "報告生成完成!" ) logger.info(f"Report generation completed: {report_id}") except Exception as e: logger.error(f"Unexpected error generating report {report_id}: {e}") try: report = db.query(GeneratedReport).filter( GeneratedReport.report_id == report_id ).first() if report: report.status = ReportStatus.FAILED.value report.error_message = f"Unexpected error: {str(e)}" db.commit() await _broadcast_report_progress( room_id, report_id, "failed", "發生未預期的錯誤", error=str(e) ) except Exception: pass finally: db.close()