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

@@ -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,