feat: Improve file display, timezone handling, and LOT management

Changes:
- Fix datetime serialization with UTC 'Z' suffix for correct timezone display
- Add PDF upload support with extension fallback for MIME detection
- Fix LOT add/remove by creating new list for SQLAlchemy JSON change detection
- Add file message components (FileMessage, ImageLightbox, UploadPreview)
- Add multi-file upload support with progress tracking
- Link uploaded files to chat messages via message_id
- Include file attachments in AI report generation
- Update specs for file-storage, realtime-messaging, and ai-report-generation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-08 12:39:15 +08:00
parent 599802b818
commit 44822a561a
36 changed files with 2252 additions and 156 deletions

View File

@@ -90,7 +90,7 @@ class GeneratedReport(Base):
)
# Relationship
room = relationship("IncidentRoom", backref="reports")
room = relationship("IncidentRoom", back_populates="reports")
# Indexes
__table_args__ = (

View File

@@ -4,7 +4,18 @@ 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
from datetime import datetime, timezone, timedelta
# Taiwan timezone (GMT+8)
TZ_GMT8 = timezone(timedelta(hours=8))
def _to_gmt8(dt: datetime) -> datetime:
"""Convert datetime to GMT+8 timezone"""
if dt.tzinfo is None:
# Assume UTC if no timezone
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(TZ_GMT8)
INCIDENT_TYPE_MAP = {
@@ -81,11 +92,11 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
created_at = room_data.get("created_at")
if isinstance(created_at, datetime):
created_at = created_at.strftime("%Y-%m-%d %H:%M")
created_at = _to_gmt8(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")
resolved_at = _to_gmt8(resolved_at).strftime("%Y-%m-%d %H:%M")
elif resolved_at is None:
resolved_at = "尚未解決"
@@ -145,7 +156,7 @@ def _format_messages(messages: List[Dict[str, Any]]) -> str:
created_at = msg.get("created_at")
if isinstance(created_at, datetime):
time_str = created_at.strftime("%Y-%m-%d %H:%M")
time_str = _to_gmt8(created_at).strftime("%Y-%m-%d %H:%M")
else:
time_str = str(created_at) if created_at else "未知時間"
@@ -164,26 +175,58 @@ def _format_messages(messages: List[Dict[str, Any]]) -> str:
def _format_files(files: List[Dict[str, Any]]) -> str:
"""Format file attachments section"""
"""Format file attachments section with context
Each file now includes:
- caption: User-provided description when uploading
- context_before: The message sent before this file
- context_after: The message sent after this file
This helps AI understand the context of each attachment.
"""
lines = ["## 附件清單"]
lines.append("每個附件包含上傳時的說明文字以及上下文訊息,幫助理解該附件的用途。")
lines.append("")
if not files:
lines.append("無附件")
return "\n".join(lines)
for f in files:
for i, f in enumerate(files, 1):
filename = f.get("filename", "未命名檔案")
file_type = f.get("file_type", "file")
uploader = f.get("uploader_name") or f.get("uploaded_by", "未知")
caption = f.get("caption") # User-provided description
context_before = f.get("context_before")
context_after = f.get("context_after")
uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime):
time_str = uploaded_at.strftime("%Y-%m-%d %H:%M")
time_str = _to_gmt8(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} 上傳)")
# Basic file info
lines.append(f"### 附件 {i}: {filename}")
lines.append(f"- 類型: {type_label}")
lines.append(f"- 上傳者: {uploader}")
lines.append(f"- 上傳時間: {time_str}")
# Caption/description if provided
if caption:
lines.append(f"- 說明: {caption}")
# Context messages to help AI understand when/why file was uploaded
if context_before or context_after:
lines.append("- 上下文:")
if context_before:
lines.append(f" - 前一則訊息: [{context_before['sender']}]: {context_before['content']}")
if context_after:
lines.append(f" - 後一則訊息: [{context_after['sender']}]: {context_after['content']}")
lines.append("") # Blank line between files
return "\n".join(lines)

View File

@@ -2,7 +2,7 @@
Request and response models for the report generation endpoints.
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ConfigDict, field_serializer
from typing import Optional, List
from datetime import datetime
from enum import Enum
@@ -45,8 +45,12 @@ class ReportStatusResponse(BaseModel):
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@field_serializer("generated_at")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
return dt.isoformat() + "Z"
class ReportListItem(BaseModel):
@@ -57,8 +61,12 @@ class ReportListItem(BaseModel):
status: ReportStatus
report_title: Optional[str] = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@field_serializer("generated_at")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
return dt.isoformat() + "Z"
class ReportListResponse(BaseModel):

View File

@@ -9,8 +9,21 @@ Creates .docx reports using python-docx with:
import io
import logging
from typing import Dict, Any, List, Optional
from datetime import datetime
from datetime import datetime, timezone, timedelta
from docx import Document
# Taiwan timezone (GMT+8)
TZ_GMT8 = timezone(timedelta(hours=8))
def _to_gmt8(dt: datetime) -> datetime:
"""Convert datetime to GMT+8 timezone"""
if dt.tzinfo is None:
# Assume UTC if no timezone
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(TZ_GMT8)
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.style import WD_STYLE_TYPE
@@ -128,11 +141,11 @@ class DocxAssemblyService:
run.font.size = TITLE_SIZE
run.font.bold = True
# Add generation timestamp
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
# Add generation timestamp in GMT+8
timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M")
subtitle = doc.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = subtitle.add_run(f"報告產生時間:{timestamp}")
run = subtitle.add_run(f"報告產生時間:{timestamp} (GMT+8)")
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(128, 128, 128)
@@ -160,19 +173,19 @@ class DocxAssemblyService:
cells[2].text = "發生地點"
cells[3].text = room_data.get("location") or "未指定"
# Row 3: Created and Resolved times
# Row 3: Created and Resolved times (in GMT+8)
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")
cells[1].text = _to_gmt8(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")
cells[3].text = _to_gmt8(resolved_at).strftime("%Y-%m-%d %H:%M")
elif resolved_at:
cells[3].text = str(resolved_at)
else:
@@ -327,13 +340,24 @@ class DocxAssemblyService:
# Add image to document
doc.add_picture(image_data, width=Inches(5))
# Add caption
# Add caption (user-provided description or filename)
user_caption = f.get("caption") # User-provided description
filename = f.get("filename", "圖片")
caption = doc.add_paragraph()
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = caption.add_run(f"{f.get('filename', '圖片')}")
# Show filename first
run = caption.add_run(filename)
run.font.size = Pt(9)
run.font.italic = True
# Add user caption if provided
if user_caption:
caption.add_run("\n")
desc_run = caption.add_run(user_caption)
desc_run.font.size = Pt(10)
doc.add_paragraph() # Spacing
else:
# Image download failed, add note
@@ -344,7 +368,7 @@ class DocxAssemblyService:
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"""
"""Add file attachment list section with captions"""
doc.add_heading("附件清單", level=1)
if not files:
@@ -352,7 +376,7 @@ class DocxAssemblyService:
return
# Create file list table
table = doc.add_table(rows=len(files) + 1, cols=4)
table = doc.add_table(rows=len(files) + 1, cols=5)
table.style = "Table Grid"
# Header row
@@ -361,6 +385,7 @@ class DocxAssemblyService:
header[1].text = "類型"
header[2].text = "上傳者"
header[3].text = "上傳時間"
header[4].text = "說明"
for cell in header:
for run in cell.paragraphs[0].runs:
run.font.bold = True
@@ -382,10 +407,13 @@ class DocxAssemblyService:
uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime):
row[3].text = uploaded_at.strftime("%Y-%m-%d %H:%M")
row[3].text = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M")
else:
row[3].text = str(uploaded_at) if uploaded_at else ""
# Caption/description column
row[4].text = f.get("caption", "") or ""
def _download_file(self, object_path: str) -> Optional[io.BytesIO]:
"""Download file from MinIO
@@ -431,9 +459,9 @@ class DocxAssemblyService:
lines.append(f"# 事件報告:{title}")
lines.append("")
# Generation timestamp
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
lines.append(f"*報告產生時間:{timestamp}*")
# Generation timestamp in GMT+8
timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M")
lines.append(f"*報告產生時間:{timestamp} (GMT+8)*")
lines.append("")
# Metadata section
@@ -455,13 +483,13 @@ class DocxAssemblyService:
created_at = room_data.get("created_at")
if isinstance(created_at, datetime):
lines.append(f"| 建立時間 | {created_at.strftime('%Y-%m-%d %H:%M')} |")
lines.append(f"| 建立時間 | {_to_gmt8(created_at).strftime('%Y-%m-%d %H:%M')} |")
else:
lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |")
resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime):
lines.append(f"| 解決時間 | {resolved_at.strftime('%Y-%m-%d %H:%M')} |")
lines.append(f"| 解決時間 | {_to_gmt8(resolved_at).strftime('%Y-%m-%d %H:%M')} |")
elif resolved_at:
lines.append(f"| 解決時間 | {str(resolved_at)} |")
else:
@@ -561,8 +589,8 @@ class DocxAssemblyService:
if files:
lines.append("## 附件清單")
lines.append("")
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 |")
lines.append("|----------|------|--------|----------|")
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 | 說明 |")
lines.append("|----------|------|--------|----------|------|")
file_type_map = {
"image": "圖片",
@@ -577,10 +605,13 @@ class DocxAssemblyService:
uploader = f.get("uploader_name") or f.get("uploader_id", "")
uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime):
uploaded_text = uploaded_at.strftime("%Y-%m-%d %H:%M")
uploaded_text = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M")
else:
uploaded_text = str(uploaded_at) if uploaded_at else ""
lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} |")
caption = f.get("caption", "") or ""
# Escape pipe characters in caption
caption = caption.replace("|", "\\|")
lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} | {caption} |")
lines.append("")
return "\n".join(lines)

