跳轉到

Time Compass DDD 多層模型架構完整指南

簡介:DDD 分層的理念與價值

Domain-Driven Design (DDD) 強調分離關切點語意一致性。Time Compass 採用的四層模型架構:

  1. Raw Layer (反腐層): 直接映射外部 API 的資料結構,作為系統邊界。
  2. Internal/Domain Layer: 核心業務轉換(清洗 HTML、時區統一、去重、型別正規化)。
  3. Read Layer (應用層): 針對 LLM 消費最佳化的簡化模型,包含計算字段(狀態、壓縮格式)。
  4. TOON Layer (展示層): 極致壓縮格式,減少 Token 消耗達 83.7%,同時保留完整語義。

此分層設計的核心價值: - 邊界隔離: 外部 API 變更不直接衝擊業務邏輯。 - 型別安全: Pydantic 驗證在每層邊界強制契約。 - 效能最佳化: 延遲轉換,在最後一刻(TOON 編碼)進行序列化。 - 可維護性: 新增資料來源時,只需實作四個模型檔案,即可整合到全棧。


資料流層級圖

┌─────────────────────────────────────────────────────────────────┐
│                          外部資料來源                              │
├─────────────────────────────────────────────────────────────────┤
│  Google Calendar API    │  Google Tasks API  │  Moodle AJAX API  │
└───────┬─────────────────┴──────┬────────────┴──────────┬────────┘
        │                        │                       │
        │ JSON Dict              │ JSON Dict             │ JSON Dict
        ▼                        ▼                       ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Raw Layer (反腐層)                           │
├─────────────────────────────────────────────────────────────────┤
│ GoogleEventRaw          GoogleTaskRaw          RawEvent          │
│ (47+ fields, camelCase) (18 fields)           (55+ fields)       │
│ ※ Google 系列目前 Bypass   ※ Google 系列 Bypass  ✓ 直接使用        │
└─────┬──────────────────────┬─────────────────────┬───────────────┘
      │  from_dict()         │  from_dict()        │  from_raw()
      │  (直接使用 dict)      │  (直接使用 dict)      │  
      ▼                      ▼                      ▼
┌─────────────────────────────────────────────────────────────────┐
│                Internal/Domain Layer (業務邏輯層)               │
├─────────────────────────────────────────────────────────────────┤
│ GoogleEventRead         GoogleTaskRead         MoodleEvent       │
│ (datetime 物件保留)      (eager-cast to str)    (HTML清洗,時區轉) │
└─────┬────────────────────┬─────────────────────┬────────────────┘
      │ to_toon_calendar() │ to_toon_tasklist()  │ to_toon_moodle()
      │                    │                     │
      ▼                    ▼                     ▼
┌─────────────────────────────────────────────────────────────────┐
│                  Read Layer (應用層 - LLM最佳化)                │
├─────────────────────────────────────────────────────────────────┤
│ ToonCalendar            ToonTaskList           MoodleEventRead    │
│ - 索引化地點、重複規則  - 按狀態分組          - 8 字段精簡版    │
│ - 日期元件化            - 去重、父子關係       - 語意化狀態      │
└────────────────┬────────────────┬───────────────────────┬────────┘
                 │                │                       │
                 └────────┬────────┴───────────┬──────────┘
                          │ safe_encode()      │
                          ▼                    ▼
┌─────────────────────────────────────────────────────────────────┐
│                   TOON Layer (展示層/傳輸層)                     │
├─────────────────────────────────────────────────────────────────┤
│ TOON 格式字串 (Schema-less Header + 縮排資料)                  │
│ 壓縮率: 83.7% tokens, 90.5% chars                             │
└─────────────────────┬────────────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│              MCP Tool Interface / BFF (外部介面層)              │
├─────────────────────────────────────────────────────────────────┤
│ get_all_calendar_events │ get_all_tasks │ get_moodle_events     │
│ get_event_from_calendar │ list_tasklists│ launch_planner_studio │
│ create_calendar_event   │ create_task   │                        │
└─────────────────────────────────────────────────────────────────┘

各整合模組的模型路徑

1. Google Calendar 模型鏈

