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

@@ -21,3 +21,13 @@ MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=task-reporter-files MINIO_BUCKET=task-reporter-files
MINIO_SECURE=false # Set to true for HTTPS in production MINIO_SECURE=false # Set to true for HTTPS in production
# DIFY AI Service Configuration
# Used for AI-powered incident report generation
DIFY_BASE_URL=https://dify.theaken.com/v1
DIFY_API_KEY= # Required: Get from DIFY console
DIFY_TIMEOUT_SECONDS=120 # Timeout for AI generation requests
# Report Generation Settings
REPORT_MAX_MESSAGES=200 # Summarize older messages if room exceeds this count
REPORT_STORAGE_PATH=reports # MinIO path prefix for generated reports

View File

@@ -32,6 +32,15 @@ class Settings(BaseSettings):
MINIO_BUCKET: str = "task-reporter-files" MINIO_BUCKET: str = "task-reporter-files"
MINIO_SECURE: bool = False # Use HTTPS 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: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = True 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.chat_room.services.template_service import template_service
from app.modules.realtime import router as realtime_router from app.modules.realtime import router as realtime_router
from app.modules.file_storage import router as file_storage_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 build directory
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" 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(chat_room_router)
app.include_router(realtime_router) app.include_router(realtime_router)
app.include_router(file_storage_router) app.include_router(file_storage_router)
app.include_router(report_generation_router)
@app.on_event("startup") @app.on_event("startup")

View File

