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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user