feat: Add AI report generation with DIFY integration

- Add Users table for display name resolution from AD authentication
- Integrate DIFY AI service for report content generation
- Create docx assembly service with image embedding from MinIO
- Add REST API endpoints for report generation and download
- Add WebSocket notifications for generation progress
- Add frontend UI with progress modal and download functionality
- Add integration tests for report generation flow

Report sections (Traditional Chinese):
- 事件摘要 (Summary)
- 時間軸 (Timeline)
- 參與人員 (Participants)
- 處理過程 (Resolution Process)
- 目前狀態 (Current Status)
- 最終處置結果 (Final Resolution)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-04 18:32:40 +08:00
parent 77091eefb5
commit 3927441103
32 changed files with 4374 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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