- Add ActionBar component with expandable toolbar for mobile - Add @mention functionality with autocomplete dropdown - Add browser notification system (push, sound, vibration) - Add NotificationSettings modal for user preferences - Add mention badges on room list cards - Add ReportPreview with Markdown rendering and copy/download - Add message copy functionality with hover actions - Add backend mentions field to messages with Alembic migration - Add lots field to rooms, remove templates - Optimize WebSocket database session handling - Various UX polish (animations, accessibility) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
548 lines
17 KiB
Python
548 lines
17 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 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()
|