Files
Timeline_Generator/backend/importer.py
beabigegg 2d37d23bcf v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 11:35:29 +08:00

518 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
CSV/XLSX 匯入模組
本模組負責處理時間軸事件的資料匯入。
支援 CSV 和 XLSX 格式,包含欄位自動對應與格式容錯功能。
Author: AI Agent
Version: 1.0.0
DocID: SDD-IMP-001
Related: TDD-UT-IMP-001
Rationale: 實現 SDD.md 定義的 POST /import API 功能
"""
import csv
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any, Optional, Union
import logging
try:
import pandas as pd
PANDAS_AVAILABLE = True
except ImportError:
PANDAS_AVAILABLE = False
from .schemas import Event, ImportResult, EventType
# 設定日誌
logger = logging.getLogger(__name__)
class ImporterError(Exception):
"""匯入器錯誤基礎類別"""
pass
class FieldMapper:
"""
欄位對應器
負責將不同的欄位名稱映射到標準欄位。
支援多語言和不同命名習慣。
"""
# 欄位對應字典
FIELD_MAPPING = {
'id': ['id', 'ID', '編號', '序號', 'identifier'],
'title': ['title', 'Title', '標題', '名稱', 'name', 'event'],
'start': ['start', 'Start', '開始', '開始時間', 'start_time', 'begin', 'time', 'Time', '時間', 'date', 'Date', '日期'],
'group': ['group', 'Group', '群組', '分類', 'category', 'phase'],
'description': ['description', 'Description', '描述', '說明', 'detail', 'note'],
'color': ['color', 'Color', '顏色', 'colour'],
}
@classmethod
def map_fields(cls, headers: List[str]) -> Dict[str, str]:
"""
將 CSV/XLSX 的欄位名稱映射到標準欄位
Args:
headers: 原始欄位名稱列表
Returns:
映射字典 {標準欄位: 原始欄位}
"""
mapping = {}
headers_lower = [h.strip() for h in headers]
for standard_field, variants in cls.FIELD_MAPPING.items():
for header in headers_lower:
if header in variants or header.lower() in [v.lower() for v in variants]:
# 找到原始 header保留大小寫
original_header = headers[headers_lower.index(header)]
mapping[standard_field] = original_header
break
return mapping
@classmethod
def validate_required_fields(cls, mapping: Dict[str, str]) -> List[str]:
"""
驗證必要欄位是否存在
Args:
mapping: 欄位映射字典
Returns:
缺少的必要欄位列表
"""
required_fields = ['id', 'title', 'start']
missing_fields = [f for f in required_fields if f not in mapping]
return missing_fields
class DateParser:
"""
日期解析器
支援多種日期格式的容錯解析。
"""
# 支援的日期格式列表
DATE_FORMATS = [
'%Y-%m-%d %H:%M:%S',
'%Y/%m/%d %H:%M:%S',
'%Y-%m-%d',
'%Y/%m/%d',
'%d-%m-%Y',
'%d/%m/%Y',
'%Y年%m月%d',
'%Y年%m月%d%H:%M:%S',
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%dT%H:%M:%S.%f',
]
@classmethod
def parse(cls, date_str: str) -> Optional[datetime]:
"""
解析日期字串
Args:
date_str: 日期字串或 Excel 日期序列號
Returns:
datetime 物件,解析失敗則回傳 None
"""
if not date_str or (isinstance(date_str, str) and not date_str.strip()):
return None
# 如果是數字Excel 日期序列號),先轉換
if isinstance(date_str, (int, float)):
if PANDAS_AVAILABLE:
try:
# Excel 日期從 1899-12-30 開始計算
return pd.to_datetime(date_str, origin='1899-12-30', unit='D')
except Exception as e:
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
return None
else:
# 沒有 pandas使用標準庫手動計算
try:
excel_epoch = datetime(1899, 12, 30)
return excel_epoch + timedelta(days=int(date_str))
except Exception as e:
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
return None
date_str = str(date_str).strip()
# 嘗試各種格式
for fmt in cls.DATE_FORMATS:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
# 嘗試使用 pandas 的智能解析(如果可用)
if PANDAS_AVAILABLE:
try:
return pd.to_datetime(date_str)
except Exception:
pass
logger.warning(f"無法解析日期: {date_str}")
return None
class ColorValidator:
"""
顏色格式驗證器
"""
# HEX 顏色正則表達式
HEX_PATTERN = re.compile(r'^#[0-9A-Fa-f]{6}$')
# 預設顏色
DEFAULT_COLORS = [
'#3B82F6', # 藍色
'#10B981', # 綠色
'#F59E0B', # 橙色
'#EF4444', # 紅色
'#8B5CF6', # 紫色
'#EC4899', # 粉色
'#14B8A6', # 青色
'#F97316', # 深橙
]
@classmethod
def validate(cls, color: str, index: int = 0) -> str:
"""
驗證顏色格式
Args:
color: 顏色字串
index: 索引(用於選擇預設顏色)
Returns:
有效的 HEX 顏色代碼
"""
# 確保 index 是整數(防止 pandas 傳入 float
index = int(index) if index is not None else 0
if not color:
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
color = str(color).strip().upper()
# 補充 # 符號
if not color.startswith('#'):
color = '#' + color
# 驗證格式
if cls.HEX_PATTERN.match(color):
return color
# 格式無效,使用預設顏色
logger.warning(f"無效的顏色格式: {color},使用預設顏色")
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
class CSVImporter:
"""
CSV/XLSX 匯入器
負責讀取 CSV 或 XLSX 檔案並轉換為 Event 物件列表。
"""
def __init__(self):
self.field_mapper = FieldMapper()
self.date_parser = DateParser()
self.color_validator = ColorValidator()
def import_file(self, file_path: Union[str, Path]) -> ImportResult:
"""
匯入 CSV 或 XLSX 檔案
Args:
file_path: 檔案路徑
Returns:
ImportResult 物件
"""
file_path = Path(file_path)
if not file_path.exists():
return ImportResult(
success=False,
errors=[f"檔案不存在: {file_path}"],
total_rows=0,
imported_count=0
)
# 根據副檔名選擇處理方式
if file_path.suffix.lower() == '.csv':
return self._import_csv(file_path)
elif file_path.suffix.lower() in ['.xlsx', '.xls']:
return self._import_xlsx(file_path)
else:
return ImportResult(
success=False,
errors=[f"不支援的檔案格式: {file_path.suffix}"],
total_rows=0,
imported_count=0
)
def _import_csv(self, file_path: Path) -> ImportResult:
"""
匯入 CSV 檔案
Args:
file_path: CSV 檔案路徑
Returns:
ImportResult 物件
"""
events = []
errors = []
try:
with open(file_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
headers = reader.fieldnames
if not headers:
return ImportResult(
success=False,
errors=["CSV 檔案為空"],
total_rows=0,
imported_count=0
)
# 欄位映射
field_mapping = self.field_mapper.map_fields(headers)
logger.info(f"CSV 欄位映射結果: {field_mapping}")
logger.info(f"原始欄位: {headers}")
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
if missing_fields:
logger.error(f"缺少必要欄位: {missing_fields}")
return ImportResult(
success=False,
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
total_rows=0,
imported_count=0
)
# 逐行處理
row_num = 1
for row in reader:
row_num += 1
try:
logger.debug(f"處理第 {row_num} 行: {row}")
event = self._parse_row(row, field_mapping, row_num)
if event:
events.append(event)
logger.debug(f"成功匯入第 {row_num}")
else:
logger.warning(f"{row_num} 行返回 None")
except Exception as e:
error_msg = f"{row_num} 行錯誤: {str(e)}"
errors.append(error_msg)
logger.error(error_msg)
return ImportResult(
success=True,
events=events,
errors=errors,
total_rows=int(row_num - 1),
imported_count=int(len(events))
)
except Exception as e:
logger.error(f"CSV 匯入失敗: {str(e)}")
return ImportResult(
success=False,
errors=[f"CSV 匯入失敗: {str(e)}"],
total_rows=0,
imported_count=0
)
def _import_xlsx(self, file_path: Path) -> ImportResult:
"""
匯入 XLSX 檔案
Args:
file_path: XLSX 檔案路徑
Returns:
ImportResult 物件
"""
if not PANDAS_AVAILABLE:
return ImportResult(
success=False,
errors=["需要安裝 pandas 和 openpyxl 以支援 XLSX 匯入"],
total_rows=0,
imported_count=0
)
try:
# 讀取第一個工作表
df = pd.read_excel(file_path, sheet_name=0)
if df.empty:
return ImportResult(
success=False,
errors=["XLSX 檔案為空"],
total_rows=0,
imported_count=0
)
# 轉換為字典列表
records = df.to_dict('records')
headers = df.columns.tolist()
# 欄位映射
field_mapping = self.field_mapper.map_fields(headers)
logger.info(f"XLSX 欄位映射結果: {field_mapping}")
logger.info(f"原始欄位: {headers}")
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
if missing_fields:
logger.error(f"缺少必要欄位: {missing_fields}")
return ImportResult(
success=False,
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
total_rows=0,
imported_count=0
)
# 逐行處理
events = []
errors = []
for idx, row in enumerate(records, start=2): # Excel 從第 2 行開始(第 1 行是標題)
try:
event = self._parse_row(row, field_mapping, idx)
if event:
events.append(event)
except Exception as e:
errors.append(f"{idx} 行錯誤: {str(e)}")
return ImportResult(
success=True,
events=events,
errors=errors,
total_rows=int(len(records)),
imported_count=int(len(events))
)
except Exception as e:
logger.error(f"XLSX 匯入失敗: {str(e)}")
return ImportResult(
success=False,
errors=[f"XLSX 匯入失敗: {str(e)}"],
total_rows=0,
imported_count=0
)
def _parse_row(self, row: Dict[str, Any], field_mapping: Dict[str, str], row_num: int) -> Optional[Event]:
"""
解析單行資料
Args:
row: 行資料字典
field_mapping: 欄位映射
row_num: 行號
Returns:
Event 物件或 None
"""
# 輔助函數:安全地轉換為字串(處理 NaN、None、float 等)
def safe_str(value):
if pd.isna(value) if PANDAS_AVAILABLE else (value is None or value == ''):
return ''
# 如果是 float 且接近整數,轉為整數後再轉字串
if isinstance(value, float):
if value == int(value):
return str(int(value))
return str(value).strip()
# 🔍 DEBUG: 顯示原始 row 和 field_mapping
logger.debug(f" Row keys: {list(row.keys())}")
logger.debug(f" Field mapping: {field_mapping}")
# 提取欄位值
event_id = safe_str(row.get(field_mapping['id'], ''))
title = safe_str(row.get(field_mapping['title'], ''))
start_str = safe_str(row.get(field_mapping['start'], '')) # 🔧 修復:也要使用 safe_str 轉換
group = safe_str(row.get(field_mapping.get('group', ''), '')) or None
description = safe_str(row.get(field_mapping.get('description', ''), '')) or None
color = safe_str(row.get(field_mapping.get('color', ''), ''))
# 🔍 DEBUG: 顯示提取的欄位值
logger.debug(f" 提取欄位 - ID: '{event_id}', 標題: '{title}', 時間: '{start_str}'")
# 驗證必要欄位
if not event_id or not title:
raise ValueError("缺少 ID 或標題")
if not start_str:
raise ValueError("缺少時間欄位")
# 解析時間(只有一個時間欄位)
start = self.date_parser.parse(start_str)
if not start:
raise ValueError(f"無效的時間: {start_str}")
# 🔧 修復:將 pandas Timestamp 轉換為標準 datetime
if PANDAS_AVAILABLE:
if isinstance(start, pd.Timestamp):
start = start.to_pydatetime()
# 驗證顏色(確保返回的是字串,不是 None
color = self.color_validator.validate(color, int(row_num))
if not color: # 防禦性檢查
color = self.color_validator.DEFAULT_COLORS[0]
# 所有事件都是時間點類型(不再有區間)
event_type = EventType.POINT
end = None # 不再使用 end 欄位
# 建立 Event 物件
try:
event = Event(
id=event_id,
title=title,
start=start,
end=end,
group=group,
description=description,
color=color,
event_type=event_type
)
# 調試:確認所有欄位類型
logger.debug(f"Event 創建成功: id={type(event.id).__name__}, title={type(event.title).__name__}, "
f"start={type(event.start).__name__}, end={type(event.end).__name__ if event.end else 'None'}, "
f"group={type(event.group).__name__ if event.group else 'None'}, "
f"description={type(event.description).__name__ if event.description else 'None'}, "
f"color={type(event.color).__name__}")
return event
except Exception as e:
logger.error(f"創建 Event 失敗: {str(e)}")
logger.error(f" id={event_id} ({type(event_id).__name__})")
logger.error(f" title={title} ({type(title).__name__})")
logger.error(f" start={start} ({type(start).__name__})")
logger.error(f" end={end} ({type(end).__name__ if end else 'None'})")
logger.error(f" group={group} ({type(group).__name__ if group else 'None'})")
logger.error(f" description={description} ({type(description).__name__ if description else 'None'})")
logger.error(f" color={color} ({type(color).__name__})")
raise
# 匯出主要介面
__all__ = ['CSVImporter', 'ImportResult', 'ImporterError']