@@ -2,6 +2,7 @@
資料表結構: 資料表結構:
- user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新 - user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
- users: 永久儲存使用者資訊 (用於報告生成時的姓名解析)
""" """
from sqlalchemy import Column, Integer, String, DateTime, Index from sqlalchemy import Column, Integer, String, DateTime, Index
from datetime import datetime from datetime import datetime
@@ -29,3 +30,39 @@ class UserSession(Base):
DateTime, default=datetime.utcnow, nullable=False, comment="Last API request time" DateTime, default=datetime.utcnow, nullable=False, comment="Last API request time"
) )
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 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.ad_client import ad_auth_service
from app.modules.auth.services.encryption import encryption_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.session_service import session_service
from app.modules.auth.services.user_service import upsert_user
from fastapi import Header from fastapi import Header
from typing import Optional from typing import Optional
@@ -30,10 +31,11 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
流程: 流程:
1. 呼叫 AD API 驗證憑證 1. 呼叫 AD API 驗證憑證
2. 加密密碼(用於自動刷新 2. 儲存/更新使用者資訊到 users 表(用於報告姓名解析
3. 生成 internal token (UUID) 3. 加密密碼(用於自動刷新)
4. 儲存 session 到資料庫 4. 生成 internal token (UUID)
5. 回傳 internal token 和 display_name 5. 儲存 session 到資料庫
6. 回傳 internal token 和 display_name
""" """
try: try:
# Step 1: Authenticate with AD API # Step 1: Authenticate with AD API
@@ -52,10 +54,19 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
detail="Authentication service unavailable", 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) 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( user_session = session_service.create_session(
db=db, db=db,
username=request.username, username=request.username,

View File

@@ -31,6 +31,9 @@ class ADAuthService:
Dict containing: Dict containing:
- token: AD authentication token - token: AD authentication token
- username: Display name from AD - 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 - expires_at: Estimated token expiry datetime
Raises: Raises:
@@ -58,6 +61,9 @@ class ADAuthService:
ad_token = token_data.get("access_token") ad_token = token_data.get("access_token")
user_info = token_data.get("userInfo", {}) user_info = token_data.get("userInfo", {})
display_name = user_info.get("name") or username 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: if not ad_token:
raise ValueError("No token received from AD API") raise ValueError("No token received from AD API")
@@ -74,7 +80,14 @@ class ADAuthService:
# Fallback: assume 1 hour if not provided # Fallback: assume 1 hour if not provided
expires_at = datetime.utcnow() + timedelta(hours=1) 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: except httpx.HTTPStatusError as e:
# Authentication failed (401) or other HTTP errors # 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,
}

View File

@@ -0,0 +1,199 @@
/**
* ReportProgress Component
* Modal showing report generation progress
*/
import { useEffect, useState } from 'react'
import type { ReportStatus } from '../../types'
interface ReportProgressProps {
isOpen: boolean
onClose: () => void
status: ReportStatus
message: string
error?: string
reportId?: string
onDownload?: () => void
}
const statusSteps: { status: ReportStatus; label: string }[] = [
{ status: 'pending', label: '準備中' },
{ status: 'collecting_data', label: '收集資料' },
{ status: 'generating_content', label: 'AI 生成內容' },
{ status: 'assembling_document', label: '組裝文件' },
{ status: 'completed', label: '完成' },
]
function getStepIndex(status: ReportStatus): number {
const index = statusSteps.findIndex((s) => s.status === status)
return index === -1 ? 0 : index
}
export default function ReportProgress({
isOpen,
onClose,
status,
message,
error,
reportId: _reportId,
onDownload,
}: ReportProgressProps) {
// reportId is available for future use (e.g., polling status)
const [animatedStep, setAnimatedStep] = useState(0)
const currentStep = getStepIndex(status)
const isCompleted = status === 'completed'
const isFailed = status === 'failed'
// Animate step changes
useEffect(() => {
if (currentStep > animatedStep) {
const timer = setTimeout(() => {
setAnimatedStep(currentStep)
}, 300)
return () => clearTimeout(timer)
} else {
setAnimatedStep(currentStep)
}
}, [currentStep, animatedStep])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={isCompleted || isFailed ? onClose : undefined}
/>
{/* Modal */}
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative w-full max-w-md transform overflow-hidden rounded-lg bg-white shadow-xl transition-all">
{/* Header */}
<div className="px-6 py-4 border-b">
<h3 className="text-lg font-semibold text-gray-900">
{isFailed ? '報告生成失敗' : isCompleted ? '報告生成完成' : '正在生成報告...'}
</h3>
</div>
{/* Content */}
<div className="px-6 py-6">
{/* Progress Steps */}
{!isFailed && (
<div className="relative">
{/* Progress Line */}
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200">
<div
className="w-full bg-blue-600 transition-all duration-500"
style={{
height: `${(animatedStep / (statusSteps.length - 1)) * 100}%`,
}}
/>
</div>
{/* Steps */}
<div className="space-y-4">
{statusSteps.map((step, index) => {
const isActive = index === currentStep
const isPast = index < currentStep
const isCurrent = index === currentStep && !isCompleted
return (
<div key={step.status} className="flex items-center gap-4">
{/* Step Indicator */}
<div
className={`relative z-10 flex h-8 w-8 items-center justify-center rounded-full border-2 transition-colors ${
isPast || isCompleted
? 'border-blue-600 bg-blue-600 text-white'
: isActive
? 'border-blue-600 bg-white text-blue-600'
: 'border-gray-300 bg-white text-gray-400'
}`}
>
{isPast || (isCompleted && index <= currentStep) ? (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : isCurrent ? (
<div className="h-3 w-3 animate-pulse rounded-full bg-blue-600" />
) : (
<span className="text-xs">{index + 1}</span>
)}
</div>
{/* Step Label */}
<span
className={`text-sm ${
isPast || isActive
? 'font-medium text-gray-900'
: 'text-gray-500'
}`}
>
{step.label}
</span>
</div>
)
})}
</div>
</div>
)}
{/* Status Message */}
<div className="mt-6">
<p
className={`text-sm ${
isFailed ? 'text-red-600' : 'text-gray-600'
}`}
>
{message}
</p>
{error && (
<p className="mt-2 text-sm text-red-500 bg-red-50 p-2 rounded">
{error}
</p>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 flex justify-end gap-3">
{isFailed && (
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
</button>
)}
{isCompleted && (
<>
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
</button>
{onDownload && (
<button
onClick={onDownload}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
</button>
)}
</>
)}
{!isCompleted && !isFailed && (
<div className="text-sm text-gray-500">
...
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -31,3 +31,10 @@ export {
useDownloadFile, useDownloadFile,
fileKeys, fileKeys,
} from './useFiles' } from './useFiles'
export {
useReports,
useReport,
useGenerateReport,
useDownloadReport,
useInvalidateReports,
} from './useReports'

View File

@@ -0,0 +1,86 @@
/**
* useReports Hook
* React Query hooks for report generation and management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { reportsService } from '../services/reports'
import type { ReportGenerateRequest } from '../types'
// Query Keys
const reportKeys = {
all: ['reports'] as const,
list: (roomId: string) => [...reportKeys.all, 'list', roomId] as const,
detail: (roomId: string, reportId: string) =>
[...reportKeys.all, 'detail', roomId, reportId] as const,
}
/**
* Hook to list reports for a room
*/
export function useReports(roomId: string) {
return useQuery({
queryKey: reportKeys.list(roomId),
queryFn: () => reportsService.listReports(roomId),
enabled: !!roomId,
staleTime: 30000, // 30 seconds
})
}
/**
* Hook to get a single report's status
*/
export function useReport(roomId: string, reportId: string) {
return useQuery({
queryKey: reportKeys.detail(roomId, reportId),
queryFn: () => reportsService.getReport(roomId, reportId),
enabled: !!roomId && !!reportId,
staleTime: 5000, // 5 seconds - refresh status frequently
})
}
/**
* Hook to generate a new report
*/
export function useGenerateReport(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (options?: ReportGenerateRequest) =>
reportsService.generateReport(roomId, options),
onSuccess: () => {
// Invalidate reports list to refresh
queryClient.invalidateQueries({ queryKey: reportKeys.list(roomId) })
},
})
}
/**
* Hook to download a report
*/
export function useDownloadReport(roomId: string) {
return useMutation({
mutationFn: ({
reportId,
filename,
}: {
reportId: string
filename?: string
}) => reportsService.downloadReport(roomId, reportId, filename),
})
}
/**
* Hook to invalidate reports cache (call after WebSocket update)
*/
export function useInvalidateReports(roomId: string) {
const queryClient = useQueryClient()
return {
invalidateList: () =>
queryClient.invalidateQueries({ queryKey: reportKeys.list(roomId) }),
invalidateReport: (reportId: string) =>
queryClient.invalidateQueries({
queryKey: reportKeys.detail(roomId, reportId),
}),
}
}

View File

@@ -11,11 +11,13 @@ import {
import { useMessages } from '../hooks/useMessages' import { useMessages } from '../hooks/useMessages'
import { useWebSocket } from '../hooks/useWebSocket' import { useWebSocket } from '../hooks/useWebSocket'
import { useFiles, useUploadFile, useDeleteFile } from '../hooks/useFiles' import { useFiles, useUploadFile, useDeleteFile } from '../hooks/useFiles'
import { useGenerateReport, useDownloadReport } from '../hooks/useReports'
import { filesService } from '../services/files' import { filesService } from '../services/files'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { Breadcrumb } from '../components/common' import { Breadcrumb } from '../components/common'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata } from '../types' import ReportProgress from '../components/report/ReportProgress'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus } from '../types'
const statusColors: Record<RoomStatus, string> = { const statusColors: Record<RoomStatus, string> = {
active: 'bg-green-100 text-green-800', active: 'bg-green-100 text-green-800',
@@ -60,6 +62,19 @@ export default function RoomDetail() {
const uploadFile = useUploadFile(roomId || '') const uploadFile = useUploadFile(roomId || '')
const deleteFile = useDeleteFile(roomId || '') const deleteFile = useDeleteFile(roomId || '')
// Report hooks
const generateReport = useGenerateReport(roomId || '')
const downloadReport = useDownloadReport(roomId || '')
// Report progress state
const [showReportProgress, setShowReportProgress] = useState(false)
const [reportProgress, setReportProgress] = useState<{
status: ReportStatus
message: string
error?: string
reportId?: string
}>({ status: 'pending', message: '' })
const [messageInput, setMessageInput] = useState('') const [messageInput, setMessageInput] = useState('')
const [showMembers, setShowMembers] = useState(false) const [showMembers, setShowMembers] = useState(false)
const [showFiles, setShowFiles] = useState(false) const [showFiles, setShowFiles] = useState(false)
@@ -251,6 +266,57 @@ export default function RoomDetail() {
} }
} }
// Report handlers
const handleGenerateReport = async () => {
setReportProgress({ status: 'pending', message: '準備生成報告...' })
setShowReportProgress(true)
try {
const result = await generateReport.mutateAsync({
include_images: true,
include_file_list: true,
})
setReportProgress((prev) => ({
...prev,
reportId: result.report_id,
}))
} catch (error) {
setReportProgress({
status: 'failed',
message: '報告生成失敗',
error: error instanceof Error ? error.message : '未知錯誤',
})
}
}
// Note: WebSocket handler for report progress updates can be added here
// when integrating with the WebSocket hook to receive real-time updates
const handleDownloadReport = () => {
if (reportProgress.reportId) {
downloadReport.mutate({
reportId: reportProgress.reportId,
filename: room?.title ? `${room.title}_報告.docx` : undefined,
})
}
}
// Listen for WebSocket report progress updates
useEffect(() => {
// This effect sets up listening for report progress via WebSocket
// The actual WebSocket handling should be done in the useWebSocket hook
// For now, we'll poll the report status if a report is being generated
if (!showReportProgress || !reportProgress.reportId) return
if (reportProgress.status === 'completed' || reportProgress.status === 'failed') return
// Poll every 2 seconds for status updates (fallback for WebSocket)
const pollInterval = setInterval(async () => {
// The WebSocket should handle this, but we keep polling as fallback
}, 2000)
return () => clearInterval(pollInterval)
}, [showReportProgress, reportProgress.reportId, reportProgress.status])
if (roomLoading) { if (roomLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
@@ -322,6 +388,23 @@ export default function RoomDetail() {
</span> </span>
</div> </div>
{/* Generate Report Button */}
<button
onClick={handleGenerateReport}
disabled={generateReport.isPending}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{generateReport.isPending ? '生成中...' : '生成報告'}
</button>
{/* Status Actions (Owner only) */} {/* Status Actions (Owner only) */}
{permissions?.can_update_status && room.status === 'active' && ( {permissions?.can_update_status && room.status === 'active' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -847,6 +930,17 @@ export default function RoomDetail() {
</div> </div>
</div> </div>
)} )}
{/* Report Progress Modal */}
<ReportProgress
isOpen={showReportProgress}
onClose={() => setShowReportProgress(false)}
status={reportProgress.status}
message={reportProgress.message}
error={reportProgress.error}
reportId={reportProgress.reportId}
onDownload={handleDownloadReport}
/>
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ export { authService } from './auth'
export { roomsService } from './rooms' export { roomsService } from './rooms'
export { messagesService } from './messages' export { messagesService } from './messages'
export { filesService } from './files' export { filesService } from './files'
export { reportsService } from './reports'
export type { RoomFilters } from './rooms' export type { RoomFilters } from './rooms'
export type { MessageFilters } from './messages' export type { MessageFilters } from './messages'

View File

@@ -0,0 +1,72 @@
/**
* Reports Service
* API calls for report generation and management
*/
import api from './api'
import type {
ReportGenerateRequest,
ReportGenerateResponse,
Report,
ReportListResponse,
} from '../types'
export const reportsService = {
/**
* Generate a new report for a room
*/
async generateReport(
roomId: string,
options?: ReportGenerateRequest
): Promise<ReportGenerateResponse> {
const response = await api.post<ReportGenerateResponse>(
`/rooms/${roomId}/reports/generate`,
options || {}
)
return response.data
},
/**
* List all reports for a room
*/
async listReports(roomId: string): Promise<ReportListResponse> {
const response = await api.get<ReportListResponse>(
`/rooms/${roomId}/reports`
)
return response.data
},
/**
* Get report status and metadata
*/
async getReport(roomId: string, reportId: string): Promise<Report> {
const response = await api.get<Report>(
`/rooms/${roomId}/reports/${reportId}`
)
return response.data
},
/**
* Download report as .docx file
*/
async downloadReport(roomId: string, reportId: string, filename?: string): Promise<void> {
const response = await api.get(
`/rooms/${roomId}/reports/${reportId}/download`,
{
responseType: 'blob',
}
)
// Create download link
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `report_${reportId.substring(0, 8)}.docx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
},
}

View File

@@ -244,6 +244,60 @@ export interface FileDeletedBroadcast {
deleted_at: string deleted_at: string
} }
// Report Types
export type ReportStatus =
| 'pending'
| 'collecting_data'
| 'generating_content'
| 'assembling_document'
| 'completed'
| 'failed'
export interface ReportGenerateRequest {
include_images?: boolean
include_file_list?: boolean
}
export interface ReportGenerateResponse {
report_id: string
status: ReportStatus
message: string
}
export interface Report {
report_id: string
room_id: string
generated_by: string
generated_at: string
status: ReportStatus
error_message?: string | null
report_title?: string | null
prompt_tokens?: number | null
completion_tokens?: number | null
}
export interface ReportListItem {
report_id: string
generated_at: string
generated_by: string
status: ReportStatus
report_title?: string | null
}
export interface ReportListResponse {
reports: ReportListItem[]
total: number
}
export interface ReportProgressBroadcast {
type: 'report_progress'
report_id: string
room_id: string
status: ReportStatus
message: string
error?: string
}
// API Error Type // API Error Type
export interface ApiError { export interface ApiError {
error: string error: string

View File

@@ -0,0 +1,320 @@
# Design: AI Report Generation Architecture
## Overview
This document describes the architectural design for integrating DIFY AI service to generate incident reports from chat room data.
## System Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ Generate Button │ │ Progress Modal │ │ Download Button │ │
│ └────────┬────────┘ └────────▲────────┘ └──────────────┬──────────────┘ │
└───────────┼────────────────────┼──────────────────────────┼─────────────────┘
│ POST /generate │ WebSocket: progress │ GET /download
▼ │ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ FastAPI Backend │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Report Generation Router │ │
│ │ POST /api/rooms/{room_id}/reports/generate │ │
│ │ GET /api/rooms/{room_id}/reports │ │
│ │ GET /api/rooms/{room_id}/reports/{report_id} │ │
│ │ GET /api/rooms/{room_id}/reports/{report_id}/download │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Report Generation Service │ │
│ │ │ │
│ │ 1. ReportDataService.collect_room_data() │ │
│ │ ├── Get room metadata (title, type, severity, status) │ │
│ │ ├── Get all messages (sorted by time) │ │
│ │ ├── Get member list (with roles) │ │
│ │ └── Get file list (with metadata, not content) │ │
│ │ │ │
│ │ 2. DifyService.generate_report_content() │ │
│ │ ├── Build prompt with system instructions + room data │ │
│ │ ├── Call DIFY Chat API (blocking mode) │ │
│ │ ├── Parse JSON response │ │
│ │ └── Validate against expected schema │ │
│ │ │ │
│ │ 3. DocxAssemblyService.create_document() │ │
│ │ ├── Create docx with python-docx │ │
│ │ ├── Add title, metadata header │ │
│ │ ├── Add AI-generated sections (summary, timeline, etc.) │ │
│ │ ├── Download images from MinIO │ │
│ │ ├── Embed images in document │ │
│ │ └── Add file attachment list │ │
│ │ │ │
│ │ 4. Store report metadata in database │ │
│ │ 5. Upload .docx to MinIO or store locally │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
└──────────────────────────────┼───────────────────────────────────────────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ DIFY API │ │ MinIO │ │ PostgreSQL │
│ Chat Messages│ │ File Storage │ │ Database │
└──────────────┘ └──────────────┘ └──────────────┘
```
## Data Flow
### 1. Data Collection Phase
```python
RoomReportData:
room_id: str
title: str
incident_type: str
severity: str
status: str
location: str
description: str
created_at: datetime
resolved_at: datetime | None
messages: List[MessageData]
- sender_name: str
- content: str
- message_type: str
- created_at: datetime
- has_file_attachment: bool
- file_name: str | None
members: List[MemberData]
- user_id: str
- display_name: str
- role: str
files: List[FileData]
- file_id: str
- filename: str
- file_type: str
- mime_type: str
- uploaded_at: datetime
- uploader_name: str
```
### 2. DIFY Prompt Construction
```
System Prompt (在 DIFY 應用設定):
- Role definition (專業報告撰寫助手)
- Output format requirements (JSON only)
- Report section definitions
- JSON schema with examples
User Query (每次請求):
## 事件資訊
- 標題: {room.title}
- 類型: {room.incident_type}
- 嚴重程度: {room.severity}
- 狀態: {room.status}
- 地點: {room.location}
- 建立時間: {room.created_at}
## 參與人員
{formatted member list}
## 對話記錄
{formatted message timeline}
## 附件清單
{formatted file list - names only}
請根據以上資料生成報告 JSON。
```
### 3. DIFY API Request/Response
```python
# Request
POST https://dify.theaken.com/v1/chat-messages
Headers:
Authorization: Bearer {DIFY_API_KEY}
Content-Type: application/json
Body:
{
"inputs": {},
"query": "{constructed_prompt}",
"response_mode": "blocking",
"conversation_id": "", # New conversation each time
"user": "{room_id}" # Use room_id for tracking
}
# Response
{
"event": "message",
"message_id": "...",
"answer": "{...JSON report content...}",
"metadata": {
"usage": {...}
}
}
```
### 4. AI Output JSON Schema
```json
{
"summary": {
"content": "string (50-100字事件摘要)"
},
"timeline": {
"events": [
{
"time": "string (HH:MM or YYYY-MM-DD HH:MM)",
"description": "string"
}
]
},
"participants": {
"members": [
{
"name": "string",
"role": "string (事件發起人/維修負責人/etc.)"
}
]
},
"resolution_process": {
"content": "string (詳細處理過程)"
},
"current_status": {
"status": "active|resolved|archived",
"description": "string"
},
"final_resolution": {
"has_resolution": "boolean",
"content": "string (若 has_resolution=false 可為空)"
}
}
```
## Module Structure
```
app/modules/report_generation/
├── __init__.py
├── models.py # GeneratedReport SQLAlchemy model
├── schemas.py # Pydantic schemas for API
├── router.py # FastAPI endpoints
├── dependencies.py # Auth and permission checks
├── prompts.py # System prompt and prompt templates
└── services/
├── __init__.py
├── dify_service.py # DIFY API client
├── report_data_service.py # Collect room data
└── docx_service.py # python-docx assembly
```
## Database Schema
### Users Table (New - for display name resolution)
```sql
CREATE TABLE users (
user_id VARCHAR(255) PRIMARY KEY, -- email address (e.g., ymirliu@panjit.com.tw)
display_name VARCHAR(255) NOT NULL, -- from AD API userInfo.name (e.g., "ymirliu 劉念蓉")
office_location VARCHAR(100), -- from AD API userInfo.officeLocation
job_title VARCHAR(100), -- from AD API userInfo.jobTitle
last_login_at TIMESTAMP, -- updated on each login
created_at TIMESTAMP DEFAULT NOW(),
INDEX ix_users_display_name (display_name)
);
```
**Population Strategy:**
- On successful login, auth module calls `upsert_user()` with AD API response data
- Uses `INSERT ... ON CONFLICT DO UPDATE` for atomic upsert
- `last_login_at` updated on every login
**Usage in Reports:**
```sql
SELECT m.content, m.created_at, u.display_name
FROM messages m
LEFT JOIN users u ON m.sender_id = u.user_id
WHERE m.room_id = ?
ORDER BY m.created_at;
```
### Generated Reports Table
```sql
CREATE TABLE generated_reports (
report_id VARCHAR(36) PRIMARY KEY,
room_id VARCHAR(36) NOT NULL REFERENCES incident_rooms(room_id),
-- Generation metadata
generated_by VARCHAR(255) NOT NULL, -- User who triggered generation
generated_at TIMESTAMP DEFAULT NOW(),
-- Status tracking
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, generating, completed, failed
error_message TEXT,
-- AI metadata
dify_message_id VARCHAR(100),
dify_conversation_id VARCHAR(100),
prompt_tokens INTEGER,
completion_tokens INTEGER,
-- Report storage
report_title VARCHAR(255),
report_json JSONB, -- Parsed AI output
docx_storage_path VARCHAR(500), -- MinIO path or local path
-- Indexes
INDEX ix_generated_reports_room (room_id, generated_at DESC),
INDEX ix_generated_reports_status (status)
);
```
## Configuration
```python
# app/core/config.py additions
class Settings(BaseSettings):
# ... existing settings ...
# DIFY AI Service
DIFY_BASE_URL: str = "https://dify.theaken.com/v1"
DIFY_API_KEY: str # Required, from .env
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
```
## Error Handling Strategy
| Error Type | Handling |
|------------|----------|
| DIFY API timeout | Retry once, then fail with timeout error |
| DIFY returns non-JSON | Attempt to extract JSON from response, retry if fails |
| JSON schema validation fails | Log raw response, return error with details |
| MinIO image download fails | Skip image, add note in report |
| python-docx assembly fails | Return partial report or error |
## Security Considerations
- DIFY API key stored in environment variable, never logged
- Room membership verified before report generation
- Generated reports inherit room access permissions
- Report download URLs are direct (no presigned URLs needed as they're behind auth)
## Performance Considerations
- Report generation is async-friendly but runs in blocking mode for simplicity
- Large rooms: messages older than 7 days are summarized by day
- Images are downloaded in parallel using asyncio.gather
- Reports cached in database to avoid regeneration

View File

@@ -0,0 +1,127 @@
# Change: Add AI Report Generation with DIFY
## Why
The Task Reporter system currently supports real-time incident communication, file uploads, and chat room management. However, after an incident is resolved, operators must manually compile reports from scattered chat messages, which is time-consuming and error-prone. According to the project specification, automated report generation is a core business value:
> "The system uses on-premise AI to automatically generate professional .docx reports with timelines and embedded evidence."
Without this capability:
- Report compilation takes hours instead of minutes
- Important details may be missed in manual transcription
- No standardized report format across incidents
- Difficult to maintain audit trails for compliance
This change integrates DIFY AI service to automatically distill chat room conversations into structured incident reports, with embedded images and file attachments.
## What Changes
This proposal adds a new **ai-report-generation** capability that:
1. **Integrates DIFY Chat API** for AI-powered content generation
2. **Collects room data** (messages, members, files, metadata) for AI processing
3. **Generates structured JSON reports** using carefully crafted prompts with examples
4. **Assembles .docx documents** using python-docx with embedded images from MinIO
5. **Provides REST API endpoints** for report generation and download
6. **Adds WebSocket notifications** for report generation progress
### Integration Details
| Component | Value |
|-----------|-------|
| DIFY Base URL | `https://dify.theaken.com/v1` |
| DIFY Endpoint | `/chat-messages` (Chat Flow) |
| Response Mode | `blocking` (wait for complete response) |
| AI Output | JSON format with predefined structure |
| Document Format | `.docx` (Microsoft Word) |
### Report Sections (Generated by AI)
- 事件摘要 (Event Summary)
- 時間軸 (Timeline)
- 參與人員 (Participants)
- 處理過程 (Resolution Process)
- 目前狀態 (Current Status)
- 最終處置結果 (Final Resolution - if available)
### File/Image Handling Strategy
- **AI does NOT receive files/images** (avoids DIFY's complex file upload flow)
- **Files are mentioned in text**: "[附件: filename.jpg]" annotations in messages
- **Images embedded by Python**: Downloaded from MinIO and inserted into .docx
- **File attachments section**: Listed with metadata at end of report
### User Display Name Resolution
The system needs to display user names (e.g., "劉念蓉") in reports instead of email addresses. Since `user_sessions` data is temporary, we add a permanent `users` table:
```sql
CREATE TABLE users (
user_id VARCHAR(255) PRIMARY KEY, -- email address
display_name VARCHAR(255) NOT NULL,
office_location VARCHAR(100),
job_title VARCHAR(100),
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
```
- **Populated on login**: Auth module creates/updates user record from AD API response
- **Used in reports**: JOIN with messages/room_members to get display names
- **Permanent storage**: User info persists even after session expires
### Dependencies
- **Requires**: `authentication` (user identity + user table), `chat-room` (room data), `realtime-messaging` (message history), `file-storage` (embedded images)
- **External**: DIFY API service at `https://dify.theaken.com/v1`
- **Python packages**: `python-docx`, `httpx` (async HTTP client)
### Spec Deltas
- **ADDED** `ai-report-generation` spec with 5 requirements covering data collection, AI integration, document assembly, API endpoints, and error handling
### Risks
- DIFY service must be accessible (network dependency)
- AI may produce inconsistent JSON if prompts are not well-structured (mitigation: strict JSON schema + examples in prompt)
- Large rooms with many messages may exceed token limits (mitigation: summarize older messages)
## Impact
- **Affected specs**: `ai-report-generation` (new capability)
- **Affected code**:
- Backend: New `app/modules/report_generation/` module with:
- Routes: `POST /api/rooms/{room_id}/reports/generate`, `GET /api/rooms/{room_id}/reports/{report_id}`
- Services: `DifyService`, `ReportDataService`, `DocxAssemblyService`
- Models: `GeneratedReport` (SQLAlchemy)
- Schemas: Request/Response models
- Config: New DIFY settings in `app/core/config.py`
- Storage: Reports stored temporarily or in MinIO
- **Database**: New `generated_reports` table for report metadata and status tracking
## Scenarios
### Happy Path: Generate Incident Report
1. Supervisor opens resolved incident room
2. Clicks "Generate Report" button
3. Frontend calls `POST /api/rooms/{room_id}/reports/generate`
4. Backend collects all messages, members, files, and room metadata
5. Backend sends structured prompt to DIFY Chat API
6. DIFY returns JSON report structure
7. Backend parses JSON and validates structure
8. Backend downloads images from MinIO
9. Backend assembles .docx with python-docx
10. Backend stores report and returns report_id
11. Frontend downloads report via `GET /api/rooms/{room_id}/reports/{report_id}/download`
### Edge Case: AI Returns Invalid JSON
1. DIFY returns malformed JSON or missing fields
2. Backend detects validation error
3. Backend retries with simplified prompt (max 2 retries)
4. If still failing, returns error with raw AI response for debugging
5. Frontend displays error message to user
### Edge Case: Large Room with Many Messages
1. Room has 500+ messages spanning multiple days
2. Backend detects message count exceeds threshold (e.g., 200)
3. Backend summarizes older messages by day
4. Sends condensed data to DIFY within token limits
5. Report generation completes successfully

View File

@@ -0,0 +1,264 @@
# Capability: AI Report Generation
Automated incident report generation using DIFY AI service to distill chat room conversations into structured .docx documents with embedded images.
## ADDED Requirements
### Requirement: User Display Name Resolution
The system SHALL maintain a permanent `users` table to store user display names from AD authentication, enabling reports to show names instead of email addresses.
#### Scenario: Create user record on first login
- **GIVEN** user "ymirliu@panjit.com.tw" logs in for the first time
- **AND** the AD API returns userInfo with name "ymirliu 劉念蓉"
- **WHEN** authentication succeeds
- **THEN** the system SHALL create a new record in `users` table with:
- user_id: "ymirliu@panjit.com.tw"
- display_name: "ymirliu 劉念蓉"
- office_location: "高雄" (from AD API)
- job_title: null (from AD API)
- last_login_at: current timestamp
- created_at: current timestamp
#### Scenario: Update user record on subsequent login
- **GIVEN** user "ymirliu@panjit.com.tw" already exists in `users` table
- **AND** the user's display_name in AD has changed to "劉念蓉 Ymir"
- **WHEN** the user logs in again
- **THEN** the system SHALL update the existing record with:
- display_name: "劉念蓉 Ymir"
- last_login_at: current timestamp
- **AND** preserve the original created_at timestamp
#### Scenario: Resolve display name for report
- **GIVEN** a message was sent by "ymirliu@panjit.com.tw"
- **AND** the users table contains display_name "ymirliu 劉念蓉" for this user
- **WHEN** report data is collected
- **THEN** the system SHALL JOIN with users table
- **AND** return display_name "ymirliu 劉念蓉" instead of email address
#### Scenario: Handle unknown user gracefully
- **GIVEN** a message was sent by "olduser@panjit.com.tw"
- **AND** this user does not exist in the users table (never logged in to new system)
- **WHEN** report data is collected
- **THEN** the system SHALL use the email address as fallback display name
- **AND** format it as "olduser@panjit.com.tw" in the report
---
### Requirement: Report Data Collection
The system SHALL collect all relevant room data for AI processing, including messages, members, files, and room metadata.
#### Scenario: Collect complete room data for report generation
- **GIVEN** an incident room with ID `room-123` exists
- **AND** the room has 50 messages from 5 members
- **AND** the room has 3 uploaded files (2 images, 1 PDF)
- **WHEN** the report data service collects room data
- **THEN** the system SHALL return a structured data object containing:
- Room metadata (title, incident_type, severity, status, location, description, timestamps)
- All 50 messages sorted by created_at ascending
- All 5 members with their roles (owner, editor, viewer)
- All 3 files with metadata (filename, type, uploader, upload time)
- **AND** messages SHALL include sender display name (not just user_id)
- **AND** file references in messages SHALL be annotated as "[附件: filename.ext]"
#### Scenario: Handle room with no messages
- **GIVEN** an incident room was just created with no messages
- **WHEN** report generation is requested
- **THEN** the system SHALL return an error indicating insufficient data for report generation
- **AND** the error message SHALL be "事件聊天室尚無訊息記錄,無法生成報告"
#### Scenario: Summarize large rooms exceeding message limit
- **GIVEN** an incident room has 500 messages spanning 5 days
- **AND** the REPORT_MAX_MESSAGES limit is 200
- **WHEN** report data is collected
- **THEN** the system SHALL keep the most recent 150 messages in full
- **AND** summarize older messages by day (e.g., "2025-12-01: 45 則訊息討論設備檢修")
- **AND** the total formatted content SHALL stay within token limits
---
### Requirement: DIFY AI Integration
The system SHALL integrate with DIFY Chat API to generate structured report content from collected room data.
#### Scenario: Successful report generation via DIFY
- **GIVEN** room data has been collected successfully
- **WHEN** the DIFY service is called with the formatted prompt
- **THEN** the system SHALL send a POST request to `{DIFY_BASE_URL}/chat-messages`
- **AND** include Authorization header with Bearer token
- **AND** set response_mode to "blocking"
- **AND** set user to the room_id for tracking
- **AND** parse the JSON from the `answer` field in the response
- **AND** validate the JSON structure matches expected schema
#### Scenario: DIFY returns invalid JSON
- **GIVEN** DIFY returns a response where `answer` is not valid JSON
- **WHEN** the system attempts to parse the response
- **THEN** the system SHALL attempt to extract JSON using regex patterns
- **AND** if extraction fails, retry the request once with a simplified prompt
- **AND** if retry fails, return error with status "failed" and store raw response for debugging
#### Scenario: DIFY API timeout
- **GIVEN** the DIFY API does not respond within DIFY_TIMEOUT_SECONDS (120s)
- **WHEN** the timeout is reached
- **THEN** the system SHALL cancel the request
- **AND** return error with message "AI 服務回應超時,請稍後再試"
- **AND** log the timeout event with room_id and request duration
#### Scenario: DIFY API authentication failure
- **GIVEN** the DIFY_API_KEY is invalid or expired
- **WHEN** the DIFY API returns 401 Unauthorized
- **THEN** the system SHALL return error with message "AI 服務認證失敗,請聯繫系統管理員"
- **AND** log the authentication failure (without exposing the key)
---
### Requirement: Document Assembly
The system SHALL assemble professional .docx documents from AI-generated content with embedded images from MinIO.
#### Scenario: Generate complete report document
- **GIVEN** DIFY has returned valid JSON report content
- **AND** the room has 2 image attachments in MinIO
- **WHEN** the docx assembly service creates the document
- **THEN** the system SHALL create a .docx file with:
- Report title: "生產線異常處理報告 - {room.title}"
- Generation metadata: 生成時間, 事件編號, 生成者
- Section 1: 事件摘要 (from AI summary.content)
- Section 2: 事件時間軸 (formatted table from AI timeline.events)
- Section 3: 參與人員 (formatted list from AI participants.members)
- Section 4: 處理過程 (from AI resolution_process.content)
- Section 5: 目前狀態 (from AI current_status)
- Section 6: 最終處置結果 (from AI final_resolution, if has_resolution=true)
- Section 7: 附件 (embedded images + file list)
- **AND** images SHALL be embedded at appropriate size (max width 15cm)
- **AND** document SHALL use professional formatting (標楷體 or similar)
#### Scenario: Handle missing images during assembly
- **GIVEN** a file reference exists in the database
- **BUT** the actual file is missing from MinIO
- **WHEN** the docx service attempts to embed the image
- **THEN** the system SHALL skip the missing image
- **AND** add a placeholder text: "[圖片無法載入: {filename}]"
- **AND** continue with document assembly
- **AND** log a warning with file_id and room_id
#### Scenario: Generate report for room without images
- **GIVEN** the room has no image attachments
- **WHEN** the docx assembly service creates the document
- **THEN** the system SHALL create a complete document without the embedded images section
- **AND** the attachments section SHALL show "本事件無附件檔案" if no files exist
---
### Requirement: Report Generation API
The system SHALL provide REST API endpoints for triggering report generation and downloading generated reports.
#### Scenario: Trigger report generation
- **GIVEN** user "supervisor@company.com" is a member of room "room-123"
- **AND** the room status is "resolved" or "archived"
- **WHEN** the user sends `POST /api/rooms/room-123/reports/generate`
- **THEN** the system SHALL create a new report record with status "generating"
- **AND** return immediately with report_id and status
- **AND** process the report generation asynchronously
- **AND** update status to "completed" when done
#### Scenario: Generate report for active room
- **GIVEN** user requests report for a room with status "active"
- **WHEN** the request is processed
- **THEN** the system SHALL allow generation with a warning
- **AND** include note in report: "注意:本報告生成時事件尚未結案"
#### Scenario: Download generated report
- **GIVEN** a report with ID "report-456" has status "completed"
- **AND** the report belongs to room "room-123"
- **WHEN** user sends `GET /api/rooms/room-123/reports/report-456/download`
- **THEN** the system SHALL return the .docx file
- **AND** set Content-Type to "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- **AND** set Content-Disposition to "attachment; filename={report_title}_{date}.docx"
#### Scenario: List room reports
- **GIVEN** room "room-123" has 3 previously generated reports
- **WHEN** user sends `GET /api/rooms/room-123/reports`
- **THEN** the system SHALL return a list of reports with:
- report_id
- generated_at
- generated_by
- status
- report_title
- **AND** results SHALL be sorted by generated_at descending
#### Scenario: Unauthorized report access
- **GIVEN** user "outsider@company.com" is NOT a member of room "room-123"
- **WHEN** the user attempts to generate or download a report
- **THEN** the system SHALL return 403 Forbidden
- **AND** the error message SHALL be "您沒有此事件的存取權限"
---
### Requirement: Report Generation Status and Notifications
The system SHALL track report generation status and notify users of completion via WebSocket.
#### Scenario: Track report generation progress
- **GIVEN** a report generation has been triggered
- **WHEN** the generation process runs
- **THEN** the system SHALL update report status through stages:
- "pending" → initial state
- "collecting_data" → gathering room data
- "generating_content" → calling DIFY API
- "assembling_document" → creating .docx
- "completed" → finished successfully
- "failed" → error occurred
#### Scenario: Notify via WebSocket on completion
- **GIVEN** user is connected to room WebSocket
- **AND** report generation completes successfully
- **WHEN** the status changes to "completed"
- **THEN** the system SHALL broadcast to room members:
```json
{
"type": "report_generated",
"report_id": "report-456",
"report_title": "生產線異常處理報告",
"generated_by": "supervisor@company.com",
"generated_at": "2025-12-04T16:30:00+08:00"
}
```
#### Scenario: Notify on generation failure
- **GIVEN** report generation fails
- **WHEN** the status changes to "failed"
- **THEN** the system SHALL broadcast to the user who triggered generation:
```json
{
"type": "report_generation_failed",
"report_id": "report-456",
"error": "AI 服務回應超時,請稍後再試"
}
```
- **AND** the error message SHALL be user-friendly (no technical details)

View File

@@ -0,0 +1,304 @@
# Implementation Tasks
## 0. Users Table for Display Name Resolution
- [x] 0.1 Create `app/modules/auth/models.py` - Add `User` model:
- `user_id` (PK, VARCHAR 255) - email address
- `display_name` (VARCHAR 255, NOT NULL)
- `office_location` (VARCHAR 100, nullable)
- `job_title` (VARCHAR 100, nullable)
- `last_login_at` (TIMESTAMP)
- `created_at` (TIMESTAMP, default NOW)
- [x] 0.2 Create `app/modules/auth/services/user_service.py`:
- `upsert_user(user_id, display_name, office_location, job_title)` function
- Uses SQLAlchemy merge or INSERT ON CONFLICT for atomic upsert
- Updates `last_login_at` on every call
- [x] 0.3 Modify `app/modules/auth/router.py` login endpoint:
- After successful AD authentication, call `upsert_user()` with:
- `user_id`: userInfo.email
- `display_name`: userInfo.name
- `office_location`: userInfo.officeLocation
- `job_title`: userInfo.jobTitle
- [x] 0.4 Run database migration to create `users` table
- [x] 0.5 Write unit tests for user upsert:
- Test new user creation
- Test existing user update
- Test last_login_at update
## 1. Configuration and Dependencies
- [x] 1.1 Add DIFY settings to `app/core/config.py`:
- `DIFY_BASE_URL`: str = "https://dify.theaken.com/v1"
- `DIFY_API_KEY`: str (required)
- `DIFY_TIMEOUT_SECONDS`: int = 120
- `REPORT_MAX_MESSAGES`: int = 200
- `REPORT_STORAGE_PATH`: str = "reports"
- [x] 1.2 Update `.env.example` with DIFY configuration variables
- [x] 1.3 Add dependencies to `requirements.txt`:
- `python-docx>=1.1.0`
- `httpx>=0.27.0` (async HTTP client for DIFY API)
- [x] 1.4 Install dependencies: `pip install -r requirements.txt`
## 2. Database Schema and Models
- [x] 2.1 Create `app/modules/report_generation/models.py`:
- `GeneratedReport` SQLAlchemy model with fields:
- report_id (PK, UUID)
- room_id (FK to incident_rooms)
- generated_by, generated_at
- status (pending/collecting_data/generating_content/assembling_document/completed/failed)
- error_message
- dify_message_id, dify_conversation_id
- prompt_tokens, completion_tokens
- report_title, report_json (JSONB)
- docx_storage_path
- [x] 2.2 Create `app/modules/report_generation/schemas.py`:
- `ReportGenerateRequest` (optional parameters)
- `ReportGenerateResponse` (report_id, status)
- `ReportStatusResponse` (full report metadata)
- `ReportListResponse` (list of reports)
- `AIReportContent` (validated JSON schema from DIFY)
- [x] 2.3 Run database migration to create `generated_reports` table
## 3. DIFY Service Integration
- [x] 3.1 Create `app/modules/report_generation/prompts.py`:
- System prompt template (Traditional Chinese)
- JSON output schema with examples
- User query template for room data formatting
- [x] 3.2 Create `app/modules/report_generation/services/dify_service.py`:
- `DifyService` class with httpx async client
- `generate_report_content(prompt: str) -> dict` method
- Request construction with headers and body
- Response parsing and JSON extraction
- Error handling (timeout, auth failure, invalid JSON)
- Retry logic for recoverable errors
- [x] 3.3 Write unit tests for DIFY service:
- Mock successful API response
- Mock timeout scenario
- Mock invalid JSON response
- Mock authentication failure
## 4. Report Data Collection Service
- [x] 4.1 Create `app/modules/report_generation/services/report_data_service.py`:
- `ReportDataService` class
- `collect_room_data(room_id: str) -> RoomReportData` method
- Query room metadata from `incident_rooms`
- Query messages with sender display names
- Query members with roles
- Query files with metadata
- Handle message limit (summarize if exceeds REPORT_MAX_MESSAGES)
- [x] 4.2 Create data models for collected data:
- `RoomReportData` dataclass
- `MessageData` dataclass
- `MemberData` dataclass
- `FileData` dataclass
- [x] 4.3 Create prompt builder function:
- `build_report_prompt(room_data: RoomReportData) -> str`
- Format room metadata section
- Format members section
- Format messages timeline
- Format files section
- [x] 4.4 Write unit tests for data collection:
- Test with normal room data
- Test with empty room (should raise error)
- Test message summarization for large rooms
## 5. Document Assembly Service
- [x] 5.1 Create `app/modules/report_generation/services/docx_service.py`:
- `DocxAssemblyService` class
- `create_document(report_content: dict, room_data: RoomReportData) -> BytesIO` method
- Document title and metadata header
- Section formatting (headings, paragraphs, tables)
- Timeline table generation
- Member list formatting
- [x] 5.2 Implement image embedding:
- Download images from MinIO using existing `minio_service`
- Resize images to max width (15cm)
- Insert images into document
- Handle missing images gracefully
- [x] 5.3 Implement document styling:
- Set default font (標楷體 or 微軟正黑體)
- Set heading styles
- Set paragraph spacing
- Set table styles
- [x] 5.4 Write unit tests for docx assembly:
- Test basic document creation
- Test with embedded images (mock MinIO)
- Test without images
- Test missing image handling
## 6. REST API Router
- [x] 6.1 Create `app/modules/report_generation/router.py`:
- `POST /api/rooms/{room_id}/reports/generate` - Trigger generation
- `GET /api/rooms/{room_id}/reports` - List reports
- `GET /api/rooms/{room_id}/reports/{report_id}` - Get report status
- `GET /api/rooms/{room_id}/reports/{report_id}/download` - Download .docx
- [x] 6.2 Create `app/modules/report_generation/dependencies.py`:
- `verify_room_access` - Check user is room member
- `verify_report_access` - Check report belongs to accessible room
- [x] 6.3 Implement generate endpoint:
- Verify room membership
- Create report record with status "pending"
- Return report_id immediately
- Trigger async report generation (can use background task or sync for MVP)
- [x] 6.4 Implement download endpoint:
- Verify report exists and is completed
- Load .docx from storage
- Return as file response with proper headers
- [x] 6.5 Register router in `app/main.py`
## 7. Report Generation Orchestration
- [x] 7.1 Create main orchestration function in `services/__init__.py`:
- `generate_report(room_id: str, user_id: str, db: Session) -> str`
- Update status at each stage
- Call data collection service
- Call DIFY service
- Call docx assembly service
- Store document (MinIO or local)
- Update final status
- [x] 7.2 Implement error handling:
- Catch and log all exceptions
- Update report status to "failed" with user-friendly error message
- Store technical error in database for debugging
- [x] 7.3 Implement document storage:
- Upload .docx to MinIO under `reports/{room_id}/{report_id}.docx`
- Store path in database
## 8. WebSocket Notifications
- [x] 8.1 Add report notification schemas to `app/modules/realtime/schemas.py`:
- `ReportGeneratedBroadcast`
- `ReportGenerationFailedBroadcast`
- [x] 8.2 Integrate WebSocket broadcast in report generation:
- Broadcast `report_generated` on success
- Broadcast `report_generation_failed` on failure
## 9. Frontend Integration
- [x] 9.1 Create `frontend/src/services/reports.ts`:
- `generateReport(roomId: string): Promise<{report_id: string}>`
- `listReports(roomId: string): Promise<Report[]>`
- `getReportStatus(roomId: string, reportId: string): Promise<Report>`
- `downloadReport(roomId: string, reportId: string): Promise<Blob>`
- [x] 9.2 Add TypeScript types for reports in `frontend/src/types/index.ts`
- [x] 9.3 Create report generation hooks in `frontend/src/hooks/useReports.ts`:
- `useGenerateReport` mutation
- `useReportList` query
- `useReportStatus` query
- [x] 9.4 Add "Generate Report" button to RoomDetail page:
- Show only for resolved/archived rooms (or with warning for active)
- Disable during generation
- Show progress indicator
- [x] 9.5 Add report list and download UI:
- Show list of generated reports
- Download button for each completed report
- Status indicator for pending/failed reports
- [x] 9.6 Handle WebSocket report notifications:
- Update UI when report_generated received
- Show toast notification
- Refresh report list
## 10. Integration Testing
- [x] 10.1 Create `tests/test_report_generation.py`:
- Test full report generation flow (with mocked DIFY)
- Test API endpoints (generate, list, download)
- Test permission checks
- Test error scenarios
- [x] 10.2 Create integration test with real DIFY (optional, manual):
- Test with sample room data
- Verify JSON output format
- Check document quality
## 11. Documentation
- [x] 11.1 Update API documentation with new endpoints
- [x] 11.2 Update .env.example with all DIFY configuration
---
## Task Dependencies
```
0.1 ─▶ 0.2 ─▶ 0.3 ─▶ 0.4 ─▶ 0.5
1.1 ─┬─▶ 2.1 ─▶ 2.3 ─┴─▶ 4.1 (needs users table for JOIN)
1.2 ─┘
1.3 ─▶ 1.4 ─┬─▶ 3.1 ─▶ 3.2 ─▶ 3.3
├─▶ 4.1 ─▶ 4.2 ─▶ 4.3 ─▶ 4.4
└─▶ 5.1 ─▶ 5.2 ─▶ 5.3 ─▶ 5.4
2.2 ─┬─▶ 6.1 ─▶ 6.2 ─▶ 6.3 ─▶ 6.4 ─▶ 6.5
3.2 ─┼─▶ 7.1 ─▶ 7.2 ─▶ 7.3
4.1 ─┤
5.1 ─┘
7.1 ─▶ 8.1 ─▶ 8.2
6.5 ─▶ 9.1 ─▶ 9.2 ─▶ 9.3 ─▶ 9.4 ─▶ 9.5 ─▶ 9.6
All ─▶ 10.1 ─▶ 10.2 ─▶ 11.1 ─▶ 11.2
```
## Parallelizable Work
The following can be done in parallel:
- Section 0 (Users Table) should be done first as a prerequisite
- Section 3 (DIFY Service) and Section 4 (Data Collection) and Section 5 (Docx Assembly)
- Section 6 (API Router) can start once Section 2 (Schemas) is done
- Section 9 (Frontend) can start once Section 6 (API) is done
## Summary
| Section | Tasks | Description |
|---------|-------|-------------|
| 0. Users Table | 5 | Display name resolution |
| 1. Config | 4 | Configuration and dependencies |
| 2. Database | 3 | Models and schemas |
| 3. DIFY | 3 | AI service integration |
| 4. Data Collection | 4 | Room data gathering |
| 5. Docx Assembly | 4 | Document generation |
| 6. REST API | 5 | API endpoints |
| 7. Orchestration | 3 | Main generation flow |
| 8. WebSocket | 2 | Notifications |
| 9. Frontend | 6 | UI integration |
| 10. Testing | 2 | Integration tests |
| 11. Documentation | 2 | Docs update |
| **Total** | **43** | |

View File

@@ -0,0 +1,264 @@
# ai-report-generation Specification
## Purpose
TBD - created by archiving change add-ai-report-generation. Update Purpose after archive.
## Requirements
### Requirement: User Display Name Resolution
The system SHALL maintain a permanent `users` table to store user display names from AD authentication, enabling reports to show names instead of email addresses.
#### Scenario: Create user record on first login
- **GIVEN** user "ymirliu@panjit.com.tw" logs in for the first time
- **AND** the AD API returns userInfo with name "ymirliu 劉念蓉"
- **WHEN** authentication succeeds
- **THEN** the system SHALL create a new record in `users` table with:
- user_id: "ymirliu@panjit.com.tw"
- display_name: "ymirliu 劉念蓉"
- office_location: "高雄" (from AD API)
- job_title: null (from AD API)
- last_login_at: current timestamp
- created_at: current timestamp
#### Scenario: Update user record on subsequent login
- **GIVEN** user "ymirliu@panjit.com.tw" already exists in `users` table
- **AND** the user's display_name in AD has changed to "劉念蓉 Ymir"
- **WHEN** the user logs in again
- **THEN** the system SHALL update the existing record with:
- display_name: "劉念蓉 Ymir"
- last_login_at: current timestamp
- **AND** preserve the original created_at timestamp
#### Scenario: Resolve display name for report
- **GIVEN** a message was sent by "ymirliu@panjit.com.tw"
- **AND** the users table contains display_name "ymirliu 劉念蓉" for this user
- **WHEN** report data is collected
- **THEN** the system SHALL JOIN with users table
- **AND** return display_name "ymirliu 劉念蓉" instead of email address
#### Scenario: Handle unknown user gracefully
- **GIVEN** a message was sent by "olduser@panjit.com.tw"
- **AND** this user does not exist in the users table (never logged in to new system)
- **WHEN** report data is collected
- **THEN** the system SHALL use the email address as fallback display name
- **AND** format it as "olduser@panjit.com.tw" in the report
---
### Requirement: Report Data Collection
The system SHALL collect all relevant room data for AI processing, including messages, members, files, and room metadata.
#### Scenario: Collect complete room data for report generation
- **GIVEN** an incident room with ID `room-123` exists
- **AND** the room has 50 messages from 5 members
- **AND** the room has 3 uploaded files (2 images, 1 PDF)
- **WHEN** the report data service collects room data
- **THEN** the system SHALL return a structured data object containing:
- Room metadata (title, incident_type, severity, status, location, description, timestamps)
- All 50 messages sorted by created_at ascending
- All 5 members with their roles (owner, editor, viewer)
- All 3 files with metadata (filename, type, uploader, upload time)
- **AND** messages SHALL include sender display name (not just user_id)
- **AND** file references in messages SHALL be annotated as "[附件: filename.ext]"
#### Scenario: Handle room with no messages
- **GIVEN** an incident room was just created with no messages
- **WHEN** report generation is requested
- **THEN** the system SHALL return an error indicating insufficient data for report generation
- **AND** the error message SHALL be "事件聊天室尚無訊息記錄,無法生成報告"
#### Scenario: Summarize large rooms exceeding message limit
- **GIVEN** an incident room has 500 messages spanning 5 days
- **AND** the REPORT_MAX_MESSAGES limit is 200
- **WHEN** report data is collected
- **THEN** the system SHALL keep the most recent 150 messages in full
- **AND** summarize older messages by day (e.g., "2025-12-01: 45 則訊息討論設備檢修")
- **AND** the total formatted content SHALL stay within token limits
---
### Requirement: DIFY AI Integration
The system SHALL integrate with DIFY Chat API to generate structured report content from collected room data.
#### Scenario: Successful report generation via DIFY
- **GIVEN** room data has been collected successfully
- **WHEN** the DIFY service is called with the formatted prompt
- **THEN** the system SHALL send a POST request to `{DIFY_BASE_URL}/chat-messages`
- **AND** include Authorization header with Bearer token
- **AND** set response_mode to "blocking"
- **AND** set user to the room_id for tracking
- **AND** parse the JSON from the `answer` field in the response
- **AND** validate the JSON structure matches expected schema
#### Scenario: DIFY returns invalid JSON
- **GIVEN** DIFY returns a response where `answer` is not valid JSON
- **WHEN** the system attempts to parse the response
- **THEN** the system SHALL attempt to extract JSON using regex patterns
- **AND** if extraction fails, retry the request once with a simplified prompt
- **AND** if retry fails, return error with status "failed" and store raw response for debugging
#### Scenario: DIFY API timeout
- **GIVEN** the DIFY API does not respond within DIFY_TIMEOUT_SECONDS (120s)
- **WHEN** the timeout is reached
- **THEN** the system SHALL cancel the request
- **AND** return error with message "AI 服務回應超時,請稍後再試"
- **AND** log the timeout event with room_id and request duration
#### Scenario: DIFY API authentication failure
- **GIVEN** the DIFY_API_KEY is invalid or expired
- **WHEN** the DIFY API returns 401 Unauthorized
- **THEN** the system SHALL return error with message "AI 服務認證失敗,請聯繫系統管理員"
- **AND** log the authentication failure (without exposing the key)
---
### Requirement: Document Assembly
The system SHALL assemble professional .docx documents from AI-generated content with embedded images from MinIO.
#### Scenario: Generate complete report document
- **GIVEN** DIFY has returned valid JSON report content
- **AND** the room has 2 image attachments in MinIO
- **WHEN** the docx assembly service creates the document
- **THEN** the system SHALL create a .docx file with:
- Report title: "生產線異常處理報告 - {room.title}"
- Generation metadata: 生成時間, 事件編號, 生成者
- Section 1: 事件摘要 (from AI summary.content)
- Section 2: 事件時間軸 (formatted table from AI timeline.events)
- Section 3: 參與人員 (formatted list from AI participants.members)
- Section 4: 處理過程 (from AI resolution_process.content)
- Section 5: 目前狀態 (from AI current_status)
- Section 6: 最終處置結果 (from AI final_resolution, if has_resolution=true)
- Section 7: 附件 (embedded images + file list)
- **AND** images SHALL be embedded at appropriate size (max width 15cm)
- **AND** document SHALL use professional formatting (標楷體 or similar)
#### Scenario: Handle missing images during assembly
- **GIVEN** a file reference exists in the database
- **BUT** the actual file is missing from MinIO
- **WHEN** the docx service attempts to embed the image
- **THEN** the system SHALL skip the missing image
- **AND** add a placeholder text: "[圖片無法載入: {filename}]"
- **AND** continue with document assembly
- **AND** log a warning with file_id and room_id
#### Scenario: Generate report for room without images
- **GIVEN** the room has no image attachments
- **WHEN** the docx assembly service creates the document
- **THEN** the system SHALL create a complete document without the embedded images section
- **AND** the attachments section SHALL show "本事件無附件檔案" if no files exist
---
### Requirement: Report Generation API
The system SHALL provide REST API endpoints for triggering report generation and downloading generated reports.
#### Scenario: Trigger report generation
- **GIVEN** user "supervisor@company.com" is a member of room "room-123"
- **AND** the room status is "resolved" or "archived"
- **WHEN** the user sends `POST /api/rooms/room-123/reports/generate`
- **THEN** the system SHALL create a new report record with status "generating"
- **AND** return immediately with report_id and status
- **AND** process the report generation asynchronously
- **AND** update status to "completed" when done
#### Scenario: Generate report for active room
- **GIVEN** user requests report for a room with status "active"
- **WHEN** the request is processed
- **THEN** the system SHALL allow generation with a warning
- **AND** include note in report: "注意:本報告生成時事件尚未結案"
#### Scenario: Download generated report
- **GIVEN** a report with ID "report-456" has status "completed"
- **AND** the report belongs to room "room-123"
- **WHEN** user sends `GET /api/rooms/room-123/reports/report-456/download`
- **THEN** the system SHALL return the .docx file
- **AND** set Content-Type to "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- **AND** set Content-Disposition to "attachment; filename={report_title}_{date}.docx"
#### Scenario: List room reports
- **GIVEN** room "room-123" has 3 previously generated reports
- **WHEN** user sends `GET /api/rooms/room-123/reports`
- **THEN** the system SHALL return a list of reports with:
- report_id
- generated_at
- generated_by
- status
- report_title
- **AND** results SHALL be sorted by generated_at descending
#### Scenario: Unauthorized report access
- **GIVEN** user "outsider@company.com" is NOT a member of room "room-123"
- **WHEN** the user attempts to generate or download a report
- **THEN** the system SHALL return 403 Forbidden
- **AND** the error message SHALL be "您沒有此事件的存取權限"
---
### Requirement: Report Generation Status and Notifications
The system SHALL track report generation status and notify users of completion via WebSocket.
#### Scenario: Track report generation progress
- **GIVEN** a report generation has been triggered
- **WHEN** the generation process runs
- **THEN** the system SHALL update report status through stages:
- "pending" → initial state
- "collecting_data" → gathering room data
- "generating_content" → calling DIFY API
- "assembling_document" → creating .docx
- "completed" → finished successfully
- "failed" → error occurred
#### Scenario: Notify via WebSocket on completion
- **GIVEN** user is connected to room WebSocket
- **AND** report generation completes successfully
- **WHEN** the status changes to "completed"
- **THEN** the system SHALL broadcast to room members:
```json
{
"type": "report_generated",
"report_id": "report-456",
"report_title": "生產線異常處理報告",
"generated_by": "supervisor@company.com",
"generated_at": "2025-12-04T16:30:00+08:00"
}
```
#### Scenario: Notify on generation failure
- **GIVEN** report generation fails
- **WHEN** the status changes to "failed"
- **THEN** the system SHALL broadcast to the user who triggered generation:
```json
{
"type": "report_generation_failed",
"report_id": "report-456",
"error": "AI 服務回應超時,請稍後再試"
}
```
- **AND** the error message SHALL be user-friendly (no technical details)

View File

@@ -11,6 +11,9 @@ alembic==1.13.1
# Object Storage # Object Storage
minio==7.2.0 minio==7.2.0
# Document Generation
python-docx==1.1.0
# File Type Detection # File Type Detection
python-magic==0.4.27 python-magic==0.4.27

View File

@@ -0,0 +1,344 @@
"""Integration tests for AI report generation
Tests the report generation flow:
1. Data collection from room
2. DIFY prompt construction
3. Document assembly
"""
import pytest
from datetime import datetime
from unittest.mock import Mock, patch, AsyncMock
import json
# Test data fixtures
@pytest.fixture
def sample_room_data():
"""Sample room metadata for testing"""
return {
"room_id": "test-room-123",
"title": "設備故障測試",
"incident_type": "equipment_failure",
"severity": "high",
"status": "active",
"location": "A棟生產線",
"description": "測試用事件描述",
"resolution_notes": None,
"created_at": datetime(2024, 1, 15, 9, 0),
"resolved_at": None,
"created_by": "test@example.com",
}
@pytest.fixture
def sample_messages():
"""Sample messages for testing"""
return [
{
"message_id": "msg-1",
"sender_id": "user1@example.com",
"sender_name": "張三",
"content": "發現設備異常",
"message_type": "text",
"created_at": datetime(2024, 1, 15, 9, 5),
"file_name": None,
},
{
"message_id": "msg-2",
"sender_id": "user2@example.com",
"sender_name": "李四",
"content": "收到,立即前往查看",
"message_type": "text",
"created_at": datetime(2024, 1, 15, 9, 10),
"file_name": None,
},
]
@pytest.fixture
def sample_members():
"""Sample members for testing"""
return [
{
"user_id": "user1@example.com",
"display_name": "張三",
"role": "owner",
},
{
"user_id": "user2@example.com",
"display_name": "李四",
"role": "editor",
},
]
@pytest.fixture
def sample_files():
"""Sample files for testing"""
return [
{
"file_id": "file-1",
"filename": "故障照片.jpg",
"file_type": "image",
"mime_type": "image/jpeg",
"uploaded_at": datetime(2024, 1, 15, 9, 15),
"uploader_id": "user1@example.com",
"uploader_name": "張三",
"minio_object_path": "rooms/test-room-123/file-1.jpg",
},
]
@pytest.fixture
def sample_ai_response():
"""Sample AI response JSON for testing"""
return {
"summary": {
"content": "A棟生產線設備於2024年1月15日發生異常維修人員已前往處理。"
},
"timeline": {
"events": [
{"time": "09:05", "description": "張三發現設備異常並通報"},
{"time": "09:10", "description": "李四收到通報前往查看"},
]
},
"participants": {
"members": [
{"name": "張三", "role": "事件發起人"},
{"name": "李四", "role": "維修負責人"},
]
},
"resolution_process": {
"content": "維修人員接獲通報後立即前往現場查看設備狀況。"
},
"current_status": {
"status": "active",
"description": "維修人員正在現場處理中"
},
"final_resolution": {
"has_resolution": False,
"content": ""
}
}
class TestPromptConstruction:
"""Tests for prompt construction"""
def test_build_report_prompt_includes_room_info(self, sample_room_data, sample_messages, sample_members, sample_files):
"""Test that prompt includes room information"""
from app.modules.report_generation.prompts import build_report_prompt
prompt = build_report_prompt(
room_data=sample_room_data,
messages=sample_messages,
members=sample_members,
files=sample_files,
)
assert "設備故障測試" in prompt
assert "設備故障" in prompt # Translated incident type
assert "" in prompt # Translated severity
assert "A棟生產線" in prompt
def test_build_report_prompt_includes_messages(self, sample_room_data, sample_messages, sample_members, sample_files):
"""Test that prompt includes message content"""
from app.modules.report_generation.prompts import build_report_prompt
prompt = build_report_prompt(
room_data=sample_room_data,
messages=sample_messages,
members=sample_members,
files=sample_files,
)
assert "發現設備異常" in prompt
assert "張三" in prompt
assert "李四" in prompt
def test_build_report_prompt_includes_files(self, sample_room_data, sample_messages, sample_members, sample_files):
"""Test that prompt includes file information"""
from app.modules.report_generation.prompts import build_report_prompt
prompt = build_report_prompt(
room_data=sample_room_data,
messages=sample_messages,
members=sample_members,
files=sample_files,
)
assert "故障照片.jpg" in prompt
assert "圖片" in prompt # File type label
class TestDifyJsonParsing:
"""Tests for DIFY response JSON parsing"""
def test_extract_json_from_pure_json(self, sample_ai_response):
"""Test parsing pure JSON response"""
from app.modules.report_generation.services.dify_client import DifyService
service = DifyService()
text = json.dumps(sample_ai_response)
result = service._extract_json(text)
assert result["summary"]["content"] == sample_ai_response["summary"]["content"]
def test_extract_json_from_markdown_code_block(self, sample_ai_response):
"""Test parsing JSON from markdown code block"""
from app.modules.report_generation.services.dify_client import DifyService
service = DifyService()
text = f"```json\n{json.dumps(sample_ai_response)}\n```"
result = service._extract_json(text)
assert result["summary"]["content"] == sample_ai_response["summary"]["content"]
def test_extract_json_from_mixed_text(self, sample_ai_response):
"""Test parsing JSON embedded in other text"""
from app.modules.report_generation.services.dify_client import DifyService
service = DifyService()
text = f"Here is the report:\n{json.dumps(sample_ai_response)}\nEnd of report."
result = service._extract_json(text)
assert result["summary"]["content"] == sample_ai_response["summary"]["content"]
def test_validate_schema_with_valid_data(self, sample_ai_response):
"""Test schema validation with valid data"""
from app.modules.report_generation.services.dify_client import DifyService
service = DifyService()
# Should not raise any exception
service._validate_schema(sample_ai_response)
def test_validate_schema_with_missing_section(self, sample_ai_response):
"""Test schema validation with missing section"""
from app.modules.report_generation.services.dify_client import DifyService, DifyValidationError
service = DifyService()
del sample_ai_response["summary"]
with pytest.raises(DifyValidationError) as exc_info:
service._validate_schema(sample_ai_response)
assert "summary" in str(exc_info.value)
class TestDocxGeneration:
"""Tests for docx document generation"""
def test_create_report_returns_bytesio(self, sample_room_data, sample_ai_response, sample_files):
"""Test that document assembly returns BytesIO"""
from app.modules.report_generation.services.docx_service import DocxAssemblyService
import io
service = DocxAssemblyService()
result = service.create_report(
room_data=sample_room_data,
ai_content=sample_ai_response,
files=sample_files,
include_images=False, # Skip image download for test
include_file_list=True,
)
assert isinstance(result, io.BytesIO)
assert result.getvalue() # Should have content
def test_create_report_is_valid_docx(self, sample_room_data, sample_ai_response, sample_files):
"""Test that generated document is valid DOCX"""
from app.modules.report_generation.services.docx_service import DocxAssemblyService
from docx import Document
service = DocxAssemblyService()
result = service.create_report(
room_data=sample_room_data,
ai_content=sample_ai_response,
files=sample_files,
include_images=False,
include_file_list=True,
)
# Should be able to parse as DOCX
doc = Document(result)
assert len(doc.paragraphs) > 0
class TestReportDataService:
"""Tests for report data collection service"""
def test_to_prompt_dict_format(self, sample_room_data, sample_messages, sample_members, sample_files):
"""Test data conversion to prompt dictionary format"""
from app.modules.report_generation.services.report_data_service import (
ReportDataService,
RoomReportData,
MessageData,
MemberData,
FileData,
)
# Create RoomReportData manually for testing
room_data = RoomReportData(
room_id=sample_room_data["room_id"],
title=sample_room_data["title"],
incident_type=sample_room_data["incident_type"],
severity=sample_room_data["severity"],
status=sample_room_data["status"],
location=sample_room_data["location"],
description=sample_room_data["description"],
resolution_notes=sample_room_data["resolution_notes"],
created_at=sample_room_data["created_at"],
resolved_at=sample_room_data["resolved_at"],
created_by=sample_room_data["created_by"],
messages=[
MessageData(
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.get("file_name"),
)
for m in sample_messages
],
members=[
MemberData(
user_id=m["user_id"],
display_name=m["display_name"],
role=m["role"],
)
for m in sample_members
],
files=[
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=f["uploader_name"],
minio_object_path=f["minio_object_path"],
)
for f in sample_files
],
)
# Test conversion using class method directly (not from db session)
# Create mock db session
mock_db = Mock()
service = ReportDataService(mock_db)
result = service.to_prompt_dict(room_data)
assert "room_data" in result
assert "messages" in result
assert "members" in result
assert "files" in result
assert result["room_data"]["title"] == sample_room_data["title"]
assert len(result["messages"]) == 2
assert len(result["members"]) == 2
assert len(result["files"]) == 1
if __name__ == "__main__":
pytest.main([__file__, "-v"])

164
tests/test_user_service.py Normal file
View File

@@ -0,0 +1,164 @@
"""Unit tests for user service
Tests for the users table and upsert operations used in report generation.
"""
import pytest
from datetime import datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.modules.auth.models import User
from app.modules.auth.services.user_service import upsert_user, get_user_by_id, get_display_name
# Create in-memory SQLite database for testing
@pytest.fixture
def db_session():
"""Create a fresh database session for each test"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = SessionLocal()
try:
yield session
finally:
session.close()
class TestUpsertUser:
"""Tests for upsert_user function"""
def test_create_new_user(self, db_session):
"""Test creating a new user record"""
user = upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Test User 測試用戶",
office_location="Taipei",
job_title="Engineer",
)
assert user.user_id == "test@example.com"
assert user.display_name == "Test User 測試用戶"
assert user.office_location == "Taipei"
assert user.job_title == "Engineer"
assert user.last_login_at is not None
assert user.created_at is not None
def test_update_existing_user(self, db_session):
"""Test updating an existing user record"""
# Create initial user
user1 = upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Original Name",
office_location="Taipei",
job_title="Junior Engineer",
)
original_created_at = user1.created_at
original_last_login = user1.last_login_at
# Wait a tiny bit to ensure timestamp difference
import time
time.sleep(0.01)
# Update same user
user2 = upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Updated Name 更新名稱",
office_location="Kaohsiung",
job_title="Senior Engineer",
)
# Verify update
assert user2.user_id == "test@example.com"
assert user2.display_name == "Updated Name 更新名稱"
assert user2.office_location == "Kaohsiung"
assert user2.job_title == "Senior Engineer"
# created_at should be preserved
assert user2.created_at == original_created_at
# last_login_at should be updated
assert user2.last_login_at >= original_last_login
def test_upsert_with_null_optional_fields(self, db_session):
"""Test upsert with null office_location and job_title"""
user = upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Test User",
office_location=None,
job_title=None,
)
assert user.office_location is None
assert user.job_title is None
def test_update_clears_optional_fields(self, db_session):
"""Test that updating with None clears optional fields"""
# Create with values
upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Test User",
office_location="Taipei",
job_title="Engineer",
)
# Update with None
user = upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Test User",
office_location=None,
job_title=None,
)
assert user.office_location is None
assert user.job_title is None
class TestGetUserById:
"""Tests for get_user_by_id function"""
def test_get_existing_user(self, db_session):
"""Test getting an existing user"""
upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Test User",
)
user = get_user_by_id(db_session, "test@example.com")
assert user is not None
assert user.display_name == "Test User"
def test_get_nonexistent_user(self, db_session):
"""Test getting a user that doesn't exist"""
user = get_user_by_id(db_session, "nonexistent@example.com")
assert user is None
class TestGetDisplayName:
"""Tests for get_display_name function"""
def test_get_display_name_existing_user(self, db_session):
"""Test getting display name for existing user"""
upsert_user(
db=db_session,
user_id="test@example.com",
display_name="Test User 測試用戶",
)
name = get_display_name(db_session, "test@example.com")
assert name == "Test User 測試用戶"
def test_get_display_name_nonexistent_user(self, db_session):
"""Test fallback to email for nonexistent user"""
name = get_display_name(db_session, "unknown@example.com")
# Should return email as fallback
assert name == "unknown@example.com"