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:
egg
2025-12-04 18:32:40 +08:00
parent 77091eefb5
commit 3927441103
32 changed files with 4374 additions and 8 deletions

View File

@@ -32,6 +32,15 @@ class Settings(BaseSettings):
MINIO_BUCKET: str = "task-reporter-files"
MINIO_SECURE: bool = False # Use HTTPS
# DIFY AI Service
DIFY_BASE_URL: str = "https://dify.theaken.com/v1"
DIFY_API_KEY: str = "" # Required for report generation
DIFY_TIMEOUT_SECONDS: int = 120 # AI generation can take time
# Report Generation
REPORT_MAX_MESSAGES: int = 200 # Summarize if exceeded
REPORT_STORAGE_PATH: str = "reports" # MinIO path prefix for reports
class Config:
env_file = ".env"
case_sensitive = True

View File

@@ -16,6 +16,7 @@ from app.modules.chat_room import router as chat_room_router
from app.modules.chat_room.services.template_service import template_service
from app.modules.realtime import router as realtime_router
from app.modules.file_storage import router as file_storage_router
from app.modules.report_generation import router as report_generation_router
# Frontend build directory
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
@@ -51,6 +52,7 @@ app.include_router(auth_router)
app.include_router(chat_room_router)
app.include_router(realtime_router)
app.include_router(file_storage_router)
app.include_router(report_generation_router)
@app.on_event("startup")

View File