層級 模型類別 檔案路徑 職責
Raw GoogleEventRaw integrations/google_calendar/models/models_raw.py 完整 API 映射(47+ 欄位),camelCase
Read GoogleEventRead integrations/google_calendar/models/models_read.py 轉換流程:from_dict(api_dict) → datetime 物件保留
TOON ToonCalendar integrations/google_calendar/models/models_toon.py 索引化地點、重複規則,月份分組,日期元件化

轉換函數鏈:

API JSON  GoogleEventRead.from_dict(raw_dict, calendar_id, calendar_summary)
          (datetime 物件)  safe_encode()  TOON 字串

特殊處理: - 全天偵測: 優先判定 date 欄位存在,否則檢查 endTimeUnspecified - 提醒格式: popup:10m 格式表示提醒方式與分鐘數 - 重複事件: 保留原始 RRULE,在 Planner 層做展開(Backend Expansion)


2. Google Tasks 模型鏈

層級 模型類別 檔案路徑 職責
Raw GoogleTaskRaw integrations/google_tasks/models/models_raw.py 完整 API 映射(18 欄位),camelCase
Read GoogleTaskRead integrations/google_tasks/models/models_read.py Eager-cast 至字串格式,狀態壓縮
TOON ToonTaskList integrations/google_tasks/models/models_toon.py 按狀態/月份分組,父子用 ID 參照

轉換函數鏈:

API JSON  GoogleTaskRead.from_dict(raw_dict, parent_title, tasklist_id, tasklist_title)
          (所有日期欄位 eager-cast 為字串)  safe_encode()  TOON 字串

特殊處理: - 全天預設: Tasks API 的 due 欄位永遠是 T00:00:00.000Z(UTC 午夜),轉為 UTC+8 時自動變成 08:00 - 狀態壓縮: ✓ MM-DDpending(節省 Token) - 去重邏輯: Core 層在 list_tasklists 中過濾重複項


3. Moodle 模型鏈

層級 模型類別 檔案路徑 職責
Raw RawEvent integrations/moodle/models/models_raw.py 原始爬蟲回應(55+ 欄位)
Internal MoodleEvent integrations/moodle/models/models_internal.py HTML 清洗、時區轉換 (UTC+8)、欄位正規化
Read MoodleEventRead integrations/moodle/models/models_read.py 語意化狀態、課程索引參照、8 欄位精簡
TOON (via MoodleEventRead) 同上 JSON → TOON 編碼

轉換函數鏈:

爬蟲 JSON  RawEvent (驗證)
           MoodleEvent.from_raw() (HTML清洗時區轉UTC+8datetime物件)
           MoodleEventRead.from_internal(event, course_idx) (語意化狀態索引參照)
           safe_encode()  TOON 字串

特殊處理: - HTML 清洗: description 欄位自動移除標籤 (<p>, <br> 等) - 時區統一: UNIX timestamp → datetime.fromtimestamp(ts, tz=UTC+8) - 狀態計算: 比對 open_at, due_at, cutoff_at 與當前時間 → Open/Not yet open/Overdue/Closed - 課程索引: c1, c2, c3 等短標籤參照外部索引表


RawModel 規格(API 原始回應)

Google Calendar - GoogleEventRaw

主要欄位 (共 47+): - 基本資訊: id, summary, description, location - 時間資訊: start, end, endTimeUnspecified, originalStartTime - 重複與例外: recurrence, recurringEventId - 參與者: organizer, attendees - 提醒: reminders {useDefault, overrides: [{method, minutes}]} - 會議: conferenceData - 顏色與可見性: colorId, visibility, transparency - 中繼資料: kind, etag, created, updated (RFC3339)

設計決策: - ✓ 完整欄位定義,作為 API 版本變更的早期預警 - ⚠️ 目前 未被 Google 整合直接使用(from_dict 接受裸 dict),僅作文檔參考 - 📌 當需要升級至 "嚴格模式" 時,應改用 GoogleEventRaw.model_validate(data) 驗證


Google Tasks - GoogleTaskRaw

主要欄位 (共 18): - 識別與內容: kind, id, title, notes - 時間欄位: updated, due, completed (RFC3339) - 狀態與關係: status, hidden, deleted, parent, position - 中繼資料: etag, selfLink, webViewLink, links - 複雜結構: assignmentInfo

