跳轉到

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(GoogleEventRawGoogleTaskRaw)目前沒有被 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 形同虛設

  • GoogleEventRawGoogleTaskRaw 定義了完整的 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=Falseerror 欄位包含錯誤訊息。

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