Files
Task_Reporter/app/modules/report_generation/router.py
egg 3927441103 feat: Add AI report generation with DIFY integration
- Add Users table for display name resolution from AD authentication
- Integrate DIFY AI service for report content generation
- Create docx assembly service with image embedding from MinIO
- Add REST API endpoints for report generation and download
- Add WebSocket notifications for generation progress
- Add frontend UI with progress modal and download functionality
- Add integration tests for report generation flow

Report sections (Traditional Chinese):
- 事件摘要 (Summary)
- 時間軸 (Timeline)
- 參與人員 (Participants)
- 處理過程 (Resolution Process)
- 目前狀態 (Current Status)
- 最終處置結果 (Final Resolution)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:32:40 +08:00

446 lines
14 KiB
Python

"""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 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"])
@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}/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"
return StreamingResponse(
io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={
"Content-Disposition": f'attachment; filename="{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()