設計決策: - ✓ Google Tasks API 結構相對簡單,易於驗證 - ⚠️ 同 Calendar,目前未直接被 Read 層引用


Moodle - RawEvent

主要欄位 (共 55+): - 事件識別: id, name, component, modulename, instance - 內容: description (HTML), location, descriptionformat - 時間資訊: timestart, timeduration, timesort, timeusermidnight, timemodified - 課程與群組: course, categoryid, groupid, userid - 狀態資訊: visible, overdue, eventtype - 圖示與訂閱: icon, subscription

設計決策: - ✓ 直接被 MoodleEvent.from_raw() 驗證和引用,是唯一的入口檢查 - 📌 Moodle 爬蟲回應因頁面變更風險高,Raw 層驗證非常重要


ReadModel 規格(業務轉換後的簡化模型)

Google Calendar - GoogleEventRead

核心欄位:

class GoogleEventRead(BaseGoogleRead):
    # 識別與基本資訊
    id: str                                    # 事件 ID
    summary: str                               # 標題
    description: Optional[str]                 # 詳細描述
    title: Optional[str]                       # 相容字段

    # 時間資訊 (datetime 物件,UTC+8)
    start: Optional[datetime]                  # 開始時間
    end: Optional[datetime]                    # 結束時間
    all_day: bool                              # 是否全天
    original_start: Optional[datetime]         # 原始預定時間
    end_time_unspecified: Optional[bool]       # 結束時間未指定

    # 重複與排程
    recurrence: Optional[str]                  # 人類可讀的重複規則
    rrule: Optional[str]                       # 原始 RRULE
    recurring_event_id: Optional[str]          # 父重複事件 ID

    # 日曆與位置
    calendar_id: Optional[str]                 # 來源日曆 ID
    calendar_summary: Optional[str]            # 來源日曆標題
    source: Optional[str]                      # 日曆名稱
    color: Optional[str]                       # 日曆顏色 (Hex)
    location: Optional[str]                    # 地點

    # 提醒
    reminders: Optional[Union[str, Dict]]      # e.g. "popup:10m"

    model_config = ConfigDict(
        arbitrary_types_allowed=True,  # 允許 datetime 物件
        populate_by_name=True,
        extra="ignore"
    )

轉換邏輯 (from_dict): 1. 提取基本欄位 (id, summary, description) 2. 時間轉換: to_datetime(start_raw) → datetime (UTC+8) 3. 全天偵測: date 欄位存在 OR endTimeUnspecified 4. 提醒格式化: {overrides} → "popup:10m" 5. 返回 GoogleEventRead (datetime 物件保留,不序列化)

設計決策: - ✓ datetime 物件保留,符合 Python 型別安全 - ✓ 延遲序列化至 TOON 層,避免多次字串轉換 - ⚠️ Pydantic 模型設定 arbitrary_types_allowed=True 以支援 datetime


Google Tasks - GoogleTaskRead

核心欄位:

class GoogleTaskRead(BaseGoogleRead):
    id: str                               # 任務 ID
    title: str                            # 任務標題
    notes: Optional[str]                  # 詳細說明

    # 時間欄位 (已 eager-cast 至字串,UTC+8)
    due: Optional[str]                    # 截止日期 (YYYY-MM-DD HH:MM)
    completed: Optional[str]              # 完成時間 (YYYY-MM-DD HH:MM)

    # 狀態 (預計算,節省 Token)
    status: str                           # "✓ MM-DD" 或 "pending"

    # 任務列表與層級
    tasklist_id: Optional[str]            # 來源任務列表 ID
    tasklist_title: Optional[str]         # 來源任務列表標題
    parent: Optional[str]                 # "Title (id=TASK_ID)" 或 None

    # 固定字段
    all_day: bool = True                  # Tasks 永遠全天

轉換邏輯 (from_dict): 1. 提取基本欄位 (id, title, notes) 2. 時間轉換 & eager-cast: to_datetime(due) → format_for_llm() → str 3. 狀態計算: completed 存在 → "✓ MM-DD", 否則 "pending" 4. 父任務格式化: parent_id 存在 → f"{parent_title} (id={parent_id})" 5. 返回 GoogleTaskRead (所有時間皆為字串)

設計決策: - ✓ Eager cast 簡化 TOON 層,針對 Tasks 簡單結構的最佳化 - ⚠️ 與 Calendar 層策略的差異體現了「漸進最佳化」哲學