@@ -2,6 +2,7 @@
資料表結構:
- user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
- users: 永久儲存使用者資訊 (用於報告生成時的姓名解析)
"""
from sqlalchemy import Column, Integer, String, DateTime, Index
from datetime import datetime
@@ -29,3 +30,39 @@ class UserSession(Base):
DateTime, default=datetime.utcnow, nullable=False, comment="Last API request time"
)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
class User(Base):
"""Permanent user information for display name resolution in reports
This table stores user information from AD API and persists even after
session expiration. Used for:
- Displaying user names (instead of emails) in generated reports
- Tracking user metadata (office location, job title)
"""
__tablename__ = "users"
user_id = Column(
String(255), primary_key=True, comment="User email address (e.g., ymirliu@panjit.com.tw)"
)
display_name = Column(
String(255), nullable=False, comment="Display name from AD (e.g., 'ymirliu 劉念蓉')"
)
office_location = Column(
String(100), nullable=True, comment="Office location from AD (e.g., '高雄')"
)
job_title = Column(
String(100), nullable=True, comment="Job title from AD"
)
last_login_at = Column(
DateTime, nullable=True, comment="Last login timestamp"
)
created_at = Column(
DateTime, default=datetime.utcnow, nullable=False, comment="First login timestamp"
)
# Indexes
__table_args__ = (
Index("ix_users_display_name", "display_name"),
)

View File

@@ -11,6 +11,7 @@ from app.modules.auth.schemas import LoginRequest, LoginResponse, LogoutResponse
from app.modules.auth.services.ad_client import ad_auth_service
from app.modules.auth.services.encryption import encryption_service
from app.modules.auth.services.session_service import session_service
from app.modules.auth.services.user_service import upsert_user
from fastapi import Header
from typing import Optional
@@ -30,10 +31,11 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
流程:
1. 呼叫 AD API 驗證憑證
2. 加密密碼(用於自動刷新
3. 生成 internal token (UUID)
4. 儲存 session 到資料庫
5. 回傳 internal token 和 display_name
2. 儲存/更新使用者資訊到 users 表(用於報告姓名解析
3. 加密密碼(用於自動刷新)
4. 生成 internal token (UUID)
5. 儲存 session 到資料庫
6. 回傳 internal token 和 display_name
"""
try:
# Step 1: Authenticate with AD API
@@ -52,10 +54,19 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
detail="Authentication service unavailable",
)
# Step 2: Encrypt password for future auto-refresh
# Step 2: Upsert user info for report generation (permanent storage)
upsert_user(
db=db,
user_id=ad_result["email"],
display_name=ad_result["username"],
office_location=ad_result.get("office_location"),
job_title=ad_result.get("job_title"),
)
# Step 3: Encrypt password for future auto-refresh
encrypted_password = encryption_service.encrypt_password(request.password)
# Step 3 & 4: Generate internal token and create session
# Step 4 & 5: Generate internal token and create session
user_session = session_service.create_session(
db=db,
username=request.username,

View File

@@ -31,6 +31,9 @@ class ADAuthService:
Dict containing:
- token: AD authentication token
- username: Display name from AD
- email: User email address
- office_location: Office location (optional)
- job_title: Job title (optional)
- expires_at: Estimated token expiry datetime
Raises:
@@ -58,6 +61,9 @@ class ADAuthService:
ad_token = token_data.get("access_token")
user_info = token_data.get("userInfo", {})
display_name = user_info.get("name") or username
email = user_info.get("email") or username
office_location = user_info.get("officeLocation")
job_title = user_info.get("jobTitle")
if not ad_token:
raise ValueError("No token received from AD API")
@@ -74,7 +80,14 @@ class ADAuthService:
# Fallback: assume 1 hour if not provided
expires_at = datetime.utcnow() + timedelta(hours=1)
return {"token": ad_token, "username": display_name, "expires_at": expires_at}
return {
"token": ad_token,
"username": display_name,
"email": email,
"office_location": office_location,
"job_title": job_title,
"expires_at": expires_at,
}
except httpx.HTTPStatusError as e:
# Authentication failed (401) or other HTTP errors

View File

@@ -0,0 +1,89 @@
"""User service for permanent user information storage
This service handles upsert operations for the users table,
which stores display names and metadata for report generation.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.modules.auth.models import User
def upsert_user(
db: Session,
user_id: str,
display_name: str,
office_location: Optional[str] = None,
job_title: Optional[str] = None,
) -> User:
"""Create or update user record with AD information
This function is called on every successful login to keep
user information up to date. Uses SQLAlchemy merge for
atomic upsert operation.
Args:
db: Database session
user_id: User email address (primary key)
display_name: Display name from AD API
office_location: Office location from AD API (optional)
job_title: Job title from AD API (optional)
Returns:
User: The created or updated user record
"""
# Check if user exists
existing_user = db.query(User).filter(User.user_id == user_id).first()
if existing_user:
# Update existing user
existing_user.display_name = display_name
existing_user.office_location = office_location
existing_user.job_title = job_title
existing_user.last_login_at = datetime.utcnow()
db.commit()
db.refresh(existing_user)
return existing_user
else:
# Create new user
new_user = User(
user_id=user_id,
display_name=display_name,
office_location=office_location,
job_title=job_title,
last_login_at=datetime.utcnow(),
created_at=datetime.utcnow(),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_id(db: Session, user_id: str) -> Optional[User]:
"""Get user by user_id (email)
Args:
db: Database session
user_id: User email address
Returns:
User or None if not found
"""
return db.query(User).filter(User.user_id == user_id).first()
def get_display_name(db: Session, user_id: str) -> str:
"""Get display name for a user, falling back to email if not found
Args:
db: Database session
user_id: User email address
Returns:
Display name or email address as fallback
"""
user = get_user_by_id(db, user_id)
if user:
return user.display_name
return user_id # Fallback to email if user not in database

View File

@@ -0,0 +1,8 @@
"""Report Generation Module
AI-powered incident report generation using DIFY service.
"""
from app.modules.report_generation.models import GeneratedReport, ReportStatus
from app.modules.report_generation.router import router
__all__ = ["GeneratedReport", "ReportStatus", "router"]

View File

@@ -0,0 +1,53 @@
"""Dependencies for report generation routes
Provides permission checks for report-related operations.
"""
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.auth import get_current_user
from app.modules.chat_room.services.membership_service import membership_service
from app.modules.chat_room.models import IncidentRoom
def require_room_member(room_id: str, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
"""Verify user is a member of the room
Args:
room_id: Room ID to check membership for
db: Database session
current_user: Current authenticated user
Returns:
dict with room_id and user_email
Raises:
HTTPException 404: If room not found
HTTPException 403: If user is not a member
"""
user_email = current_user["username"]
# Check if room exists
room = db.query(IncidentRoom).filter(IncidentRoom.room_id == room_id).first()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found"
)
# Check if user is a member (or admin)
is_admin = membership_service.is_system_admin(user_email)
if not is_admin:
role = membership_service.get_user_role_in_room(db, room_id, user_email)
if not role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a member of this room"
)
return {
"room_id": room_id,
"user_email": user_email,
"room": room,
}

View File

@@ -0,0 +1,100 @@
"""SQLAlchemy models for report generation
Tables:
- generated_reports: Stores report metadata and generation status
"""
from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Index, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
import enum
from app.core.database import Base
class ReportStatus(str, enum.Enum):
"""Report generation status"""
PENDING = "pending"
COLLECTING_DATA = "collecting_data"
GENERATING_CONTENT = "generating_content"
ASSEMBLING_DOCUMENT = "assembling_document"
COMPLETED = "completed"
FAILED = "failed"
class GeneratedReport(Base):
"""Generated report model for incident reports"""
__tablename__ = "generated_reports"
report_id = Column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4()),
comment="Unique report identifier (UUID)"
)
room_id = Column(
String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"),
nullable=False, comment="Reference to incident room"
)
# Generation metadata
generated_by = Column(
String(255), nullable=False,
comment="User email who triggered report generation"
)
generated_at = Column(
DateTime, default=datetime.utcnow, nullable=False,
comment="Report generation timestamp"
)
# Status tracking
status = Column(
String(30), default=ReportStatus.PENDING.value, nullable=False,
comment="Current generation status"
)
error_message = Column(
Text, nullable=True,
comment="User-friendly error message if generation failed"
)
# DIFY AI metadata
dify_message_id = Column(
String(100), nullable=True,
comment="DIFY API message ID for tracking"
)
dify_conversation_id = Column(
String(100), nullable=True,
comment="DIFY conversation ID"
)
prompt_tokens = Column(
Integer, nullable=True,
comment="Number of prompt tokens used"
)
completion_tokens = Column(
Integer, nullable=True,
comment="Number of completion tokens used"
)
# Report content
report_title = Column(
String(255), nullable=True,
comment="Generated report title"
)
report_json = Column(
JSON, nullable=True,
comment="Parsed AI output as JSON"
)
docx_storage_path = Column(
String(500), nullable=True,
comment="Path to generated .docx file in MinIO or local storage"
)
# Relationship
room = relationship("IncidentRoom", backref="reports")
# Indexes
__table_args__ = (
Index("ix_generated_reports_room_date", "room_id", "generated_at"),
Index("ix_generated_reports_status", "status"),
)
def __repr__(self):
return f"<GeneratedReport {self.report_id} status={self.status}>"

View File

@@ -0,0 +1,199 @@
"""Prompt templates for DIFY AI report generation
Contains the prompt construction logic for building the user query
sent to DIFY Chat API.
"""
from typing import List, Dict, Any
from datetime import datetime
INCIDENT_TYPE_MAP = {
"equipment_failure": "設備故障",
"material_shortage": "物料短缺",
"quality_issue": "品質問題",
"other": "其他",
}
SEVERITY_MAP = {
"low": "",
"medium": "",
"high": "",
"critical": "緊急",
}
STATUS_MAP = {
"active": "處理中",
"resolved": "已解決",
"archived": "已封存",
}
MEMBER_ROLE_MAP = {
"owner": "建立者",
"editor": "編輯者",
"viewer": "檢視者",
}
def build_report_prompt(
room_data: Dict[str, Any],
messages: List[Dict[str, Any]],
members: List[Dict[str, Any]],
files: List[Dict[str, Any]],
) -> str:
"""Build the complete prompt for DIFY report generation
Args:
room_data: Room metadata (title, type, severity, status, etc.)
messages: List of messages with sender_name, content, created_at
members: List of members with display_name, role
files: List of files with filename, file_type, uploaded_at, uploader_name
Returns:
Formatted prompt string for DIFY API
"""
sections = []
# Section 1: Event Information
sections.append(_format_room_info(room_data))
# Section 2: Participants
sections.append(_format_members(members))
# Section 3: Message Timeline
sections.append(_format_messages(messages))
# Section 4: File Attachments
sections.append(_format_files(files))
# Section 5: Instructions
sections.append(_format_instructions())
return "\n\n".join(sections)
def _format_room_info(room_data: Dict[str, Any]) -> str:
"""Format room metadata section"""
incident_type = INCIDENT_TYPE_MAP.get(
room_data.get("incident_type"), room_data.get("incident_type")
)
severity = SEVERITY_MAP.get(room_data.get("severity"), room_data.get("severity"))
status = STATUS_MAP.get(room_data.get("status"), room_data.get("status"))
created_at = room_data.get("created_at")
if isinstance(created_at, datetime):
created_at = created_at.strftime("%Y-%m-%d %H:%M")
resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime):
resolved_at = resolved_at.strftime("%Y-%m-%d %H:%M")
elif resolved_at is None:
resolved_at = "尚未解決"
lines = [
"## 事件資訊",
f"- 標題: {room_data.get('title', '未命名')}",
f"- 類型: {incident_type}",
f"- 嚴重程度: {severity}",
f"- 目前狀態: {status}",
f"- 發生地點: {room_data.get('location', '未指定')}",
f"- 建立時間: {created_at}",
f"- 解決時間: {resolved_at}",
]
if room_data.get("description"):
lines.append(f"- 事件描述: {room_data['description']}")
if room_data.get("resolution_notes"):
lines.append(f"- 解決備註: {room_data['resolution_notes']}")
return "\n".join(lines)
def _format_members(members: List[Dict[str, Any]]) -> str:
"""Format participants section"""
lines = ["## 參與人員"]
if not members:
lines.append("無參與人員記錄")
return "\n".join(lines)
for member in members:
display_name = member.get("display_name") or member.get("user_id", "未知")
role = MEMBER_ROLE_MAP.get(member.get("role"), member.get("role", "成員"))
lines.append(f"- {display_name} ({role})")
return "\n".join(lines)
def _format_messages(messages: List[Dict[str, Any]]) -> str:
"""Format message timeline section"""
lines = ["## 對話記錄"]
if not messages:
lines.append("無對話記錄")
return "\n".join(lines)
for msg in messages:
sender = msg.get("sender_name") or msg.get("sender_id", "未知")
content = msg.get("content", "")
msg_type = msg.get("message_type", "text")
created_at = msg.get("created_at")
if isinstance(created_at, datetime):
time_str = created_at.strftime("%Y-%m-%d %H:%M")
else:
time_str = str(created_at) if created_at else "未知時間"
# Handle different message types
if msg_type == "file":
file_name = msg.get("file_name", "附件")
lines.append(f"[{time_str}] {sender}: [上傳檔案: {file_name}]")
elif msg_type == "image":
lines.append(f"[{time_str}] {sender}: [上傳圖片]")
elif msg_type == "system":
lines.append(f"[{time_str}] [系統]: {content}")
else:
lines.append(f"[{time_str}] {sender}: {content}")
return "\n".join(lines)
def _format_files(files: List[Dict[str, Any]]) -> str:
"""Format file attachments section"""
lines = ["## 附件清單"]
if not files:
lines.append("無附件")
return "\n".join(lines)
for f in files:
filename = f.get("filename", "未命名檔案")
file_type = f.get("file_type", "file")
uploader = f.get("uploader_name") or f.get("uploaded_by", "未知")
uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime):
time_str = uploaded_at.strftime("%Y-%m-%d %H:%M")
else:
time_str = str(uploaded_at) if uploaded_at else ""
type_label = "圖片" if file_type == "image" else "檔案"
lines.append(f"- [{type_label}] {filename} (由 {uploader}{time_str} 上傳)")
return "\n".join(lines)
def _format_instructions() -> str:
"""Format generation instructions"""
return """## 報告生成指示
請根據以上資料,生成一份結構化的事件報告。報告必須為 JSON 格式,包含以下欄位:
1. **summary**: 事件摘要 (50-100字)
2. **timeline**: 按時間順序的事件時間軸
3. **participants**: 參與人員及其角色
4. **resolution_process**: 詳細的處理過程描述
5. **current_status**: 目前狀態說明
6. **final_resolution**: 最終處置結果(若已解決)
請直接輸出 JSON不要包含其他說明文字。"""

View 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()

View File

@@ -0,0 +1,105 @@
"""Pydantic schemas for report generation API
Request and response models for the report generation endpoints.
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class ReportStatus(str, Enum):
"""Report generation status"""
PENDING = "pending"
COLLECTING_DATA = "collecting_data"
GENERATING_CONTENT = "generating_content"
ASSEMBLING_DOCUMENT = "assembling_document"
COMPLETED = "completed"
FAILED = "failed"
# Request Schemas
class ReportGenerateRequest(BaseModel):
"""Request to generate a report (optional parameters)"""
include_images: bool = Field(default=True, description="Whether to embed images in the report")
include_file_list: bool = Field(default=True, description="Whether to include file attachment list")
# Response Schemas
class ReportGenerateResponse(BaseModel):
"""Response after triggering report generation"""
report_id: str = Field(..., description="Unique report identifier")
status: ReportStatus = Field(..., description="Initial status (typically 'pending')")
message: str = Field(default="Report generation started", description="Status message")
class ReportStatusResponse(BaseModel):
"""Full report metadata response"""
report_id: str
room_id: str
generated_by: str
generated_at: datetime
status: ReportStatus
error_message: Optional[str] = None
report_title: Optional[str] = None
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
class Config:
from_attributes = True
class ReportListItem(BaseModel):
"""Report item in list response"""
report_id: str
generated_at: datetime
generated_by: str
status: ReportStatus
report_title: Optional[str] = None
class Config:
from_attributes = True
class ReportListResponse(BaseModel):
"""List of reports for a room"""
reports: List[ReportListItem]
total: int
# AI Report Content Schemas (validated JSON from DIFY)
class TimelineEvent(BaseModel):
"""Single event in timeline"""
time: str = Field(..., description="Time of event (HH:MM or YYYY-MM-DD HH:MM)")
description: str = Field(..., description="Event description")
class ParticipantInfo(BaseModel):
"""Participant information"""
name: str = Field(..., description="Participant name")
role: str = Field(..., description="Role in incident (e.g., 事件發起人, 維修負責人)")
class AIReportContent(BaseModel):
"""Validated JSON schema from DIFY AI response"""
summary: dict = Field(..., description="Event summary with 'content' field")
timeline: dict = Field(..., description="Timeline with 'events' list")
participants: dict = Field(..., description="Participants with 'members' list")
resolution_process: dict = Field(..., description="Resolution process with 'content' field")
current_status: dict = Field(..., description="Current status with 'status' and 'description' fields")
final_resolution: dict = Field(..., description="Final resolution with 'has_resolution' and 'content' fields")
@classmethod
def validate_structure(cls, data: dict) -> bool:
"""Validate the basic structure of AI response"""
required_keys = ["summary", "timeline", "participants", "resolution_process", "current_status", "final_resolution"]
for key in required_keys:
if key not in data:
return False
return True
# Error Response
class ErrorResponse(BaseModel):
"""Error response"""
detail: str

View File

@@ -0,0 +1,13 @@
"""Report generation services"""
from app.modules.report_generation.services.dify_client import dify_service, DifyService
from app.modules.report_generation.services.report_data_service import ReportDataService, RoomReportData
from app.modules.report_generation.services.docx_service import docx_service, DocxAssemblyService
__all__ = [
"dify_service",
"DifyService",
"ReportDataService",
"RoomReportData",
"docx_service",
"DocxAssemblyService",
]

View File

@@ -0,0 +1,261 @@
"""DIFY AI Service Client
Handles communication with DIFY Chat API for report generation.
"""
import json
import re
import httpx
from typing import Dict, Any, Optional
from dataclasses import dataclass
from app.core.config import get_settings
settings = get_settings()
@dataclass
class DifyResponse:
"""DIFY API response container"""
message_id: str
conversation_id: str
answer: str
parsed_json: Optional[Dict[str, Any]] = None
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
class DifyServiceError(Exception):
"""Base exception for DIFY service errors"""
pass
class DifyAPIError(DifyServiceError):
"""API request failed"""
pass
class DifyJSONParseError(DifyServiceError):
"""Failed to parse JSON from AI response"""
pass
class DifyValidationError(DifyServiceError):
"""Response JSON doesn't match expected schema"""
pass
class DifyService:
"""DIFY Chat API client for report generation"""
REQUIRED_SECTIONS = [
"summary",
"timeline",
"participants",
"resolution_process",
"current_status",
"final_resolution",
]
def __init__(self):
self.base_url = settings.DIFY_BASE_URL.rstrip("/")
self.api_key = settings.DIFY_API_KEY
self.timeout = settings.DIFY_TIMEOUT_SECONDS
self._client = httpx.AsyncClient(timeout=self.timeout)
async def generate_report(self, prompt: str, room_id: str) -> DifyResponse:
"""Send prompt to DIFY and get AI-generated report content
Args:
prompt: Constructed prompt with room data and instructions
room_id: Room ID used as user identifier for tracking
Returns:
DifyResponse with parsed JSON content
Raises:
DifyAPIError: If API request fails
DifyJSONParseError: If response is not valid JSON
DifyValidationError: If JSON doesn't contain required sections
"""
url = f"{self.base_url}/chat-messages"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"inputs": {},
"query": prompt,
"response_mode": "blocking",
"conversation_id": "", # New conversation each time
"user": room_id, # Use room_id for tracking
}
try:
response = await self._client.post(url, headers=headers, json=payload)
response.raise_for_status()
except httpx.TimeoutException as e:
raise DifyAPIError(f"DIFY API timeout after {self.timeout}s") from e
except httpx.HTTPStatusError as e:
error_detail = ""
try:
error_detail = e.response.text[:500]
except Exception:
pass
raise DifyAPIError(
f"DIFY API error: {e.response.status_code} - {error_detail}"
) from e
except httpx.RequestError as e:
raise DifyAPIError(f"DIFY API connection error: {str(e)}") from e
# Parse API response
try:
data = response.json()
except json.JSONDecodeError as e:
raise DifyAPIError(f"Invalid JSON response from DIFY: {str(e)}") from e
# Extract fields from response
message_id = data.get("message_id", "")
conversation_id = data.get("conversation_id", "")
answer = data.get("answer", "")
# Extract token usage from metadata
metadata = data.get("metadata", {})
usage = metadata.get("usage", {})
prompt_tokens = usage.get("prompt_tokens")
completion_tokens = usage.get("completion_tokens")
if not answer:
raise DifyAPIError("Empty answer from DIFY API")
# Parse JSON from answer
parsed_json = self._extract_json(answer)
# Validate required sections
self._validate_schema(parsed_json)
return DifyResponse(
message_id=message_id,
conversation_id=conversation_id,
answer=answer,
parsed_json=parsed_json,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
)
def _extract_json(self, text: str) -> Dict[str, Any]:
"""Extract JSON from AI response text
Handles cases where:
1. Response is pure JSON
2. JSON is wrapped in markdown code blocks
3. JSON is embedded in other text
Args:
text: Raw text from AI response
Returns:
Parsed JSON as dictionary
Raises:
DifyJSONParseError: If no valid JSON found
"""
text = text.strip()
# Try 1: Direct parse (pure JSON)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Try 2: Extract from markdown code blocks ```json ... ``` or ``` ... ```
code_block_pattern = r"```(?:json)?\s*([\s\S]*?)\s*```"
matches = re.findall(code_block_pattern, text)
for match in matches:
try:
return json.loads(match.strip())
except json.JSONDecodeError:
continue
# Try 3: Find JSON object in text (first { to last })
json_start = text.find("{")
json_end = text.rfind("}")
if json_start != -1 and json_end != -1 and json_end > json_start:
try:
return json.loads(text[json_start : json_end + 1])
except json.JSONDecodeError:
pass
raise DifyJSONParseError(
f"Could not extract valid JSON from AI response. Response preview: {text[:200]}..."
)
def _validate_schema(self, data: Dict[str, Any]) -> None:
"""Validate that response contains all required sections
Args:
data: Parsed JSON dictionary
Raises:
DifyValidationError: If required sections are missing
"""
missing = [key for key in self.REQUIRED_SECTIONS if key not in data]
if missing:
raise DifyValidationError(
f"AI response missing required sections: {', '.join(missing)}"
)
# Validate summary has content
summary = data.get("summary", {})
if not isinstance(summary, dict) or not summary.get("content"):
raise DifyValidationError("summary section missing 'content' field")
# Validate timeline has events list
timeline = data.get("timeline", {})
if not isinstance(timeline, dict) or not isinstance(
timeline.get("events"), list
):
raise DifyValidationError("timeline section missing 'events' list")
# Validate participants has members list
participants = data.get("participants", {})
if not isinstance(participants, dict) or not isinstance(
participants.get("members"), list
):
raise DifyValidationError("participants section missing 'members' list")
# Validate resolution_process has content
resolution = data.get("resolution_process", {})
if not isinstance(resolution, dict) or "content" not in resolution:
raise DifyValidationError(
"resolution_process section missing 'content' field"
)
# Validate current_status has status and description
current_status = data.get("current_status", {})
if not isinstance(current_status, dict):
raise DifyValidationError("current_status must be a dictionary")
if "status" not in current_status or "description" not in current_status:
raise DifyValidationError(
"current_status section missing 'status' or 'description' field"
)
# Validate final_resolution has has_resolution (content required only if has_resolution=true)
final = data.get("final_resolution", {})
if not isinstance(final, dict):
raise DifyValidationError("final_resolution must be a dictionary")
if "has_resolution" not in final:
raise DifyValidationError(
"final_resolution section missing 'has_resolution' field"
)
# content is required only when has_resolution is true
if final.get("has_resolution") and "content" not in final:
raise DifyValidationError(
"final_resolution section missing 'content' field when has_resolution is true"
)
async def close(self):
"""Close HTTP client"""
await self._client.aclose()
# Singleton instance
dify_service = DifyService()

