# B4 交接檔:轉檔服務端串接 `analysis_info`(input_shape metadata) > **狀態**:Draft — 給 `kneron_model_converter` repo 的人實作 > **作者**:Architect Agent(visionA) > **最後更新**:2026-06-21 > **跨 repo**:本檔在 visionA repo(`docs/autoflow/04-architecture/`),描述的改動全部在 `kneron_model_converter` repo --- ## 1. 目標(一句話) 讓 `kneron_model_converter` 的 **`GET /api/v1/jobs/{id}` response 帶上 top-level `analysis_info` 物件**(含 input_shape / 維度 metadata),使 visionA 能在轉檔完成後顯示 model 的 input_shape。 ### 為什麼這份交接這麼短的工作量 **值已經 parse 出來了,只差「往上串」。** bie worker(`services/workers/bie/core.py`)在量化階段已從 ONNX graph 讀出 `batch_size / channels / height / width` 並放進 `process_bie_core()` 的 return dict 的 `analysis_info` 欄位(L87-93)。但這個 dict 從 worker 回到 consumer 後就被丟掉了 —— consumer 推 done event 時沒帶它,所以一路到 `GET /jobs/{id}` response 都沒有這個資訊。 **visionA 端已全部就緒、等接。** visionA backend 已實作好 `analysis_info` 的解析(`converter_client.go`),frontend 也已實作顯示。轉檔端串好的那一刻,visionA 自動就能顯示,不需要 visionA 再改任何 code。轉檔端**沒串好**時 visionA 維持空白、不報錯(全 optional)。 --- ## 2. 完整鏈路缺口表 資料從 bie worker 一路傳到 `GET /jobs/{id}` response 要經過 4 個檔。目前**第 1 個檔已有值**,後面 3 個檔把值「丟掉」了。 | # | 檔案 | 現況 | 要改什麼 | |---|------|------|---------| | 1 | `services/workers/bie/core.py` | ✅ **已 parse**,`process_bie_core()` return dict 含 `analysis_info`(L87-93) | **不用改**(可選:直接補一個 `input_shape` 陣列欄位,見 §4) | | 2 | `services/workers/consumer.py` | ❌ `_process_message` 拿到 `result`(L206)含 analysis_info,但 `_push_done(job_id, "ok")`(L212)沒帶它;`_push_done`(L175-186)組的 done message 也沒這欄位 | **改**:把 `result["analysis_info"]` 串進 done message | | 3 | `apps/task-scheduler/src/services/doneListener.js` | ❌ 解析 done message 只取 `{ job_id, step, result, reason }`(L102),丟掉 analysis_info | **改**:解析出 `analysis_info`,傳給 `jobService.advanceJob` | | 4a | `apps/task-scheduler/src/services/jobService.js` | ❌ `advanceJob(jobId, completedStage)`(L246)不接收也不持久化 analysis_info | **改**:`advanceJob` 多收一個 analysis_info 參數,寫進 job record(Redis) | | 4b | `apps/task-scheduler/src/routes/v1/jobs.js` | ❌ `serializeJobForResponse(job)`(L151-227)組 GET response body 沒有 `analysis_info` 欄位 | **改**:在 response body 加 top-level `analysis_info`(completed 時帶,否則 null) | > **關鍵理解**:done event 是「stage 完成事件」,bie 是 pipeline 第 2 階段(onnx → **bie** → nef)。analysis_info 是 bie 階段產出的。**bie stage 完成時就要把 analysis_info 持久化到 job record**,這樣即使後面還有 nef 階段、job record 也已經帶著 analysis_info,等到整個 job COMPLETED 後 `GET /jobs/{id}` 就讀得到。 --- ## 3. Result schema(**一字不差對齊 visionA backend 已定案的讀取格式**) visionA backend 在 `GET /api/v1/jobs/{id}` response 的 **top-level `analysis_info` 物件**(與 `input`/`parameters`/`error`/`result_object_keys` 同層)讀 metadata。 ### 3.1 目標 JSON(GET /jobs/{id} response,節錄) ```json { "job_id": "abc-123", "status": "completed", "stage": null, "progress": 100, "input": { "...": "..." }, "result_object_keys": { "...": "..." }, "parameters": { "...": "..." }, "error": null, "analysis_info": { "input_shape": [1, 3, 224, 224], "batch_size": 1, "channels": 3, "height": 224, "width": 224, "classes": ["face", "person"], "framework": "onnx" } } ``` ### 3.2 欄位定義(對齊 visionA `converterAnalysisInfoJSON`) | 欄位 | 型別 | 必填 | 說明 | |------|------|------|------| | `input_shape` | `[]int` | optional | **優先表示法**。NCHW 順序,如 `[1, 3, 224, 224]`。給了這個,visionA 直接用 | | `batch_size` | `int` | optional | **後備**:`input_shape` 缺時用這 4 個維度組 | | `channels` | `int` | optional | 後備維度 | | `height` | `int` | optional | 後備維度 | | `width` | `int` | optional | 後備維度 | | `classes` | `[]string` | optional | 分類標籤,如 `["face", "person"]` | | `framework` | `string` | optional | 來源框架,如 `"onnx"` | ### 3.3 visionA 端對映優先序(已實作、不會變) visionA 收到 `analysis_info` 後,這樣決定 input_shape: 1. `input_shape` 陣列**非空** → 直接用 2. 否則 `batch_size`/`channels`/`height`/`width` 四維**全齊** → 組 `[batch, channel, height, width]`(NCHW) 3. 四維**缺任一** → `nil`(不亂組半套維度,防呆) 4. 都沒有 → `nil` **全 optional**:轉檔端整個 `analysis_info` 不給、或給空物件 `{}`、或給部分欄位,visionA 都不報錯,維持空白顯示。 --- ## 4. 兩種表示法都接受(轉檔端可二選一) bie worker 現在原生輸出的是**拆開的四維**(`batch_size`/`channels`/`height`/`width`,core.py L89-92),沒有 `input_shape` 陣列。 visionA **兩種都接**: | 轉檔端給法 | visionA 處理 | 建議 | |-----------|-------------|------| | (a) 直接給 `input_shape: [1,3,224,224]` 陣列 | 走優先序 #1,直接用 | ✅ **更推薦** — 語意最明確、未來若有非 4 維 shape(如 NLP 模型)也能表達 | | (b) 給拆開的 `batch_size`/`channels`/`height`/`width` 四維 | 走優先序 #2,組成 NCHW | ✅ 也可 — bie worker 現成欄位,改動最小 | **最省事路徑(推薦)**:consumer / doneListener / jobService 在傳遞時,**原封不動把 bie worker 的 `analysis_info` dict 往上傳**(裡面已含 `batch_size`/`channels`/`height`/`width`),最後 `serializeJobForResponse` 直接吐出去。visionA 走後備路徑 #2 即可。如果順手在 core.py 補一個 `input_shape` 陣列欄位更好,但非必要。 > ⚠️ 注意:bie worker 的 `analysis_info` 還有一個 `input_name` 欄位(core.py L88)。visionA 不讀它,無害,可一起往上傳、也可在某一層 drop 掉,都行。 --- ## 5. 各檔要改的細節 > 以下是「建議改法」,實際命名 / 風格依轉檔端 repo 慣例。核心是**讓 analysis_info 一路傳到 job record,再從 GET response 吐出**。 ### 5.1 `services/workers/consumer.py`(檔 #2) `_push_done` 目前不帶 metadata。把 bie 的 analysis_info 串進去: ```python def _push_done(self, job_id, result, reason=None, analysis_info=None): message = { "job_id": job_id, "step": self.stage, "result": result, "completed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), } if reason: message["reason"] = reason if analysis_info: # ← 新增 message["analysis_info"] = analysis_info self.client.xadd("queue:done", {"data": json.dumps(message)}) ``` `_process_message` 成功路徑(L211-212)把 `result` 裡的 analysis_info 帶上: ```python result = self.process_fn(input_paths, output_path, parameters) self._upload_output(job_id, job_dir) logger.info(...) self._push_done(job_id, "ok", analysis_info=result.get("analysis_info")) # ← 改 ``` > 只有 bie stage 的 `result` 有 analysis_info;onnx / nef stage 的 `result.get("analysis_info")` 是 `None`,`_push_done` 的 `if analysis_info` 自然 skip,無害。 ### 5.2 `apps/task-scheduler/src/services/doneListener.js`(檔 #3) L101-102 解析 done message 時多解一個欄位,並傳給 advanceJob: ```js const data = JSON.parse(fields[1]); const { job_id, step, result, reason, analysis_info } = data; // ← 多解 analysis_info if (result === 'ok') { await jobService.advanceJob(job_id, step, analysis_info); // ← 多傳一個參數 } else { await jobService.failJob(job_id, step, reason || 'Unknown error'); } ``` ### 5.3 `apps/task-scheduler/src/services/jobService.js`(檔 #4a) `advanceJob` 多收 analysis_info,寫進 job record(持久化到 Redis): ```js async function advanceJob(jobId, completedStage, analysisInfo) { // ← 多收參數 const job = await getJob(jobId); if (!job) { /* ...既有... */ return; } const currentIndex = STAGES.indexOf(completedStage); if (currentIndex < 0) { /* ...既有... */ return; } recordStageComplete(job, completedStage); // ← 新增:bie stage 帶 analysis_info 時,持久化到 job record // (bie 是中間 stage;先寫進 record,等 job COMPLETED 後 GET 就讀得到) if (analysisInfo && typeof analysisInfo === 'object') { job.analysis_info = analysisInfo; } // ...既有的推進 / COMPLETED 邏輯不變(setJob 會把 job.analysis_info 一起寫進 Redis)... } ``` > 為什麼寫在 `advanceJob` 而非等 COMPLETED 才寫:bie 完成時呼叫的是 `advanceJob(jobId, 'bie', analysisInfo)`,此時 stage 推進到 nef、job 還沒 COMPLETED。把 analysis_info 在這一刻寫進 record,後續 nef 完成走 COMPLETED branch 時 record 已帶著它(`setJob` 是整個 record 序列化寫 Redis,不會掉欄位)。 ### 5.4 `apps/task-scheduler/src/routes/v1/jobs.js`(檔 #4b) `serializeJobForResponse(job)`(L151-227)的 return 物件加一個 top-level `analysis_info`: ```js return { job_id: job.job_id, user_id: job.user_id || null, status: externalStatus, // ...既有欄位... parameters, metadata, analysis_info: // ← 新增 top-level 欄位 job.analysis_info && typeof job.analysis_info === 'object' ? job.analysis_info : null, created_by_client_id: job.created_by_client_id || null, }; ``` > - 放 top-level(與 `input`/`parameters`/`error` 同層),**不要**塞進 `metadata` 或 `parameters`,visionA 讀的是 top-level `analysis_info`。 > - completed 與否都可以吐(visionA 只在 completed 後才查到 metadata;但即使 in_progress 帶上也無害,visionA 全 optional)。建議**有就吐、沒有回 null**,最簡單。 > - 這個 helper 同時被 GET /:id 與 GET 列表(`listJobsHandler`)共用,改一處兩邊都生效。 --- ## 6. 測試建議(轉檔端怎麼驗) ### 6.1 端到端驗(最直接) 1. 跑一個完整轉檔 job(onnx → bie → nef),等到 `status: completed` 2. `GET /api/v1/jobs/{id}`,確認 response **top-level 有 `analysis_info`**,且 `batch_size`/`channels`/`height`/`width`(或 `input_shape`)有實際數值(不是 null / 0) 3. 確認數值與 bie worker log 印的維度一致(core.py L44-47 讀到的值) ### 6.2 單元 / 整合驗(對齊既有 test 風格) - `jobService.advanceJob` test:傳 `analysisInfo` → `getJob` 後確認 `job.analysis_info` 有寫進去;不傳 → `job.analysis_info` 維持 undefined - `serializeJobForResponse` test:job record 有 `analysis_info` → output 有 top-level `analysis_info`;沒有 → output 為 `null` - 既有 `getJobs.integration.test.js` 可加一個 case:job record 帶 analysis_info → GET response 帶 analysis_info ### 6.3 回歸(不要弄壞既有) - onnx / nef stage 的 done event **不帶** analysis_info → `advanceJob` 不應因 undefined 參數報錯(`if (analysisInfo && ...)` guard) - 既有 job record(無 `analysis_info` 欄位)→ GET response 的 `analysis_info` 回 `null`,不報錯 - `failJob` 路徑不受影響(fail 不帶 analysis_info) --- ## 7. 跨 repo 對接點(給轉檔端參考) visionA 端讀取位置(**轉檔端不用改,僅供對照確認 schema 對齊**): | 項目 | 位置 | |------|------| | visionA unmarshal type | `visionA-backend/internal/conversion/converter_client.go` → `converterJobJSON.AnalysisInfo`(L961)/ `converterAnalysisInfoJSON`(L968-976) | | visionA 對映邏輯 | 同檔 `toInputShape()`(L985-998)+ `parseConverterJob`(L1037-1042) | | visionA 既有對齊測試 | `visionA-backend/internal/conversion/converter_client_test.go`(L1343-1482,4 個 case:明確 input_shape / 四維後備 / 部分維度防呆 / 缺 analysis_info) | | visionA struct tag(權威 schema) | `input_shape` / `batch_size` / `channels` / `height` / `width` / `classes` / `framework`(converter_client.go L969-975) | **驗證對齊的最快方法**:把 §3.1 的 JSON 丟進 visionA 的 `parseConverterJob`(或對照 `converter_client_test.go` L1361-1372 的 test fixture),確認 `cj.InputShape == [1,3,224,224]`。轉檔端 GET response 只要長得跟那個 test fixture 一樣,就一定對得上。 --- ## 8. 額外發現 / 注意事項 1. **bie worker 多一個 `input_name` 欄位**(core.py L88):visionA 不讀,往上傳無害,drop 也無害。 2. **bie worker 假設輸入是 4 維 NCHW**(core.py L44-47 直接 index `dim[0..3]`):對非 4 維模型(如某些 NLP / 1D 模型)會 IndexError。這是**既有行為、不在本次交接範圍**,但若未來要支援非影像模型,core.py 這段要加防呆。提醒一下、不必現在處理。 3. **classes / framework 目前 bie worker 沒產**:core.py 的 analysis_info 只有 input_name + 四維,沒有 `classes` / `framework`。visionA 這兩個欄位全 optional,轉檔端要不要補由你決定。若不補,visionA 對應欄位留空,不影響 input_shape 顯示。 4. **done event 的 at-least-once 語意**:doneListener 的 ACK 在 try 內(advanceJob throw 時不 ACK → 重投遞)。`advanceJob` 寫 analysis_info 是冪等的(同一份 record 重寫同樣的 analysis_info),重投遞不會出問題,符合既有設計。 5. **stage 順序**:pipeline 是 `onnx → bie → nef`(jobService.js L46 `STAGES`)。analysis_info 在 bie stage 產出,bie 完成時走的是 `advanceJob`(推進到 nef)而非 COMPLETED branch,所以**一定要在 advanceJob 寫**,不能只在 COMPLETED 那段寫,否則會漏。