Moodle - MoodleEventRead

核心欄位:

class MoodleEventRead(BaseModel):
    title: str                      # 事件標題
    course_idx: str                 # 課程索引 (c1, c2, c3...)
    status: str                     # Open/Not yet open/Overdue/Closed
    due_date: str                   # YYYY-MM-DD 或 YYYY-MM-DD HH:MM
    description: str                # 截斷至 200 字元
    semester: str                   # 114-1 格式

    # 條件式欄位(只在特定狀態出現,節省 Token)
    open_date: Optional[str]        # 僅 "Not yet open" 狀態
    cutoff_date: Optional[str]      # 僅 "Overdue (Grace period)" 狀態

轉換邏輯 (from_internal): 1. 提取事件標題、課程索引 2. 狀態計算 (根據時間戳) 3. 條件式欄位: 只在相應狀態時才包含 open_date 或 cutoff_date 4. 描述截斷至 200 字元,超過則加 "..." 5. 學期轉換: "114學年度 第 1 學期" → "114-1"

設計決策: - ✓ 8 欄位是精簡與完整性的平衡點 - ✓ 條件式欄位符合 TOON 變長記錄設計 - 📌 課程索引參照外部的 CourseCatalog 索引表


TOON 格式規格與壓縮率分析

TOON 格式特性

Token-Oriented Object Notation (TOON) 是極致壓縮格式,核心特性:

  1. Schema-less Header: 首行定義欄位名稱,後續行直接列值(消除重複 Key)
  2. 外部索引化: 重複值(如地點、重複規則)提取至索引表,內部用短標籤參照
  3. 日期元件化: ISO DateTime 分解為最小單位(日、週幾、時分)
  4. 語義分組: 按月份(日曆)或按狀態(任務)組織資料
  5. 型別推斷: 透過 Header 推斷欄位型別,省略不必要的引號

TOON 格式範例

Google Calendar TOON

# calendar_list[2]{id,summary,color}:
# L1: "會議室 1"  (Location Index)
# R1: "FREQ=WEEKLY;BYDAY=WE"  (Recurrence Index)

calendar_name[10]{id,summary,lid,rid,notes,st_d,st_wd,st_hm,en_d,en_hm}:
  2026-01: (2 events)
    event_1,標題 A,0,R1,"會議筆記",15,3,09:00,15,10:00
    event_2,標題 B,L1,0,"",16,4,14:00,16,15:30

  2026-02: (1 event)
    event_3,標題 C,L1,R1,"待確認",20,2,10:00,20,11:30

解碼說明: - st_d=15: 開始日期為 15 號 - st_wd=3: 開始星期三(1=一, 7=日) - st_hm=09:00: 開始時分 - en_d=15: 結束日期(同一天用數字,不同日期標記為 15+1) - lid=0: 地點索引 0(未指定) - rid=R1: 重複規則索引

Google Tasks TOON

tasklist_name[5]{id,title,due,completed,status}:
  pending: (3 tasks)
    task_1,準備簡報,2026-02-15,0,pending
    task_2,回覆郵件,2026-02-20,0,pending
    task_3,修改程式碼,0,0,pending

  completed: (2 tasks)
    task_4,提交報告,2026-01-30,2026-02-01,✓ 02-01
    task_5,參加會議,2026-01-25,2026-01-25,✓ 01-25

Moodle TOON

moodle_events[8]{title,course_idx,status,due_date,description,semester,open_date,cutoff_date}:
  c1_prog_assign_3,c1,Open,2026-02-20 23:59,"請完成第 3 題程式設計...","114-1",0,0
  c2_quiz_midterm,c2,Not yet open,2026-03-15 10:00,"期中考重點:Ch1-Ch5..","114-1",2026-03-10 00:00,0
  c3_project_final,c3,Overdue (Grace period),2026-02-15 23:59,"期末專題:實作..","114-1",0,2026-02-22 23:59

壓縮效益實測數據

測試資料: - Google Calendar: 188 個事件(含重複、全天、跨年) - Google Tasks: 47 個任務(含父子關係) - Moodle: 10 個課程事件

壓縮結果:

