Skip to content

Repository files navigation

護流 CareFlow

CareFlow — AI 照護工作站 你護老,我護你。

香港前線長者照護工作者的 AI 行政助理。系統強制「AI 擬稿、人工複核」工作流:所有 AI 抽取結果都必須經社工確認後才寫入正式檔案,個資錄音稿支援「閱後即焚」。


一、四條流水線

代號 流程 輸入 輸出 狀態
α 志工紙本表 → NGO Excel 多張手填表照片 對應 NGO 模板的 .xlsx ✅ 上線
β 家訪語音 → 結構化報告 粵語錄音 + 模板 結構化 JSON / docx ✅ 上線
γ 政府福利表 → 已填寫 PDF 長者資料 + 表格 已填 PDF ✅ 上線
θ 自訂 PDF 空表 → 可重用模板 任意 PDF 空白表 欄位座標 + bbox 模板 ✅ 上線

每條流水線都進入相同的「左圖右表 / 左音右表」人工審查介面:信心值色標、欄位 bbox 溯源、所有修改寫入 corrections 表用於 prompt 反饋。


二、技術棧

層 選擇
文字 / 推理 LLM DeepSeek-V4-Pro / Flash(OpenAI 兼容,streaming 模式)
視覺 VLM Azure AI Foundry · GPT-5.1(azure-ai-inference SDK,wrapper 自動處理 Foundry projects endpoint)
語音 ASR DashScope · fun-asr / fun-asr-realtime(阿里雲百煉,粵語)
後端 Python 3.12 + FastAPI + SQLModel + Alembic
任務隊列 Celery(背景 LLM / 渲染任務)
資料庫 SQLite(單機部署)
前端 Vite + React 18 + TypeScript + TailwindCSS(古文卷宗設計系統)
Excel openpyxl(保留模板格式 / 合併格 / 公式)
PDF PyMuPDF + Qwen-VL bbox 抽取
加密 Fernet(錄音稿 at-rest 對稱加密 + 閱後即焚)
部署 Docker Compose

三路 LLM client(backend/app/llm/client.py)互相獨立,任一路缺 key 即各自退回 mock,不影響其餘兩路。


三、快速啟動

1. 準備 .env

cp backend/.env.example backend/.env

打開 backend/.env,填入三組 key(缺一可,會自動降級為 mock):

# 文字推理(DeepSeek 官方)
DEEPSEEK_API_KEY=sk-...
DEEPSEEK_BASE_URL=https://api.deepseek.com

# 視覺抽取(Azure AI Foundry · GPT-5.1)
AZURE_OPENAI_ENDPOINT=https://<resource>.services.ai.azure.com/api/projects/<project>
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_DEPLOYMENT=gpt-5.1
AZURE_OPENAI_API_VERSION=2024-05-01-preview
AZURE_OPENAI_MODEL=gpt-5.1

# 語音轉錄(DashScope · fun-asr)
DASHSCOPE_API_KEY=sk-...
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

Azure 端點可直接貼 Foundry「project」URL(/api/projects/<project>)— _FoundryWrapper 會自動降到 resource root 並掛 /models。

2. 本地開發

# 後端
cd backend
uv venv && source .venv/bin/activate
uv pip install -e .
alembic upgrade head
uvicorn app.main:app --reload --port 8000

# 前端
cd frontend
npm install
npm run dev   # http://localhost:5173

3. Docker 部署

docker compose -f docker-compose.deploy.yml up
# 打開 http://localhost:8080

停止:docker compose -f docker-compose.deploy.yml down


四、目錄結構

CareFlow/
├── backend/
│   ├── app/
│   │   ├── main.py              FastAPI 入口
│   │   ├── config.py            pydantic-settings + .env
│   │   ├── db.py                SQLModel + SQLite
│   │   ├── llm/
│   │   │   ├── client.py        三路 client(DeepSeek / Azure Foundry / DashScope)
│   │   │   ├── vision.py        GPT-5.1 視覺抽取(α / θ)
│   │   │   ├── text.py          DeepSeek 文字 / 結構化(β / γ)
│   │   │   └── asr.py           fun-asr 粵語語音
│   │   ├── services/
│   │   │   ├── volunteer_form.py        α 流水線
│   │   │   ├── home_visit.py            β 流水線(含 phase1 抽取)
│   │   │   ├── welfare_form_filler.py   γ 流水線(PDF 半自動填寫)
│   │   │   ├── theta_template.py        θ 流水線(PDF 模板學習)
│   │   │   ├── excel_export.py          openpyxl 寫入
│   │   │   └── pdf_render.py            PyMuPDF 渲染 + 加密
│   │   └── api/
│   │       ├── volunteer.py     α 上傳 / 抽取 / 審查 / 匯出
│   │       ├── home_visit.py    β 錄音上傳 / 抽取 / 焚毀
│   │       ├── welfare_form.py  γ 半自動填寫
│   │       ├── theta.py         θ PDF 模板學習 / 套用
│   │       ├── history.py       歷史 / 篩選 / diff
│   │       ├── elders.py        長者資料 CRUD
│   │       └── diagnose.py      三通道健康檢查
│   └── alembic/                 資料庫遷移
├── frontend/
│   └── src/
│       ├── pages/
│       │   ├── Dashboard.tsx
│       │   ├── VolunteerUpload.tsx / VolunteerReview.tsx
│       │   ├── HomeVisit.tsx / HomeVisitReview.tsx
│       │   ├── WelfareForm.tsx
│       │   ├── ThetaUpload.tsx / ThetaAudit.tsx
│       │   ├── Templates.tsx          NGO Excel / θ 模板管理
│       │   ├── Settings.tsx           三通道偏好
│       │   ├── History.tsx / HistoryDetail.tsx
│       ├── components/
│       │   ├── Layout.tsx             側欄 + skip-link
│       │   ├── DropLabel.tsx          全站拖入元件
│       │   └── StatusStamp.tsx        卷宗風格狀態徽記
│       └── lib/
│           └── visitStatus.ts         STATUS_LABELS / TERMINAL_STATUSES
├── docs/
│   ├── COSTS.md                       NGO 成本估算
│   └── NGO-MEETING-CHEATSHEET.md      面談話術 + Q&A
├── docker-compose.yml
├── docker-compose.deploy.yml
└── backend/.env.example

五、人工審查(強制)

所有 AI 輸出不允許直接落到正式檔。四條流水線共用同一審查模型:

輸入(照片 / 錄音 / PDF)──► AI 抽取(status = pending_review)
                                  │
                                  ▼
            左輸入 / 右表格審查介面
            ├─ 逐欄信心值色標(紅 < 0.7、黃 0.7–0.9、綠 ≥ 0.9)
            ├─ 點欄位看 AI 從何處抽(α / θ 顯示 bbox)
            └─ 任何修改寫入 corrections 表(後續 prompt 改進)
                                  │
                                  ▼
            社工點「確認並用印」(status = confirmed)
                                  │
                                  ▼
            寫入正式檔(Excel / PDF / docx)
            β 錄音稿可隨時「閱後即焚」(Fernet 解密 key 銷毀)

六、改動歷史

v0.4.6-foundry-reasoning · 2026-05-21 HKT(θ 全頁 RemoteDisconnected 修補:reasoning 預算與超時透傳)

切回 gpt-5.1 (Foundry) 後 θ 上傳「GPT 分析全頁」一律報:

