visionA/docs/autoflow/04-architecture/b4-converter-handoff.md
jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

253 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# B4 交接檔:轉檔服務端串接 `analysis_info`input_shape metadata
> **狀態**Draft — 給 `kneron_model_converter` repo 的人實作
> **作者**Architect AgentvisionA
> **最後更新**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 recordRedis |
| 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 目標 JSONGET /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_infoonnx / 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. 跑一個完整轉檔 jobonnx → 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` testjob record 有 `analysis_info` → output 有 top-level `analysis_info`;沒有 → output 為 `null`
- 既有 `getJobs.integration.test.js` 可加一個 casejob 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-14824 個 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 L88visionA 不讀往上傳無害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 那段寫,否則會漏。