DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
14 KiB
Markdown
253 lines
14 KiB
Markdown
# 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 那段寫,否則會漏。
|