指標 JSON 標準 TOON 格式 壓縮率
字元數 (Chars) 304,082 28,789 90.5%
Token 數 (GPT-4o) 96,772 15,800 83.7%
資訊密度 6.1× -

結論: - 在相同 Context Window 下,TOON 格式允許 AI 處理多出 6.1 倍 的行程資料 - Token 節省達 83.7%,直接降低 LLM API 成本與推理延遲 - 人類可讀性保持良好(層次化 YAML 式縮排)


型別安全與 Pydantic 驗證

漸進式驗證策略

API 原始資料
    ↓ [Raw Layer - 可選]
Pydantic 驗證 (GoogleEventRaw, RawEvent 等)
    ↓ [Read Layer - 強制]
Model.from_dict() / from_raw() [業務邏輯]
    ↓ [檢驗所有欄位都被顯式轉換]
讀取模型 (GoogleEventRead, MoodleEventRead)
    ↓ [TOON Layer - 自動]
safe_encode() [型別感知序列化]
    ↓ [輸出層 - 最終檢查]
MCP 工具回傳

關鍵的 Pydantic 配置

GoogleEventRead & GoogleCalendarListRead:

from pydantic import BaseModel, ConfigDict

class GoogleEventRead(BaseModel):
    id: str
    start: Optional[datetime]
    end: Optional[datetime]

    model_config = ConfigDict(
        arbitrary_types_allowed=True,  # 允許 datetime 物件
        populate_by_name=True,         # alias 相容
        extra="ignore"                 # 忽略 API 額外欄位
    )

MoodleEventRead:

class MoodleEventRead(BaseModel):
    title: str
    course_idx: str
    status: str
    open_date: Optional[str] = None    # 條件式欄位
    cutoff_date: Optional[str] = None

    model_config = ConfigDict(
        extra="ignore",
        from_attributes=True           # 支援 ORM 模式
    )

Field Validators

MoodleEvent 的 HTML 清洗:

from pydantic import field_validator

class MoodleEvent(BaseModel):
    description: str = ""

    @field_validator("description", mode="before")
    @classmethod
    def clean_description(cls, v: Any) -> str:
        """自動清除 HTML 標籤"""
        if not isinstance(v, str):
            return ""
        # 移除 <p>, <br>, &nbsp; 等
        clean = re.sub(r"<[^>]+>", "", v)
        return clean.replace("&nbsp;", " ")

型別安全反面案例與改進

問題(舊實作的型別混亂):

# ❌ 型別混亂
start: Union[datetime, str]  # 何時是 datetime,何時是字串?

改進(現行實作):

# ✓ 清晰的型別約定
start: Optional[datetime]  # 總是 datetime
# 序列化延遲至最後一刻(TOON Layer)
toon_str = safe_encode(event)


繼承與組成的設計決策

繼承層級

BaseModel (Pydantic)
    │
    ├─ BaseGoogleRequest ─────→ ListEventsRequest, InsertEventRequest, ...
    │
    ├─ BaseGoogleRaw ─────────→ GoogleEventRaw, GoogleTaskRaw, ...
    │
    ├─ BaseGoogleRead ────────→ GoogleEventRead, GoogleTaskRead, ...
    │
    └─ BaseModel (直接繼承)
        ├─ MoodleEvent (Internal/Domain)
        ├─ MoodleEventRead (Read Layer)
        └─ CourseCatalog, LocationIndexItem, ...

設計決策詳解

1. 為何使用 BaseGoogleRequest

目的: 統一 Google API 請求的轉換介面。

具體實現(Calendar ListEventsRequest):

class ListEventsRequest(BaseGoogleRequest):
    BATCH_ENDPOINT = "https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events"

    calendar_id: str
    time_min: Optional[str]  # RFC3339
    time_max: Optional[str]
    single_events: bool = False

    def to_http(self) -> Dict:
        """轉換為 HTTP 組件"""
        return {
            "method": "GET",
            "url": self.BATCH_ENDPOINT.format(calendarId=self.calendar_id),
            "params": {"timeMin": self.time_min, "timeMax": self.time_max}
        }

    def parse_response(self, data: Dict) -> PageEnvelope[GoogleEventRaw]:
        """API 返回 {items: [...], nextPageToken?: ...}"""
        return PageEnvelope(
            items=[GoogleEventRaw(**item) for item in data.get("items", [])],
            next_page_token=data.get("nextPageToken")
        )

