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:
@@ -2,6 +2,7 @@
|
||||
|
||||
資料表結構:
|
||||
- user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
|
||||
- users: 永久儲存使用者資訊 (用於報告生成時的姓名解析)
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Index
|
||||
from datetime import datetime
|
||||
@@ -29,3 +30,39 @@ class UserSession(Base):
|
||||
DateTime, default=datetime.utcnow, nullable=False, comment="Last API request time"
|
||||
)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Permanent user information for display name resolution in reports
|
||||
|
||||
This table stores user information from AD API and persists even after
|
||||
session expiration. Used for:
|
||||
- Displaying user names (instead of emails) in generated reports
|
||||
- Tracking user metadata (office location, job title)
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(
|
||||
String(255), primary_key=True, comment="User email address (e.g., ymirliu@panjit.com.tw)"
|
||||
)
|
||||
display_name = Column(
|
||||
String(255), nullable=False, comment="Display name from AD (e.g., 'ymirliu 劉念蓉')"
|
||||
)
|
||||
office_location = Column(
|
||||
String(100), nullable=True, comment="Office location from AD (e.g., '高雄')"
|
||||
)
|
||||
job_title = Column(
|
||||
String(100), nullable=True, comment="Job title from AD"
|
||||
)
|
||||
last_login_at = Column(
|
||||
DateTime, nullable=True, comment="Last login timestamp"
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime, default=datetime.utcnow, nullable=False, comment="First login timestamp"
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_users_display_name", "display_name"),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.modules.auth.schemas import LoginRequest, LoginResponse, LogoutResponse
|
||||
from app.modules.auth.services.ad_client import ad_auth_service
|
||||
from app.modules.auth.services.encryption import encryption_service
|
||||
from app.modules.auth.services.session_service import session_service
|
||||
from app.modules.auth.services.user_service import upsert_user
|
||||
from fastapi import Header
|
||||
from typing import Optional
|
||||
|
||||
@@ -30,10 +31,11 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
|
||||
流程:
|
||||
1. 呼叫 AD API 驗證憑證
|
||||
2. 加密密碼(用於自動刷新)
|
||||
3. 生成 internal token (UUID)
|
||||
4. 儲存 session 到資料庫
|
||||
5. 回傳 internal token 和 display_name
|
||||
2. 儲存/更新使用者資訊到 users 表(用於報告姓名解析)
|
||||
3. 加密密碼(用於自動刷新)
|
||||
4. 生成 internal token (UUID)
|
||||
5. 儲存 session 到資料庫
|
||||
6. 回傳 internal token 和 display_name
|
||||
"""
|
||||
try:
|
||||
# Step 1: Authenticate with AD API
|
||||
@@ -52,10 +54,19 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
detail="Authentication service unavailable",
|
||||
)
|
||||
|
||||
# Step 2: Encrypt password for future auto-refresh
|
||||
# Step 2: Upsert user info for report generation (permanent storage)
|
||||
upsert_user(
|
||||
db=db,
|
||||
user_id=ad_result["email"],
|
||||
display_name=ad_result["username"],
|
||||
office_location=ad_result.get("office_location"),
|
||||
job_title=ad_result.get("job_title"),
|
||||
)
|
||||
|
||||
# Step 3: Encrypt password for future auto-refresh
|
||||
encrypted_password = encryption_service.encrypt_password(request.password)
|
||||
|
||||
# Step 3 & 4: Generate internal token and create session
|
||||
# Step 4 & 5: Generate internal token and create session
|
||||
user_session = session_service.create_session(
|
||||
db=db,
|
||||
username=request.username,
|
||||
|
||||
@@ -31,6 +31,9 @@ class ADAuthService:
|
||||
Dict containing:
|
||||
- token: AD authentication token
|
||||
- username: Display name from AD
|
||||
- email: User email address
|
||||
- office_location: Office location (optional)
|
||||
- job_title: Job title (optional)
|
||||
- expires_at: Estimated token expiry datetime
|
||||
|
||||
Raises:
|
||||
@@ -58,6 +61,9 @@ class ADAuthService:
|
||||
ad_token = token_data.get("access_token")
|
||||
user_info = token_data.get("userInfo", {})
|
||||
display_name = user_info.get("name") or username
|
||||
email = user_info.get("email") or username
|
||||
office_location = user_info.get("officeLocation")
|
||||
job_title = user_info.get("jobTitle")
|
||||
|
||||
if not ad_token:
|
||||
raise ValueError("No token received from AD API")
|
||||
@@ -74,7 +80,14 @@ class ADAuthService:
|
||||
# Fallback: assume 1 hour if not provided
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
|
||||
return {"token": ad_token, "username": display_name, "expires_at": expires_at}
|
||||
return {
|
||||
"token": ad_token,
|
||||
"username": display_name,
|
||||
"email": email,
|
||||
"office_location": office_location,
|
||||
"job_title": job_title,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Authentication failed (401) or other HTTP errors
|
||||
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user