View File

@@ -0,0 +1,445 @@
"""Document Assembly Service
Creates .docx reports using python-docx with:
- Title and metadata header
- AI-generated content sections
- Embedded images from MinIO
- File attachment list
"""
import io
import logging
from typing import Dict, Any, List, Optional
from datetime import datetime
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.style import WD_STYLE_TYPE
from app.core.config import get_settings
from app.core.minio_client import get_minio_client
from app.modules.file_storage.services.minio_service import upload_file
settings = get_settings()
logger = logging.getLogger(__name__)
# Constants for document styling
TITLE_SIZE = Pt(18)
HEADING_SIZE = Pt(14)
BODY_SIZE = Pt(11)
CHINESE_FONT = "Microsoft JhengHei"
FALLBACK_FONT = "Arial"
class DocxAssemblyService:
"""Service to create and assemble .docx incident reports"""
# Mapping of status values to Chinese labels
STATUS_MAP = {
"active": "處理中",
"resolved": "已解決",
"archived": "已封存",
}
INCIDENT_TYPE_MAP = {
"equipment_failure": "設備故障",
"material_shortage": "物料短缺",
"quality_issue": "品質問題",
"other": "其他",
}
SEVERITY_MAP = {
"low": "",
"medium": "",
"high": "",
"critical": "緊急",
}
def __init__(self):
self.minio_client = get_minio_client()
self.bucket = settings.MINIO_BUCKET
def create_report(
self,
room_data: Dict[str, Any],
ai_content: Dict[str, Any],
files: List[Dict[str, Any]],
include_images: bool = True,
include_file_list: bool = True,
) -> io.BytesIO:
"""Create a complete incident report document
Args:
room_data: Room metadata (title, type, severity, status, etc.)
ai_content: AI-generated content (summary, timeline, participants, etc.)
files: List of files with metadata
include_images: Whether to embed images in the report
include_file_list: Whether to include file attachment list
Returns:
BytesIO object containing the .docx file
"""
doc = Document()
# Configure document styles
self._setup_styles(doc)
# Add title
self._add_title(doc, room_data)
# Add metadata table
self._add_metadata_table(doc, room_data)
# Add AI-generated sections
self._add_summary_section(doc, ai_content)
self._add_timeline_section(doc, ai_content)
self._add_participants_section(doc, ai_content)
self._add_resolution_process_section(doc, ai_content)
self._add_current_status_section(doc, ai_content)
self._add_final_resolution_section(doc, ai_content)
# Add images if requested
if include_images and files:
self._add_images_section(doc, files)
# Add file attachment list if requested
if include_file_list and files:
self._add_file_list_section(doc, files)
# Save to BytesIO
output = io.BytesIO()
doc.save(output)
output.seek(0)
return output
def _setup_styles(self, doc: Document):
"""Configure document styles"""
# Configure default paragraph style
style = doc.styles["Normal"]
font = style.font
font.name = CHINESE_FONT
font.size = BODY_SIZE
def _add_title(self, doc: Document, room_data: Dict[str, Any]):
"""Add document title"""
title = doc.add_heading(level=0)
run = title.add_run(f"事件報告:{room_data.get('title', '未命名事件')}")
run.font.size = TITLE_SIZE
run.font.bold = True
# Add generation timestamp
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
subtitle = doc.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = subtitle.add_run(f"報告產生時間:{timestamp}")
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(128, 128, 128)
doc.add_paragraph() # Spacing
def _add_metadata_table(self, doc: Document, room_data: Dict[str, Any]):
"""Add metadata summary table"""
table = doc.add_table(rows=4, cols=4)
table.style = "Table Grid"
# Row 1: Type and Severity
cells = table.rows[0].cells
cells[0].text = "事件類型"
incident_type = room_data.get("incident_type", "other")
cells[1].text = self.INCIDENT_TYPE_MAP.get(incident_type, incident_type)
cells[2].text = "嚴重程度"
severity = room_data.get("severity", "medium")
cells[3].text = self.SEVERITY_MAP.get(severity, severity)
# Row 2: Status and Location
cells = table.rows[1].cells
cells[0].text = "目前狀態"
status = room_data.get("status", "active")
cells[1].text = self.STATUS_MAP.get(status, status)
cells[2].text = "發生地點"
cells[3].text = room_data.get("location") or "未指定"
# Row 3: Created and Resolved times
cells = table.rows[2].cells
cells[0].text = "建立時間"
created_at = room_data.get("created_at")
if isinstance(created_at, datetime):
cells[1].text = created_at.strftime("%Y-%m-%d %H:%M")
else:
cells[1].text = str(created_at) if created_at else "未知"
cells[2].text = "解決時間"
resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime):
cells[3].text = resolved_at.strftime("%Y-%m-%d %H:%M")
elif resolved_at:
cells[3].text = str(resolved_at)
else:
cells[3].text = "尚未解決"
# Row 4: Description (spanning all columns)
cells = table.rows[3].cells
cells[0].text = "事件描述"
# Merge remaining cells for description
cells[1].merge(cells[3])
cells[1].text = room_data.get("description") or "無描述"
# Style table cells
for row in table.rows:
for i, cell in enumerate(row.cells):
for paragraph in cell.paragraphs:
for run in paragraph.runs:
run.font.size = BODY_SIZE
if i % 2 == 0: # Header cells
run.font.bold = True
doc.add_paragraph() # Spacing
def _add_summary_section(self, doc: Document, ai_content: Dict[str, Any]):
"""Add event summary section"""
doc.add_heading("事件摘要", level=1)
summary = ai_content.get("summary", {})
content = summary.get("content", "無摘要內容")
p = doc.add_paragraph(content)
p.paragraph_format.first_line_indent = Pt(24)
def _add_timeline_section(self, doc: Document, ai_content: Dict[str, Any]):
"""Add timeline section"""
doc.add_heading("事件時間軸", level=1)
timeline = ai_content.get("timeline", {})
events = timeline.get("events", [])
if not events:
doc.add_paragraph("無時間軸記錄")
return
# Create timeline table
table = doc.add_table(rows=len(events) + 1, cols=2)
table.style = "Table Grid"
# Header row
header = table.rows[0].cells
header[0].text = "時間"
header[1].text = "事件"
for cell in header:
for run in cell.paragraphs[0].runs:
run.font.bold = True
# Event rows
for i, event in enumerate(events):
row = table.rows[i + 1].cells
row[0].text = event.get("time", "")
row[1].text = event.get("description", "")
doc.add_paragraph() # Spacing
def _add_participants_section(self, doc: Document, ai_content: Dict[str, Any]):
"""Add participants section"""
doc.add_heading("參與人員", level=1)
participants = ai_content.get("participants", {})
members = participants.get("members", [])
if not members:
doc.add_paragraph("無參與人員記錄")
return
# Create participants table
table = doc.add_table(rows=len(members) + 1, cols=2)
table.style = "Table Grid"
# Header row
header = table.rows[0].cells
header[0].text = "姓名"
header[1].text = "角色"
for cell in header:
for run in cell.paragraphs[0].runs:
run.font.bold = True
# Member rows
for i, member in enumerate(members):
row = table.rows[i + 1].cells
row[0].text = member.get("name", "")
row[1].text = member.get("role", "")
doc.add_paragraph() # Spacing
def _add_resolution_process_section(self, doc: Document, ai_content: Dict[str, Any]):
"""Add resolution process section"""
doc.add_heading("處理過程", level=1)
resolution = ai_content.get("resolution_process", {})
content = resolution.get("content", "無處理過程記錄")
p = doc.add_paragraph(content)
p.paragraph_format.first_line_indent = Pt(24)
def _add_current_status_section(self, doc: Document, ai_content: Dict[str, Any]):
"""Add current status section"""
doc.add_heading("目前狀態", level=1)
current_status = ai_content.get("current_status", {})
status = current_status.get("status", "unknown")
status_text = self.STATUS_MAP.get(status, status)
description = current_status.get("description", "")
p = doc.add_paragraph()
p.add_run(f"狀態:").bold = True
p.add_run(status_text)
if description:
doc.add_paragraph(description)
def _add_final_resolution_section(self, doc: Document, ai_content: Dict[str, Any]):
"""Add final resolution section"""
doc.add_heading("最終處置結果", level=1)
final = ai_content.get("final_resolution", {})
has_resolution = final.get("has_resolution", False)
content = final.get("content", "")
if has_resolution:
if content:
p = doc.add_paragraph(content)
p.paragraph_format.first_line_indent = Pt(24)
else:
doc.add_paragraph("事件已解決,但無詳細說明。")
else:
doc.add_paragraph("事件尚未解決或無最終處置結果。")
def _add_images_section(self, doc: Document, files: List[Dict[str, Any]]):
"""Add images section with embedded images from MinIO"""
image_files = [f for f in files if f.get("file_type") == "image"]
if not image_files:
return
doc.add_heading("相關圖片", level=1)
for f in image_files:
try:
# Download image from MinIO
image_data = self._download_file(f.get("minio_object_path", ""))
if image_data:
# Add image to document
doc.add_picture(image_data, width=Inches(5))
# Add caption
caption = doc.add_paragraph()
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = caption.add_run(f"{f.get('filename', '圖片')}")
run.font.size = Pt(9)
run.font.italic = True
doc.add_paragraph() # Spacing
else:
# Image download failed, add note
doc.add_paragraph(f"[圖片載入失敗: {f.get('filename', '未知')}]")
except Exception as e:
logger.error(f"Failed to embed image {f.get('filename')}: {e}")
doc.add_paragraph(f"[圖片嵌入失敗: {f.get('filename', '未知')}]")
def _add_file_list_section(self, doc: Document, files: List[Dict[str, Any]]):
"""Add file attachment list section"""
doc.add_heading("附件清單", level=1)
if not files:
doc.add_paragraph("無附件")
return
# Create file list table
table = doc.add_table(rows=len(files) + 1, cols=4)
table.style = "Table Grid"
# Header row
header = table.rows[0].cells
header[0].text = "檔案名稱"
header[1].text = "類型"
header[2].text = "上傳者"
header[3].text = "上傳時間"
for cell in header:
for run in cell.paragraphs[0].runs:
run.font.bold = True
# File type mapping
file_type_map = {
"image": "圖片",
"document": "文件",
"log": "記錄檔",
}
# File rows
for i, f in enumerate(files):
row = table.rows[i + 1].cells
row[0].text = f.get("filename", "")
file_type = f.get("file_type", "file")
row[1].text = file_type_map.get(file_type, file_type)
row[2].text = f.get("uploader_name") or f.get("uploader_id", "")
uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime):
row[3].text = uploaded_at.strftime("%Y-%m-%d %H:%M")
else:
row[3].text = str(uploaded_at) if uploaded_at else ""
def _download_file(self, object_path: str) -> Optional[io.BytesIO]:
"""Download file from MinIO
Args:
object_path: MinIO object path
Returns:
BytesIO containing file data, or None if download fails
"""
if not object_path:
return None
try:
response = self.minio_client.get_object(self.bucket, object_path)
data = io.BytesIO(response.read())
response.close()
response.release_conn()
return data
except Exception as e:
logger.error(f"Failed to download file from MinIO: {object_path} - {e}")
return None
def upload_report(
self,
report_data: io.BytesIO,
room_id: str,
report_id: str,
) -> Optional[str]:
"""Upload generated report to MinIO
Args:
report_data: BytesIO containing the .docx file
room_id: Room ID for path organization
report_id: Report ID for unique filename
Returns:
MinIO object path if successful, None otherwise
"""
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
object_path = f"{settings.REPORT_STORAGE_PATH}/{room_id}/{report_id}_{timestamp}.docx"
# Get file size
report_data.seek(0, 2) # Seek to end
file_size = report_data.tell()
report_data.seek(0) # Reset to beginning
success = upload_file(
bucket=self.bucket,
object_path=object_path,
file_data=report_data,
file_size=file_size,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
if success:
logger.info(f"Report uploaded to MinIO: {object_path}")
return object_path
else:
logger.error(f"Failed to upload report to MinIO")
return None
# Singleton instance
docx_service = DocxAssemblyService()

View File

@@ -0,0 +1,263 @@
"""Report Data Collection Service
Collects all room data needed for AI report generation:
- Room metadata (title, type, severity, status, etc.)
- Messages with sender display names
- Room members with display names
- File attachments with uploader names
"""
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import desc
from app.modules.chat_room.models import IncidentRoom, RoomMember
from app.modules.realtime.models import Message
from app.modules.file_storage.models import RoomFile
from app.modules.auth.models import User
@dataclass
class MessageData:
"""Message data for report generation"""
message_id: str
sender_id: str
sender_name: str
content: str
message_type: str
created_at: datetime
file_name: Optional[str] = None
@dataclass
class MemberData:
"""Member data for report generation"""
user_id: str
display_name: str
role: str
@dataclass
class FileData:
"""File data for report generation"""
file_id: str
filename: str
file_type: str
mime_type: str
uploaded_at: datetime
uploader_id: str
uploader_name: str
minio_object_path: str
@dataclass
class RoomReportData:
"""Complete room data for report generation"""
room_id: str
title: str
incident_type: str
severity: str
status: str
location: Optional[str]
description: Optional[str]
resolution_notes: Optional[str]
created_at: datetime
resolved_at: Optional[datetime]
created_by: str
messages: List[MessageData] = field(default_factory=list)
members: List[MemberData] = field(default_factory=list)
files: List[FileData] = field(default_factory=list)
class ReportDataService:
"""Service to collect room data for report generation"""
def __init__(self, db: Session):
self.db = db
def collect_room_data(self, room_id: str) -> Optional[RoomReportData]:
"""Collect all data for a room
Args:
room_id: Room ID to collect data for
Returns:
RoomReportData with all collected data, or None if room not found
"""
# Get room metadata
room = self.db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room:
return None
# Collect messages with sender names
messages = self._collect_messages(room_id)
# Collect members with display names
members = self._collect_members(room_id)
# Collect files with uploader names
files = self._collect_files(room_id)
return RoomReportData(
room_id=room.room_id,
title=room.title,
incident_type=room.incident_type.value if room.incident_type else "other",
severity=room.severity.value if room.severity else "medium",
status=room.status.value if room.status else "active",
location=room.location,
description=room.description,
resolution_notes=room.resolution_notes,
created_at=room.created_at,
resolved_at=room.resolved_at,
created_by=room.created_by,
messages=messages,
members=members,
files=files,
)
def _collect_messages(self, room_id: str) -> List[MessageData]:
"""Collect messages with sender display names"""
# Query messages with LEFT JOIN to users table for display names
results = (
self.db.query(Message, User.display_name)
.outerjoin(User, Message.sender_id == User.user_id)
.filter(Message.room_id == room_id)
.filter(Message.deleted_at.is_(None)) # Exclude deleted messages
.order_by(Message.created_at)
.all()
)
messages = []
for msg, display_name in results:
# Extract file name from metadata if it's a file reference
file_name = None
if msg.message_type.value in ("image_ref", "file_ref") and msg.message_metadata:
file_name = msg.message_metadata.get("filename")
messages.append(MessageData(
message_id=msg.message_id,
sender_id=msg.sender_id,
sender_name=display_name or msg.sender_id, # Fallback to sender_id
content=msg.content,
message_type=msg.message_type.value,
created_at=msg.created_at,
file_name=file_name,
))
return messages
def _collect_members(self, room_id: str) -> List[MemberData]:
"""Collect room members with display names"""
results = (
self.db.query(RoomMember, User.display_name)
.outerjoin(User, RoomMember.user_id == User.user_id)
.filter(RoomMember.room_id == room_id)
.filter(RoomMember.removed_at.is_(None)) # Exclude removed members
.all()
)
members = []
for member, display_name in results:
members.append(MemberData(
user_id=member.user_id,
display_name=display_name or member.user_id, # Fallback to user_id
role=member.role.value if member.role else "viewer",
))
return members
def _collect_files(self, room_id: str) -> List[FileData]:
"""Collect room files with uploader display names"""
results = (
self.db.query(RoomFile, User.display_name)
.outerjoin(User, RoomFile.uploader_id == User.user_id)
.filter(RoomFile.room_id == room_id)
.filter(RoomFile.deleted_at.is_(None)) # Exclude deleted files
.order_by(RoomFile.uploaded_at)
.all()
)
files = []
for f, display_name in results:
files.append(FileData(
file_id=f.file_id,
filename=f.filename,
file_type=f.file_type,
mime_type=f.mime_type,
uploaded_at=f.uploaded_at,
uploader_id=f.uploader_id,
uploader_name=display_name or f.uploader_id, # Fallback to uploader_id
minio_object_path=f.minio_object_path,
))
return files
def to_prompt_dict(self, data: RoomReportData) -> Dict[str, Any]:
"""Convert RoomReportData to dictionary format for prompt builder
Args:
data: RoomReportData object
Returns:
Dictionary with room_data, messages, members, files keys
"""
room_data = {
"room_id": data.room_id,
"title": data.title,
"incident_type": data.incident_type,
"severity": data.severity,
"status": data.status,
"location": data.location,
"description": data.description,
"resolution_notes": data.resolution_notes,
"created_at": data.created_at,
"resolved_at": data.resolved_at,
"created_by": data.created_by,
}
messages = [
{
"message_id": m.message_id,
"sender_id": m.sender_id,
"sender_name": m.sender_name,
"content": m.content,
"message_type": m.message_type,
"created_at": m.created_at,
"file_name": m.file_name,
}
for m in data.messages
]
members = [
{
"user_id": m.user_id,
"display_name": m.display_name,
"role": m.role,
}
for m in data.members
]
files = [
{
"file_id": f.file_id,
"filename": f.filename,
"file_type": f.file_type,
"mime_type": f.mime_type,
"uploaded_at": f.uploaded_at,
"uploader_id": f.uploader_id,
"uploader_name": f.uploader_name,
"minio_object_path": f.minio_object_path,
}
for f in data.files
]
return {
"room_data": room_data,
"messages": messages,
"members": members,
"files": files,
}