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>
This commit is contained in:
445
app/modules/report_generation/router.py
Normal file
445
app/modules/report_generation/router.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user