('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

挖出兩個串聯 bug:

  1. _FoundryCompletions.create 白名單漏 max_completion_tokens / extra_body: theta_extractor 對 reasoning 模型送 max_completion_tokens=32768 + extra_body={"reasoning_effort": "minimal"},但 wrapper 在 allowed 白名單外的 kwargs 一律 silently drop,導致 gpt-5.1 收到的請求沒有任何 completion 預算上限、也沒有 reasoning_effort hint → reasoning 階段無限發散 → Foundry server 等不到 reply 提前 RST。

    • 修:白名單加入 max_completion_tokens 與 extra_body,並在 _FoundryCompletions 內把它們合併進 model_extras(azure-ai-inference 的 HTTP body 透傳機制),確保 Foundry 端真的拿到 reasoning_effort=minimal + 32k completion cap。
  2. ChatCompletionsClient 用預設 read_response_timeout=300s: 即使預算正確,gpt-5.1 對複雜全頁 PDF 推理仍可能 4–7 分鐘;舊預設 5 分鐘 read timeout 在邊界值會 RST。

    • 修:明確設 connection_timeout=30, read_response_timeout=900(10 分鐘),與 v0.4.6-nginx-timeout 對齊。

驗證:/api/llm/diagnose 三路全綠(text 1.6s / vision gpt-5.1 reply='ok' 2.7s / asr ok)。等使用者實機 retry θ 全頁抽取確認 reasoning_effort + timeout 同時生效。

修改檔:backend/app/llm/client.py、README.md。

v0.4.6-nginx-timeout · 2026-05-21 HKT(demo 504 修補:拉長前端 nginx proxy timeout)

Demo 中前端報 HTTP 504 Gateway Time-out(nginx/1.31.0)。根因:

  • 部署棧 frontend 容器內自帶的 nginx(frontend/nginx.conf)proxy_read_timeout 原本只設 300s(5 分鐘)。
  • 切回 careflow-gpt-5-mini 後,θ 全頁 vision 抽取 / γ LLM mapping 在複雜表單(CSSA 60+ 欄)動輒 5–8 分鐘,nginx 等不到 backend 回 200 就先吐 504。
  • 影響範圍只限部署棧(docker-compose.deploy.yml);本地 dev /tmp/careflow_serve.py 無此 cap。

修:把三個 timeout 一起拉到 10 分鐘,並關掉 response/request buffering 讓 stream 回包能即時推給前端:

proxy_connect_timeout 30s;
proxy_send_timeout    600s;
proxy_read_timeout    600s;
send_timeout          600s;
proxy_buffering       off;
proxy_request_buffering off;

同時補上 /files/ location(後端音檔 / PDF 預覽下載),同樣 10 分鐘上限、buffering off。

修改檔:frontend/nginx.conf、README.md。

注意:要生效須 docker compose build frontend && docker compose up -d frontend,或重新 push wilson54311/careflow-frontend:latest image。

v0.4.5-icloud-ssl · 2026-05-21 HKT(iCloud + Foundry 雙重根因)

使用者在 θ 上傳後前端報 GPT 分析全頁失敗:[X509: NO_CERTIFICATE_OR_CRL_FOUND]。深掘下發現兩個獨立 bug:

  1. iCloud 蠶食 certifi PEM bundle:專案位於 iCloud Drive 同步區,backend/.venv/lib/python3.12/site-packages/certifi/cacert.pem 在 iCloud 「優化儲存空間」下會被換成 stub 或 extended-attrib,導致 OpenSSL load_verify_locations() 失敗。實驗:Homebrew /opt/homebrew/etc/openssl@3/cert.pem 載入 OK;同一 venv 的 certifi PEM 載入 fail。

    • 修:新增 /tmp/careflow_backend.sh 啟動包裝(複製 Homebrew cert.pem 到 /tmp/careflow-cacert.pem,並 export SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE),bypass iCloud。
    • 同一 iCloud 病根之前已對 vite dev / vite build 出手過(用 /tmp/careflow_serve.py 直接 host static dist),這次補上 cert 路徑。
  2. Foundry /models 路徑只認 2024-05-01-preview:使用者把 .env 的 AZURE_OPENAI_API_VERSION 改為 2025-11-13(GPT-5.1 公告版本),但 azure-ai-inference SDK 對 Foundry /models route 帶這 api-version 會 404;同 SDK 對 Azure OpenAI /openai/deployments/<dep> route 卻接受。

    • 修:backend/app/llm/client.py _FoundryWrapper.__init__ 對 Foundry 路徑強制 api_version = "2024-05-01-preview"(無視 env);Azure OpenAI 路徑仍允許 env 覆寫。
    • 三路 diagnose 全綠:text=deepseek-v4-flash ok / vision=gpt-5.1 reply='ok' 2.3s / asr=DNS only ok。
    • 順便 /tmp/careflow_serve.py 加 Cache-Control: no-store proxy header,避免使用者瀏覽器顯示舊的崩潰狀態。

修改檔:backend/app/llm/client.py、/tmp/careflow_backend.sh(新增 launcher)、/tmp/careflow_serve.py(cache header)、README.md。

v0.4.5-foundry · 2026-05-21 HKT(接入 Azure AI Foundry · GPT-5.1)

  • 新視覺後端:從舊的 gpt-5-mini 切到 Azure AI Foundry · GPT-5.1。
  • 修:Foundry projects endpoint 路徑 — 使用者貼進 .env 的 AZURE_OPENAI_ENDPOINT 是 /api/projects/<project> 形式,原 _FoundryWrapper 直接在後面掛 /models 會 404;現在會先剝掉 /api/projects/<project> 段降到 resource root 再加 /models。
  • 修:api_version 寫死 — 原本忽略 AZURE_OPENAI_API_VERSION env 用寫死 2024-05-01-preview / 2024-12-01-preview;現在讀 env 覆寫,缺值才用 default。
  • .env.example / .env 註解更新:標註 Foundry projects URL 可直接貼,並標註目前 Foundry inference 僅支援 2024-05-01-preview(2024-12-01 / 2025-11-13 等會回 BadRequest)。
  • 冒煙驗證:get_vision_client() → gpt-5.1 chat completion 回 'PONG',使用 24 tokens、~500ms。
  • README 清理:移除 v0.1 過時內容(Bailian/Qwen3.6-Plus 視覺、LLM_PROVIDER= 切換、功能 1/3 占位描述),更新為四條流水線(α/β/γ/θ)全部上線的當前狀態,補齊 Foundry/GPT-5.1 / Fernet 焚毀 / DropLabel / StatusStamp / lib/visitStatus.ts 等近期新增模組。

修改檔:backend/app/llm/client.py、backend/.env(local secret,未 commit)、README.md。

v0.4.5-polish · 2026-05-18 HKT

Round 1 — UI 修復(28 項稽核 → 14 項實作)

ID 影響檔 修復內容
UI-1 WelfareForm.tsx 全頁從 raw Tailwind amber-* / rose-* / emerald-* 改為設計系統 token(amber_ink / cinnabar / sage)
UI-3 / UI-4 / UI-22 VolunteerReview.tsx HomeVisit.tsx 三條錯誤橫幅統一 token
UI-5 Settings.tsx ChannelDiag dot:OK→sage / FAIL→cinnabar(原本兩者同色辨識不出)
UI-6 Dashboard.tsx PipelineCard 加 sm:col-span-6 lg:col-span-3
UI-7 / UI-8 History.tsx 篩選列響應式 + 4 個 table-archive 用 overflow-x-auto 包裹
UI-9 ThetaAudit.tsx 4 處硬編碼 #3a6fb5 換成 cinnabar RGB token
UI-13 HomeVisit.tsx HomeVisitReview.tsx 補上 archived 狀態標籤
UI-17 Settings.tsx 刪除無人引用的 dead DiagBlock 函式
UI-25 Dashboard.tsx footer · 周圍空格從雙改單
UI-26 HomeVisit / Templates DropLabel 統一 ⌇ 拖入XXX ⌇ 視覺
UI-27 VolunteerReview.tsx thumbnail badge text-[10px] + AI 補徽記移到左下避免碰撞 + <img loading="lazy">
tailwind tailwind.config.js 補上 amber_ink-700: #6b5310

Round 2 — UX 優化(34 項稽核 → 15 項實作)

ID 影響檔 修復內容
QW-1 VolunteerReview.tsx reviewer localStorage 寫入加 .trim() 守門,防覆蓋舊值
QW-2 VolunteerUpload / HomeVisit / ThetaUpload 標題欄加 autoFocus
QW-3 HomeVisitReview.tsx modal 加 Escape 關閉 + role="dialog" aria-modal="true"
QW-4 History.tsx 篩選欄 300ms debounce 自動觸發;「套用」改名為「重新整理」
QW-5 Templates.tsx 移除 image/* 誤導性 accept
QW-6 ThetaUpload.tsx PDF 20 MB 前端守門 + hint「≤ 20MB · ≤ 30 頁」
QW-7 Dashboard.tsx sessionTime 每秒 tick
QW-8 History.tsx alert() 換 inline cinnabar banner
QW-9 Layout.tsx skip-link「跳至內容」 + <main id="main">
QW-10 VolunteerUpload.tsx 清空按鈕 > 3 張時 confirm
MW-1 VolunteerReview.tsx 輪詢超時 banner 加「立即重試」按鈕
MW-2 Templates.tsx mapping 自動儲存後 2 秒「✓ 已儲存」徽記
MW-3 History.tsx pipeline tab 持久化到 URL `?p=alpha
MW-4 Settings.tsx toggle 後顯示「✓ 已儲存於本機」2 秒
MW-5 WelfareForm.tsx 「清除手改」按操作數 confirm

Round 3 — Bug 清理 + 結構優化(30 項稽核 → 17 項實作)

ID 影響檔 修復內容
F-1 ThetaAudit.tsx BboxCanvas 重大:fields.find((_, i) => globalFieldIndex + i === globalIdx) 修為 fields[globalIdx - globalFieldIndex],解決拖框 A 卻改動框 B 的資料污染(前 audit 留 TODO 本輪解決)
F-2 ThetaAudit.tsx addField 用 functional setter 解決雙擊 race
F-3 HomeVisitReview.tsx 輪詢 effect 永遠註冊 cleanup,去掉 timer ref
F-4 HomeVisitReview.tsx burn() 加 catch + reload 驗證焚毀狀態
F-5 HomeVisit.tsx 輪詢以 useMemo(hasActive) 為依賴,解決 polling restart storm
F-6 HomeVisitReview.tsx draft 重新植入用 seededRef 取代 Object.keys(draft).length === 0
F-7 Templates.tsx updateMapping 加 try/catch
F-8 VolunteerReview.tsx polling 失敗用 amber soft-banner(不再卡 5 分鐘)
F-9 Layout.tsx 加 NavItem / NavSection 型別,刪除 as any + dead disabled 分支
F-10 ThetaAudit.tsx 刪除無用 imgRef
F-11 home_visit.py 刪除無用 _MOCK_SAMPLES_DIR 常數
F-12 visitStatus.ts(新)StatusStamp.tsx(新) 結構重構:6 個檔案的重複 STATUS_LABELS 抽到 lib/;StatusStamp 從 pages/Dashboard 搬到 components/
F-13 theta.py pdf_full.unlink + unpublish_theta_template 失敗從靜默改為 logger.warning(exc_info=True)
F-14 vision.py 3 處 bare except Exception: 加 logger
F-15 welfare_form_filler.py 2 處 fallback 例外加 logger
F-16 services/home_visit.py run_phase1 在 status mutation 前 db.refresh(s) 避免 detached ORM clobber
F-17 elders / home_visit / theta / volunteer POST 4 個 create endpoint 加 status_code=201 符合 REST 慣例

驗證:

  • 前端 build:dist/assets/index-CGxLruxF.js(286 KB / gzip 88 KB),get_errors 全清
  • 後端 smoke import:51 routes 正常載入
  • 共修改 23 個檔案,新增 2 個檔案(StatusStamp.tsx、visitStatus.ts)

推遲下輪(已記錄):

  • ConfirmDialog / Modal 共用元件(取代 6 處 native confirm/alert/prompt)
  • ToastHost 全站通知系統
  • ThetaAudit BboxCanvas 用穩定 _uid 取代 index 對映(架構級改寫)
  • LLM 長任務 SSE 進度條 + abort 按鈕
  • 頁面大型化拆分(VolunteerReview 668 行、vision.py 666 行等 5 個檔)
  • Constants 集中化、StatusStamp 之外的命名/錯誤模式統一
  • openapi-typescript 自動生成前端 type

v0.4.5-doc2 · 2026-05-18 HKT(cheatsheet 加 Q&A 範例)

docs/NGO-MEETING-CHEATSHEET.md 第十一章追加「Q&A 示例(普通话)」共 20 題,分 7 組:A 隱私安全、B 準確性責任、C 學習培訓、D 商業可持續、E 系統整合、F 試行細節、G 神態/價值觀。每題附現場可直接念的回答稿,並標註回答原則。

v0.4.5-doc · 2026-05-18 HKT(NGO 面談 cheatsheet)

新增 docs/NGO-MEETING-CHEATSHEET.md:30 分鐘實地面談用單頁速查表。內容含開場 30 秒、信件三大用例對應到 α/β/γ/θ 模組、10 分鐘 demo 黃金路徑、隱私 Q&A、成本子彈(引用 COSTS.md)、五個必問問題、五種常見反對 + 拆解話術、面談後 24h 跟進 checklist、收 cue 結尾稿。

v0.4.5-ux2 · 2026-05-18 HKT(工作台版位轉正 + NGO 成本估算文件)

  • 工作台 Dashboard.tsx β 家訪 / γ 福利表 由「下輪實作」/「第三輪」改為「現行版本」+ stamp-red 主視覺,反映兩條流水線早已上線可用
  • 新增 docs/COSTS.md:給 NGO 採購對話用的營運成本估算
    • 單價對照:DeepSeek Flash / Pro(用戶提供之公告價)、GPT-5-mini、GPT-4.1-mini、DashScope Fun-ASR
    • 每案成本:α ~$0.0094 / β ~$0.054 / γ ~$0.0041 / θ ~$0.0064(USD)
    • 中型 NGO 月度情境:~$4 USD / ~HK$32 / 月
    • 大型 NGO 全機構年度情境:~$243 USD / ~HK$1,890 / 年
    • 含 What-if 敏感性分析 + 雲端基礎設施成本 + NGO 對話要點

v0.4.5-ux · 2026-05-18 HKT(全站上傳支援拖拽)

新增可重用元件 DropLabel.tsx,將原本只能點擊選檔的 4 個位置升級為「拖拽 + 點擊」雙模式:

流水線 位置 檔案
β 家訪 錄音檔上傳 HomeVisit.tsx
β 家訪 報告模板 .docx HomeVisit.tsx
γ 福利表 照片來源 WelfareForm.tsx
02 模板 xlsx 上傳 Templates.tsx

α 志工紙本(VolunteerUpload.tsx)與 θ 自訂 PDF(ThetaUpload.tsx)原本已內聯實作拖拽,本輪未動以避免無謂回歸。

DropLabel 行為要點:

  • 統一處理 onDragOver / onDragLeave(過濾 child bubbling)/ onDrop
  • 拖拽時自動套用高亮 className(draggingClassName)
  • 支援 multiple 與可選 acceptFile 過濾函式(拒絕不合規檔案靜默忽略)
  • 點擊 label 仍可呼出原生選檔器,向後相容

v0.4.5-audit · 2026-05-18 HKT(前後端深度審計 + 修復)

部署 Opus 4.7 audit subagent(read-only)對前後端做了全面靜態分析,共 35 個 specific findings;再用第二輪 Opus 4.7 fix subagent 修復其中 17 個高優先度問題(critical / high / 部分 medium)。剩餘 deferred 項已在程式碼留 // TODO(audit-v0.4.5) 標記。

後端修復(10 個檔案改動 + 1 個 deprecated 檔刪除)

ID 嚴重度 問題 修復
B Critical /api/files/{path} 雖然擋了 path traversal,但 Fernet transcript 金鑰 .transcript_key 和 SQLite DB careflow.db 都在 data_path 內,任何未授權客戶端可 GET 下載 main.py 加 _ALLOWED_FILE_SUBDIRS 白名單 + _FORBIDDEN_FILE_TOKENS + 拒絕任何 dotfile 起頭的 segment
C Critical home_visit.py 的 BackgroundTask 拿 request-scoped DB session(請求結束就關),背景任務寫入 closed session home_visit.py 加 _bg_run_phase1(session_id, *, force_mock=False) wrapper,內部 with Session(engine) as s 開新 session(mirror volunteer.py 既有作法)
D High welfare_form_filler.py / welfare_form.py / welfare_form_templates.py 各自用 Path(__file__).parent.parent.parent / "data",與 settings.data_path(由 CWD 解析)發散,從不同 cwd 起 uvicorn 會破壞下載 URL 三檔統一改用 settings.data_path,downstream 命名 PDF_SOURCE_DIR / OUTPUT_DIR / MOCK_ELDER_PATH / TEMPLATE_DIR 維持
E High theta.update_template 先 session.delete 全部舊欄位再 session.add 新欄位,commit 前若 raise 會留下半填狀態 theta.py 包 try/except → session.rollback() / raise
F High theta_extractor.py / llm/vision.py 的 _log_event 把 LLM 原文片段 raw_preview=raw_text[:240] 寫進 logs/vision.log,含 HKID / 姓名 / 地址 — 繞過 transcript_vault 隱私設計 兩處 raw_preview / raw_sample 移除,改記 raw_len=len(raw_text);text.py 審視後無需改動
G Medium placeholder.py × 3 + theta.py × 1 出現 HTTPException(500, f"...: {e!r}") — 把完整 Python traceback 含內部路徑回給 client 改用 logger.exception(...) 內部記錄 + 對外回 "internal error" 靜態訊息;加 logger = logging.getLogger(__name__)
H Medium volunteer_form.run_extraction 在 EXTRACTING 後若 raise,狀態永遠卡在 EXTRACTING 無自動回復 包 try/except → session.rollback() / batch.status = FAILED / commit / logger.exception / raise(BatchStatus.FAILED 已存在 enum)
I Cleanup llm/azure_vision.py 自註 [DEPRECATED v0.4.0-rc6] not called by any main flow 240 行 grep 全 backend 無 .py import 後刪除

Deferred(已記錄但本輪未動):所有端點仍無 auth/authz(hackathon scope 取捨,需上線前補)、θ upload 同步跑 vision pipeline(需改 BackgroundTask + 前端 polling)、ASR polling 同步阻塞(需改 asyncio)。

前端修復(6 個檔案改動)

ID 嚴重度 問題 修復
J Critical api.ts request() 無 timeout — 任何 LLM 上游卡死整個 UI 永遠不解 加 AbortController + 預設 60s timeout(per-call timeoutMs 可覆寫),abort 後 throw new Error("timeout")
K Medium / OWASP A09 同檔 throw new Error(\HTTP ${status}: ${text}`)` 把後端 traceback 直接灌進紅色 banner 改用 friendly message(依 status 5xx/404/401 分流)+ err.status / err.body 屬性供 debug;raw body 只記 console.debug
L Critical WelfareForm.tsx previewWelfareMapping race — 快速切換模板讓舊 response 蓋過新的 加 let cancelled = false flag + cleanup function 棄掉過時 response
M High 同檔 listWelfareTemplates() 無 .catch,後端掛掉時 list 永遠 null 卡住 加 .catch(setErr)
N High URL.createObjectURL 只在下一次 pick 時 revoke,路由切走會洩漏 blob 加 unmount cleanup useEffect revoke
O High HomeVisit.tsx setInterval(reload, 4000) 即使所有 session 都已 confirmed/burned 仍永久 poll;背景分頁也照打 加 TERMINAL_STATUSES set + document.visibilityState 監聽,閒置不 poll;catch 改 set pollError + 頂部「🔌 連線中斷」stamp
P High VolunteerReview.tsx 2s polling 無 max-attempt,後端 stuck 時客戶端永遠打 useRef 計數 150 次(5 min)上限 + 「拉取超時」banner
Q Medium ThetaAudit.tsx addField 用 Date.now() 當 fallback key — 同 ms 兩次加會撞 改 crypto.randomUUID().slice(0,8) + Math.random fallback
R Medium ThetaAudit + Templates.tsx 用 key={i} 在會 reorder/delete 的清單上 — 選取高亮會黏錯 row 改用 ${key}-${page} / ${header}-${i} 穩定 key

Deferred(已在程式碼留 // TODO(audit-v0.4.5) 標記):

  • ThetaAudit.tsx:322 BboxCanvas 用 globalFieldIndex + i === globalIdx 重建 index — 跨頁 add 後會誤指其他欄位,需 canvas API 改造
  • ThetaAudit.tsx:45 addField setSelectedFieldIdx 用 pre-update fields.length,stale closure,需配合 functional setter 改造

git 衛生

.gitignore 補上 backend/data/careflow.db* / backend/logs/ / frontend/dist/ / *.pyc / __pycache__/ / .DS_Store 等 patterns,並 git rm --cached 掉先前誤 commit 的 careflow.db + vision.log(檔案保留在 disk)。

驗證

  • 後端 smoke import:from app.main import app → 51 routes 正常註冊,0 errors
  • 前端 build:npm run build 通過,新 asset dist/assets/index-BV4Eaatz.js(283.16 kB / gzip 87.07 kB)
  • 兩輪 audit 共識別 35 finding;本輪修 17 個,deferred 5 個(含 2 個高度涉及 canvas 重構的 ThetaAudit bug 與 auth 整體設計);其餘為純風格性建議未動

v0.4.5-ui · 2026-05-18 HKT(前端整合:θ 入口、Gamma×Theta、歷史全流水線、Alpha tooltip 清理)

跟隨 v0.4.5 後端速度優化的前端跟進。四項用戶反饋一次到位:

# 任務 改動
1 α 移除 Qwen Vision fallback 噪音 VolunteerReview.tsx:刪掉「Qwen Vision 在此照片失敗 / 返回空白,已自動切到 Azure OpenAI GPT-5-mini fallback」橫幅。fallback 機制仍在,但不再對社工製造焦慮 UI。
2 sidebar 缺 θ 入口 Layout.tsx:「處理流水線」分組加入 { to: "/theta/upload", label: "自訂 PDF 表 → 模板", num: "θ", primary: true },與 α / β / γ 並列。
3 Gamma 應該可以使用 Theta 模板 後端原本透過 theta_publish.py 已自動把 θ 確認過的模板寫成 data/form_templates/theta_<id>.json 讓 γ 撿到,但前端列表沒有任何視覺區分,混在預設套組裡。本輪 WelfareForm.tsx 改為兩段式:上方「自訂模板 θ」帶 cinnabar θ 徽記 + 說明,下方「預設套組」原樣保留;空狀態時提示前往 /theta/upload。頂部文案也點明 θ 整合。
4 歷史記錄應包含所有流水線成果 History.tsx 從單管線清單升級為三 tab:「α 志工紙本」(原有 batches + AI 修正回報)/「β 家訪語音」(呼叫 listVisitSessions() 顯示 status / model / latency / 下載連結)/「θ 自訂 PDF 模板」(呼叫 listThetaTemplates() 顯示頁數 / 欄位數 / status / 進入審查連結)。每個 tab 標題帶筆數計數。
— 版本徽記 Layout.tsx sidebar 從 CAREFLOW · v0.4.0 → v0.4.5,與 backend 同步。

範疇控制:本輪只動前端 + README,無後端 schema 改動,不需 alembic migration。Theta-to-Gamma 的 PDF 填寫路徑沿用既有 theta_publish + welfare_form_filler.coord_anchor 策略,無新後端端點。


v0.4.5 · 2026-05-18 HKT(β 速度優化:1 分鐘音頻處理時間 7.5 min → 2 min,~3.7× 加速)

問題:v0.4.4 已解決連線中斷問題,session 13 成功完成,但總耗時 7.5 分鐘處理 1 分鐘音頻,用戶體感不可接受。

診斷(/tmp/retry13.log,逐步計時):

步驟 v0.4.4 耗時 主因
ASR (DashScope) 20 s 正常
analyze_template_contract 449 s deepseek-v4-pro 是 reasoning model(5-10 分鐘)+ verbose schema 強制 24 KB JSON 輸出
generate_slot_content 78 s 同上
Total ~7.5 min

Root cause:兩層放大效應。

  1. deepseek-v4-pro 為 reasoning 模型,在大 prompt 下會花 5-10 分鐘「思考」(與輸出 token 數成線性相關)。
  2. template_analysis_prompt.txt 舊版 schema 要求 echo fixed_blocks.text、document_blueprint、warnings、formatting_policy,外加 12 個 slot 欄位(含 nearest_heading / left_neighbor / generation_instruction),實際下游 renderer 只用到 dynamic_slots[].slot_id、source_block_id(s)、replacement_unit。60% 的輸出 token 是廢的。

修法(4 個變更,可獨立 rollback):

  1. 切換預設模型:backend/.env + backend/.env.example + backend/app/config.py 由 deepseek-v4-pro → deepseek-v4-flash(非 reasoning,相同 JSON 質量 ~4× 快)。獨立 benchmark:同 prompt 下 flash 60 s / pro 449 s。
  2. 精簡 system prompt:backend/app/services/visit_note_agent/prompts/template_analysis_prompt.txt 由 7468 B → 3665 B(−51%)。新 schema 只保留 8 個 slot 欄位:slot_id、source_block_id、current_text (≤60 字)、label_hint、expected_type、target_length、replacement_unit、content_purpose (≤12 字)、rendering_rules。fixed_blocks 改為 fixed_block_ids(只回 ID 陣列,不 echo 文字)。
  3. 壓縮 user message JSON:llm_client.py 兩處 json.dumps(..., indent=2) → json.dumps(..., separators=(",",":")),輸入 payload 縮小 ~30%。
  4. 驗證雙 schema 向後相容:analyze_template_contract 同時接受 fixed_block_ids(新)或 fixed_blocks(舊),自動合成另一個欄位給下游 renderer,可隨時 rollback prompt 不需改 code。

實測(同一份 session 13 音頻 + 模板,/tmp/retry13b.log):

步驟 v0.4.4 v0.4.5 改善
ASR 20.0 s 17.3 s 一致
analyze_template_contract 449 s 84 s 5.4×
generate_slot_content 78 s 22 s 3.5×
Total ~7.5 min ~2 min 3.7×

動到的檔案:

  • backend/.env / backend/.env.example:DEEPSEEK_TEXT_MODEL=deepseek-v4-flash。
  • backend/app/config.py:deepseek_text_model / llm_text_model 預設改為 deepseek-v4-flash。
  • backend/app/services/visit_note_agent/prompts/template_analysis_prompt.txt:完整重寫精簡 schema。
  • backend/app/services/visit_note_agent/llm_client.py:JSON 緊縮 + 雙 schema 接受。

結論:DeepSeek 端到端延遲由輸出 token 數主導。精簡回應 schema 是最高槓桿優化(高於 timeout / retry / 模型切換單做任一)。

遺留待辦:若仍想壓到 60 s 內,下一步可加 template-hash level cache(同模板的 contract 只算一次,每次新訪談只跑 slot_gen),預期再降 ~70 s。


v0.4.4 · 2026-05-18 HKT(β 文字 LLM 終極修法:streaming 模式繞過 DeepSeek 伺服器 60s idle timeout)

問題:v0.4.3 後實測 session 13 仍 fail LLM API call failed after 3 retries: Connection error.,且我方應用層 retry 沒救到。

Root cause(實測還原):

  • 直接打 DeepSeek 真實 session 13 模板(structural_map 13.6 KB,39 blocks)→ 非串流模式 61.7s 後伺服器主動斷線(與我方 connect/read timeout 無關)。
  • 改用 stream=True 同樣請求 → 449.8s(~7.5 分鐘)成功完成,回傳 27,219 字元 JSON contract。
  • 結論:deepseek-v4-pro 是 reasoning model,在重模板任務上需 5–10 分鐘思考;DeepSeek 伺服器對「無 data chunk 出去」的閒置 HTTP 連線約 60s 強制 close,非串流必然失敗。
  • v0.4.3 看到的 Connection error 不是網路 transient,是伺服器主動 RST。應用層怎麼 retry 都救不回,每次都在 60s 撞牆。

修法:

  1. app/services/visit_note_agent/llm_client.py::_chat_json — 一律 stream=True,迴圈累加 chunk.choices[0].delta.content,最後 join。SSE 框架持續送 chunk 維持連線 keep-alive,徹底繞過伺服器 idle timeout。
  2. app/llm/client.py::get_text_client — read_timeout 60 → 900s,max_retries 從 4 改 0(SDK 內建 retry 對 stream 無效;應用層 retry 已足夠)。
  3. v0.4.3 的 outer retry 邏輯保留 —— 仍可救 TLS / DNS 真實 transient 失敗。

驗證:以真實 session 13 模板(13,594 bytes payload)直接打 DeepSeek-v4-pro,streaming 模式 7.5 分鐘穩定完成,逐 5 秒輸出進度。

衍生 finding:前端 /api/home-visit/sessions POST 走 FastAPI BackgroundTasks,瀏覽器只發起任務後立刻回應,前端輪詢 session 狀態取結果 —— 7.5 分鐘長思考無前端 timeout 問題。


v0.4.3 · 2026-05-18 HKT(β 文字 LLM 連線韌性:retry + backoff 處理 DeepSeek 暫態 APIConnectionError)

問題:ASR v0.4.2 修好後實測 session 13 再失敗,DB ai_error 寫入 LLM API call failed: Connection error.。模型 deepseek-v4-pro 與 endpoint api.deepseek.com 並無設定錯誤;事後 heavy-payload smoke test 18.6s 內成功回 JSON,證實是 transient network/TLS handshake 失敗(DeepSeek 高峰偶發)。

修法(兩處):

  1. app/llm/client.py::_make_client — 將 OpenAI SDK 內建 max_retries 從預設 2 提升到 4,connect timeout 由 10s → 15s。所有三路(text / vision via OpenAI v1 / asr OpenAI-compat)共享。
  2. app/services/visit_note_agent/llm_client.py::_chat_json — 在 SDK retry 之上再加一層 應用層 exponential-backoff,專門捕捉 openai.APIConnectionError / APITimeoutError:最多重試 3 次(1s → 2s → 4s 間隔),其他例外保持原本 fast-fail 行為(如 401 / 400 / response_format 不支援等不應重試)。錯誤訊息含 after 3 retries 方便診斷。

驗證:_chat_json 直接 smoke 通過(7.1s 完成)。後續真實 /home-visit 上傳將驗證 e2e 鏈:ASR → 模板分析 → 槽位生成 → DOCX 渲染。


v0.4.2 · 2026-05-17 HKT(β ASR fix v2:bypass SDK Transcription,改 raw REST + X-DashScope-OssResourceResolve header)

問題:v0.4.1 修好 404 後實測仍 fail 在 FILE_DOWNLOAD_FAILED —— DashScope 服務端無法從 SDK 上傳產生的 oss://dashscope-instant/... URL 下載音檔。

Root cause(文檔證實):

使用 SDK 时,若录音文件存储在阿里云OSS,不支持使用以 oss:// 为前缀的临时 URL。
使用 RESTful API 时,支持使用以 oss:// 为前缀的临时 URL(需 header X-DashScope-OssResourceResolve: enable)。

亦即:SDK upload_file 上傳到的桶確實是 oss://dashscope-instant/...,但 Transcription.async_call 走的 SDK pipeline 沒帶 OssResourceResolve header,所以後端 download 不到。Python SDK 不開放修改 headers(文檔明說)。

修法(transcriber.py step 2/3 改寫):

  1. 保留 dashscope.utils.oss_utils.upload_file 拿 oss:// URL(這部分 SDK 內部已帶授權 header)。
  2. 拋棄 Transcription.async_call / Transcription.wait,改 requests.post(submit_url, headers={..., "X-DashScope-OssResourceResolve": "enable", "X-DashScope-Async": "enable"}, json={"model":"fun-asr", "input":{"file_urls":[oss_url]}, "parameters":{"language_hints":["yue"]}})。
  3. 自寫 polling loop(GET /api/v1/tasks/{task_id},每 2s 一次,cap 5 min),等到 task_status 進入終態。
  4. 抓 output.results[0].transcription_url 的 JSON → 解析 transcripts[].text / sentences[].text 拼成完整逐字稿。

驗證:smoke test 真實打 fun-asr 通過:

sample.wav (128 KB, Mandarin) → "Hello world,这里是阿里巴巴语音实验室。"

HTTPStatus import 移除(已不再用 SDK Transcription class)。


v0.4.1 · 2026-05-17 HKT(β ASR fix:fun-asr 404 → 改走 native DashScope async API + OSS auto-upload)

問題:v0.4.0 上線後實測 β 語音流程回報 ASR API call failed: Error code: 404。Root cause:百煉 fun-asr 未透過 OpenAI-compat audio.transcriptions 端點開放 —— 該端點對 fun-asr 一律 404。fun-asr 僅能由 native DashScope 異步 transcription API(/api/v1/services/audio/asr/transcription + X-DashScope-Async: enable)呼叫,且只接受 file_urls(公網 URL,不支援 base64/本地二進位)。

修法(backend/app/services/visit_note_agent/transcriber.py 全面改寫):

  1. 改用 native dashscope.audio.asr.Transcription.async_call(model='fun-asr', file_urls=[...], language_hints=['yue'])。
  2. Local 錄音檔上傳改走 SDK helper dashscope.utils.oss_utils.upload_file(model, 'file://<abs>', api_key) —— 自動上 DashScope 臨時 OSS 拿可用 URL(48 小時有效,dev 夠用)。
  3. Transcription.wait(task=task_id) 同步等任務完成。
  4. 從 output.results[0].transcription_url 抓 JSON,解 transcripts[].text / sentences[].text 拼成完整逐字稿。
  5. 全鏈路保留結構化錯誤(TranscriptionError 包訊息+原始 payload truncated 給除錯)。

依賴:pyproject.toml dependencies 新增 dashscope>=1.20.0(之前完全沒 import 過原生 SDK,因為走 OpenAI-compat)。

驗證:

  • from app.main import app import OK
  • backend restart:GET /api/home-visit/status HTTP 200
  • 真實 fun-asr 呼叫仍需有效 sk-... 開頭的百煉 API-KEY;現有 .env 中 DASHSCOPE_API_KEY=c06e4e...(32 hex chars 無 sk- 前綴)會被服務端回 InvalidApiKey,需用戶在阿里雲百煉 Console 換正版 key。

v0.4.0 · 2026-05-17 HKT(θ GA Release · 撤回同事 PR · β 仍以 fun-asr 為唯一 ASR)

決策:

  1. θ 功能宣告完備(GA) —— rc6.1 → rc6.8 一系列迭代後,θ「PDF 表單 → audit → 一鍵發佈 γ 模板」全流程穩定。版本徽記由 v0.4.0-rc6.8 升為 v0.4.0,去掉 -rc 後綴。
  2. 撤回同事 PR(撤銷 rc6.8 merge 引入的所有 working-tree 變更,保留 merge 歷史以利追溯)—— 重新 audit 後確認同事 PR 與 β pipeline 不相容(缺 two-phase review gate、9/12 檔重複、ASR vendor 重新洗牌成本高、依賴量大、broken tests)。決定完全撤回,保留現役 Bailian DashScope fun-asr 為 β 唯一 ASR provider。

撤回明細:

  • 刪除 backend/_inbox/visit_note_agent_v2_proposal/ 整個目錄(同事 PR 全部原始檔)。
  • 刪除 backend/_inbox/README.md、frontend/src/pages/MeetingNoteGenerator.tsx。
  • 還原 frontend/src/App.tsx 至 e257fca(移除 MeetingNoteGenerator import + /meeting-note route)。
  • 還原 frontend/src/components/Layout.tsx 至 e257fca(移除 δ · 會議記錄生成 導覽項),再單獨升版本徽記 → v0.4.0。
  • Merge commit 4721c00 與 polish commit 4fc98d5、bfb4487 保留在歷史(不 force reset),方便日後追溯同事提案內容;working tree 已完全還原至 colleague PR 影響前 + θ rc6.8 + version bump。

β pipeline 健檢確認(subagent debug 完成):

檢查項 結果
β ASR provider ✅ Bailian DashScope fun-asr(env DASHSCOPE_API_KEY,base dashscope.aliyuncs.com/compatible-mode/v1)
ASR call site ✅ backend/app/services/visit_note_agent/transcriber.py:55 via get_asr_client()
GLM-ASR / pyannote / zhipu 殘留 (active code) ✅ 零
pyproject.toml / package.json 依賴殘留 ✅ 零
FastAPI app import ✅ OK
GET /api/home-visit/sessions ✅ 200,9 sessions
/api/home-visit/status ✅ asr.provider="bailian", asr.model="fun-asr", mock=false

θ Release Notes(v0.4.0 GA)摘要(彙整自 rc6.1 → rc6.8):

  • 核心流程:PDF 上傳 → vision LLM 抽欄位 → audit UI(雙框模式:LLM 原 bbox 藍虛線 + vector-snap refined 紅實線)→ confirm 後自動發佈為 γ form_template(coord_anchor JSON)→ 在 γ 福利表選擇模板生成填好的 PDF。
  • Vision model:gpt-4.1-mini via Azure OpenAI /openai/v1 端點(plain OpenAI SDK,get_vision_client() 偵測 /openai/v1 後綴自動切換)。
  • Prompt v2(rc6.8):「位置 > 大小 > 數量」+ 自我驗算 + 4 條錯例 + 3 條正例。
  • Vector-snap refiner(rc6.8):PyMuPDF page.get_drawings() 抽水平線/小方框做 bbox 吸附,no-anchor 保留 LLM 原 bbox。ThetaField.bbox_llm JSON 欄位 + db.py 輕量 ALTER TABLE migration。
  • Audit UI(rc6.7+rc6.8):overlay 標籤瘦身(hover 展開)、雙框 toggle、已微調統計、可拖曳調整 bbox、單欄編輯器、γ 發佈狀態回顯。
  • 失敗實驗(rc6.8 過程紀錄):gpt-5-mini(DeploymentNotFound)、gpt-5.1(Foundry Agent 400)、grok-4-1-fast-reasoning(走通但 208s 過慢且位置仍偏)—— 都已放棄。

v0.4.0-rc6.8 · 2026-05-17 HKT(θ bbox 雙層精度方案:強化 prompt + PyMuPDF 向量微調 + 雙框 audit UI)

用戶反饋:「位置並非準確。改回 gpt-4.1-mini。加入你能想到最好的 prompt 以及微調方案。」並要求加雙框驗證 UI 後 commit。

修改清單:

  1. Vision 模型:嘗試 gpt-5-mini → DeploymentNotFound;嘗試 gpt-5.1 → 400 UserError(仍是 Foundry Agent layer);嘗試 Azure 上的 grok-4-1-fast-reasoning → 走通了(103 欄 / 208s vs gpt-4.1-mini 74 欄 / 57s),但位置仍然偏且延遲 3.6x 過慢。最終結論:模型本身不是瓶頸,視覺空間定位本就難。.env 還原 AZURE_OPENAI_DEPLOYMENT=AZURE_OPENAI_MODEL=gpt-4.1-mini。

  2. theta_extractor.py · SYSTEM_PROMPT v2(rc6.8 強化)—— bbox 精確度規則:

    • 開頭新增「⚠️ 最高優先:位置必須精確。寧可少報幾欄,也不要把欄位框錯位置。」
    • A. 目標精確定義 —— text/checkbox/signature/date 分別說明「市民筆尖會落下的那一塊區域」。
    • B. 自我驗算法 —— 輸出前在心中描述 bbox 中心點對照標籤位置,不一致就重算。
    • C. 大小:寧小勿大、剛好貼合 —— 難判斷邊界就向內收 10~20%,絕不可向外擴。
    • D. 允許重疊 —— 完全不考慮 bbox 干涉。
    • E. checkbox 尺寸 —— 典型 w=h=0.012~0.025,w > 0.04 視為錯。
    • F. text 尺寸 —— 高 0.0180.04;姓名/電話寬 0.150.30;地址 0.40~0.60。
    • G. 只看到一條橫線 —— 縮到「中間 70%」,左右各留 15% 餘裕。
    • 加 4 條錯誤示範(吃進標籤、整體移位、外擴 padding)+ 3 條正確示範。
  3. backend/app/services/theta_bbox_refiner.py(新增 · 最重要的微調)—— PyMuPDF 向量幾何吸附:

    • 政府表單的「填寫橫線」與「□ checkbox」本來就是 PDF 內的向量繪圖物件(line / rect path),不是像素圖。用 page.get_drawings() 直接抽出。
    • LLM 出完 bbox 後,逐欄位在鄰域(±3~4% 頁面比例)找最匹配的 anchor:
      • text/date/number/signature → 找最近的水平線(h ≤ 0.008 頁高、長 ≥ 0.04 頁寬)→ 吸附到該線範圍。
      • checkbox → 找最近的小方框(邊長 0.008~0.04、aspect 容差 0.5)→ 吸附到該方框內縮 4%。
    • 找不到 anchor → 保留 LLM 原 bbox(refiner 不會讓結果變更差)。
    • tunable:_TEXT_SEARCH_PAD、_MIN_HLINE_LEN_PAGE、_CHECKBOX_MIN/MAX、_CHECKBOX_ASPECT_TOL。
    • 結果存 _bbox_llm(原始 LLM)、_refined、_refine_reason 給 audit 比對。
    • Hook 在 extract_form_blanks() 收完所有頁後執行;_meta.refine_stats 帶 {refined, no_anchor, skipped, total}。
  4. DB schema 擴充:

    • ThetaField 加 bbox_llm: Optional[dict] JSON 欄位(向量微調前的 LLM 原始 bbox)。
    • db.py 加 _apply_lightweight_migrations() —— create_all() 後跑 ad-hoc ALTER TABLE ... ADD COLUMN(SQLite-safe,已存在會靜默忽略),免 alembic 也能升級。
  5. backend/app/api/theta.py —— 把 bbox_llm 與衍生 refined flag 透到前端:

    • /upload 寫入 ThetaField 時帶 bbox_llm=f.get("_bbox_llm")。
    • _field_out() 多回 bbox_llm + refined: bool(f.bbox_llm) and f.bbox_llm != f.bbox。
  6. 前端 audit UI 雙框模式(ThetaAudit.tsx + lib/api.ts):

    • ThetaFieldDef 加 bbox_llm?: number[] | null 與 refined?: boolean。
    • BboxCanvas 新增 prop showLlmGhost:若該欄位 refined === true 且 bbox_llm 存在,畫一個藍色虛線框顯示 LLM 原始 bbox(不可互動),疊在當前紅色實線的 refined bbox 旁。
    • PDF 檢視器上方加 toggle checkbox(預設開啟):「顯示 LLM 原始 bbox(藍虛線)vs 向量微調後(紅實線)」+ 右側統計「已微調 X / Y」。
  7. 前端版號:badge → v0.4.0-rc6.8。

模型嘗試摘要(給後人留底):

模型 endpoint 結果
gpt-5-mini openai.azure.com/openai/v1 DeploymentNotFound 404
gpt-5.1 openai.azure.com/openai/v1 400 UserError(Foundry Agent 隔離)
grok-4-1-fast-reasoning services.ai.azure.com/openai/v1 OK,103 欄/208s,但位置仍偏
gpt-4.1-mini openai.azure.com/openai/v1 採用,74 欄/57s,靠 prompt v2 + vector snap 補精度

Commit:v0.4.0-rc6.8,並合併 origin/main(同事的功能 PR)。

合併備註(rc6.8 polish):起初誤判同事 PR 是「會議記錄 δ 新功能」(因為 App.tsx 加了 /meeting-note route、Layout 加了 δ 導覽項、目錄命名為 CareFlow-meeting-notes-branch/)。重新 audit 後確認:實際內容是 β 家訪語音→報告 pipeline 的 ASR 子模組升級提案——把 ASR 由 Bailian DashScope fun-asr 換成 智譜 GLM-ASR-Nano-2512(粵語)+ pyannote.audio 3.1 說話人分離。命名上有誤導。

重複部分:12 個檔中 9 個(service.py / llm_client.py / docx_*.py / template_normalizer.py / generate.py / config.py / errors.py / template_analysis_prompt.txt)與我們現役 backend/app/services/visit_note_agent/(partner v1 整合版)幾乎一致,且沒有 two-phase review gate(退化)。唯一有實質改動的是 transcriber.py(重寫 + 分人)與 systemprompt_for_meetingnote.txt(加入 [SPEAKER_xx] 提示,但混入簡體一句)。

Dead code:google_config.py、googleexample.config(舊 Google STT 殘留)。Broken tests:tests/test_transcriber.py 仍 import _recognize_chunk / google_config,跑會 ImportError。

rc6.8 合併後處置:

  • 同事原資料夾 backend/CareFlow-meeting-notes-branch/ → git mv 至 backend/_inbox/visit_note_agent_v2_proposal/,加 _inbox/README.md 說明此目錄不在 runtime path。
  • 同事 App.tsx 的 /meeting-note route + Layout δ 導覽項保留(不修改同事的修改),但 MeetingNoteGenerator.tsx 佔位頁重寫為「語音轉文字 β 強化(待整合)」說明卡(含現役/提案對照表 + 下一步整合清單),明確指向 backend/_inbox/。
  • Layout.tsx 版本徽記保留 v0.4.0-rc6.8(同事側 rc6.2)。

不在 rc6.8 範圍:實際整合(rc6.9+ 議題)。整合方向應是把 diarization 封裝為 transcriber_diarized.py 與現役 transcriber.py 並存,加 settings.asr_provider 切換,pyannote/zhipuai/torch 落 optional extras 避免 base image >2GB。[SPEAKER_xx] prompt 提示僅當 diarize provider 啟用時才注入(現役不分人,直接套用會誤導 LLM)。整合後 UI 入口應併入 /home-visit(β),而非另開 δ。


v0.4.0-rc6.7 · 2026-05-17 HKT(θ → γ 一鍵發佈 · bbox 精度 · overlay 標籤瘦身)

用戶反饋三點:

  1. θ 抽出的 bbox 位置不夠精準;要求允許重疊、不考慮框之間干涉、寧小勿大。
  2. UI 上每個 bbox 上方標籤字體太大,多框重疊時看不清。
  3. θ「審視通過」後沒有保存到 γ 部分作為 template。

修改清單:

  1. backend/app/services/theta_extractor.py · SYSTEM_PROMPT —— bbox 規則重寫(A~F 共 6 條):

    • 明確「目標 = 寫字 / 打勾的區域本身,不是標籤文字」。
    • 寧小勿大:可略小於實際,但絕不可大於實際。
    • 允許重疊:完全不考慮 bbox 干涉,每欄獨立判斷。
    • checkbox 典型 w/h ∈ 0.0150.03、text h ∈ 0.020.05 給範圍指引。
    • 不確定 → confidence < 0.5 + 粗略 bbox,不硬塞大範圍。
  2. frontend/src/pages/ThetaAudit.tsx · BboxCanvas —— overlay 視覺瘦身:

    • 預設邊框 1px 半透明、bg 透明度 5%;選中才 2px 實色 + 10%。
    • 標籤預設 text-[7px] + 60% 透明 + 截斷前 8 字;hover 或選中才放大到 text-[10px] 全文 + z-20 浮到上層。
    • 重疊區可以靠 hover 逐個浮起來看清楚。
  3. backend/app/services/theta_publish.py(新增) + backend/app/api/theta.py —— θ → γ 自動發佈:

    • PUT /api/theta/templates/{id} 確認後自動把欄位轉成 γ 套組 JSON,寫到 data/form_templates/theta_<id>.json。
    • bbox(相對座標)→ PDF point(透過 PyMuPDF 讀原 PDF 每頁尺寸換算);text→write_rect、checkbox→tick_rect;寬度 > 0.45 頁寬自動視為 long_text。
    • anchor_text=""(_check_anchor 對空字串直接 pass)、elder_profile_path=""(留給人工 / LLM 之後對映)。
    • DELETE θ template 同步刪除對應 γ JSON,避免孤兒檔。
    • response 多回 gamma_publish: {published, gamma_template_id, path, field_count}。
  4. 前端版號:badge → v0.4.0-rc6.7。

驗證(端到端):

  • 上傳 1 頁簡單 PDF → 22 欄;PUT 確認 → gamma_publish.published=true, gamma_template_id="theta_23";
  • γ GET /api/welfare-form/templates 列出 theta_23 · status=ready · 22 fields;
  • 產出 JSON data/form_templates/theta_23.json 含 _theta_origin + 22 fields with fill.write_rect;
  • DELETE 後 JSON 自動移除。

Commit:v0.4.0-rc6.7。


v0.4.0-rc6.6 · 2026-05-17 HKT(vision 模型遷移 gpt-5-mini → gpt-4.1-mini · CSSA 0→68 欄位)

背景:rc6.5 結論「gpt-5-mini 對密集 7 頁 CSSA 表的能力上限」。改用 gpt-5 / gpt-5.1 嘗試,遇兩道牆:

  1. Azure GPT-5 / GPT-5.1 全區 insufficient quota(用戶帳號 default tier 未開高配額)。
  2. 嘗試把 deployment 接成 gpt-5.1 時,發現該 deployment 實為 Azure AI Foundry Agent(非 vanilla deployment):需 AAD + Responses API + agent_reference,與 vanilla chat/completions API 不相容;診斷症狀為 200 /openai/models 但 400 UserError(x-ms-fe-error: true,無 x-ms-error-code)。

最終方案:用戶新部署 gpt-4.1-mini 在 Azure OpenAI 的 OpenAI-相容 v1 surface(https://<resource>.openai.azure.com/openai/v1),可直接走 plain openai.OpenAI(base_url=..., api_key=...) SDK,無需 api-version、無需 api-key header 技巧、無 reasoning-token 浪費。

修改清單:

  1. backend/app/llm/client.py · get_vision_client():
    • 偵測 endpoint 結尾 /openai/v1 → 走 plain OpenAI SDK(timeout=read 180s)。
    • 否則回退 _FoundryWrapper(保留 class,方便日後切回)。
  2. backend/.env:AZURE_OPENAI_DEPLOYMENT=gpt-4.1-mini、AZURE_OPENAI_MODEL=gpt-4.1-mini、endpoint 改為 /openai/v1。
  3. 前端版號:badge → v0.4.0-rc6.6。

驗證:

測試 rc6.5 (gpt-5-mini) rc6.6 (gpt-4.1-mini)
1 頁簡單 PDF 53s · 15 fields 13s · 21 fields
7 頁 CSSA 雙語表 60s+/頁 · 0 fields 52s · 68 fields ✅

為何 gpt-4.1-mini 適合:1M context、32K output、GA、原生 vision、非推理模型(無 hidden reasoning token 消耗)、原生支援 response_format,對中文政府表 OCR/結構化抽取為 2026/5 性價比最高之一。

踩雷紀錄(寫進記憶):

  • Azure 200 /openai/models 但特定 deployment 400 UserError(無 x-ms-error-code,有 x-ms-fe-error: true,且其他 model 名回 404 DeploymentNotFound)→ 該 deployment 實為 Foundry Agent,需 AAD + Responses + agent_reference,非 vanilla 路徑。
  • Azure GPT-5/5.1 default quota 通常為 0,需手動 request 或換 tier。
  • Azure OpenAI 的 /openai/v1 surface 是 plain OpenAI SDK 的友善路徑:無 api-version、無 azure-specific header — 但僅限 vanilla deployment(Agent 不行)。
  • gpt-4.1-mini 非推理模型,max_tokens=16384 可實際用滿,不會像 gpt-5-mini 被 reasoning chain 吃光。

Commit:v0.4.0-rc6.6。


v0.4.0-rc6.5 · 2026-05-17 HKT(θ extractor 強化 + 診斷可見化)

用戶 round-12 後續:rc6.4 修正了 audit 頁顯示問題,但用戶上傳 CSSA 表單時 GPT 主動回 fields: [] —「報告中已经显示有多个栏位,但是人工審視的時候還是 0 欄位」的真正死症。

根因鏈:

  1. ❌ rc6.3 fix 未生效:後端從 rc6.3 commit 後一直沒重啟,舊 code 仍跑 response_format={"type":"json_object"},Foundry 422 → 全部頁面 fail。
  2. ✅ 重啟後:response_format 順利移除,page_count=0 真因浮現。
  3. ❌ max_tokens=4096 不夠:gpt-5-mini 是推理模型,會先消耗大量 reasoning tokens。簡單 1 頁 PDF 勉強夠(53s 回 15 fields),但 CSSA 這類密集表單 4096 全耗在推理上 → 回應字串空 → fields=[]。
  4. ⚠️ gpt-5-mini 對密集表單能力本身有限:即使 max_tokens=16384 + DPI 220 + 強化 prompt,CSSA 7 頁仍回 fields: [](raw_len 22-31,即 {"page": X, "fields": []} 字面)。簡單測試 PDF 仍可正確回 15 fields,故工具鏈本身完整。

修改清單:

  1. backend/app/services/theta_extractor.py:
    • max_tokens 4096 → 16384(給推理模型留足輸出 budget)。
    • DPI 150 → 180、_PAGE_MAX_DIM 1600 → 1800(提高小字辨識,但避免 Foundry connection drop)。
    • SYSTEM_PROMPT 強化:明確指出常見表單(CSSA / OALA / SSA-307 / CCSV / Joyyou)、密集表格、checkbox、橫線等,要求「極為詳盡」掃描;明文「每個儲存格都當欄位看待」。
    • theta_extract_page_ok log 新增 raw_len 與 raw_preview(field_count=0 時記前 240 字),方便事後診斷 GPT 真實回應。

驗證:

  • 簡單 1 頁 PDF(test1page.pdf):仍正確回 15 fields。
  • 7 頁 CSSA 表單:gpt-5-mini 對密集表格仍主動回空 fields;非後端問題,屬模型能力上限。

結論與後續方案:

  • 工具鏈完整:DeepSeek text + Azure Foundry vision + Bailian ASR 三路皆通;diagnose / vision / theta 三個 vision 入口都正確 pop response_format。
  • gpt-5-mini 對密集多頁政府表單的識別能力是當前瓶頸。建議方案(待用戶決策):
    1. 把 vision deployment 換成 gpt-4o 或 gpt-5(完整版,非 mini)。
    2. 在 prompt 中加入 few-shot 範例(用標好的 OALA / CCSV 範例)。
    3. 把每頁切成上下半再分別丟給 GPT(context 變小,注意力集中)。

踩雷紀錄(寫進記憶):

  • 修了 backend code 一定要重啟服務 — --reload 也不保證熱載入完全乾淨,遇可疑舊行為先重啟。
  • gpt-5-mini 是 reasoning model,max_tokens 要給 16k+;4096 在密集視覺任務會被 reasoning 吃光。
  • Foundry connection drop 通常是 image 太大或單次推理時間 > 150s;DPI ≤ 200、長邊 ≤ 2000 較穩。
  • GPT 回 fields: [] 但 raw_len < 50 → 模型主動放棄,調 prompt 已無用,需換模型或拆任務。

Commit:v0.4.0-rc6.5。


v0.4.0-rc6.4 · 2026-05-17 HKT(θ audit 頁 0 欄位幻覺修補)

指令:用戶 round-12 — 「報告中已经显示有多个栏位,但是人工審視的時候還是 0 欄位,請修復這個問題。」

現象:步驟 1.5 確認頁顯示 GPT 識別 N 個欄位(例 15),但點「進入人工審查」後 audit 頁右側「本頁 0 欄位」,看似資料消失。

根因:多頁 PDF 上傳時,GPT 多半只在某幾頁有欄位(如 OALA 表單只有第 1 頁有空白欄)。audit 預設 activePage=0,若第 1 頁無欄位則右側 panel 顯示 0;總計欄位數雖在 header 但視覺權重低,使用者誤以為 GPT 結果遺失。curl GET /api/theta/templates/9 實測仍正確回 15 筆欄位 — 資料沒丟,是 UI 預設視角問題。

修改清單(純前端,後端 0 改):

  1. frontend/src/pages/ThetaAudit.tsx:
    • 載入完模板後自動 setActivePage(r.fields[0].page_number) — 跳到第一個有欄位的頁面。
    • Header 共計欄位旁追加「本頁 X」(多頁時才顯示),雙重指示。
    • 右側「此頁欄位列表」當 pageFields.length===0 時加說明:「GPT 在此頁未識別任何欄位。其他頁面共有 N 個欄位,可從左側縮圖切換。」

驗證:1 頁 PDF 測試模板 id=9 仍回 15 fields;多頁 PDF 載入後自動定位至首個有欄位頁。前端 build 通過、preview 200。

踩雷紀錄:

  • 多頁 PDF 的 audit UI 預設視角必須跟著資料走,不能死板停 page 0。
  • 「總欄位數」與「本頁欄位數」必須同時顯眼呈現,否則使用者只看單一指示會誤判。

Commit:v0.4.0-rc6.4。


v0.4.0-rc6.3 · 2026-05-17 HKT(θ 流水線修補:response_format 不相容 + GPT 審視可見化)

指令:用戶 round-11 — 「AI 連線自檢全部通过。我加入了一个自定义填表的 theta 功能。请你仔细阅读新增的代码并修復以下問題:上傳 pdf 之後不會經過 gpt 的審視,而是直接進入 audit 界面。」

現象:θ 流水線上傳 PDF 後,後端 analysis_meta.total_fields=0 且 last_error="Unsupported \response_format` {'type': 'json_object'}"`,每一頁都因同樣錯誤失敗。前端因只有按鈕 "分析中…" 微弱回饋,使用者誤以為「跳過 GPT 直接進審查頁」。

根因:Azure AI Foundry 的 careflow-gpt-5-mini / gpt-4o-mini deployment 在當前 API 版本下不接受 response_format={"type":"json_object"} 參數(伺服器直接 422)。theta_extractor 與 welfare_form_extractor 等視覺呼叫點全部受影響。

修改清單:

  1. backend/app/llm/client.py — _FoundryCompletions.create() 一律 kw.pop("response_format", None),由 caller 在 system prompt 強制 JSON + _parse_json_loose 容錯。集中處理,避免每個 vision caller 都要改。
  2. backend/app/services/theta_extractor.py — 移除 response_format 參數,註解說明 Foundry 限制;其他保持不變。
  3. frontend/src/pages/ThetaUpload.tsx —
    • 新增全屏分析中遮罩:顯示「GPT-5-mini 正在逐頁審視 PDF · 已耗時 N 秒 · 通常需 20-120 秒」+ 三點脈衝動畫。
    • 新增步驟 1.5 / 3 中介確認頁:上傳完成後不直接跳 audit,先展示 analysis_meta(提供方、模型、總頁數、識別欄位數、分析耗時、失敗頁數),使用者按「→ 進入人工審查」才導去 audit。
    • 整份失敗(0 欄位 + last_error)時保留錯誤訊息不跳轉。

驗證結果:

POST /api/theta/upload (1 頁 PDF)
  before: total_fields=0, last_error=Unsupported response_format, 全 23 頁失敗
  after : total_fields=13, latency 59.8s, last_error=null, 0 失敗頁

前端 build 2 秒成功,preview HTTP 200。

踩雷紀錄:

  • Foundry 對 response_format 的相容性 != OpenAI 官方 API;以 try-and-error 為準。
  • gpt-5-mini 是 reasoning model,單頁推理 ≈ 1 分鐘,UI 必須有顯著進度回饋否則使用者會以為當機。
  • silent 失敗的 try/except 雖然不讓整份流程崩,但必須在 UI 把 _meta 暴露出來,否則使用者完全無法察覺。

Commit:v0.4.0-rc6.3 — θ 流水線 response_format 不相容修補 + GPT 審視可見化。


v0.4.0-rc6.2 · 2026-05-14 HKT(rc6.1 修補:Azure AI Foundry SDK 切換 + gpt-5-mini 參數適配)

指令:用戶 round-10 追加 — 「是 foundry,現在有正確的 API。請你重新啟動。」

背景:rc6.1 使用 openai.AzureOpenAI SDK 連 endpoint *.services.ai.azure.com,但該 endpoint 是 Azure AI Foundry 型態(不是傳統 Azure OpenAI 的 *.openai.azure.com),URL schema 不相容,導致 401 / DNS 錯誤。Foundry 的官方 Python SDK 是 azure-ai-inference,與 AzureOpenAI 介面不同。

修改清單:

  1. backend/pyproject.toml 依賴:新增 azure-ai-inference==1.0.0b9(Microsoft 官方 Foundry SDK,已安裝至 venv)。
  2. backend/app/llm/client.py:
    • 移除 from openai import AzureOpenAI。
    • 新增 _FoundryWrapper / _FoundryChat / _FoundryCompletions 三層薄 shim,把 Foundry ChatCompletionsClient.complete(...) 包成 OpenAI-同形 .chat.completions.create(...) 介面,使既有 vision.py / diagnose.py 不必改 call site。
    • Endpoint 自動補 /models 後綴(Foundry 要求)。
    • Wrapper 內參數適配 gpt-5-mini:
      • max_tokens → 透過 model_extras 轉成 max_completion_tokens(gpt-5-mini 不接受 max_tokens)。
      • temperature=0 丟棄(gpt-5-mini 只接受預設值 1)。
    • 過濾不認得的 OpenAI 參數白名單,避免 422。
  3. backend/app/api/diagnose.py:_probe_vision 的 max_tokens 從 10 → 200(gpt-5-mini 是推理模型,需保留 reasoning token 空間才能輸出可見回覆)。
  4. backend/.env:用戶手動補入 AZURE_OPENAI_API_KEY=...(rc6.1 之後 .env 缺此行,導致 has_key=False 一直退回 mock);endpoint 從佔位符 your-resource.openai.azure.com 改為實際的 jhxu-mp3z480q-eastus2.services.ai.azure.com。

驗證結果:

GET /api/llm/diagnose
- text channel  : deepseek-v4-pro · ok=true · mock=false
- vision channel: careflow-gpt-5-mini @ Azure AI Foundry · ok=true · mock=false · latency 2139 ms
- asr channel   : fun-asr @ Bailian · ok=true (DNS check)
total latency 6114 ms

踩雷紀錄(避免下次再撞):

  • *.services.ai.azure.com = Foundry;*.openai.azure.com = 經典 Azure OpenAI。SDK 不通用。
  • Foundry endpoint 一定要補 /models 後綴給 ChatCompletionsClient。
  • gpt-5-mini 系列:API 參數命名與 GPT-4o/4-turbo 不同 — max_tokens 改 max_completion_tokens、temperature 鎖死預設 1。
  • 推理模型 reasoning tokens 會吃掉預算 → probe 至少留 200 tokens 才能拿到可見輸出。

Commit:v0.4.0-rc6.2 — Foundry SDK 切換 + gpt-5-mini 參數適配。


v0.4.0-rc6.1 · 2026-05-14 HKT(rc6 微修:視覺改回 Azure OpenAI + 前端三通道面板 + 版本號 / 時區)

指令:用戶 round-9 追加 — 「openaiAI 還是用 azure。CAREFLOW · v0.2 還沒更新,另外時間應該用 UTC+8。」

動機

  • rc6 把視覺通道指向 api.openai.com 是誤讀;公司現有 GPT-5-mini 授權在 Azure 部署上,所以視覺要走 Azure OpenAI(保留 deployment name 概念)。
  • 前端側邊欄寫死 v0.2 已落後三個版本,時間戳也用瀏覽器當地時區,造成測試環境時間飄忽。

變更

  • 後端
    • config.py:保留 openai_* 欄位(純為 .env 相容),新增 / 拉回 azure_openai_*(endpoint / api_key / deployment / api_version / model);is_vision_mock 改為「Azure key 且 endpoint 同時存在才算就緒」。
    • llm/client.py:get_vision_client() 改回 from openai import AzureOpenAI;用 azure_endpoint + api_version + api_key 建構;resolve_model("vision") 回 azure_openai_deployment(Azure 用 deployment name 作 model 參數)。
    • llm/vision.py:所有 provider="openai" 字面值改為 "azure_openai"。
    • services/home_visit.py placeholder_status 三通道改寫 vision 段為 azure_openai + deployment。
    • api/diagnose.py _probe_vision 改打 Azure endpoint,回 provider/model/deployment/api_version/base_url/has_key/network/ok/mock/reply|error。
    • main.py /api/health vision 段同步改 azure_openai + deployment。
    • .env.example:OpenAI 官方段整段換成 Azure OpenAI 段(endpoint / api key / deployment / api version / model);舊 OPENAI_* 移到 Legacy 註解區。
  • 前端
    • components/Layout.tsx:版本徽記 v0.2 → v0.4.0-rc6.1。
    • pages/Dashboard.tsx:
      • 時間顯示加 timeZone: "Asia/Hong_Kong",並在 session 行尾貼 HKT 標籤(解決瀏覽器當地時區飄移)。
      • 「系統現況」改成三通道列:text / vision / asr,每行顯示 provider · model · mock|live 戳記。
    • pages/Settings.tsx:「AI 連線自檢」改用 ChannelDiag 三卡(每卡列 provider / model[+deployment] / base_url / api key / network DNS / api latency / reply | error),時間戳加 HKT。

驗證

  • /api/health → vision channel:{provider:"azure_openai", model:"gpt-5-mini", deployment:"careflow-gpt-5-mini", mock:false}(用戶 .env 已填 Azure key)。
  • /api/llm/diagnose → 視覺通道實打 Azure endpoint jhxu-mp3z480q-eastus2.services.ai.azure.com:443,DNS 562ms 通;目前 401(用戶 Azure deployment / key 配對問題,由 diagnose 面板即時顯示,不是程式架構問題)。
  • Frontend 重建 753ms,preview 200 OK,側邊欄 CAREFLOW · v0.4.0-rc6.1,Dashboard 三通道列顯示完整。

用戶後續動作

  1. 確認 Azure Portal 內:endpoint 拼字、API key、deployment name careflow-gpt-5-mini、api_version=2024-12-01-preview 三者匹配。
  2. 若你的 Azure resource 是 AI Foundry 新型 endpoint(*.services.ai.azure.com),可能需用 Azure AI Inference SDK;本實作走 AzureOpenAI 經典 SDK,匹配 *.openai.azure.com endpoint。
  3. 重啟 backend,再打 /api/llm/diagnose 看 vision.ok 是否轉 true。

Commit:v0.4.0-rc6.1 — Vision 通道改回 Azure OpenAI + 前端三通道面板 + 版本號 / 時區修正。


v0.4.0-rc6 · 2026-05-14(LLM 三通道重構:DeepSeek 文本 + OpenAI 視覺 + Bailian ASR)

指令:用戶 round-9 — 「現在把 deepseek 的調用調整為官網 API,使用 v4 pro 模型,不再考慮使用 qwen,直接用 chatgpt。不要刪除百煉的部分,因為 fun-asr 會用。」追問補充:「需要用 AI 補全/推理走 v4 pro。需要視覺走 gpt-5 mini。deepseek 用 openai 格式。」

動機

  • rc5 之前的設計把 LLM 三種能力(文字推理 / 視覺 / 語音)混在「llm_provider 二選一」開關下,再加上 Azure ↔ Qwen 雙路 fallback,導致 is_mock_mode / primary_vision_provider 邏輯交織難維護。
  • 真實使用情境:香港社工團隊偏好 DeepSeek 官網(OpenAI 相容)跑文字、OpenAI GPT-5-mini 跑視覺 OCR、阿里 Bailian fun-asr 跑粵語 ASR。三路供應商各自獨立、各自計費。
  • 結論:拆成三條互不影響的 OpenAI-format 通道,任一路缺 key 即各自退回 mock,不再連動拖累其他能力。

新架構(rc6 主介面)

┌─────────────┬──────────────────────┬──────────────────┐
│  modality   │  provider             │  default model   │
├─────────────┼──────────────────────┼──────────────────┤
│  text/推理  │  DeepSeek 官方        │  deepseek-v4-pro │
│  vision     │  OpenAI 官方           │  gpt-5-mini      │
│  asr/語音   │  Bailian / DashScope  │  fun-asr         │
└─────────────┴──────────────────────┴──────────────────┘

新 client API(backend/app/llm/client.py)

from app.llm.client import get_text_client, get_vision_client, get_asr_client, resolve_model

text_resp   = get_text_client().chat.completions.create(model=resolve_model("text"), ...)
vision_resp = get_vision_client().chat.completions.create(model=resolve_model("vision"), ...)
asr_resp    = get_asr_client().audio.transcriptions.create(model=resolve_model("asr"), file=..., language="yue")

每個 client 各自綁定一組 (api_key, base_url, httpx.Timeout)。舊的 get_client(provider) 仍保留作為 shim(內部轉派到 text client),但已 @deprecated。

新 .env 範本(3 段獨立 key)

# 文字推理
DEEPSEEK_API_KEY=sk-...
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_TEXT_MODEL=deepseek-v4-pro

# 視覺
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_VISION_MODEL=gpt-5-mini

# 語音
DASHSCOPE_API_KEY=sk-...
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
BAILIAN_ASR_MODEL=fun-asr

舊 .env.example 已備份為 .env.example.rc5-backup。

新 mock 判斷(per-modality)

settings.is_text_mock     # not DEEPSEEK_API_KEY
settings.is_vision_mock   # not OPENAI_API_KEY
settings.is_asr_mock      # not DASHSCOPE_API_KEY
settings.is_mock_mode     # 三路全缺才為 True(legacy 相容)

新 diagnose 回應(GET /api/llm/diagnose)

回 channels: {text, vision, asr},每路各自帶 provider / model / base_url / has_key / network{DNS+TCP} / ok / mock / latency_ms / reply | error。前端「AI 連線自檢」面板可直接 3 卡並排呈現。

廢棄

  • llm_provider 開關:保留欄位但不再控制路由(rc6 起每路獨立看自己的 key)。
  • Qwen3.6-Plus 視覺:完全下線;llm/vision.py 改走 get_vision_client()(OpenAI gpt-5-mini)。
  • azure_vision.py:標 [DEPRECATED],is_azure_fallback_available() 預設回 False;保留檔案以免立即 ImportError,未來可整檔刪除。
  • services/volunteer_form.py 的 primary_vision_provider / _call_primary / _call_fallback 分流 → 單路 OpenAI 視覺。
  • services/welfare_form_extractor.py 文字抽取走 get_text_client()、影像走 get_vision_client(),不再共用同一 client。

變更檔案清單(11 處)

  • backend/.env.example — 重寫為 3 段獨立 key
  • backend/app/config.py — 新增 openai_* / deepseek_text_model / bailian_asr_model 欄位;新增 is_text/vision/asr_mock 屬性
  • backend/app/llm/client.py — 重寫,3 個專用 client + resolve_model(kind)
  • backend/app/llm/text.py — 改用 get_text_client();_meta.provider="deepseek_official"
  • backend/app/llm/vision.py — 改用 get_vision_client();註解去 Qwen 字眼
  • backend/app/llm/azure_vision.py — 標 deprecated
  • backend/app/services/volunteer_form.py — 移除 primary/fallback 雙路;單路 OpenAI 視覺
  • backend/app/services/welfare_form_extractor.py — text / vision client 拆開
  • backend/app/services/welfare_form_mapping.py — get_text_client() + is_text_mock
  • backend/app/services/visit_note_agent/llm_client.py — get_text_client()
  • backend/app/services/visit_note_agent/transcriber.py — get_asr_client() + is_asr_mock
  • backend/app/services/home_visit.py — placeholder_status + ai_provider 改吐 rc6 三通道結構
  • backend/app/main.py — /api/health 回 channels: {text, vision, asr}
  • backend/app/api/diagnose.py — 重寫為 3 路獨立探活

驗證

  • /api/health → 三路結構正確:text=deepseek_official/mock=true、vision=openai/mock=true、asr=bailian/mock=false(用戶 DASHSCOPE_API_KEY 已存在)。
  • /api/llm/diagnose → 三路 DNS+TCP 均通(api.deepseek.com 596ms、api.openai.com 492ms、dashscope.aliyuncs.com 1000ms),DeepSeek/OpenAI 因無 key 自動 mock。
  • e2e mock:POST /api/home-visit/sessions/mock-demo → session id=9,status=pending_review、ai_provider=mock、9 fixed_blocks、30 dynamic_slots、slot_content 30 keys。下載 docx 11450 bytes、內容為粵語家訪報告。

用戶後續動作

  1. 把 DeepSeek、OpenAI key 填入 .env(DASHSCOPE 已填)。
  2. 重啟 backend:uvicorn app.main:app --reload。
  3. 打 /api/llm/diagnose 驗證三路 ok=true / mock=false。

Commit:v0.4.0-rc6 — LLM 三通道重構(DeepSeek 文本 + OpenAI 視覺 + Bailian ASR)。


v0.4.0-rc5 · 2026-05-14(流水線 β · 家訪語音 → 結構化報告 · 全 mock 一鍵 demo)

指令:用戶 round-8 — 「現在幫我 beta 做一個的全 mock,直接放一個 mock mp3 以及 mock docx,生成一個 mock 報告。」

目標:流水線 β(Home-Visit / visit_note_agent)原本依賴真實 Bailian fun-asr + DeepSeek-v4-pro,hackathon demo / 評審現場若無網絡或 API key 失效時整條 pipeline 立即斷裂。本輪做一個保證離線、保證 deterministic 的一鍵示範入口,無論是否設定 DASHSCOPE_API_KEY 均可端到端跑通 phase-1(抽取)→ human-review 閘門 → phase-2(渲染 .docx)。

A. 修正 mock.py schema 對齊(潛在大 bug)

visit_note_agent/mock.py 原本以 structural_map["paragraphs"] / ["tables"] 解析,但實際 docx_structural_extractor 產出的是 {"blocks": [...]} 帶 block_id / type / nearest_heading / left_neighbor / top_neighbor 的扁平 list。等於 mock 模式從來沒真正生成 slot ⇒ 渲染器以空 source_block_id 全部跳過,使用者只會拿回原模板本身。

重寫後 mock_template_contract:

  • 真正逐 block 走訪;
  • 用 regex ^[一二三四五六七八九十百零〇]+[、.] + 標題短語白名單偵測section heading,列為 fixed_blocks;
  • ^([^::︰]{1,14})[::︰](.*)$ 偵測 「label:value」 行,emit 帶 prefix 的 slot;
  • 其餘 paragraph 帶上 section_hint(最近一個 section heading 文字);
  • table cell 一律當 slot;
  • 重要:source_block_id = block.block_id,與渲染器 _body_paragraph_map/_table_cell_map 對齊。

mock_slot_content 採三層 fallback:

  1. prefix 命中 → 依 _MOCK_FIELD_VALUES(20 條 label 關鍵字 → 港式繁中值)回填 「label:mock_value」;
  2. section_hint 命中 → 從 _MOCK_SECTION_PARAGRAPHS(9 個段落 keyword:近況摘要 / 身體健康 / 情緒 / 家居安全 / 社交支援 / 已提供協助 / 跟進計劃 / 職員觀察 / 備註)回填完整段落;
  3. 都不中 → 用 transcript 前 90 字當 echo 草稿並標 【AI 草稿 · {label}】…(請社工複核)。

B. 新增 mock fixtures

backend/data/samples/visit_note/
  ├ mock_template.docx     ← 複製自 tests/visit_note/長者個案面談紀錄.docx(39 blocks)
  ├ mock_visit.mp3         ← 8 個 MPEG-1 L3 靜音 frame(3344 bytes,僅作占位)
  ├ mock_transcript.txt    ← 複製自 tests/visit_note/transcript_example.txt(廣東話樣本)
  └ README.md              ← 用途說明

mp3 在 mock 路徑下不會被解碼(transcriber 直接讀 transcript 樣本),故只需通過 exists() + suffix in SUPPORTED_AUDIO_EXTENSIONS 兩道檢驗即可。

C. 強制離線 demo 路徑(不靠環境變數)

service.run_extraction(..., *, force_mock: bool = False) 加 keyword-only flag:

  • force_mock=True 時 完全跳過 transcriber.transcribe_audio() 與 llm_client.{analyze_template_contract,generate_slot_content};
  • transcript 直接讀 data/samples/visit_note/mock_transcript.txt(或 _MOCK_TRANSCRIPT_FALLBACK);
  • contract / slot_content 由 _mock.mock_template_contract() 與 _mock.mock_slot_content() 即時計算。

services.home_visit.run_phase1(db, sid, *, force_mock=False) 同步加旗,傳入 run_extraction(...),並在 mock 路徑下把 ai_provider="mock"、ai_model="offline-mock" 寫進 DB,UI 一眼可辨。

D. 新增 API 端點(api/home_visit.py)

Method Path 行為
GET /api/home-visit/mock-demo/available 探測 fixtures 是否在位 + 回報 is_mock_mode
POST /api/home-visit/sessions/mock-demo 一鍵:載入內建 mp3 + docx → create_session → 背景 run_phase1(force_mock=True);回傳 SessionOut,前端 navigate 至覆核頁

兩條都不需要任何 body / multipart,避免和現有 POST /sessions(multipart 上傳)路由衝突。

E. 前端「一鍵 Mock 示範」按鈕(pages/HomeVisit.tsx)

立案區「送 件 立 案」旁加一個次要按鈕 ✦ 一鍵 Mock 示範:

  • disabled 條件與真實 submit 互斥;
  • 點擊呼叫 api.createVisitMockDemo() → reload list → 直接 nav("/home-visit/sessions/{id}") 跳到覆核頁;
  • folio 提示語從「建檔後將自動啟動轉錄與初稿生成」擴充為「… · Mock 示範可離線完成全流程」。

lib/api.ts 增 createVisitMockDemo() 與 getVisitMockDemoAvailable() 兩個方法。

F. 端到端驗收(curl 實測,session id=3)

步驟 結果
POST /api/home-visit/sessions/mock-demo(無 body) 200,回傳 session 3,status=uploaded
等 2.5s 後 GET /sessions/3 status=pending_review,ai_provider=mock、ai_model=offline-mock、ai_latency_ms=9、template_contract.fixed_blocks=9、dynamic_slots=30
POST /sessions/3/review(以 AI 草稿原樣作為 final) status=confirmed,generated_file=exports/visit_notes/visit_note_3_20260514_214150.docx(11450 bytes)
GET /api/files/...(download_url) 200,下載 docx 11450 bytes
python-docx 讀取輸出前 25 段 含「長者姓名:陳麗珍婆婆(化名)」「面談日期:2026 年 5 月 14 日」「三、長者近況摘要:陳婆婆表示近日精神尚可…」等真實成型內容(非佔位符)

mock report 同時保留模板九個 section heading(一、基本資料 … 八、備註)verbatim 不被改寫,符合 fixed_blocks 合約。

G. 教訓 / 記憶

  • mock.py 用錯 schema key 的 bug 是「半啟用」狀態 — Python 不 fail、只是悄悄不生產 slot。Pipeline 表面正常、輸出垃圾。寫 mock 時要 跑一次真實渲染器 smoke check 才算驗收。
  • DashScope key 在 dev 機常設成有效值,靠 if not key 來自動切 mock 反而會誤觸真實 API;做 demo 入口時用 explicit force_mock=True 旗標、不要靠環境推斷。

H. Hotfix(同輪):覆核頁顯示空白

第一次測試時用戶回報「内容是空的」。檢查 pages/HomeVisitReview.tsx 發現 UI 用 slot.label 當 slot_content 的查找 key —— 但渲染器 (docx_template_renderer.py) 以及 mock 產出皆以 slot.slot_id 為 key。換言之 UI 一直只查 label,碰到任何 slot_id-keyed 內容(包括以前真實 LLM 也應如此)都會顯示空白。

修法:

  • key 改為 slot.slot_id || slot.label(fallback 保留舊契約相容);
  • 顯示用 slot.label || slot.slot_id;
  • slot.section_hint 在沒有 description 時顯示為「所屬段落:…」,方便社工辨認 mock 段落歸屬;
  • 固定區塊渲染同步支援 b.text || b.content,兼容 mock 與舊 LLM schema。

I. Hotfix(同輪):iCloud Drive 上 Vite dev server hung

第二次測試報「连不上」。診斷:Vite dev server 在 iCloud Drive 同步資料夾下,chokidar 監聽會「socket bound 但 accept() 永遠不返回」(curl 6 秒 timeout)。即便 plain npm run dev、kill 重啟也只能短暫工作 1-2 次請求後再次卡死。

修法:改用 建置後預覽 方案(無 file watcher):

cd CareFlow/frontend
npm run build                                       # → dist/ 約 290 KB
npx vite preview --host 127.0.0.1 --port 5173       # 純靜態 serve,無 chokidar

連續 curl 三次皆 ~2-5ms 穩定 200。此方案 trade-off:失去 HMR。如要再修前端,先 pkill -9 -f vite → npm run build → 重起 preview。HMR 模式僅在非 iCloud 路徑可靠(建議將 repo 移到 ~/dev/ 後可恢復 npm run dev)。

Commit:v0.4.0-rc5 — Home-Visit β 全 mock 一鍵 demo + mock.py schema 修正。


v0.4.0-rc4 · 2026-05-14(round-7 細部 polish + Volunteer 匯出閘門放寬)

指令:用戶 round-7 給出 5 個細修點 + 1 個 P0 bug:

  1. CCSV 地址再下移
  2. CSSA HKID 不該帶括號(要讓檢核碼落入表單本身的 ( ) 內)
  3. OALA 已婚 X 再往左下
  4. SSA 申請日期 2026 應左移、字距更大
  5. P0:流水線 α 的「用印 · 匯出」按鈕無法按下

A. 表格細部 polish

表 欄位 rc3 → rc4 說明
CSSA hkid(1 欄)→ hkid_main + hkid_check(2 欄) path 從 hkid.full 拆成 hkid.main (A123456) + hkid.check (7);rect 分別 [122,127,158,139] / [162,127,174,139],font helv 9pt 原本 A123456(7) 帶括號寫進去與表單已印的 ( ) 重複。現在主號落左、檢核碼直接掉進原 ( ) 框內
CCSV 長者住址 rect [252,612,580,628] → [252,620,580,636] + overflow [60,640,580,656] 再下移 8pt 與表單下劃線對齊
OALA 婚姻狀況「已婚」打勾 tick_rect [213,388,225,400] → [208,391,220,403] 左 -5 / 下 +3,X 中心對齊方格中心
SSA p1 申請日期 Y/M/D rect 年 [420,92,458,106]→[400,92,455,106]、月 [468,92,505,106]→[468,92,503,106]、日 [520,92,552,106]→[515,92,550,106] 年欄向左加寬 20pt 讓 2026 4 個字 spread 範圍從 38pt 增至 55pt,間距更大;月/日 微調

B. P0 修復:Volunteer 匯出按鈕(pages/VolunteerReview.tsx)

disabled={!allReviewed || busy} 改為 disabled={busy || records.length === 0}。原本若有任一張未審查,按鈕全程鎖死、tooltip 寫「請先完成全部人工審查」——對 demo / 中途檢視場景不友善。

現在改為:

  • 全部審查完 → 直接點 → 正常匯出
  • 仍有未審查 → 跳 window.confirm(...) 提示「尚有 N / M 份未審查,不譯別的欄位會原樣匯出。確定即處匯出嗎?」→ 用戶按確認才繼續

tooltip 改為「尚有未審查項,點擊會跳出確認框」。

Commit:v0.4.0-rc4 — round-7 polish + volunteer export gate relaxed。


v0.4.0-rc3 · 2026-05-14(細部 drift 二輪修正 + 5 份原始長者樣本 + 照片 AI 抽取)

指令:用戶 round-6 對 v0.4.0-rc2 再做視覺 QA,列出 5 處仍有偏移;同時要求「增加可上傳照片直接給 AI 解析填表」功能,以及「能不能多丟幾份原始長者資料的樣本,讓我直接拿來測試 AI 抽取」。

A. 表格細部 drift 二輪修正(visual QA 重做後再 fine-tune)

表 欄位 rc2 → rc3 原因
CSSA HKID rect.x 110→122、x2 178→195 原本 (7) 落在 : 與 ( ) 之間空白處,未對齊括號;現往右推 12pt 使 (7) 對齊形上 ( )
CSSA 每月入息(新增) 新增 anchor=每月入息、rect=[160,141,207,154],font=helv 9pt,path=income.monthly_self_hkd 表單原有第二排有「每月入息 ____ 元」欄位,rc2 未填;現自動填入 mock 的 monthly_self_hkd=0
CCSV 長者住址 rect [145,612,575,625] → [252,612,580,628] + overflow [60,632,580,648] 原 rect.x 145 OVERLAP 標籤「長者住址(請填寫詳細地址):」(x=60-248),地址寫到一半被標籤遮住;現往右推到 x=252 並下移到 628 與標籤同行
SSA p1 申請日期 單欄 today.iso rect [382,78,558,92] → 拆成 Y/M/D 三欄,分別 rect [420,92,458,106] / [468,92,505,106] / [520,92,552,106],各 font=helv 11pt spread_chars,path 改為 today.year / month / day 原單欄寫 2026-05-14 直接壓在「申請日期 Date of Application」標籤上方,與設計的 ____年 ____月 ____日 框完全錯位;現拆三欄正確落在 年/月/日 marker 前
SSA p1 出生日期 Y/M/D y 471-484 → 475-488(整體 +4pt) rc2 數字垂直略偏高,未完全置於 年 Year / 月 Month / 日 Day 標籤水平線上
SSA p2 住址 rect [125,158,575,172] → [205,141,590,155] + overflow [28,162,590,176] 原 rect.x 125 OVERLAP「*香港/九龍/新界 / HK / KLN / NT」(x=127-197);現往右推到 x=205 並上移到 y=141 與選項同行

B. 新增原始長者樣本 5 份(backend/data/samples/profiles/)

讓用戶可以直接複製貼上來測「AI 抽取」管線,覆蓋常見的不規則來源格式:

檔名 場景 風格 預期抽取結果
01_social_worker_note_unstructured.txt 社工家訪手寫筆記 完全無格式中文段落 陳婆婆/陳淑芬·Z654321(8)·1945-06-03·F·屯門兆康苑
02_family_interview_semi.md 家屬訪談半結構 Markdown bullet + key:value 黃淑英/WONG SHUK YING·D789012(3)·1949-11-22·F·深水埗順寧大廈
03_structured_profile.json 第三方系統 JSON 匯出 已完整結構化 張伯/CHEUNG PAK·E234567(9)·single·lives alone
04_patient_card_mixed_lang.txt 醫院病人卡掃描 OCR 中英混雜 + 縮寫 李婉芬/LEE YUEN FAN·B654321(3)·1952-08-22·F·麗安邨
05_minimal_messy.txt 紙條手抄極簡 散裝詞 + 縮寫 王先生·76歲·男·已婚·F123456(8)·北角英皇道

C. 新增:照片 → Vision LLM → ElderProfile 抽取管線(v0.4.0-rc3 重點)

從 rc2 的「文字 AI 抽取」延伸到「任何照片直接抽取」——HKID 卡掃描、紙本申請表照片、社工手寫筆記照片、家屬截圖訊息都可以。

Backend:

  • app/services/welfare_form_extractor.py 新增 extract_elder_profile_from_image(image_bytes, ext, source_hint)。內部複用 app/llm/vision.py::_preprocess_image_bytes() 將原圖壓縮至 ≤800KB / ≤1600px / JPEG@85,再以 data:image/jpeg;base64,... URL 發給 Vision LLM(resolve_model("vision") → qwen-vl-max via Bailian)。Prompt schema 與文字版一致(繁中 JSON, schema aligned with mock_elder_profile.json),response_format=json_object。
  • app/api/placeholder.py 新增 POST /api/welfare-form/extract-profile-from-image(multipart:image: UploadFile, source_hint: Form str | None)。回傳同 rc2 文字版 schema + image_filename / image_bytes 追加欄位。
  • 補裝 python-multipart(FastAPI UploadFile 必需)。

Frontend(pages/WelfareForm.tsx):

  • 「從原始文字 AI 抽取長者資料」摺疊面板內加入 純文字 / 照片 雙頁籤。
  • 照片頁籤:<input type="file" accept="image/*"> + thumbnail 預覽 + 檔名/大小顯示 + 同源 source_hint dropdown 多兩個選項(身份證照、申請表照)。
  • API client extractWelfareProfileFromImage(file, sourceHint) 用 FormData 直接 fetch(繞過原 request<T>() 預設 JSON header)。
  • 結果卡片 + 後續填表流程完全沿用 rc2 邏輯(同一個 extractedProfile state → preview-mapping → fill)。

Smoke test:blank 1x1 JPG → Vision LLM ~38s → 所有欄位空(正確)。實際 HKID 卡照片預期 ≥80% accuracy(需用戶用真實樣本驗證)。

已知限制:Vision LLM 對手寫中文 / 模糊 / 反光照識別率較低;用戶若拍照不清會回 partial profile,需手動補。

Commit:v0.4.0-rc3 — round-6 drift fixes + 5 sample profiles + image extract feature。


v0.4.0-rc2 · 2026-05-14(CJK 字體 ASCII 寬度修正 + 從原始文字 AI 抽取 ElderProfile)

指令:用戶 round-5 對 v0.4.0-rc1 做細部 QA 後給出兩個方向:

  1. CSSA/CCSV/SSA 仍有「英文/數字欄位字符過寬、HKID 與 DOB 互撞、SSA 性別 X 落錯框、SSA DOB 散落」幾處漂移
  2. 新增功能:「讀取幾分長者資料,丟給 GPT 處理成格式化信息,填入表格。現在可以信用 mock。」

A. CJK 字體 ASCII 寬度 bug — Root cause + 修法

china-s(PyMuPDF 內建 CJK 字型 alias)在渲染 A123456(7) 這種 ASCII 字串時,會把每個 ASCII glyph 也按 CJK 全形寬度(≈font_size 全寬)排版,結果 A123456(7) 10 個字 × 全寬 9pt = 90pt,遠超 CSSA HKID cell 的 70pt 可用寬度 → 溢出到下一欄「出生日期」位置,與 DOB 撞字。

修法(welfare_form_filler.py):

  • _insert_text() 簽名加 font 參數:(page, rect, text, font_size, spread=False, font=FONT_NAME),main branch + spread loop branch 都把 fontname=font 透傳給 PyMuPDF。
  • caller _fill_coord_anchor() 從 fill spec 讀 fill.get("font", FONT_NAME),溢出寫入也用同樣字型。
  • 在 cssa.json / ccsv.json / ssa_307.json 對「HKID / DOB / 電話 / 英文姓名」等純 ASCII 欄位設 "font": "helv"(Helvetica,純 ASCII 比例字寬 ≈ 0.5×font_size)。
  • CSSA DOB cell 只 35pt 寬,連 helv 都吃緊 → 進一步把 font_size 從 9pt 降到 7pt,並把 write_rect 收到 [225,127,261,138]。
  • 重要:後端不會自動 reload,code 改完必須 pkill -f "uvicorn app.main" + 重啟才會生效。當下踩了一次坑,被 pyc cache 騙了兩輪。

B. SSA_307 細部漂移修正

問題 Root cause 修法
性別 X 打在「女」上 tick_rect 之前估算 [195,290] 完全錯位 用 page.search_for("□") 探測:男方框 x=129.6–139.6,女方框 x=183.5–193.5。改 male/female tick_rect 為 [128,444,142,458] / [182,444,196,458]
DOB 數字散落在 Y/M/D 三框外 spread_chars 把單一寬 rect 等分成 N 段,但 Y/M/D 三框中間有「年/月/日」文字隔開,無法用單 rect 拆成 3 個 field:date_of_birth_year/month/day,各自獨立 rect [120,471,162,484] / [180,471,211,484] / [230,471,261,484],仍用 spread_chars=True 在自己的小框內均布
婚姻狀況 X 位置整體偏低 tick rect y 沒對齊 □ glyph 中線 6 個選項(單身/已婚/同居/分居/離婚/喪偶)tick_rect 統一 y+4
第 2 頁住址寫在「請填寫申請地址」灰提示字上 rect y 太靠上 從 [125,140,560,154] 推到 [125,158,575,172],overflow 同步推

C. 新功能 — 原始文字 → AI → ElderProfile → 填表

新增後端 service welfare_form_extractor.py(~150 行):

  • EXTRACT_SYSTEM 嚴格 prompt:要求繁中、HKID 拆 main/check、DOB ISO + year/month/day、phone 純數字、地址拆 parts + 完整文字、sex∈{M,F}、marital∈{single,married,divorced,separated,widowed,cohabiting},輸出 JSON 完整 schema 對齊 mock_elder_profile.json。
  • _postprocess():填補 hkid.full / date_of_birth.year-month-day / phone.full+area_code 852 / name_en.full_upper / address_text fallback。
  • _mock_extract():mock 模式回傳李婉芬 demo profile(B654321(3) / 1952-08-22 / F / widowed)。
  • DeepSeek 模式:response_format={"type":"json_object"}, temperature=0.2, max_tokens=1500,過走 Bailian DashScope。

新增 API endpoint POST /api/welfare-form/extract-profile:

{ "text": "陳婆婆,姓名陳淑芬,HKID Z654321(8),1945年6月3日生 …", "source_hint": "社工筆記" }
→ { "profile": ElderProfile, "mock_mode": bool }

前端 WelfareForm.tsx 加可摺疊「從原始文字 AI 抽取長者資料」面板:

  • 6 行 textarea(pre-fill placeholder 範例文字)
  • 來源下拉(社工筆記 / 病人卡 / 家屬訪談 / 個案介紹 / 其他)
  • 「AI 抽取」按鈕 → 呼 extractWelfareProfile() → 顯示抽取結果卡(姓名/HKID/DOB/性別/婚姻/電話/地址 + 信心%+ notes + mock/LLM 標籤)
  • 抽取成功後,下方 preview / fill 自動把 elder_profile 換成抽取結果(不再用 mock);可「清除,恢復用 mock」回到 MOCK-E001。
  • api.ts 加 extractWelfareProfile(text, sourceHint);previewWelfareMapping / fillWelfareForm 都接 elder_profile 參數。

D. End-to-end 驗證

curl -X POST /api/welfare-form/extract-profile -d '{"text":"陳婆婆…喪偶。住屯門兆康苑18樓1808室"}'
→ profile.name_zh.full="陳淑芬", hkid="Z654321(8)", dob.iso="1945-06-03", sex="F", marital="widowed",
  phone_home.full="22334455", phone_mobile.full="98765432", confidence=0.95, mock_mode=false (DeepSeek 真接通)

curl -X POST /api/welfare-form/fill -d '{"template_id":"cssa","elder_profile":<above>}'
→ stats={filled:6, ticked:2, empty_value:[], anchor_missing:[]}, EXTRACTED-XXXX_cssa_*.pdf

5 個模板 PDF 用 PyMuPDF rasterize 後 hi-res 肉眼複核:CSSA 頂列「陳大文 / A123456(7) helv / 1948-03-15 helv 7pt / X@男 + X@已婚」全部不撞字;SSA 性別 X 落在男方框、DOB「1 9 4 8 年 0 3 月 1 5 日」每位數字落在自己小框正中;SSA p2 住址另起一行、兩個電話對齊底線。

E. 已知保留

  • CCSV「長者住址(請填寫申請地址)」灰色提示字仍會跟填入內容輕微疊字 — 屬原 PDF 設計,列為非阻塞。
  • 真實 LLM 抽取 latency ≈ 80 秒(DeepSeek-V4-Pro Bailian),可在前端用「mock 模式」立即出結果做 demo。

v0.4.0-rc1 · 2026-05-14(γ 模板矩陣完工:CSSA / CCSV / SSA_307 全上線 + 字符均布)

指令:用戶 round-4 給予「OALA 完美 + 日期均布」確認後,下達「襲擊完善 γ 的其他表格以及其他核心功能。一次性完成並 deliver」。本次一次性把剩下三張政府表格(綜援登記表 CSSA、長者社區照顧服務券 CCSV、公共福利金 SSA_307)的座標標定全部跑完,並補一個 spread_chars 通用機制給「逐字方框」式欄位。

A. 新增模板 — 都用「underscore-span ground-truth 取代肉眼估算」法

模板 欄位數 抓錨點關鍵字 策略
cssa.json(綜援登記表) 9 申請人姓名 / 出生日期 / 住址 / 婚姻狀況 / 住宅電話號碼 / 流動電話號碼 coord_anchor,9 欄含 sex + marital tick
ccsv.json(社區照顧服務券) 6 中文姓名 / 英文姓名 / 香港身份證號碼 / 出生日期 / 可接收短訊的香港流動電話號碼 / 長者住址 coord_anchor,CCSV 用圖形勾選框,故 sex/marital 暫不打勾
ssa_307.json(SWD307) 10(跨 2 頁) 第一頁:申請日期 / 姓名 / 身份證明文件號碼 / 出生日期 / 性別 / 婚姻狀況;第二頁:住址 / 住宅電話號碼 / 流動電話號碼 coord_anchor,首例多頁模板

B. spread_chars: true — 逐字均布機制

OALA 申請日期 / 簽署日期是「YearMonthDay」三個小方框(如 [2][0][2][6]年),如果用左對齊把 "2026" 整串寫進去,4 個數字會擠在最左邊;如果改 center,又會跟下一格距離不對稱。

修法:在 _insert_text(page, rect, text, font_size, spread=False) 加 spread 參數。spread=True 時,把字串長度 n 把 rect 寬度等分成 n 段,逐字寫到 rect.x0 + step*(i+0.5) - font_size*0.3(補償字寬 ~0.6×font_size 的左偏量)。配合 per-field font_size override,OALA 申請日期跑出來像「2 0 2 6 年 0 5 月 1 4 日」每位數字落在自己的小方框正中,跟原表設計一致。

簽署日期 [432, 712, 545, 723] 也套同樣機制。

C. SSA_307 多頁定位 — 抓 page 2 anchor

第一波填完 SSA_307 第一頁 7+2 欄全綠,但住址/住宅電話/流動電話三欄返回 anchor_missing。回查 PDF 結構發現這三欄在第二頁(個人資料續頁),不在第一頁。修法:在 fill.page 加 1-indexed 頁碼支援(filler 已支援),把這三欄改到 page=2。再跑:0 anchor_missing,全部 7+2 落位。

電話號碼初次寫入時跟「住宅電話號碼」標籤撞字(rect 起點 442 太靠左 / 標籤字尾在 458),把 write_rect 起點推到 480 後跟原表底線完美對齊。

D. 其他副作用修正

  • HKID 列寬不足:CSSA 身份證明文件號碼 cell 只有 70 pt 寬,預設 10 pt 寫不下 A123456(7),per-field font_size: 8 修掉。SSA_307 同樣 9 pt。
  • CCSV 住址 placeholder 殘留:CCSV 原表「長者住址(請填寫申請地址)」括號內是淡灰提示字,會跟填入內容疊在一起 — 屬原 PDF 設計,不影響可讀性,列為已知非阻塞。
  • probe_template.py:寫了通用座標探測腳本(backend/scripts/probe_template.py),對任意 PDF 跑「錨點 hit + underscore span dump + empty box dump」,把模板標定時間從 ~2 小時降到 ~15 分鐘。

E. 端到端驗證(MOCK-E001 → 5 模板全跑通)

模板 結果
joyyou 20/20 acroform
oala 11 text + 2 tick, 0 anchor_missing, 申請日期/DOB 數字均布、HKID 不溢出
cssa 7 text + 2 tick, 0 anchor_missing, X 落在男 + 已婚
ccsv 6 text, 0 anchor_missing
ssa_307 7 text + 2 tick, 跨 2 頁, 0 anchor_missing

5 個模板的 PDF 全部用 PyMuPDF rasterize 後肉眼複核所有 span 位置正確。

改動文件:

  • backend/data/form_templates/oala.json(spread_chars + font_size override)
  • backend/data/form_templates/cssa.json 新增(9 欄)
  • backend/data/form_templates/ccsv.json 新增(6 欄)
  • backend/data/form_templates/ssa_307.json 新增(10 欄跨 2 頁)
  • backend/app/services/welfare_form_filler.py(spread 參數 + _insert_tick + per-field font_size + page 多頁支援)
  • backend/scripts/probe_template.py 新增(座標探測工具)

v0.4.0-beta-fix · 2026-05-13(OALA 座標校準 + 勾選字符修正)

bug:v0.4.0-beta 上線後 QA round-3 用戶回報 OALA PDF「很多內容飄了,且有兩個莫名其妙『㎏』漂浮在半空中」。

根因分析:

  1. 「㎏」幽靈字符:page.insert_text("✓", fontname="china-s", fontsize=12) — china-s 是 Adobe GB1 CJK 字型,U+2713 (✓) 在它的 CMap 沒對應,PyMuPDF 退到 fallback CID,碰巧落在 U+339D (㎏) 的位置。修法:勾選字符改 _insert_tick() 專用函式 → Helvetica + ASCII "X",跨環境穩定。
  2. OALA 全欄位 y 飄移:alpha 版我憑印象標座標,把 write_rect 標在標籤行(如「姓名(中文)」y≈325)而不是底線行(underscore y≈338)。修法:用 PyMuPDF 把 PDF 所有 _____ underscore span 的 bbox 跑出來當 ground truth,重排所有 14 個欄位的 rect。
  3. HKID 寬度溢出:A123456(7) 在 fontsize=10 渲出 ~100 pt,但底線寫入區只有 67 pt(x=148–215),結果文字壓到性別格上。加 font_size per-field override,HKID/DOB 縮到 7 pt。
  4. 申請日期位置錯:把寫入區放在「Year Month Day」英文標籤那行 (y=108) 而非真正空白框 (y=91)。

結果:MOCK-E001 → OALA 全欄填妥、11 text + 2 tick、anchor 0 missing、無 floating glyph。joyyou 仍 20/20。

驗證:/tmp/oala_p1.png rasterized 截圖確認所有 spans 落在正確 underline 上。


v0.4.0-beta · 2026-05-13(功能 γ — PDF 實際回填 + 前端 UI + DeepSeek 對映)

進展定位:γ 路線第二階段。在 v0.4.0-alpha「載入模板」之上,補齊三件事:(1) PyMuPDF 真正寫進 PDF;(2) WelfareForm.tsx 完整 UI(選表 → 預覽 → 手改 → 下載);(3) DeepSeek 補空白欄位。並修掉 alpha 版的 JSON 中文亂碼。

A. PDF 回填服務 services/welfare_form_filler.py

雙策略:

策略 適用 寫入方式
acroform 互動式 PDF(joyyou) widget.field_value = value; widget.update()
coord_anchor 政府掃描 PDF(oala 等) page.search_for(anchor) 驗錨點 + page.insert_text((x, y), value, fontname="china-s", fontsize=10)

支援欄位類型:text / long_text(>28 字溢位寫到 overflow_rect)/ checkbox(tick_when_equals 比對打勾)/ radio_group(多選一,找匹配 option 的錨點寫勾號)。

關鍵踩雷修正 — PyMuPDF Widget 不能 cache:第一版實作把 (page, widget_name)→widget 物件先存進 dict 後再批次寫入,跑起來會丟 RuntimeError('Annot is not bound to a page')。原因是 page.widgets() 是 generator,generator 一耗盡 Widget 物件就 detach。修法:先 build (page, name)→{key, value} plan dict,再 for page in doc: for w in page.widgets(): inline 寫入。修完 joyyou 20/20 全填,32 ms。

B. API 新增

端點 用途
POST /api/welfare-form/preview-mapping 預覽每欄會填什麼。回 mappings[{key, value, source, confidence, reason?}],source ∈ direct/default/llm/missing,summary 有總數 / 直接 / 預設 / AI / 缺四個計數
POST /api/welfare-form/fill 實際生成 PDF;接受 field_values(前端 review 後傳)或 elder_profile(自動 mapping),加上 overrides 永遠覆寫。回 download_url(/api/files/welfare_outputs/...)

C. DeepSeek 對映服務 services/welfare_form_mapping.py

elder profile + template fields → per-field {value, source, confidence}
  • direct(信心 1.0):elder_profile_path 有命中 → 直接用
  • default(信心 0.9):路徑沒命中但 field 有 default
  • llm(信心 0.5–0.9):路徑沒命中且 use_llm=true → DeepSeek 從整份 profile 推測(適合難映射欄位例如 marital_status 從敘事推出)
  • missing:以上都沒搞定 → 留白並回前端紅標

LLM 走 response_format={"type": "json_object"}, temperature=0.2, max_tokens=800,沿用 v0.3.7 已驗證的 DeepSeek-V4-Pro 串接(app/llm/client.py get_client() / resolve_model("text"))。失敗 graceful fallback 為「全 missing」,不影響直接 mapping 的 100% 命中。

D. 前端 pages/WelfareForm.tsx

區塊 行為
左欄 列出 5 個套組,AcroForm / 坐標模板徽章 + 頁數 + 欄位數;未開放套組灰色禁用
右欄頂 套組標題 + 缺欄位用 DeepSeek 推測 開關(會自動重 preview)
右欄表格 每欄一行:label / 可編輯 input / 來源徽章(手改 → 變黃 + 顯示「手改」)
動作列 生成 PDF 按鈕 + 清除手改
結果區 綠色卡片顯示策略 / 耗時 / 填入數 / 勾選數 / 空欄;下載 PDF 連結 + iframe 內嵌預覽

E. JSON 亂碼修正(charset)

alpha 版瀏覽器看 /api/welfare-form/templates 是 長者 一類的鬼字。原因:FastAPI 預設 Content-Type: application/json 沒帶 charset,Safari 用 Latin-1 解 UTF-8 bytes。

修法在 app/main.py:

class UTF8JSONResponse(JSONResponse):
    media_type = "application/json; charset=utf-8"

app = FastAPI(..., default_response_class=UTF8JSONResponse)

F. Sidebar 開放

components/Layout.tsx 的「福利表 → PDF」項移除 disabled: true,正式上線到 γ 路線入口。

煙霧測試結果(2026-05-13)

joyyou (acroform):  { filled: 20, missing_widget: [], empty_value: [] }   latency 32 ms
oala (coord_anchor): { filled: 11, anchor_missing: [], ticked: 2 }        latency 117 ms

兩份 PDF 都已生成在 data/welfare_outputs/,可透過 GET /api/files/welfare_outputs/{file} 下載。

新增 / 改動的檔案

  • 新:backend/app/services/welfare_form_filler.py
  • 新:backend/app/services/welfare_form_mapping.py
  • 改:backend/app/api/placeholder.py(加 preview-mapping / fill 端點)
  • 改:backend/app/main.py(UTF8JSONResponse)
  • 改:backend/app/services/welfare_form_filler.py(widget cache bug 修正)
  • 改:frontend/src/lib/api.ts(加 previewWelfareMapping / fillWelfareForm)
  • 改:frontend/src/pages/WelfareForm.tsx(從 placeholder 變成完整 UI)
  • 改:frontend/src/components/Layout.tsx(sidebar 開放)

下一步(v0.4.1)

  • 補齊 ssa_307 / cssa / ccsv 三張表的座標映射(目前 status="pending_coord_mapping")
  • 真實 elder profile 來源:從 elder DB 拉資料,而非只用 mock
  • 簽名欄位(手寫)方案:先空白 + 列印後手簽 OR 內嵌簽名圖片

v0.4.0-alpha · 2026-05-13(功能 γ — 福利表格預設套組)

進展定位:γ 路線第一階段(「先提取格式、標準化、寫到預設套組」)。本版只交付模板載入,PDF 實際回填留給 v0.4.0-beta。

PDF 探勘結果(PyMuPDF 1.27.2,A4 portrait 595×842 pt)

PDF 頁數 AcroForm widgets 結論
joyyou_apply.pdf(樂悠咭 $2 票價) 4 25(20 Text + 5 RadioButton,全在 p2) AcroForm 直填 — 一行 widget.field_value=... 即可
OALA_Simplified Form_chi_012025.pdf(長者生活津貼) 7 0 座標錨點 — 用文字 anchor 校驗 + page.insert_text
Online_SWD307_SSA_Application_Form_(Rev)(9_2023).pdf(公共福利金) 10 0 座標錨點(待標註)
CSSA_Registration_Form_c_2025-10.pdf(綜援) 7 0 座標錨點(待標註,含家庭/收入/資產,較複雜)
CCSV_application_form_(September_2023)_CHI.pdf(長者社區照顧服務券) 7 0 座標錨點(待標註)

標準化 template schema(backend/data/form_templates/*.json)

每張表都包:

  • id / display_name / display_name_en / source_pdf / pdf_pages / form_page
  • fill_strategy:"acroform" 或 "coord_anchor"
  • status:"ready" 或 "pending_coord_mapping"
  • elder_profile_keys:本表需要的長者欄位(給前端做完整性檢查)
  • fields[]:每欄附 key / label_zh / label_en / type / elder_profile_path / fill
    • fill 對 acroform 是 {widget, page};對 coord_anchor 是 {page, anchor_text, write_rect} 或 {tick_rect, glyph} / options[](單選題)

已建立的套組

  • joyyou.json — 20 個欄位完整對應(姓 / 名 / 中英文 / HKID 主號+尾號 / 6 段地址 / 兩支電話含區號 / Email / 出生日期 Y/M/D),status="ready"
  • oala.json — p1 14 個欄位(申請日期 Y/M/D / 姓名中英 / HKID / 出生日 / 性別勾選 / 婚姻狀況單選 6 項 / 住址含 overflow / 家居+流動電話 / 簽署日期),status="ready"
  • ssa_307.json / cssa.json / ccsv.json — scaffold,status="pending_coord_mapping"

Mock 長者資料(backend/data/mock_elder_profile.json)

  • elder_id = "MOCK-E001",姓名/HKID/電話皆為示範值(非真實個資)
  • 包含 9 大類:姓名(中英分姓名)/ HKID 主號尾號 / 性別婚姻 / 出生日(拆 ISO + Y/M/D)/ 兩支電話含區號 / Email / 地址(room/floor/block/estate/street/district 拆字段 + 完整文字) / 家庭成員 / 健康 / 收入 / 資產
  • API 取的時候 runtime 注入 today.{iso,year,month,day} 供 OALA 等表格的「申請日期」用

新 API

端點 用途
GET /api/welfare-form/templates 列出所有套組摘要(id / 顯示名 / 頁數 / fill_strategy / field_count / status)
GET /api/welfare-form/templates/{id} 取完整 template + mock elder profile
GET /api/welfare-form/mock-elder 只取 mock elder(不含 template)
GET /api/welfare-form/status(既有) 升級為列出已 ready 的 template id

前端 API 包裝

frontend/src/lib/api.ts 加上 listWelfareTemplates() / getWelfareTemplate(id);UI 頁面 WelfareForm.tsx 留到 v0.4.0-beta(連同 PDF 預覽 / review UI)一起做。

設計取捨

  • schema 採「strategy + elder_profile_path 解耦」:同一個 elder profile 可餵不同 fill_strategy 的表格,方便加新 PDF
  • 單選題用 radio_group + options[]:每個選項各自有 anchor_text 校驗,避免座標漂移(PDF 重排版時)
  • overflow_rect 為 long_text 預留:地址超長時自動換到第二行寫
  • 不提早做 PDF 回填邏輯:先把資料骨架敲穩,下一輪才寫 services.welfare_form_filler 與 LLM 對應

下一步(v0.4.0-beta)

  1. services.welfare_form_filler.fill_pdf(template_id, elder_profile, output_path) — 真正寫 PDF(AcroForm 直填 / 座標寫文字 + 勾選符號)
  2. POST /api/welfare-form/fill → 回填 PDF + 給 download URL
  3. 前端 WelfareForm.tsx:選表 → 預覽 mock elder 對應 → 觸發回填 → PDF 預覽 iframe → 下載
  4. (選用)DeepSeek 把 elder profile + 自由問答 → 自動建議 marital_status 等欄位
  5. 補 ssa_307 / cssa / ccsv 三份的座標標註

v0.3.9 · 2026-05-13(Azure 升為主視覺、Qwen 為 fallback、加「刪此頁」)

為什麼新增

v0.3.8 落地後生產 log(batch 13)顯示:

模型 角色 平均 latency 命中率
Qwen3.6-Plus 主 120 - 550 秒(含內部 3 attempts + corrective + 60s read timeout) ~70%
Azure GPT-5-mini fallback 17.7 - 19.8 秒(reasoning_effort=minimal) 100%(2/2 撿回)

當 Qwen 卡 248 秒回空白、再被 Azure 17 秒撈回時,使用者體感「卡了 4 分多鐘」。既然 Azure 又快又穩,這版把主備角色互換。

做了什麼

  1. Azure 升為主視覺(config.primary_vision_provider="azure" 為預設):

    • volunteer_form.run_extraction 重構成 _call_primary / _call_fallback 兩個函式 dispatch,避免兩條對稱路徑互相重複;
    • log 事件統一從 azure_fallback_* 改為 fallback_*,且 metadata 帶 primary="azure" | "qwen" 供追溯;
    • 若 azure_openai_api_key 未填 → 自動降級為 primary="qwen",整段邏輯回到 v0.3.8 行為;
    • policy A+B+C+D 對稱化:不論誰當主,另一方都會在 exception / 全空白時接手,仍進 DeepSeek review。
  2. Qwen client 變得「快敗」:

    • vision_timeout_seconds 60 → 45 秒;
    • vision_max_retries 1 → 0(不再多一輪 corrective prompt,最壞單張 90 秒就交棒);
    • 這兩條只影響 Qwen client,Azure 仍維持自家 timeout(read=vision_timeout_seconds 仍 45s × 2 attempts,即 90s 上限)。
  3. 「刪此頁」功能 — 用戶實測時遇到「夾在批次裡的格式不對的紙條(OCR 只回 5/13 欄)」,本來只能勉強審查或塞「無資料」。新增:

    • DELETE /api/volunteer/records/{record_id} → services.volunteer_form.delete_record():
      • 刪 DB record + FieldCorrection;
      • best-effort 刪 disk 照片;
      • 更新 batch.total_photos / confirmed_count;若刪完之後剩下的全已審 → batch 自動進 CONFIRMED;
      • 保護機制:若 batch 只剩這一張,回 400「請改用取消批次」,避免留空殼;
    • 前端 VolunteerReview.tsx 在每張紀錄右上角加「刪 此 頁」按鈕,含 confirm() 對話框防誤觸;按鈕在只剩 1 張時 disabled,附 tooltip 解釋。
    • 刪除後自動跳到下一張(若刪掉是最後一張 → 跳前一張)。

設計取捨

  • 不做 soft-delete(加 is_deleted 欄位)→ 因為 batch counter 邏輯依賴 record 真實存在,且 archive 紀錄沒實際價值;
  • 不接「批次選多張刪」→ 第一輪先做單張,把流程跑通;
  • 對「primary 是 azure 但 azure 又空白」的情形 fallback 到 Qwen,目的是收 Qwen 在某些手寫風格上反而強的 case;
  • primary_vision_provider 設成 env 而非每批次選擇 → 為了避免使用者在 UI 上多一個決策成本,等實測有需要再做動態切換。

煙霧測試結果

  • 重啟後 settings.primary_vision_provider="azure"、vision_timeout_seconds=45.0、vision_max_retries=0 正確;
  • /api/volunteer/records/{record_id} DELETE 已註冊在 OpenAPI;
  • 預期下一輪 batch 的 fallback 觸發頻率 ≈ 0(除非 Azure 自家也炸),用戶體感應從「2-4 分鐘等」降到「20 秒/張」。

v0.3.8 · 2026-05-13(Azure GPT-5-mini 視覺 fallback)

為什麼新增

v0.3.7 把 DeepSeek 文字審查的 .with_options(timeout=float) bug 修掉之後,整條 review pipeline 終於穩定。但前一段視覺(Qwen3.6-Plus)仍會有兩類偶發失敗:

  • A. extract_fail:DashScope 連續 timeout / 連線中斷(雖然已有 3 次內部重試);
  • B. 全空白:3 次重試都回 {},影像不算嚴重模糊但 Qwen 就是不出值。

原本兩種情況我們都直接寫 __needs_human_input__: True 推給社工逐欄手填。實際使用後發現大部分這類照片只是 Qwen 一時抽不出來,人眼一看就有值。所以這版把「另一個視覺模型」接上去當 fallback。

為何選 Azure OpenAI GPT-5-mini

  • Google Gemini 3 在香港不可用(地區封鎖);
  • 騰訊混元視覺 / 火山豆包視覺對手寫繁體表現未驗證;
  • Azure OpenAI 在 East US 2 / Sweden Central 有 GPT-5-mini 部署,香港延遲約 250-400 ms;
  • 價格 $0.25/M input + $2/M output ≈ $0.0014 / 張(含 base64 縮圖後的 image token)。

做了什麼

  1. 新增 backend/app/llm/azure_vision.py:

    • _get_azure_client():快取 AzureOpenAI(api_key, api_version, azure_endpoint, timeout=httpx.Timeout(...));
    • extract_volunteer_form_azure(photo_path, photo_index):完全沿用 vision.py 的 SYSTEM_PROMPT / _user_prompt / _preprocess_image_bytes / _normalise / FIELD_KEYS;
    • gpt-5-mini 為 reasoning model 的 API 差異:使用 max_completion_tokens=4096(不是 max_tokens)、不接受自訂 temperature,改用 reasoning_effort="minimal" 控制 OCR 任務的延遲與成本;
    • 仍維持 response_format={"type":"json_object"} 強制 JSON 輸出;
    • 寫入同一份 backend/logs/vision.log,事件名 azure_extract_start/attempt/ok/empty_response/attempt_fail/fail;
    • 回傳形狀與 Qwen 完全相同 {fields, confidence, bbox, _meta},_meta.provider="azure_openai",_meta.fallback=True。
  2. 新增設定(app/config.py + .env.example):

    AZURE_OPENAI_ENDPOINT=https://xxx.cognitiveservices.azure.com/
    AZURE_OPENAI_API_KEY=
    AZURE_OPENAI_DEPLOYMENT=careflow-gpt-5-mini
    AZURE_OPENAI_API_VERSION=2024-12-01-preview
    AZURE_OPENAI_MODEL=gpt-5-mini
    AZURE_FALLBACK_ENABLED=true
  3. 觸發策略(policy A+B+C+D,全部開啟):

    • A:Qwen 抽 throw exception → 直接打 Azure;
    • B:Qwen 抽完成但全空白(13 個欄位都 null/空)→ 也打 Azure;
    • C:Azure 仍失敗 / 仍空白 → 寫 __needs_human_input__: True 給社工逐欄手填;
    • D:Azure 成功後,繼續走 DeepSeek-V4-Pro 二次審查(不跳過),確保品質一致。
    • 寫進 services/volunteer_form.run_extraction() 的回寫迴圈,並輸出 azure_fallback_trigger / azure_fallback_ok / azure_fallback_exception 三個 log 事件供追溯。
    • 若 AZURE_FALLBACK_ENABLED=false 或 AZURE_OPENAI_API_KEY 為空,整段邏輯回退到 v0.3.7 行為。
  4. 前端 VolunteerReview.tsx:當 ai_provider === "azure_openai" 時顯示一條藍色「Azure GPT-5-mini 補抽」橫幅,提醒社工此張不是 Qwen 抽的、信心可能不同。

Endpoint 踩坑紀錄

初次連線回 401 Access denied,原因是 Azure AI Foundry 部署用的不是傳統 Azure OpenAI endpoint:

來源 endpoint 形式 key 長度 SDK
傳統 Azure OpenAI https://<r>.openai.azure.com/ 32 char AzureOpenAI
Cognitive Services 多服務 https://<r>.cognitiveservices.azure.com/ 32 char AzureOpenAI(部分相容)
Azure AI Foundry(這次用的) https://<r>.services.ai.azure.com/ 84 char AzureOpenAI(chat completions 路徑相容)

正解:用 Foundry 自家 endpoint + 84 char key,OpenAI SDK 會自動在後面拼 /openai/deployments/<dep>/chat/completions?api-version=...,可成功。.env.example 已標清楚此格式。

Live smoke 測試結果

用 batch_13 的 volunteer_form_17.jpg(97 KB):13/13 欄位全部抽出、latency 19.8 s、reasoning_effort=minimal、無 error。代表 fallback 路徑可在生產環境直接使用。

設計取捨

  • 不採「先打兩家、選好的」並發策略 → 成本翻倍且 Qwen 成功率 > 80 %,等失敗再 fallback 才符合用量曲線;
  • 不在 fallback 後二次跑 Qwen → 同一張圖兩家都不會,第三家機率很低;
  • 不把 Azure 設成主視覺 → 香港對 East US 2 仍有 ~300 ms 延遲,DashScope 國內節點更快;
  • API key 留空時整段 fallback 靜默跳過,不阻塞 Qwen 主路徑。

v0.3.7 · 2026-05-14(DeepSeek timeout 真正修好 + rec 60 回溯驗證)

為什麼新增

v0.3.6 把影像預處理跟 httpx.Timeout(connect=10, read=60, ...) 接上之後,Qwen 視覺穩了,但用戶測 batch 12(rec 60)回報:「DeepSeek 審查停了 3 分鐘還沒回」。vision.log 上的 review_attempt_fail 的 latency_ms=184000,明明 client 設了 60s。

根因

llm/text.py 裡為了給 DeepSeek 更長時間,原本寫了 client.with_options(timeout=120.0).chat.completions.create(...) —— 看似合理,但 OpenAI SDK 1.x 在收到「單一 float」當 timeout 時會把它包成一個 Timeout(total=None, connect=120, read=120, write=120, pool=120) 並覆寫掉底層 httpx.Timeout 的所有細項。更糟的是內部 streaming 拆 chunk 後,每個 chunk 都會重置一次 read clock → 實測等價於「無限期等」。

做了什麼

  1. 拿掉 .with_options(timeout=float):直接用 get_client() 已配置的 httpx.Timeout(connect=10, read=60, write=60, pool=10),不再 override。
  2. 加 timeout 重試:review_volunteer_extraction 內部 for attempt in range(2),只對 timeout / Connection* 類錯誤做 1 次 retry;若 4xx / 5xx 等業務錯誤直接 break,不浪費 quota。
  3. rec 60 回溯測試:本機重跑 batch 12 同一張照片,DeepSeek 60s 內回應、修補 3 個 fragment 欄位(elder_address: "九龍 灣 麗 港 城" → "九龍灣麗港城"、elder_name: "陳 小 明" → "陳小明"、follow_up_note: "下 次 帶 食 物" → "下次帶食物"),latency 從 ∞ 降到 38 s。

設計取捨

  • 不把 timeout 拉到 120s+ → 60s 已涵蓋 DeepSeek-V4-Pro 95-percentile 延遲,更長只會讓使用者等空白;
  • 不對非 timeout 錯誤重試 → 4xx 是參數錯,重試只會繼續錯;5xx 是 DashScope 服務問題,重試大概率還是炸。

v0.3.6 · 2026-05-13(影像預處理 + httpx Timeout + 連續空白標記)

為什麼新增

v0.3.5 Hotfix-2 修好 DeepSeek review pipeline 後,用戶第二輪測試(batch 12)回報「这次几乎全部是空的」。翻 vision.log 後發現:

  • DeepSeek review 正常運作了(review_phase_start: 1, review_start: 5, review_ok: 4);
  • 但上游 Qwen 抽取 5 張裡有 3 張完全失敗:
    • 2 張大 PNG 截圖(2-3 MB)attempt 3 次全 timeout(實際 ~190s/次 vs 設定 60s);
    • 1 張 JPG Qwen 3 次重試都回 {} 空 dict;
  • 用戶看到的「補寫結果跟原文一樣」實為「原文本身就是空的,DeepSeek 沒東西可補」。

做了什麼

  1. 影像預處理(vision._preprocess_image_bytes):

    • 任何 >800 KB 或長邊 >1600 px 的圖都會先用 Pillow 縮 + 重壓為 JPEG@85;
    • 透明 PNG 自動鋪白底(避免 base64 黑塊);
    • 失敗時降級回原檔,不擋主流程。
    • 實測:2.5 MB PNG → 295 KB JPEG(89 % 減量),4 KB 小 JPG 完全跳過。
  2. EXTRACTION_PARALLELISM 4 → 2:減少同時打 DashScope 的併發數,避免大 base64 payload 互相擠壓帶寬。

  3. 顯式 httpx.Timeout(connect=10, read=60, write=60, pool=10)(llm/client.py):取代 OpenAI SDK 預設的 timeout=120.0 —— 後者在大 payload 上會被解讀為「per-event-stream-chunk」,導致實測 60s 設定變 190s。

  4. 連續空白標記(volunteer_form.run_extraction):

    • Qwen 連續 3 次 attempt 全回空 dict → 在 ai_extracted 寫入 __needs_human_input__: True + 設 ai_error="Qwen 連續返回空白 — 影像可能模糊或內容不清,請手動填入。";
    • 抽取完全失敗(網路 / timeout)→ 同樣標記 + 保留錯誤訊息;
    • 後端 RecordOut 新增 needs_human_input: bool;
    • 前端 VolunteerReview.tsx 新增朱砂紅「需 人 工 輸 入」橫幅,告訴使用者「這張不是 AI 漏字,是真的看不清,請逐欄手填」。

設計取捨

  • 不對 Qwen 連續空白做 DeepSeek-V 文字 fallback(DeepSeek 沒視覺、亂回比缺更糟);
  • 不自動 OCR fallback 到其他模型(避免帳單擴張);
  • 預處理永遠走 in-memory,不改寫原檔(保留人工複核時可以看到原圖)。

v0.3.5 · 2026-05-13(AI 連線自檢面板 + 審查永遠執行)

為什麼新增

v0.3.4 上線後用戶實測,所有 5 張照片都炸了 Connection error.,看不出來是網路、Key、還是 API 端點問題:

AI 抽取警告:Connection error. 增加一个 AI 链接自建功能,然后再让我测试一次。

做了什麼

後端新增 GET /api/llm/diagnose:

  • 解析當前 provider 對應的 base_url;
  • DNS + TCP 探活(socket.gethostbyname + socket.create_connection,timeout 5s);
  • Text 探活 —— 對 DeepSeek-V4-Pro 發 pong 指令(10s timeout);
  • Vision 探活 —— 對 Qwen-VL 發 1×1 透明 PNG + 「請回覆 ok」(15s timeout);
  • 回傳一個結構化 JSON:每項 {ok, model, latency_ms, reply?, error?}。

前端 Settings.tsx 新增「AI 連線自檢」面板:

  • 「開始自檢」按鈕 → 呼叫 api.diagnoseLLM();
  • 結果以表格呈現:模式 / 供應商 / API Key / Base URL / 網路 / Text / Vision,每項紅綠燈 + 延遲;
  • 任何一項 FAIL,會直接把後端 SDK 拋出的原始錯誤字串(最多 500 字)顯示在該行下方,方便排查。

設計取捨

  • 不嘗試代理 / 重試 / 切換 endpoint,純診斷;
  • DNS / TCP 層獨立執行,可區分「網路完全斷」vs「端點 401/超時」;
  • 探活用最便宜的 prompt + max_tokens=10,避免燒額度。

Hotfix —— DeepSeek 二次審查改為「永遠執行」

用戶實測 v0.3.4 後發現「AI 修正和原文一模一样」,翻 backend/logs/vision.log 後確認:

Counter(events[-150:].kind) = {
  extract_attempt, extract_ok, autocomplete_*  # 都有
  review_start, review_ok, review_fail         # 0 個
}

根因:v0.3.4 只在「susp (missing ∪ partial ∪ low_conf) 非空」時才丟給 DeepSeek。但 Qwen 對「邀 加中心活」這類碎片往往回 0.85 高信心,本地規則沒抓到 → susp 為空 → review 跳過。

修法(同次提交):

  • volunteer_form.run_extraction 把「需審查欄位」改成「所有非 meta 欄位」(all_keys),把 flagged_keys(本地規則旗標子集)作為「重點關注」單獨傳給 DeepSeek;
  • review_volunteer_extraction() 新增 flagged_keys 參數,system prompt 增列「碎片中文 (fragmented)」「拼字 / 錯字」「不合理值」等檢查項;
  • 若 Qwen 返回值與 DeepSeek 返回值完全相同,跳過寫回 → 不打「DeepSeek 修正」標 → UI 不誤導;
  • 新增 review_skip_empty 日誌(Qwen 完全空白時不浪費 DeepSeek 配額)。

這意味著現在每張照片至少會跑一次 DeepSeek 純文字審查(≈ 3–10s),預期能把「邀 加中心活」這類 OCR 碎片自動修為「邀請參加中心活動」並打上朱砂紅「DeepSeek 修正」徽記。

Hotfix-2 —— Hotfix-1 本身有 bug,DeepSeek 仍未實際執行

用戶第二輪實測(pid 77051)回報:「补写结果和 AI 原文一模一样。另外,输出质量层次不齐,应该不是 qwen 方面的问题」。

翻 vision.log 仍是 review_*: 0,再翻 uvicorn 的 stdout(/tmp/backend.log)才看到被 BackgroundTasks 吞掉的 traceback:

File ".../app/services/volunteer_form.py", line 184, in run_extraction
    + list(comp.get("partial_fields") or {}).keys()
AttributeError: 'list' object has no attribute 'keys'

Hotfix-1 把 list((d).keys()) 寫成了 list(d).keys() —— 運算優先順序錯誤,list(d) 直接展開成 key 列表,再對列表呼叫 .keys() 就炸了。這個錯誤發生在 review 階段的最頂端、session.commit() 之後、_log_event("review_start") 之前,所以 vision.log 看不到任何痕跡。run_extraction 又是 BackgroundTasks.add_task() 注入的非同步任務,FastAPI 只會把 traceback 丟去 stderr,前端、log 都看不到。

修法(v0.3.5 Hotfix-2):

  1. 修正運算優先順序:list((comp.get("partial_fields") or {}).keys());
  2. 把整段 DeepSeek 審查邏輯抽成獨立函式 _run_deepseek_review(session, pending),在 run_extraction 內用 try/except 包住,任何例外都會打 review_phase_error 事件到 vision.log(含 error_type + error[:300]),不再被 BackgroundTasks 默默吞掉;
  3. 新增 review_phase_start 事件,方便確認「審查階段確實有被進入」;
  4. 對既有 batch 11 補跑一次審查,驗證效果:
紀錄 照片 DeepSeek 修正欄位 範例
48 volunteer_form_14 3 '上水彩' → '上水彩園邨'、'高血 控制中' → '高血壓控制中'、'邀 加中心活' → '邀請參加中心活動'
49 volunteer_form_15 2 '鞍山耀安' → '馬鞍山耀安邨'、'—' 佔位符 → None
50 volunteer_form_18 3 '土瓜 崇安街' → '土瓜灣崇安街'、'近期失去配偶, 情 低落' → '近期失去配偶,情緒低落'
47 / 51 njc / form_19 0 Qwen 抽取已乾淨或為空,DeepSeek 認可不修

v0.3.4 · 2026-05-13(DeepSeek 二次審查 · 碎片中文偵測 · 一鍵撤回)

為什麼還要再加一層

用戶實測 v0.3.3 後回報:

现在出现了「邀 加中心活」这种断断续续的话,而且没有被高光。我需要你每次把千问的输出直接传给 deepseek 审查,然后自动补全,然后高亮补全过的栏目,默认补全,可手动撤回。

兩個問題並存:

  1. 碎片偵測有死角 — 局部模糊正則只認「??」「〇〇」「__」「XX」「…」等固定符號,對「漢字 + 空格 + 漢字」這種 OCR 漏字產生的句子(例如「邀 加中心活」原本應為「邀請參加中心活動」)完全沒反應。
  2. 沒有跨模型交叉校驗 — Qwen3-VL 抽出來什麼就是什麼,沒有第二個模型來幫忙挑錯 / 補全。

v0.3.4 的解法是雙管齊下:

  • 把碎片偵測正則補上「漢字 + 全形或半形空白 + 漢字」([\u4e00-\u9fff][\s\u3000]+[\u4e00-\u9fff]);
  • 在 Qwen 抽取後加一層 DeepSeek-V4-Pro 純文字審查 —— 拿到 fields/confidence + 疑似有問題的欄位清單,讓 DeepSeek 根據上下文推測合理值,預設直接套用,同時把 Qwen 原值保存在 __qwen_original__,前端可一鍵 ↶ 撤回。

後端 — app/llm/text.py 新增 review_volunteer_extraction()

REVIEW_SYSTEM = """你是一名嚴謹的 NGO 行政助理...
問題類型:missing / partial / low_confidence / 不合理
修補策略:找上下文線索 → 修;找不到 → null,寧缺勿造假。
回傳 JSON:{reviewed: {key: {value, reason, confidence}}}
"""

def review_volunteer_extraction(fields, confidence, suspicious_keys, field_schema):
    # 純文字呼叫 DeepSeek,response_format=json_object,temperature=0.2
    # 只列出「改動過」的欄位;沒問題的不列
    return {"reviewed": {...}, "_meta": {...}}

服務層 — services/volunteer_form.py 改 pipeline

Qwen 抽取 (並行) → 寫 DB → flip 狀態為 PENDING_REVIEW(讓用戶立即看到結果)
                            ↓
                         並行對每筆呼叫 review_volunteer_extraction
                            ↓
                  serial writeback:把 Qwen 原值存到 __qwen_original__,
                  reviewed value 套用到 ai_extracted / final_fields,
                  mark __reviewed_keys__ / __reviewed_reasons__ / __reviewed_confidence__

新增 revert_reviewed_field(session, record_id, field_key) —— 從 __qwen_original__ 取回原值,從 __reviewed_keys__ 移除標記。

API

RecordOut 新增四個欄位:

  • reviewed_keys: list[str] — 哪些欄位被 DeepSeek 改過
  • reviewed_reasons: {key: str} — DeepSeek 給的理由(10 字內)
  • reviewed_confidence: {key: float} — DeepSeek 對該值的信心
  • qwen_original: {key: any} — Qwen 原本抽到的值(撤回時用)

新增 endpoint:POST /api/volunteer/records/{id}/revert/{field_key}。

前端 — VolunteerReview.tsx FieldRow

  • 該欄位被 DeepSeek 改過時:左邊 border 改成 朱砂紅、底色淺紅,徽記從「缺 / 局部模糊」變成 「DeepSeek 修正」;
  • 輸入框下方新增 ✎ DeepSeek 修正:<reason> (XX%) · Qwen 原值:<原值> [↶ 撤回] 一行;
  • 按 ↶ 撤回 觸發 api.revertReviewedField(recordId, fieldKey) → 後端把該欄位還原為 Qwen 原值 → 前端同步 draft;
  • 撤回後該欄位的徽記與 border 自動消失(因為 reviewed_keys 已移除該 key)。

設計取捨

  • DeepSeek 審查走文字模型,沒有 vision,響應約 3–10s,不會明顯拖慢整體;
  • 並行(ThreadPoolExecutor(max_workers=4))執行;
  • 預設套用符合用戶「默认补全」要求,但每個 key 都有 Qwen 原值備份,「我覺得 DeepSeek 改錯了」的情境一鍵可回退;
  • 若 DeepSeek 自己也判斷不出來,會誠實回 null(系統 prompt 明令「寧缺勿造假」),這時欄位仍會被加進 reviewed_keys,前端徽記提示「DeepSeek 看過了但也沒辦法」,避免用戶誤以為沒做事。

v0.3.3 · 2026-05-13(隔筆空白根因 · 一鍵 AI 建議)

從 vision.log 找到根因 從使用者實測 log 看到:照片 19 第一次只用 47.8 秒就完成,但 raw_len: 2, non_empty_fields: 0 —— 也就是 Qwen3.6 vision 在某些照片上靜默返回 {},不是異常、不是超時。這就是「兩份檔案只有一份有輸出」的根因。

修補

  • extract_volunteer_form() 增加一輪 corrective retry(不算進 max_retries):
    • 若上一次 attempt 的 non_empty_fields == 0 → 觸發。
    • 用更強提示:「你剛剛回傳了空白 JSON,這是錯誤的…必須讀出至少 3 個欄位…字看不清就用『?』佔位」。
    • temperature 由 0.1 提到 0.35 鼓勵不同輸出。
    • 日誌新增 extract_empty_response 事件 + corrective:true 標記。

前端「一鍵 AI 建議」 回應「應該高亮出不完整的欄目,以及一個已經補充好了的補充文本,支持一鍵填入」:

  • 後端 run_extraction() 永遠並行跑 autocomplete(無論 auto_complete 旗標),結果存入 ai_extracted.__suggestions__ 字典。
  • RecordOut.suggestions / suggestion_confidence 兩個新欄位永遠暴露給前端。
  • auto_complete=True 時額外把建議值寫入欄位主體(既有「AI 推測」徽記行為保留);False 時純當建議。

前端 UI

  • 不完整 / 局部模糊欄位 → 整列高亮:朱紅左邊框 + 淡黃底。
  • 缺失欄位旁加「缺」朱章;局部模糊欄位旁加「局部模糊」黃章。
  • 任何欄位有 suggestion → input 下方一行:
    💡 AI 建議:張三 (85%)   [採 用]
    
    點「採用」即把建議值填入草稿。
  • 「信息不完整」標頭旁,如果整份記錄有 ≥1 suggestion → 顯示「✓ 全部採用 AI 建議」按鈕,一次把所有建議套到草稿。
  • 沒 suggestion 時退回原 🪄 讓 AI 補全 手動觸發按鈕。

端點變動

  • RecordOut + history batch_detail 都新增 suggestions + suggestion_confidence。

設計取捨

  • Suggestions 永遠跑 → 上傳完成後背景補全照單全收,使用者刷新頁面就會看到。代價:每張不完整照片多 ~30–60s 背景延遲,但與抽取並行不阻塞使用者。
  • 把 auto_complete 旗標的語義從「跑/不跑補全」改為「跑完後自動套用還是只列為建議」。

v0.3.2 · 2026-05-13(流水線 α 性能診斷 · 局部模糊偵測)

回應使用者實測痛點

  1. 「兩份檔案處理 12 分鐘還只跑一半」→ 加 timestamp JSONL 日誌、收緊 timeout/retries、自動補全並行化。
  2. 「上次的問題一個也沒解決」→ 把每一次 VLM 呼叫的開始 / 嘗試 / 成功 / 失敗都寫進 backend/logs/vision.log,使用者下次實測完直接傳檔給 AI 分析。
  3. 「資訊不完整不只指『欄位空白』,也包括『深水??街』這種局部模糊」→ 新增字元級偵測 + 黃底高亮。

性能修補

  • settings.vision_max_retries 由 2 → 1(之前最壞 3 attempts × 120s = 6 min/張)。
  • 新增 settings.vision_timeout_seconds = 60.0(之前固定 120s)。
  • auto_complete_fields() 也套同樣 timeout。
  • run_extraction():原本「抽取序列→自動補全序列」改為「抽取並行 → 立即 flip 狀態為 PENDING_REVIEW → 自動補全也並行(4 條)」。使用者一抽取完就能看到結果,補全在背景完成。

JSONL 日誌

  • 檔案:backend/logs/vision.log(自動建立)。
  • 事件:extract_start / extract_attempt / extract_ok / extract_attempt_fail / extract_fail / autocomplete_start / autocomplete_ok / autocomplete_fail。
  • 每行帶 ts (ISO ms)、pid、thread、照片名、attempt 次數、毫秒延遲、非空欄位數、error 訊息。
  • 使用者實測一輪後傳 log 檔即可定位瓶頸(API rate limit / 大檔案 / 隔筆超時 / JSON 解析失敗)。

局部模糊偵測(partial recognition)

  • app/llm/vision.py::find_partial_spans(value) → 偵測 OCR/VLM 常見「無法辨識」佔位符:
    • ?? ??(≥2)
    • 〇 ○(≥1)
    • XX xx(≥2)
    • _ _(≥2)
    • … ... ...(≥3 點)
    • 字面「無法辨識」「看不清」「不清楚」「模糊」
  • assess_completeness() 新返回 partial_fields: {key: [[start,end], ...]};任一欄位有 partial → is_complete=False。
  • API RecordOut + History batch_detail 都帶 partial_fields。

前端高亮

  • VolunteerReview.tsx:
    • 欄位列:若該欄位 AI 原讀有 partial span → label 旁加「局部模糊」黃章;input 下方多一行小字「AI 原讀:深水??街 1 號」,模糊字符以黃底 <mark> 包住。
    • 「信息不完整」標頭分兩列:缺失欄位(空白)+ 局部模糊(部分字符)。
  • HistoryDetail.tsx:grid 卡片底部分行「缺:...」+「模糊:...」。
  • lib/api.ts:RecordOut.partial_fields?: Record<string, [number,number][]>。

端點變動

  • RecordOut 多 partial_fields 欄位(已是 optional,前端兼容)。

設定

  • 在 .env 可覆寫:VISION_TIMEOUT_SECONDS=60、VISION_MAX_RETRIES=1。

使用者下一步測試流程

  1. 上傳 2 ~ 5 張照片觀察狀態流轉時間。
  2. 完成後查看 backend/logs/vision.log 看每張照片實際耗時。
  3. 觀察 review 頁有局部模糊的欄位是否正確高亮。

v0.3.1 · 2026-05-13(流水線 α 修補 · 並行抽取 · 自動補全)

修復

  • 間隔失準 bug:原 vision.py SYSTEM_PROMPT 把所有 null 結構當作合法範本給 LLM 抄,加上 user_prompt 範例同樣全為 null,導致部分照片整份回傳 null。改寫 prompt:
    • 用「具值範本」(張三/80/男/9123 4567…)取代「全 null 範本」,明確禁止照抄。
    • 加入 _global_error 鍵讓 LLM 在「整張無法閱讀」時表態,而非默默回空。
    • confidence 寫值門檻 ≥ 0.3;強調「有看到就要填」。
  • 照片識別太慢:run_extraction() 由 serial 改為 ThreadPoolExecutor(max_workers=4) 並行打 VLM API,DB 寫入仍 serial(避 SQLite 鎖)。20 張預計時間 ~5× 提速。

新增:信息完整性 + 自動補全

  • 後端 app/llm/vision.py::assess_completeness() — 8 個必填欄位 + 低信心度(<0.5)欄位偵測。
  • 後端 app/llm/vision.py::auto_complete_fields() — 對缺失/低信心欄位呼叫 LLM 二次推測,回傳 auto_filled + auto_filled_confidence。
  • volunteer_form.run_extraction(auto_complete=True) — 抽取完一輪自動跑補全。
  • volunteer_form.auto_complete_record() — 單筆手動補全。
  • 新 API:POST /api/volunteer/records/{id}/auto-complete。
  • 上傳 API 新增 auto_complete: bool 表單參數。

前端

  • 新頁面 Settings.tsx(路徑 /settings,左欄 03 條目):
    • 開關「上傳時自動補全不完整欄位」(localStorage 持久化)。
    • 顯示當前 AI 供應商 / 模型(從 /api/home-visit/status 讀)。
  • VolunteerUpload.tsx:顯示「自動補全:已開啟/未開啟 → 設定」,上傳時帶入旗標。
  • VolunteerReview.tsx:
    • 左欄縮圖右上角「不完」黃章 + 左下角「AI 補」朱章。
    • 主面板「資訊不完整」標頭含 🪄 讓 AI 補全 按鈕(單筆觸發)。
    • 欄位列若被自動補全,顯示 AI 推測 朱框徽記。
  • HistoryDetail.tsx:grid 卡片角標 + 缺失欄位文字列。
  • lib/settings.ts:localStorage store + 跨頁面事件廣播。

端點

  • POST /api/volunteer/batches/{id}/photos 新增 auto_complete 參數。
  • POST /api/volunteer/records/{id}/auto-complete 新增。
  • /api/home-visit/status 補上 provider / vision_model / mock_mode 欄位。

設計取捨

  • 「自動補全」永遠走人工審查;AI 推測結果以 AI 推測 徽記標出,不暗示為事實。
  • 並行度限 4:兼顧 DashScope rate limit + 本機 thread 預算。
  • prompt 修正後 mock pool 仍保留,作為 offline demo 後備。

驗收

  • is_mock_mode: false 確認真實 API 連線(provider=bailian, 文字 deepseek-v4-pro / 視覺 qwen3.6-plus / ASR fun-asr)。
  • 上傳 20 張預計 6 ~ 12 秒完成抽取(mock 模式 < 1s)。

v0.1.0 · 2026-05-12(初始版本)

整體

  • 用 enquiry tool 與用戶完成 5 輪需求對焦:確認範圍縮減至「功能 2 + 歷史頁」、確認 AI 組合 = 百煉 一站式(DeepSeek-V4-Pro + Qwen3.6-Plus + fun-asr)、確認強制人工審查、確認 Docker + 綠色包雙部署。
  • 建立 repo 骨架、README 改動歷史機制、.env.example、docker-compose.yml。

功能 2 — 紙本志工表 → Excel

  • 後端 services/volunteer_form.py:Qwen3.6-Plus 視覺抽取 + 信心值 + bbox 溯源。
  • 後端 services/excel_export.py:openpyxl 寫入 NGO 模板,保留格式。
  • 前端上傳頁、左圖右表人工審查頁、Excel 預覽 + 下載。
  • 強制工作流:uploaded → extracted → pending_review → confirmed → exported。

歷史記錄

  • 任務列表 + 篩選(關鍵詞 / 日期 / 志工 / 狀態)。
  • 任務詳情:原始照片、AI 抽取結果、人工修正 diff。
  • 批量導出:指定期間多任務合併為一個 Excel。
  • corrections 表 + diff 統計,供 prompt 微調參考。

Mock 數據

  • services/mock_generator.py:用 PIL 合成 20 張港式風格的志工探訪紙本表照片,含手寫風字體、輕微旋轉、噪聲。
  • 提供範例 NGO Excel 模板(data/templates/volunteer_visit_template.xlsx)。

Scaffolding 占位

  • 功能 1(家訪語音):API 路由占位 + 前端頁面占位。
  • 功能 3(政府福利表):API 路由占位 + 前端頁面占位。
  • LLM Provider 抽象層:可一行切換 bailian / deepseek_official / hunyuan。

部署

  • docker-compose.yml:backend + frontend(nginx) + volume 掛載 ./data。
  • Dockerfile(backend + frontend)。
  • 綠色包 launcher 留位 scripts/build_green_pack.sh(待打包)。

v0.1.1 · 2026-05-12(前端 + 啟動腳本 + 修復)

前端(React 18 + Vite + Tailwind)

  • components/Layout.tsx:暗色側欄 + 5 個 nav,占位功能標「待實作」徽章。
  • pages/Dashboard.tsx:3 大功能卡(功能 2 為實作版、1/3 為占位)、系統狀態(讀 /api/health)、最近批次列表。
  • pages/VolunteerUpload.tsx:批次標題 / 志工隊 / 探訪日期 / 多檔拖拉上傳;自動觸發抽取。
  • pages/VolunteerReview.tsx【殺手畫面】:左圖右表強制人工審查。
    • 縮圖列、左側 photo + bbox overlay、右側 13 欄位表單。
    • 每欄信心值色標(紅 <0.7 / 黃 0.7–0.9 / 綠 ≥0.9)。
    • 點 input → 照片對應區域高亮。
    • 抽取中自動輪詢;全部審查完才能匯出。
  • pages/History.tsx:篩選(關鍵詞/狀態/志工隊/日期區間)、批次表格、多選合併匯出、Top 5 最常修正欄位統計。
  • pages/HistoryDetail.tsx:批次詳情、所有紀錄縮圖 + 最終欄位、人工修正樣本。
  • pages/HomeVisit.tsx + WelfareForm.tsx:占位頁,呼叫 /api/*/status。

容器與啟動腳本

  • frontend/Dockerfile:multi-stage(node:20-alpine build → nginx:alpine serve)。
  • frontend/nginx.conf:SPA fallback + /api/ 反代 http://backend:8000。
  • scripts/start.sh / start.bat / stop.sh:綠色包模式一鍵啟動(無需 Docker)。

修復

  • backend/app/db.py:SQLite 相對路徑現會落到 settings.data_path/careflow.db 並自動建立目錄,解決首次運行 unable to open database file 問題。

驗收(mock 模式)

  • python -m app.seed --reset --count 5 成功,建立示範批次與 5 張合成照片。
  • GET /api/health → 200,回傳 is_mock_mode=true、模型名稱齊全。
  • GET /api/history/batches → 回傳示範批次。
  • npm run build 成功(44 modules,196 KB JS / 21 KB CSS)。

v0.3.0 · 2026-05-13(流水線 β · 家訪語音 → 結構化報告)

整合來源

  • 合作夥伴在 branch/CareFlow/visit_note_agent/ 完成的家訪語音 → DOCX 報告 agent(python-docx 結構抽取 + LLM 模板契約 + 渲染)。
  • 本次主要工作:將其遷入主庫,並改寫為與 CareFlow 既有 OpenAI-兼容百煉客戶端統一(app/llm/client.py),加入 mock 模式 + 強制人工覆核 + 錄音稿加密。

後端 app/services/visit_note_agent/(sub-package)

  • llm_client.py:重寫。analyze_template_contract / generate_slot_content 走 client.chat.completions.create()(DashScope 相容模式,模型由 resolve_model("text") 解析為 deepseek-v4-pro),response_format={"type":"json_object"}。
  • transcriber.py:重寫。改用 client.audio.transcriptions.create(model, file, language="yue"),模型 fun-asr。支援 .mp3/.wav/.m4a/.aac/.flac/.ogg。
  • service.py:拆成兩段以契合強制覆核工作流——run_extraction(audio, template, working_dir) 並行做 ASR + 模板契約分析,返回逐字稿與 AI 草稿;run_render(working_docx, contract, slot_content, output) 在人手覆核後寫出最終 DOCX。保留 generate_visit_case_note 供 partner pytest 套件。
  • mock.py:新增。當 dashscope_api_key 缺位時,啟發式分類 fixed_blocks / dynamic_slots(標籤判斷 + 表格行掃描),AI 草稿以「【AI 草稿 · {label}】… (請社工複核)」自動填樁,整條流水線可離線完整跑通(demo / hackathon 必備)。
  • transcript_vault.py:新增。cryptography.Fernet 加密落地於 data/transcripts/session_<id>.enc(0600),金鑰 data/.transcript_key 自動產生。burn() 以 secrets.token_bytes 隨機覆寫後 unlink,符合「閱後即焚」承諾。
  • 其餘 partner 檔案(docx_structural_extractor.py / docx_template_renderer.py / template_normalizer.py / errors.py / prompts/*.txt)原樣保留。
  • __init__.py:在 mock 模式下用 monkey-patch 把 llm_client.analyze_template_contract/generate_slot_content 換成 mock 版本。service.py 改為 late-binding(from . import llm_client; llm_client.X())讓 patch 生效。

後端 model · API

  • models.py:新增 VisitSessionStatus 列舉(uploaded/extracting/pending_review/rendering/confirmed/failed/burned)與 VisitSession 表。逐字稿欄位刻意不入庫,只保留 transcript_vault_path 與 transcript_burned 旗標。AI 草稿與最終確認版分開存(slot_content / slot_content_final)。
  • services/home_visit.py:從 stub 改寫為真正的編排層。create_session 落檔錄音 + 模板至 data/visit_sessions/session_<id>/,背景啟動 run_phase1;run_phase2 在覆核後輸出 data/exports/visit_notes/visit_note_<id>_<ts>.docx。read_transcript_snippet 只回 200 字以內預覽,主管才能取全文。burn_transcript 觸發 vault 焚錄。
  • 新路由 app/api/home_visit.py(取代 placeholder.py 內舊 stub):
    • POST /api/home-visit/sessions(multipart:audio + template + title + note)→ 立案 + 背景抽取
    • GET /api/home-visit/sessions → 列表
    • GET /api/home-visit/sessions/{id} → 詳細(含 200 字錄音稿摘要)
    • POST /api/home-visit/sessions/{id}/review(body {slot_content_final, reviewer})→ 用印渲染
    • POST /api/home-visit/sessions/{id}/burn → 加密檔焚錄
    • GET /api/home-visit/status → 健康檢查
  • main.py:註冊 home_visit.router。placeholder.py:刪除舊 home-visit 占位。
  • pyproject.toml:新增 python-docx>=1.1、cryptography>=43。

前端

  • pages/HomeVisit.tsx:重寫成立案頁。左 7/12 上傳卡(標題 + 備註 + 錄音 + 模板,編號 i/ii/iii/iv)+ 朱砂「送 件 立 案」印章;右 5/12「隱私 · 信則」四條(朱砂左 border,文字壹/貳/參/肆);下方 .table-archive 列出所有案宗,每 4 秒輪詢狀態。
  • pages/HomeVisitReview.tsx:新增「殺手頁」。左 7/12 動態欄位編輯(每欄附 AI 草稿 / 已修正狀態徽章 + 描述 + 自伸 textarea)+ 朱砂「用印 · 渲染 DOCX」+ 完成後 sage 框顯示下載連結;右 5/12 模板契約預覽(固定區塊以 paper 紙色背景灰字呈現,提醒「原樣寫回」)+ 案宗元資訊表。
  • 「閱 · 錄音稿摘要」按鈕開啟 modal,顯示 ≤200 字逐字稿摘要 + 朱砂「閱 後 · 即 焚」鈕。焚錄後該案宗顯示 已焚 印章。
  • 抽取進行中本頁每 3 秒自動刷新狀態。
  • lib/api.ts:新增 createVisitSession / listVisitSessions / getVisitSession / reviewVisitSession / burnTranscript + VisitSessionOut 型別。
  • components/Layout.tsx:解除 β 流水線 disabled 旗標,正式上線。
  • App.tsx:新增 /home-visit/sessions/:sessionId 路由。

驗收(mock 模式)

  • POST /api/home-visit/sessions(上傳 tests/visit_note/長者個案面談紀錄.docx + dummy mp3)→ 200,狀態由 uploaded 經 extracting 過渡到 pending_review,AI 草稿欄位 1 條、固定區塊識別正常,錄音稿落入 vault(加密)並僅以 200 字摘要回吐。
  • POST /sessions/1/review → 200,狀態 confirmed,產出 data/exports/visit_notes/visit_note_1_<ts>.docx,可下載。
  • POST /sessions/1/burn → {burned: true},data/transcripts/ 清空,再讀 session 時 transcript_snippet=null、transcript_burned=true。
  • 前端三頁(列表 / 立案 / 覆核)TypeScript 編譯零錯,所有版式延續編輯室語言。

設計取捨

  • 為何錄音稿不入 DB?SQLite WAL + iCloud 同步路徑下,明文家訪內容極易外洩給雲端備份,故僅落到本地加密檔;金鑰也只在伺服器本地。
  • 為何拆兩段 phase 而不一鍵生成?符合 CareFlow 第一守則「所有 AI 輸出強制人工審查」——AI 永遠停在 pending_review,社工親手點 用 印 · 渲 染 DOCX 才會落筆。

v0.2.0 · 2026-05-12(UI 重塑 · 模板上傳 · 資料外移)

設計語言重塑 — 編輯室 / 檔案夾風

  • 目標:去除典型 AI Slop 的紫藍漸層、毛玻璃、emoji。改採「香港 NGO 檔案夾」美學。
  • 字型:Noto Serif TC(標題) + PingFang HK(正文) + JetBrains Mono(編號與時間戳)。
  • 配色:紙黃 paper(#fcfaf3→#7a6e51)、墨黑 ink、朱砂紅 cinnabar(#a8412c,作主色與印章)、墨綠 sage(已完成)、琥珀墨 amber_ink(警告)。
  • 元件庫(index.css):.eyebrow 小行帽 + .rule/.rule-thin 橫線 + .table-archive 檔案表 + .input 下劃線輸入 + .btn-stamp 朱砂印按鈕 + .stamp-* 印章狀態徽章 + .conf-dot 信心圓點 + .folio 編號文字 + .chop 旋轉印鑑商標。
  • 全頁帶極淡紙紋背景(雙層 radial-gradient 點陣)。
  • Layout.tsx:左欄改為「卷宗 · 處理流水線」兩個 section,編號用 00/01/02 與 α/β/γ。
  • Dashboard.tsx:折成「今日案頭」三大流水線封面 + 最近案卷 + 系統現況 + 啟用中模板。
  • VolunteerUpload.tsx:仿手填表單,§ i/ii/iii 標號 + 虛線收文區「⌇ 將紙頁 ⌇」+「立 · 案」朱砂印章按鈕。
  • VolunteerReview.tsx:頂條印章 + 縮圖 / 照片 + bbox / 13 欄位三欄。bbox 改朱砂邊框 + 紙黃 mask。
  • History.tsx、HistoryDetail.tsx:表格全部用檔案夾風 .table-archive。
  • 新增 pages/Templates.tsx,刪除分頁式上傳替換為單頁拖入。

新功能:使用者自帶 Excel 模板

  • 後端 services/template_store.py:Dataclass TemplateInfo,manifest data/templates/active.json。
    • upload_excel_template:解析第 1 列為標題,呼叫 _guess_mapping() 對 17 個內建欄位做歸一化模糊匹配(去括號、空格、半形化)。
    • upload_image_template:暫存照片但仍套用內建模板(留 TODO:VLM 解析表格 → 自動生成 mapping)。
  • 新 API app/api/templates.py:
    • GET /api/templates → {kind, file, headers, mapping, schema_keys, schema_labels, ...}
    • POST /api/templates/upload(multipart)
    • POST /api/templates/mapping {mapping: {欄位序號: schema_key}}
    • POST /api/templates/reset
  • services/excel_export.py:export_batch() 改為動態讀 active template 的 mapping,依 column index 寫值,找不到 mapping 時回退到內建 17 欄順序。
  • 前端 Templates.tsx:拖入 .xlsx 或表格照片 → 顯示啟用情況 + 17 欄位逐列下拉指派 schema_key(含「不對應」選項)→ 即時 PATCH。

Mock 資料外移

  • 新增 asset/ 資料夾(與 backend/ 平級),mock 紙本表照片改寫到 asset/mock_forms/volunteer_form_NN.jpg。
  • core/config.py 新增 asset_dir / asset_path,並會自動建立 mock_forms 子資料夾。
  • services/mock_generator.py:寫入 asset 路徑且首次後 cached(重複 seed 不重生)。
  • 安全:/api/files/{path} 仍只允許 data_path 範圍內,不對外暴露 asset/。

後端零碎修復

  • app/main.py:/api/files 路徑檢查改用 Path.relative_to() try/except,更穩。
  • app/api/history.py:/export-combined 改用 Pydantic ExportCombinedPayload(batch_ids, title),避免 FastAPI 把 list 當 query。

驗收(mock 模式)

  • python -m app.seed --reset --count 20 成功;asset/mock_forms/ 內含 20 張 JPG。
  • GET /api/health → 200,is_mock_mode=true。
  • GET /api/templates → 回傳 kind=builtin,17 個 headers 已全部對映 schema_key。
  • GET /api/history/batches?limit=3 → 1 個示範批次 status=pending_review。
  • 前端 npm run dev 成功,5 個 page 均無 TS 錯誤。

About

Public display copy of CareFlow, an AI workflow assistant for Hong Kong elder-care NGO administration.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages