Time Compass — DDD 資料流架構¶
本文件記錄專案中三大整合模組(Google Calendar、Google Tasks、Moodle)的領域模型轉換鏈。 每個箭頭標註負責轉換的函數,每個方框標註所屬的 DDD 層。
全局架構總覽¶
flowchart TD
subgraph 外部資料來源
GC_API["Google Calendar API"]
GT_API["Google Tasks API"]
MD_API["Moodle AJAX API"]
end
subgraph "Request Layer (Transport)"
GC_REQ["ListEventsRequest\nInsertEventRequest\n..."]
GT_REQ["ListTasksRequest\nInsertTaskRequest\n..."]
end
subgraph "Batch API (Infrastructure)"
DISPATCH["batch_execute_async()"]
BATCH_BUILD["build_generic_batch_body()\nmultipart/mixed 封裝"]
BATCH_SEND["send_batch_request_payload()\naiohttp POST"]
BATCH_PARSE["parse_generic_batch_response()\n→ List of {ok, status, data}"]
end
subgraph "Raw Layer (Anti-Corruption)"
GC_RAW["GoogleEventRaw\n(47+ fields, camelCase)"]
GT_RAW["GoogleTaskRaw\n(18 fields, camelCase)"]
MD_RAW["RawEvent / RawCalendarData\n(55+ fields)"]
end
subgraph "Internal Layer (Domain)"
MD_INT["MoodleEvent\n(cleaned datetime, HTML → text)"]
end
subgraph "Read Layer (Application)"
GC_READ["GoogleEventRead\n(datetime objects preserved)"]
GT_READ["GoogleTaskRead\n(Eager-cast to strings)"]
MD_READ["MoodleEventRead\n(8 fields, 語意化 status)"]
end
subgraph "TOON Layer (Presentation)"
GC_TOON["to_toon_calendar() → dict"]
GT_TOON["to_toon_tasklist() → dict"]
MD_TOON["to_toon_moodle() → dict"]
ENCODE["safe_encode() → TOON string"]
end
subgraph "Planner Presentation (BFF)"
PLANNER_NORM["_normalize_calendar_events_for_day()\n(Color Mapping: ID+Summary → 1-8)"]
end
subgraph "MCP Tool Layer (Interface)"
T_CAL["get_all_calendar_events\nget_event_from_calendar\nlist_calendars\ncreate_calendar_event\nget_free_busy"]
T_TASK["get_all_tasks\nlist_tasklists\nget_task_from_tasklist\ncreate_task"]
T_MOODLE["get_moodle_events"]
T_COMP["get_time_context\nlaunch_planner_studio"]
end
GC_REQ -->|"to_http()"| DISPATCH
GT_REQ -->|"to_http()"| DISPATCH
DISPATCH --> BATCH_BUILD --> BATCH_SEND
BATCH_SEND -->|"HTTP Response"| BATCH_PARSE
BATCH_PARSE -->|"raw=true: {ok,data}"| GC_API
BATCH_PARSE -->|"raw=true: {ok,data}"| GT_API
GC_API -->|"JSON dict"| GC_RAW
GT_API -->|"JSON dict"| GT_RAW
MD_API -->|"AJAX JSON"| MD_RAW
GC_API -.->|"from_dict(Dict)"| GC_READ
GT_API -.->|"from_dict(Dict)"| GT_READ
GC_RAW -.->|"※ 目前被 Bypass"| GC_READ
GT_RAW -.->|"※ 目前被 Bypass"| GT_READ
MD_RAW -->|"MoodleEvent.from_raw()"| MD_INT
MD_INT -->|"MoodleEventRead.from_internal()"| MD_READ
GC_READ -->|"to_toon_calendar()"| GC_TOON
GC_READ -->|"expand_recurring_events()\n(Planner Only)"| PLANNER_EXP["Expanded Events"]
PLANNER_EXP --> PLANNER_NORM
GT_READ -->|"to_toon_tasklist()"| GT_TOON
MD_READ -->|"to_toon_moodle()"| MD_TOON
GC_TOON --> ENCODE
GT_TOON --> ENCODE
MD_TOON --> ENCODE
ENCODE --> T_CAL
ENCODE --> T_TASK
ENCODE --> T_MOODLE
T_CAL --> T_COMP
T_TASK --> T_COMP
T_MOODLE --> T_COMP [!IMPORTANT] Google Calendar 和 Google Tasks 的 Raw Model(
GoogleEventRaw、GoogleTaskRaw)目前沒有被 Read 層直接引用。 轉換函數from_dict()接受的是Dict[str, Any](API 回傳的原始 JSON),而非 Raw Pydantic Model。 這意味著 Raw Layer 目前主要作為 API 文檔鏡像,而非資料流的必經節點。
時區與時間格式流動¶
所有時間資料最終統一歸集到 Asia/Taipei (UTC+8),由 time_tool.py 中的共用函數驅動。
核心轉換函數¶
| 函數 | 位置 | 作用 |
|---|---|---|
to_datetime(v) | utils/time_tool.py | 接受任何輸入(RFC3339、UNIX timestamp、date、dict),統一輸出 Asia/Taipei datetime |
format_for_llm(dt) | utils/time_tool.py | 將 datetime 格式化為 YYYY-MM-DD HH:MM(UTC+8,省略秒) |
to_rfc(v, mode) | utils/time_tool.py | 將任何輸入轉為 RFC3339 字串(含 +08:00),用於 API 請求參數 |
詳細流程 → data-flow-timing.md
本頁記錄高層概念,細節實現(如何避免精度流失、為何選擇 datetime 而非 str、週期性事件錨點邏輯等)請參考專門文檔。
各模組時間轉換鏈¶
flowchart LR
subgraph "Google Calendar"
GC_IN["API: RFC3339\n2026-01-15T08:00:00+08:00\n或 date: 2026-01-15"]
GC_TD["to_datetime()"]
GC_FMT["format_for_llm()"]
GC_OUT["Read: 2026-01-15 08:00\n(含 summary+title)"]
GC_TOON["TOON: t=08:00~09:00\nd=15"]
GC_IN --> GC_TD --> GC_FMT --> GC_OUT --> GC_TOON
end
subgraph "Google Tasks"
GT_IN["API: RFC3339 UTC\n2026-01-15T00:00:00.000Z"]
GT_TD["to_datetime()"]
GT_FMT["format_for_llm()"]
GT_OUT["Read: 2026-01-15 08:00\n(UTC+8 字串)"]
GT_TOON["TOON: due=15\ndone_at=15 08:00"]
GT_IN --> GT_TD --> GT_FMT --> GT_OUT --> GT_TOON
end
subgraph "Moodle"
MD_IN["API: UNIX timestamp\n1737007200"]
MD_DT["datetime.fromtimestamp\n(ts, tz=Asia/Taipei)"]
MD_OUT["Internal: datetime\n(UTC+8 物件)"]
MD_FMT["strftime\n(YYYY-MM-DD HH:MM)"]
MD_TOON["TOON: due_d=16\ndue_hm=14:00"]
MD_IN --> MD_DT --> MD_OUT --> MD_FMT --> MD_TOON
end Naive Datetime 處理策略¶
| 情境 | to_datetime() 行為 |
|---|---|
帶 Z 後綴(UTC) | 替換為 +00:00 → 轉 Asia/Taipei |
帶時區偏移(如 +08:00) | 直接轉 Asia/Taipei |
| Naive datetime(無時區) | 預設為 Asia/Taipei(非 UTC) |
Naive date(YYYY-MM-DD) | 視為當天 00:00 (Asia/Taipei) |
| UNIX timestamp | datetime.fromtimestamp(ts, tz=UTC) → 轉 Asia/Taipei |
dict ({dateTime} / {date}) | 提取對應值後遞迴處理 |
Google Tasks API 的
due欄位永遠是T00:00:00.000Z(UTC 午夜),轉換後變為08:00(UTC+8)。 因此GoogleTaskRead.all_day固定為True,測試斷言中須注意此 +8 小時偏移。
重複事件展開策略 (Recurring Events)¶
針對 Planner Studio,我們採用 Backend Expansion 策略: 1. Fetch: API 使用 singleEvents=False 獲取 Master Events (含 RRULE)。 2. Transfer: GoogleEventRead 保留 rrule 原始字串。 3. Expand: 在 planner_utils.py 中使用 dateutil.rrule 進行展開。 - Master: 展開為多個實例。 - Exception: 修改過的實例(原 Master 該日期被抑制)。 - Cancellation: 刪除的實例(原 Master 該日期被移除)。 4. Render: 前端接收已展開且標準化的平坦事件列表。
子文件導覽¶
| 區段 | 文件 | 摘要 |
|---|---|---|
| HTTP Transport | data-flow-transport.md | Batch API 封裝、Request Model 設計、API 原始 JSON 範例 |
| Google Calendar | data-flow-calendar.md | GoogleEventRead 轉換、全天偵測、RRULE、地點索引、list_calendars |
| Google Tasks | data-flow-tasks.md | GoogleTaskRead 轉換、父子關係、去重、list_tasklists Core 層過濾 |
| Moodle | data-flow-moodle.md | 三層 DDD(Raw→Internal→Read)、課程索引、語意化 status |
| 共用 & MCP 輸出 | data-flow-mcp-output.md | safe_encode()、MCP 工具回傳格式表、複合工具、錯誤格式 |
架構觀察與已知問題¶
⚠️ Google 系列:Raw Layer 形同虛設¶
GoogleEventRaw和GoogleTaskRaw定義了完整的 API 結構,但from_dict()接受的是裸dict- 這意味著 Raw Model 並未參與實際資料流,僅作為文檔參考
- 風險:API 欄位變更時不會被 Pydantic 驗證捕獲
✅ Moodle:完整的三層 DDD¶
- Raw → Internal → Read 三層明確分工
MoodleEvent.from_raw()是唯一入口,保證了轉換的一致性- Internal Layer 負責 HTML 清洗、時區轉換等「髒活」
⚠️ 已棄用的 API¶
GoogleEventRead.from_raw()標記為 Deprecated,內部委派給from_dict()或from_raw_model()
📝 維護建議¶
修改模型時,請遵循以下檢查清單: 1. 確認上游(API 回傳格式)是否有變化 2. 確認下游(TOON 輸出格式)是否需要調整 3. 確認時區轉換:新增時間欄位時務必經過 to_datetime() → format_for_llm() 鏈路 4. 更新本文件或對應子文件中的函數說明 5. 執行 uv run pytest tests/unit/ -v 驗證
錯誤處理機制 (Error Handling)¶
Result Pattern (ResourceContext)¶
系統不再使用 Python Exception 作為錯誤傳遞機制,而是採用 Result Pattern: - Partial Failure: 若個別來源(如 Tasks)失敗但其他成功 → ResourceContext 回傳 ok=True,該來源資料為空且 Log 記錄錯誤。 - Total Failure: 若發生頂層嚴重錯誤(如 Auth Token 無效) → ResourceContext 回傳 ok=False,error 欄位包含錯誤訊息。
flowchart TD
API["API Call"] -->|Success| OK["ResourceContext(ok=True)"]
API -->|Partial Fail (e.g. Moodle Timeout)| OK_PARTIAL["ResourceContext(ok=True)\nmoodle=None\nLog Error"]
API -->|Critical Fail (e.g. Auth)| ERR["ResourceContext(ok=False, error='Msg')"]
OK --> MCP["MCP Tool"]
OK_PARTIAL --> MCP
ERR --> MCP_CHECK{"Is Context OK?"}
MCP_CHECK -->|No| RETURN_ERR["Return JSON: {ok: false, error: ...}"]
MCP_CHECK -->|Yes| PROCESS["Process Data"] 測試證據收集 (Evidence Collection)¶
測試層引入 tests.helpers.evidence 模組,用於捕捉並保存真實的 I/O 資料: - 目的: 建立 Regression Baseline,確保重構不改變輸出結構。 - 儲存位置: tests/evidence/{test_file_name}/{test_case_name}.{ext} - 使用時機: 當測試涉及複雜物件結構(如 ResourceContext 或 TOON String)時,必須保存 Evidence。
Runtime 2.0 Data Flow (Passive Viewer Architecture)¶
此架構支援 Browse Mode (動態瀏覽),但 不包含 後端 AI 規劃能力。
flowchart TD
subgraph Frontend [Planner UI]
UI_DATE[Date Navigation] -->|On Change| API_REQ[GET /api/context/fetch]
API_REQ -->|Await| UI_RENDER[Re-render Calendar]
end
subgraph Backend [MCP Server / Runtime]
API_GW[API Gateway\n(planner_routes.py)]
RUNTIME[PassiveRuntime\n(runtime.py)]
PROVIDER[DataProvider\n(integrations)]
API_GW -->|Forward| RUNTIME
RUNTIME -->|Fetch Range| PROVIDER
end
subgraph External [Google / Moodle]
GC[Google Calendar]
GT[Google Tasks]
MD[Moodle]
end
PROVIDER -->|Batch Request| GC
PROVIDER -->|Batch Request| GT
PROVIDER -->|Async Crawl| MD
GC -->|Raw Data| PROVIDER
GT -->|Raw Data| PROVIDER
MD -->|Raw Data| PROVIDER
PROVIDER -->|Combine & TOON| RUNTIME
RUNTIME -->|PlannerPayload| API_GW
API_GW -->|JSON| Frontend