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:
10
.env.example
10
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
89
app/modules/auth/services/user_service.py
Normal file
89
app/modules/auth/services/user_service.py
Normal 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
|
||||||
8
app/modules/report_generation/__init__.py
Normal file
8
app/modules/report_generation/__init__.py
Normal 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"]
|
||||||
53
app/modules/report_generation/dependencies.py
Normal file
53
app/modules/report_generation/dependencies.py
Normal 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,
|
||||||
|
}
|
||||||
100
app/modules/report_generation/models.py
Normal file
100
app/modules/report_generation/models.py
Normal 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}>"
|
||||||
199
app/modules/report_generation/prompts.py
Normal file
199
app/modules/report_generation/prompts.py
Normal 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,不要包含其他說明文字。"""
|
||||||
445
app/modules/report_generation/router.py
Normal file
445
app/modules/report_generation/router.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""API routes for report generation
|
||||||
|
|
||||||
|
FastAPI router with all report-related endpoints:
|
||||||
|
- POST /api/rooms/{room_id}/reports/generate - Trigger report generation
|
||||||
|
- GET /api/rooms/{room_id}/reports - List reports for a room
|
||||||
|
- GET /api/rooms/{room_id}/reports/{report_id} - Get report status/metadata
|
||||||
|
- GET /api/rooms/{room_id}/reports/{report_id}/download - Download report .docx
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.minio_client import get_minio_client
|
||||||
|
from app.modules.auth import get_current_user
|
||||||
|
from app.modules.report_generation import schemas
|
||||||
|
from app.modules.report_generation.models import GeneratedReport, ReportStatus
|
||||||
|
from app.modules.report_generation.dependencies import require_room_member
|
||||||
|
from app.modules.report_generation.services.report_data_service import ReportDataService
|
||||||
|
from app.modules.report_generation.services.dify_client import (
|
||||||
|
dify_service,
|
||||||
|
DifyAPIError,
|
||||||
|
DifyJSONParseError,
|
||||||
|
DifyValidationError,
|
||||||
|
)
|
||||||
|
from app.modules.report_generation.services.docx_service import docx_service
|
||||||
|
from app.modules.report_generation.prompts import build_report_prompt
|
||||||
|
from app.modules.realtime.websocket_manager import manager as ws_manager
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _broadcast_report_progress(
|
||||||
|
room_id: str,
|
||||||
|
report_id: str,
|
||||||
|
status: str,
|
||||||
|
message: str,
|
||||||
|
error: str = None,
|
||||||
|
):
|
||||||
|
"""Broadcast report generation progress via WebSocket
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: Room ID to broadcast to
|
||||||
|
report_id: Report ID
|
||||||
|
status: Current status (pending, collecting_data, generating_content, etc.)
|
||||||
|
message: Human-readable progress message
|
||||||
|
error: Error message if failed
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"type": "report_progress",
|
||||||
|
"report_id": report_id,
|
||||||
|
"room_id": room_id,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if error:
|
||||||
|
payload["error"] = error
|
||||||
|
|
||||||
|
await ws_manager.broadcast_to_room(room_id, payload)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/rooms/{room_id}/reports", tags=["Report Generation"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=schemas.ReportGenerateResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def generate_report(
|
||||||
|
room_id: str,
|
||||||
|
request: schemas.ReportGenerateRequest = None,
|
||||||
|
background_tasks: BackgroundTasks = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
room_context: dict = Depends(require_room_member),
|
||||||
|
):
|
||||||
|
"""Trigger AI report generation for a room
|
||||||
|
|
||||||
|
This endpoint starts an async report generation process:
|
||||||
|
1. Collects room data (messages, members, files)
|
||||||
|
2. Sends data to DIFY AI for content generation
|
||||||
|
3. Assembles .docx document with AI content and images
|
||||||
|
4. Stores report in MinIO
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: Room ID to generate report for
|
||||||
|
request: Optional generation parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Report ID and initial status
|
||||||
|
"""
|
||||||
|
if request is None:
|
||||||
|
request = schemas.ReportGenerateRequest()
|
||||||
|
|
||||||
|
user_email = room_context["user_email"]
|
||||||
|
|
||||||
|
# Create report record with pending status
|
||||||
|
report = GeneratedReport(
|
||||||
|
room_id=room_id,
|
||||||
|
generated_by=user_email,
|
||||||
|
status=ReportStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(report)
|
||||||
|
|
||||||
|
# Start background generation task
|
||||||
|
background_tasks.add_task(
|
||||||
|
_generate_report_task,
|
||||||
|
report_id=report.report_id,
|
||||||
|
room_id=room_id,
|
||||||
|
include_images=request.include_images,
|
||||||
|
include_file_list=request.include_file_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.ReportGenerateResponse(
|
||||||
|
report_id=report.report_id,
|
||||||
|
status=schemas.ReportStatus.PENDING,
|
||||||
|
message="Report generation started",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=schemas.ReportListResponse)
|
||||||
|
async def list_reports(
|
||||||
|
room_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
room_context: dict = Depends(require_room_member),
|
||||||
|
):
|
||||||
|
"""List all reports for a room
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: Room ID to list reports for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of report summaries sorted by generation time (newest first)
|
||||||
|
"""
|
||||||
|
reports = (
|
||||||
|
db.query(GeneratedReport)
|
||||||
|
.filter(GeneratedReport.room_id == room_id)
|
||||||
|
.order_by(GeneratedReport.generated_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
schemas.ReportListItem(
|
||||||
|
report_id=r.report_id,
|
||||||
|
generated_at=r.generated_at,
|
||||||
|
generated_by=r.generated_by,
|
||||||
|
status=schemas.ReportStatus(r.status),
|
||||||
|
report_title=r.report_title,
|
||||||
|
)
|
||||||
|
for r in reports
|
||||||
|
]
|
||||||
|
|
||||||
|
return schemas.ReportListResponse(
|
||||||
|
reports=items,
|
||||||
|
total=len(items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{report_id}", response_model=schemas.ReportStatusResponse)
|
||||||
|
async def get_report_status(
|
||||||
|
room_id: str,
|
||||||
|
report_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
room_context: dict = Depends(require_room_member),
|
||||||
|
):
|
||||||
|
"""Get report status and metadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: Room ID
|
||||||
|
report_id: Report ID to get status for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Report metadata including status, token usage, etc.
|
||||||
|
"""
|
||||||
|
report = (
|
||||||
|
db.query(GeneratedReport)
|
||||||
|
.filter(
|
||||||
|
GeneratedReport.report_id == report_id,
|
||||||
|
GeneratedReport.room_id == room_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Report not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.ReportStatusResponse(
|
||||||
|
report_id=report.report_id,
|
||||||
|
room_id=report.room_id,
|
||||||
|
generated_by=report.generated_by,
|
||||||
|
generated_at=report.generated_at,
|
||||||
|
status=schemas.ReportStatus(report.status),
|
||||||
|
error_message=report.error_message,
|
||||||
|
report_title=report.report_title,
|
||||||
|
prompt_tokens=report.prompt_tokens,
|
||||||
|
completion_tokens=report.completion_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{report_id}/download")
|
||||||
|
async def download_report(
|
||||||
|
room_id: str,
|
||||||
|
report_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
room_context: dict = Depends(require_room_member),
|
||||||
|
):
|
||||||
|
"""Download generated report as .docx
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: Room ID
|
||||||
|
report_id: Report ID to download
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse with .docx file
|
||||||
|
"""
|
||||||
|
report = (
|
||||||
|
db.query(GeneratedReport)
|
||||||
|
.filter(
|
||||||
|
GeneratedReport.report_id == report_id,
|
||||||
|
GeneratedReport.room_id == room_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Report not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if report.status != ReportStatus.COMPLETED.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Report is not ready for download. Status: {report.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not report.docx_storage_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Report file not found in storage",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download from MinIO
|
||||||
|
try:
|
||||||
|
minio_client = get_minio_client()
|
||||||
|
response = minio_client.get_object(
|
||||||
|
settings.MINIO_BUCKET,
|
||||||
|
report.docx_storage_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
content = response.read()
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
# Create filename from report title or ID
|
||||||
|
filename = report.report_title or f"report_{report.report_id[:8]}"
|
||||||
|
filename = f"{filename}.docx"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download report from MinIO: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to retrieve report file",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_report_task(
|
||||||
|
report_id: str,
|
||||||
|
room_id: str,
|
||||||
|
include_images: bool = True,
|
||||||
|
include_file_list: bool = True,
|
||||||
|
):
|
||||||
|
"""Background task to generate report
|
||||||
|
|
||||||
|
This task:
|
||||||
|
1. Updates status to COLLECTING_DATA + WebSocket notification
|
||||||
|
2. Collects room data
|
||||||
|
3. Updates status to GENERATING_CONTENT + WebSocket notification
|
||||||
|
4. Calls DIFY AI for content
|
||||||
|
5. Updates status to ASSEMBLING_DOCUMENT + WebSocket notification
|
||||||
|
6. Creates .docx document
|
||||||
|
7. Uploads to MinIO
|
||||||
|
8. Updates status to COMPLETED (or FAILED) + WebSocket notification
|
||||||
|
"""
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = db.query(GeneratedReport).filter(
|
||||||
|
GeneratedReport.report_id == report_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
logger.error(f"Report not found: {report_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Phase 1: Collecting Data
|
||||||
|
report.status = ReportStatus.COLLECTING_DATA.value
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "collecting_data", "正在收集聊天室資料..."
|
||||||
|
)
|
||||||
|
|
||||||
|
data_service = ReportDataService(db)
|
||||||
|
room_data = data_service.collect_room_data(room_id)
|
||||||
|
|
||||||
|
if not room_data:
|
||||||
|
report.status = ReportStatus.FAILED.value
|
||||||
|
report.error_message = "Room not found"
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "failed", "報告生成失敗", error="找不到聊天室"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convert to dict for prompt builder
|
||||||
|
prompt_data = data_service.to_prompt_dict(room_data)
|
||||||
|
|
||||||
|
# Phase 2: Generating Content
|
||||||
|
report.status = ReportStatus.GENERATING_CONTENT.value
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "generating_content", "AI 正在分析並生成報告內容..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build prompt and call DIFY
|
||||||
|
prompt = build_report_prompt(
|
||||||
|
room_data=prompt_data["room_data"],
|
||||||
|
messages=prompt_data["messages"],
|
||||||
|
members=prompt_data["members"],
|
||||||
|
files=prompt_data["files"],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dify_response = await dify_service.generate_report(prompt, room_id)
|
||||||
|
except (DifyAPIError, DifyJSONParseError, DifyValidationError) as e:
|
||||||
|
report.status = ReportStatus.FAILED.value
|
||||||
|
report.error_message = str(e)
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "failed", "AI 生成失敗", error=str(e)
|
||||||
|
)
|
||||||
|
logger.error(f"DIFY error for report {report_id}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save AI response data
|
||||||
|
report.dify_message_id = dify_response.message_id
|
||||||
|
report.dify_conversation_id = dify_response.conversation_id
|
||||||
|
report.prompt_tokens = dify_response.prompt_tokens
|
||||||
|
report.completion_tokens = dify_response.completion_tokens
|
||||||
|
report.report_json = dify_response.parsed_json
|
||||||
|
|
||||||
|
# Extract title from summary
|
||||||
|
ai_content = dify_response.parsed_json
|
||||||
|
if ai_content and "summary" in ai_content:
|
||||||
|
summary_content = ai_content["summary"].get("content", "")
|
||||||
|
# Use first 50 chars of summary as title
|
||||||
|
report.report_title = summary_content[:50] + "..." if len(summary_content) > 50 else summary_content
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Phase 3: Assembling Document
|
||||||
|
report.status = ReportStatus.ASSEMBLING_DOCUMENT.value
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "assembling_document", "正在組裝報告文件..."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
docx_data = docx_service.create_report(
|
||||||
|
room_data=prompt_data["room_data"],
|
||||||
|
ai_content=ai_content,
|
||||||
|
files=prompt_data["files"],
|
||||||
|
include_images=include_images,
|
||||||
|
include_file_list=include_file_list,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
report.status = ReportStatus.FAILED.value
|
||||||
|
report.error_message = f"Document assembly failed: {str(e)}"
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "failed", "文件組裝失敗", error=str(e)
|
||||||
|
)
|
||||||
|
logger.error(f"Document assembly error for report {report_id}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Upload to MinIO
|
||||||
|
storage_path = docx_service.upload_report(
|
||||||
|
report_data=docx_data,
|
||||||
|
room_id=room_id,
|
||||||
|
report_id=report_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not storage_path:
|
||||||
|
report.status = ReportStatus.FAILED.value
|
||||||
|
report.error_message = "Failed to upload report to storage"
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "failed", "報告上傳失敗", error="無法上傳到儲存空間"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Phase 4: Completed
|
||||||
|
report.docx_storage_path = storage_path
|
||||||
|
report.status = ReportStatus.COMPLETED.value
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "completed", "報告生成完成!"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Report generation completed: {report_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error generating report {report_id}: {e}")
|
||||||
|
try:
|
||||||
|
report = db.query(GeneratedReport).filter(
|
||||||
|
GeneratedReport.report_id == report_id
|
||||||
|
).first()
|
||||||
|
if report:
|
||||||
|
report.status = ReportStatus.FAILED.value
|
||||||
|
report.error_message = f"Unexpected error: {str(e)}"
|
||||||
|
db.commit()
|
||||||
|
await _broadcast_report_progress(
|
||||||
|
room_id, report_id, "failed", "發生未預期的錯誤", error=str(e)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
105
app/modules/report_generation/schemas.py
Normal file
105
app/modules/report_generation/schemas.py
Normal 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
|
||||||
13
app/modules/report_generation/services/__init__.py
Normal file
13
app/modules/report_generation/services/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
261
app/modules/report_generation/services/dify_client.py
Normal file
261
app/modules/report_generation/services/dify_client.py
Normal 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()
|
||||||
445
app/modules/report_generation/services/docx_service.py
Normal file
445
app/modules/report_generation/services/docx_service.py
Normal 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()
|
||||||
263
app/modules/report_generation/services/report_data_service.py
Normal file
263
app/modules/report_generation/services/report_data_service.py
Normal 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,
|
||||||
|
}
|
||||||
199
frontend/src/components/report/ReportProgress.tsx
Normal file
199
frontend/src/components/report/ReportProgress.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,3 +31,10 @@ export {
|
|||||||
useDownloadFile,
|
useDownloadFile,
|
||||||
fileKeys,
|
fileKeys,
|
||||||
} from './useFiles'
|
} from './useFiles'
|
||||||
|
export {
|
||||||
|
useReports,
|
||||||
|
useReport,
|
||||||
|
useGenerateReport,
|
||||||
|
useDownloadReport,
|
||||||
|
useInvalidateReports,
|
||||||
|
} from './useReports'
|
||||||
|
|||||||
86
frontend/src/hooks/useReports.ts
Normal file
86
frontend/src/hooks/useReports.ts
Normal 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),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
72
frontend/src/services/reports.ts
Normal file
72
frontend/src/services/reports.ts
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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** | |
|
||||||
264
openspec/specs/ai-report-generation/spec.md
Normal file
264
openspec/specs/ai-report-generation/spec.md
Normal 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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
344
tests/test_report_generation.py
Normal file
344
tests/test_report_generation.py
Normal 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
164
tests/test_user_service.py
Normal 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"
|
||||||
Reference in New Issue
Block a user