View File

@@ -13,7 +13,7 @@ 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.realtime.models import Message, MessageType
from app.modules.file_storage.models import RoomFile
from app.modules.auth.models import User
@@ -38,9 +38,17 @@ class MemberData:
role: str
@dataclass
class FileContextMessage:
"""Context message near a file upload"""
sender_name: str
content: str
created_at: datetime
@dataclass
class FileData:
"""File data for report generation"""
"""File data for report generation with context"""
file_id: str
filename: str
file_type: str
@@ -49,6 +57,10 @@ class FileData:
uploader_id: str
uploader_name: str
minio_object_path: str
# File context - the description/caption and surrounding messages
message_content: Optional[str] = None # Caption/description from file upload message
context_before: Optional[FileContextMessage] = None # Message before file
context_after: Optional[FileContextMessage] = None # Message after file
@dataclass
@@ -173,7 +185,7 @@ class ReportDataService:
return members
def _collect_files(self, room_id: str) -> List[FileData]:
"""Collect room files with uploader display names"""
"""Collect room files with uploader display names and message context"""
results = (
self.db.query(RoomFile, User.display_name)
.outerjoin(User, RoomFile.uploader_id == User.user_id)
@@ -185,6 +197,31 @@ class ReportDataService:
files = []
for f, display_name in results:
# Get file message content (caption/description)
message_content = None
context_before = None
context_after = None
if f.message_id:
# Get the file's message to extract caption
file_message = self.db.query(Message).filter(
Message.message_id == f.message_id
).first()
if file_message:
# Extract caption (content that's not default [Image] or [File] prefix)
content = file_message.content
if not content.startswith("[Image]") and not content.startswith("[File]"):
message_content = content
# Get context: 1 message before and 1 after the file message
context_before = self._get_context_message(
room_id, file_message.sequence_number, before=True
)
context_after = self._get_context_message(
room_id, file_message.sequence_number, before=False
)
files.append(FileData(
file_id=f.file_id,
filename=f.filename,
@@ -192,12 +229,45 @@ class ReportDataService:
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
uploader_name=display_name or f.uploader_id,
minio_object_path=f.minio_object_path,
message_content=message_content,
context_before=context_before,
context_after=context_after,
))
return files
def _get_context_message(
self, room_id: str, sequence_number: int, before: bool = True
) -> Optional[FileContextMessage]:
"""Get a context message before or after a given sequence number"""
query = (
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))
.filter(Message.message_type.in_([MessageType.TEXT, MessageType.SYSTEM])) # Only text context
)
if before:
query = query.filter(Message.sequence_number < sequence_number)
query = query.order_by(desc(Message.sequence_number))
else:
query = query.filter(Message.sequence_number > sequence_number)
query = query.order_by(Message.sequence_number)
result = query.first()
if result:
msg, display_name = result
return FileContextMessage(
sender_name=display_name or msg.sender_id,
content=msg.content,
created_at=msg.created_at,
)
return None
def to_prompt_dict(self, data: RoomReportData) -> Dict[str, Any]:
"""Convert RoomReportData to dictionary format for prompt builder
@@ -244,8 +314,9 @@ class ReportDataService:
for m in data.members
]
files = [
{
files = []
for f in data.files:
file_dict = {
"file_id": f.file_id,
"filename": f.filename,
"file_type": f.file_type,
@@ -254,9 +325,20 @@ class ReportDataService:
"uploader_id": f.uploader_id,
"uploader_name": f.uploader_name,
"minio_object_path": f.minio_object_path,
"caption": f.message_content, # User-provided caption/description
}
for f in data.files
]
# Add context if available
if f.context_before:
file_dict["context_before"] = {
"sender": f.context_before.sender_name,
"content": f.context_before.content,
}
if f.context_after:
file_dict["context_after"] = {
"sender": f.context_after.sender_name,
"content": f.context_after.content,
}
files.append(file_dict)
return {
"room_data": room_data,