優勢: - ✓ 統一的批量 API 轉換邏輯 - ✓ 易於新增新的請求類型 - ⚠️ Moodle 並不使用此基類(爬蟲轉換不同)

2. 為何 BaseGoogleRaw 隱含 kindetag

設計意圖: 反映 Google API 的通用欄位規範。

用途: - kind 用於型別識別(雖然通常已知) - etag 用於樂觀更新鎖(insert/update 時需提供)

3. 為何 Moodle 不使用基類,而 Google 系列使用?

原因: - Google APIs 遵循強標準化 → 基類有高價值 - Moodle 是逆向工程爬蟲 → 各字段排版差異大 → 基類價值低 - Moodle 有專屬的 Internal Layer → 獨立演進

4. 為何 PageEnvelope 繼承自 BaseGoogleRaw

class PageEnvelope(BaseGoogleRaw, Generic[TItem]):
    next_page_token: Optional[str]
    items: List[TItem]

設計決策: - parse_response() 的返回型別統一為 PageEnvelope[BaseGoogleRaw] - 這允許單一資源請求和列表請求回應都被統一處理 - ⚠️ 有技術債:PageEnvelope 繼承 BaseGoogleRaw 有語義不清之嫌 - 📌 可改進方案:獨立的 Response 基類取代現行継承

5. 組成 vs. 繼承:索引表設計

組成模式(TOON 層索引化):

class ToonCalendar(BaseModel):
    source: ToonCalendarSource              # 組成
    location_index: List[LocationIndexItem] # 組成
    recurrence_index: List[RecurrenceIndexItem]
    month: Dict[str, Dict[str, List[ToonEvent]]]

實現細節:

def to_toon_calendar(events: List[GoogleEventRead]) -> ToonCalendar:
    """轉換步驟"""
    location_idx, location_map = _build_location_index(events)
    recurrence_idx, recurrence_map = _build_recurrence_index(events)

    month_struct = defaultdict(lambda: defaultdict(list))

    for event in events:
        start_parts = _parse_datetime_parts(event.start)
        toon_event = ToonEvent(
            id=event.id,
            summary=event.summary,
            lid=location_map.get(event.location, 0),
            rid=recurrence_map.get(event.rrule, 0),
            st_d=start_parts["day"],
            # ...
        )
        month_key = start_parts["month"]
        day_key = str(start_parts["day"])
        month_struct[month_key][day_key].append(toon_event)

    return ToonCalendar(
        source=ToonCalendarSource(...),
        location_index=location_idx,
        recurrence_index=recurrence_idx,
        month=dict(month_struct)
    )


資料流與型別檢查點

完整的 Google Calendar 資料流

1. API 取得
   └─ GET /calendar/v3/calendars/{calendarId}/events?timeMin={rfcTime}&...
      → JSON Response: {items: [{id, summary, start: {dateTime}, ...}, ...]}

2. Raw 層驗證 (可選)
   └─ GoogleEventRaw.model_validate(raw_item)
      ✓ 驗證欄位型別、rename camelCase
      ⚠️ 目前被 bypass,直接使用字典

3. Read 層轉換 (必須)
   └─ GoogleEventRead.from_dict(
        raw_dict,
        calendar_id="primary",
        calendar_summary="我的日曆"
      )
      → to_datetime(start_raw)  # RFC3339 → datetime(tz=UTC+8)
      → GoogleEventRead(
          id="event123",
          summary="會議",
          start=datetime(..., tzinfo=UTC+8),
          end=datetime(..., tzinfo=UTC+8),
          all_day=False
        )

4. TOON 層編碼 (最後一刻序列化)
   └─ safe_encode(events_list)
      → json.dumps(events, default=default_serializer)
        * default_serializer 檢測 datetime 物件
        * 呼叫 format_for_llm(dt) → "2026-01-15 09:00"
      → toon_format.encode(clean_data)
        * Schema-less Header: "id,summary,start,end,all_day,..."
        * 日期元件化、地點索引化
        * 結果: TOON 字串

5. MCP 工具輸出
   └─ return safe_encode({"events": toon_calendar})

錯誤處理與型別驗證

ValidationError 捕捉點:

# 1. Raw 層驗證 (若啟用)
try:
    raw_event = GoogleEventRaw.model_validate(api_response)
except PydanticValidationError as e:
    logger.error(f"Raw validation failed: {e}")
    # 決策: fallback 至 from_dict() 直接使用 dict

# 2. Read 層轉換
try:
    read_event = GoogleEventRead.from_dict(api_response, ...)
except (KeyError, TypeError, ValueError) as e:
    logger.error(f"from_dict failed: {e}")
    # 決策: 跳過此事件,記錄故障事務

# 3. TOON 序列化
try:
    toon_str = safe_encode(events)
except Exception as e:
    logger.error(f"safe_encode failed: {e}")
    # 決策: fallback 至標準 JSON
    toon_str = json.dumps(events, default=str)

貫穿各整合的統一設計原則

1. 時間統一:UTC+8 Asia/Taipei

所有模型在通過 Read Layer 時,時間欄位須經由 to_datetime() 轉換為 UTC+8 timezone-aware datetime:

from time_compass.utils.time_tool import to_datetime, format_for_llm, USER_TZ

# USER_TZ = ZoneInfo("Asia/Taipei")

# 轉換範例
rfc3339_str = "2026-01-15T09:00:00+08:00"
dt = to_datetime(rfc3339_str)  # → datetime(2026,1,15,9,0,tz=UTC+8)

unix_timestamp = 1737007200
dt = datetime.fromtimestamp(unix_timestamp, tz=USER_TZ)

# LLM 格式化
llm_str = format_for_llm(dt)  # → "2026-01-15 09:00"

2. 字串清洁:HTML 移除 & 特殊符號轉譯

Moodle HTML 清洗:

def _clean_html(text: str) -> str:
    if not text:
        return ""
    clean = re.sub(r"<[^>]+>", "", text)
    clean = clean.replace("&nbsp;", " ")
    clean = clean.replace("&amp;", "&")
    return clean.strip()

3. ID 與索引參考設計

課程索引參照 (Moodle):

# 外部索引表 (CourseCatalog)
catalogs = [
    CourseCatalog(idx="c1", name="資料結構"),
    CourseCatalog(idx="c2", name="演算法"),
]

# MoodleEventRead 內只保留 course_idx = "c1"
# TOON 編碼時解析 c1 對應的課程名稱

4. 遺漏欄位的策略

層級 遺漏欄位處理 例子
Raw 使用 Optional[type] 允許遺漏 location: Optional[str] = None
Read 使用預設值或 None all_day: bool = False
TOON 條件式欄位或索引 0/empty open_date: Optional[str] = None

延伸閱讀與進一步優化方向

已知技術債

  1. Google Raw Layer 形同虛設
  2. 狀態: ⚠️ Active Issue
  3. 改進方案: 升級所有 from_dict()model_validate() + from_raw()

  4. PageEnvelope 語義不清

  5. 狀態: 📌 Technical Debt
  6. 改進方案: 分離 Response[T]ListResponse[T] 基類

  7. Moodle 缺乏標準化契約

  8. 狀態: ⚠️ Fragile
  9. 改進方案: 增加網頁版本嗅探與回歸測試

潛在優化

  1. TOON 格式版本化 (v1, v2 ...)
  2. 支援格式演進,向後相容

  3. 按需加載索引

  4. 大型日曆只輸出 Hot 月份的完整索引

  5. 增量更新 (Deltas)

  6. 記錄上次 fetch 的 etag,只傳遞變更部分

  7. 草稿模式整合

  8. 將 Planner 生成的草稿事件納入統一模型

總結

Time Compass 的 DDD 四層架構提供了: - 型別安全: Pydantic 在每層邊界強制驗證 - 關注點分離: Raw / Internal / Read / TOON 各司其職 - 性能最佳化: 延遲序列化 + TOON 極致壓縮 (83.7% token 節省) - 易擴展性: 新增資料來源時遵循四層模式即可無縫整合

此架構平衡了完整性 (捕捉所有 API 欄位) 與簡潔性 (LLM 讀取時的極簡化),是建構複雜多源整合系統的參考典範。


參考資源


更新日期:2026年3月2日 | 完整版本(1000+ 行)| Time Compass DDD Architecture Reference