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(保留模板格式 / 合併格 / 公式) |
| PyMuPDF + Qwen-VL bbox 抽取 | |
| 加密 | Fernet(錄音稿 at-rest 對稱加密 + 閱後即焚) |
| 部署 | Docker Compose |
三路 LLM client(backend/app/llm/client.py)互相獨立,任一路缺 key 即各自退回 mock,不影響其餘兩路。
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/v1Azure 端點可直接貼 Foundry「project」URL(
/api/projects/<project>)—_FoundryWrapper會自動降到 resource root 並掛/models。
# 後端
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:5173docker 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 銷毀)
切回 gpt-5.1 (Foundry) 後 θ 上傳「GPT 分析全頁」一律報:
('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
挖出兩個串聯 bug:
-
_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。
- 修:白名單加入
-
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。
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,或重新 pushwilson54311/careflow-frontend:latestimage。
使用者在 θ 上傳後前端報 GPT 分析全頁失敗:[X509: NO_CERTIFICATE_OR_CRL_FOUND]。深掘下發現兩個獨立 bug:
-
iCloud 蠶食 certifi PEM bundle:專案位於 iCloud Drive 同步區,
backend/.venv/lib/python3.12/site-packages/certifi/cacert.pem在 iCloud 「優化儲存空間」下會被換成 stub 或 extended-attrib,導致 OpenSSLload_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 路徑。
- 修:新增 /tmp/careflow_backend.sh 啟動包裝(複製 Homebrew cert.pem 到
-
Foundry
/models路徑只認2024-05-01-preview:使用者把.env的AZURE_OPENAI_API_VERSION改為2025-11-13(GPT-5.1 公告版本),但 azure-ai-inference SDK 對 Foundry/modelsroute 帶這 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-flashok / vision=gpt-5.1reply='ok' 2.3s / asr=DNS only ok。 - 順便
/tmp/careflow_serve.py加Cache-Control: no-storeproxy header,避免使用者瀏覽器顯示舊的崩潰狀態。
- 修:backend/app/llm/client.py
修改檔:backend/app/llm/client.py、/tmp/careflow_backend.sh(新增 launcher)、/tmp/careflow_serve.py(cache header)、README.md。
- 新視覺後端:從舊的
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_VERSIONenv 用寫死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.1chat 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。
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
docs/NGO-MEETING-CHEATSHEET.md 第十一章追加「Q&A 示例(普通话)」共 20 題,分 7 組:A 隱私安全、B 準確性責任、C 學習培訓、D 商業可持續、E 系統整合、F 試行細節、G 神態/價值觀。每題附現場可直接念的回答稿,並標註回答原則。
新增 docs/NGO-MEETING-CHEATSHEET.md:30 分鐘實地面談用單頁速查表。內容含開場 30 秒、信件三大用例對應到 α/β/γ/θ 模組、10 分鐘 demo 黃金路徑、隱私 Q&A、成本子彈(引用 COSTS.md)、五個必問問題、五種常見反對 + 拆解話術、面談後 24h 跟進 checklist、收 cue 結尾稿。
- 工作台 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 對話要點
新增可重用元件 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 仍可呼出原生選檔器,向後相容
部署 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) 標記。
| 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)。
| 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:322BboxCanvas 用globalFieldIndex + i === globalIdx重建 index — 跨頁 add 後會誤指其他欄位,需 canvas API 改造ThetaAudit.tsx:45addFieldsetSelectedFieldIdx 用 pre-updatefields.length,stale closure,需配合 functional setter 改造
.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通過,新 assetdist/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 後端速度優化的前端跟進。四項用戶反饋一次到位:
| # | 任務 | 改動 |
|---|---|---|
| 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.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:兩層放大效應。
deepseek-v4-pro為 reasoning 模型,在大 prompt 下會花 5-10 分鐘「思考」(與輸出 token 數成線性相關)。template_analysis_prompt.txt舊版 schema 要求 echofixed_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):
- 切換預設模型:
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。 - 精簡 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 文字)。 - 壓縮 user message JSON:
llm_client.py兩處json.dumps(..., indent=2)→json.dumps(..., separators=(",",":")),輸入 payload 縮小 ~30%。 - 驗證雙 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.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 撞牆。
修法:
app/services/visit_note_agent/llm_client.py::_chat_json— 一律stream=True,迴圈累加chunk.choices[0].delta.content,最後 join。SSE 框架持續送 chunk 維持連線 keep-alive,徹底繞過伺服器 idle timeout。app/llm/client.py::get_text_client—read_timeout60 → 900s,max_retries從 4 改 0(SDK 內建 retry 對 stream 無效;應用層 retry 已足夠)。- 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 問題。
問題: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 高峰偶發)。
修法(兩處):
app/llm/client.py::_make_client— 將 OpenAI SDK 內建max_retries從預設 2 提升到 4,connecttimeout 由 10s → 15s。所有三路(text / vision via OpenAI v1 / asr OpenAI-compat)共享。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(需 headerX-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 改寫):
- 保留
dashscope.utils.oss_utils.upload_file拿 oss:// URL(這部分 SDK 內部已帶授權 header)。 - 拋棄
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"]}})。 - 自寫 polling loop(
GET /api/v1/tasks/{task_id},每 2s 一次,cap 5 min),等到task_status進入終態。 - 抓
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.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 全面改寫):
- 改用 native
dashscope.audio.asr.Transcription.async_call(model='fun-asr', file_urls=[...], language_hints=['yue'])。 - Local 錄音檔上傳改走 SDK helper
dashscope.utils.oss_utils.upload_file(model, 'file://<abs>', api_key)—— 自動上 DashScope 臨時 OSS 拿可用 URL(48 小時有效,dev 夠用)。 Transcription.wait(task=task_id)同步等任務完成。- 從
output.results[0].transcription_url抓 JSON,解transcripts[].text/sentences[].text拼成完整逐字稿。 - 全鏈路保留結構化錯誤(
TranscriptionError包訊息+原始 payload truncated 給除錯)。
依賴:pyproject.toml dependencies 新增 dashscope>=1.20.0(之前完全沒 import 過原生 SDK,因為走 OpenAI-compat)。
驗證:
from app.main import appimport OK- backend restart:
GET /api/home-visit/statusHTTP 200 - 真實 fun-asr 呼叫仍需有效
sk-...開頭的百煉 API-KEY;現有.env中DASHSCOPE_API_KEY=c06e4e...(32 hex chars 無sk-前綴)會被服務端回InvalidApiKey,需用戶在阿里雲百煉 Console 換正版 key。
決策:
- θ 功能宣告完備(GA) —— rc6.1 → rc6.8 一系列迭代後,θ「PDF 表單 → audit → 一鍵發佈 γ 模板」全流程穩定。版本徽記由
v0.4.0-rc6.8升為v0.4.0,去掉-rc後綴。 - 撤回同事 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(移除MeetingNoteGeneratorimport +/meeting-noteroute)。 - 還原
frontend/src/components/Layout.tsx至e257fca(移除δ · 會議記錄生成導覽項),再單獨升版本徽記 →v0.4.0。 - Merge commit
4721c00與 polish commit4fc98d5、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_anchorJSON)→ 在 γ 福利表選擇模板生成填好的 PDF。 - Vision model:
gpt-4.1-minivia 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_llmJSON 欄位 +db.py輕量ALTER TABLEmigration。 - 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 過慢且位置仍偏)—— 都已放棄。
用戶反饋:「位置並非準確。改回 gpt-4.1-mini。加入你能想到最好的 prompt 以及微調方案。」並要求加雙框驗證 UI 後 commit。
修改清單:
-
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。 -
theta_extractor.py · SYSTEM_PROMPTv2(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.018
0.04;姓名/電話寬 0.150.30;地址 0.40~0.60。 - G. 只看到一條橫線 —— 縮到「中間 70%」,左右各留 15% 餘裕。
- 加 4 條錯誤示範(吃進標籤、整體移位、外擴 padding)+ 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}。
- 政府表單的「填寫橫線」與「□ checkbox」本來就是 PDF 內的向量繪圖物件(line / rect path),不是像素圖。用
-
DB schema 擴充:
ThetaField加bbox_llm: Optional[dict]JSON 欄位(向量微調前的 LLM 原始 bbox)。db.py加_apply_lightweight_migrations()——create_all()後跑 ad-hocALTER TABLE ... ADD COLUMN(SQLite-safe,已存在會靜默忽略),免 alembic 也能升級。
-
backend/app/api/theta.py—— 把bbox_llm與衍生refinedflag 透到前端:/upload寫入ThetaField時帶bbox_llm=f.get("_bbox_llm")。_field_out()多回bbox_llm+refined: bool(f.bbox_llm) and f.bbox_llm != f.bbox。
-
前端 audit UI 雙框模式(
ThetaAudit.tsx+lib/api.ts):ThetaFieldDef加bbox_llm?: number[] | null與refined?: boolean。BboxCanvas新增 propshowLlmGhost:若該欄位refined === true且bbox_llm存在,畫一個藍色虛線框顯示 LLM 原始 bbox(不可互動),疊在當前紅色實線的 refined bbox 旁。- PDF 檢視器上方加 toggle checkbox(預設開啟):「顯示 LLM 原始 bbox(藍虛線)vs 向量微調後(紅實線)」+ 右側統計「已微調 X / Y」。
-
前端版號: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-noteroute、Layout 加了δ導覽項、目錄命名為CareFlow-meeting-notes-branch/)。重新 audit 後確認:實際內容是 β 家訪語音→報告 pipeline 的 ASR 子模組升級提案——把 ASR 由 Bailian DashScopefun-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-noteroute + 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(β),而非另開 δ。
用戶反饋三點:
- θ 抽出的 bbox 位置不夠精準;要求允許重疊、不考慮框之間干涉、寧小勿大。
- UI 上每個 bbox 上方標籤字體太大,多框重疊時看不清。
- θ「審視通過」後沒有保存到 γ 部分作為 template。
修改清單:
-
backend/app/services/theta_extractor.py · SYSTEM_PROMPT—— bbox 規則重寫(A~F 共 6 條):- 明確「目標 = 寫字 / 打勾的區域本身,不是標籤文字」。
- 寧小勿大:可略小於實際,但絕不可大於實際。
- 允許重疊:完全不考慮 bbox 干涉,每欄獨立判斷。
- checkbox 典型 w/h ∈ 0.015
0.03、text h ∈ 0.020.05 給範圍指引。 - 不確定 → confidence < 0.5 + 粗略 bbox,不硬塞大範圍。
-
frontend/src/pages/ThetaAudit.tsx · BboxCanvas—— overlay 視覺瘦身:- 預設邊框 1px 半透明、bg 透明度 5%;選中才 2px 實色 + 10%。
- 標籤預設
text-[7px]+ 60% 透明 + 截斷前 8 字;hover 或選中才放大到text-[10px]全文 +z-20浮到上層。 - 重疊區可以靠 hover 逐個浮起來看清楚。
-
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}。
- PUT
-
前端版號: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 withfill.write_rect; - DELETE 後 JSON 自動移除。
Commit:v0.4.0-rc6.7。
背景:rc6.5 結論「gpt-5-mini 對密集 7 頁 CSSA 表的能力上限」。改用 gpt-5 / gpt-5.1 嘗試,遇兩道牆:
- Azure GPT-5 / GPT-5.1 全區 insufficient quota(用戶帳號 default tier 未開高配額)。
- 嘗試把 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 浪費。
修改清單:
backend/app/llm/client.py · get_vision_client():- 偵測 endpoint 結尾
/openai/v1→ 走 plainOpenAISDK(timeout=read 180s)。 - 否則回退
_FoundryWrapper(保留 class,方便日後切回)。
- 偵測 endpoint 結尾
backend/.env:AZURE_OPENAI_DEPLOYMENT=gpt-4.1-mini、AZURE_OPENAI_MODEL=gpt-4.1-mini、endpoint 改為/openai/v1。- 前端版號: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但特定 deployment400 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/v1surface 是 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。
用戶 round-12 後續:rc6.4 修正了 audit 頁顯示問題,但用戶上傳 CSSA 表單時 GPT 主動回 fields: [] —「報告中已经显示有多个栏位,但是人工審視的時候還是 0 欄位」的真正死症。
根因鏈:
- ❌ rc6.3 fix 未生效:後端從 rc6.3 commit 後一直沒重啟,舊 code 仍跑
response_format={"type":"json_object"},Foundry 422 → 全部頁面 fail。 - ✅ 重啟後:response_format 順利移除,page_count=0 真因浮現。
- ❌
max_tokens=4096不夠:gpt-5-mini 是推理模型,會先消耗大量 reasoning tokens。簡單 1 頁 PDF 勉強夠(53s 回 15 fields),但 CSSA 這類密集表單 4096 全耗在推理上 → 回應字串空 → fields=[]。 ⚠️ gpt-5-mini 對密集表單能力本身有限:即使max_tokens=16384+ DPI 220 + 強化 prompt,CSSA 7 頁仍回fields: [](raw_len 22-31,即{"page": X, "fields": []}字面)。簡單測試 PDF 仍可正確回 15 fields,故工具鏈本身完整。
修改清單:
backend/app/services/theta_extractor.py:max_tokens4096 → 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_oklog 新增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 對密集多頁政府表單的識別能力是當前瓶頸。建議方案(待用戶決策):
- 把 vision deployment 換成
gpt-4o或gpt-5(完整版,非 mini)。 - 在 prompt 中加入 few-shot 範例(用標好的 OALA / CCSV 範例)。
- 把每頁切成上下半再分別丟給 GPT(context 變小,注意力集中)。
- 把 vision deployment 換成
踩雷紀錄(寫進記憶):
- 修了 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。
指令:用戶 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 改):
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。
指令:用戶 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 等視覺呼叫點全部受影響。
修改清單:
backend/app/llm/client.py—_FoundryCompletions.create()一律kw.pop("response_format", None),由 caller 在 system prompt 強制 JSON +_parse_json_loose容錯。集中處理,避免每個 vision caller 都要改。backend/app/services/theta_extractor.py— 移除response_format參數,註解說明 Foundry 限制;其他保持不變。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 審視可見化。
指令:用戶 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 介面不同。
修改清單:
backend/pyproject.toml依賴:新增azure-ai-inference==1.0.0b9(Microsoft 官方 Foundry SDK,已安裝至 venv)。backend/app/llm/client.py:- 移除
from openai import AzureOpenAI。 - 新增
_FoundryWrapper/_FoundryChat/_FoundryCompletions三層薄 shim,把 FoundryChatCompletionsClient.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。
- 移除
backend/app/api/diagnose.py:_probe_vision的max_tokens從 10 → 200(gpt-5-mini 是推理模型,需保留 reasoning token 空間才能輸出可見回覆)。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 參數適配。
指令:用戶 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.pyplaceholder_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/healthvision 段同步改 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 endpointjhxu-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 三通道列顯示完整。
用戶後續動作
- 確認 Azure Portal 內:endpoint 拼字、API key、deployment name
careflow-gpt-5-mini、api_version=2024-12-01-preview三者匹配。 - 若你的 Azure resource 是 AI Foundry 新型 endpoint(
*.services.ai.azure.com),可能需用 Azure AI Inference SDK;本實作走AzureOpenAI經典 SDK,匹配*.openai.azure.comendpoint。 - 重啟 backend,再打
/api/llm/diagnose看 vision.ok 是否轉 true。
Commit:v0.4.0-rc6.1 — Vision 通道改回 Azure OpenAI + 前端三通道面板 + 版本號 / 時區修正。
指令:用戶 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 段獨立 keybackend/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— 標 deprecatedbackend/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_mockbackend/app/services/visit_note_agent/llm_client.py—get_text_client()backend/app/services/visit_note_agent/transcriber.py—get_asr_client()+is_asr_mockbackend/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、內容為粵語家訪報告。
用戶後續動作
- 把 DeepSeek、OpenAI key 填入
.env(DASHSCOPE 已填)。 - 重啟 backend:
uvicorn app.main:app --reload。 - 打
/api/llm/diagnose驗證三路ok=true / mock=false。
Commit:v0.4.0-rc6 — LLM 三通道重構(DeepSeek 文本 + OpenAI 視覺 + Bailian ASR)。
指令:用戶 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:
prefix命中 → 依_MOCK_FIELD_VALUES(20 條 label 關鍵字 → 港式繁中值)回填「label:mock_value」;- section_hint 命中 → 從
_MOCK_SECTION_PARAGRAPHS(9 個段落 keyword:近況摘要 / 身體健康 / 情緒 / 家居安全 / 社交支援 / 已提供協助 / 跟進計劃 / 職員觀察 / 備註)回填完整段落; - 都不中 → 用 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 入口時用 explicitforce_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 修正。
指令:用戶 round-7 給出 5 個細修點 + 1 個 P0 bug:
- CCSV 地址再下移
- CSSA HKID 不該帶括號(要讓檢核碼落入表單本身的
( )內) - OALA 已婚 X 再往左下
- SSA 申請日期 2026 應左移、字距更大
- 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。
指令:用戶 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 withmock_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_hintdropdown 多兩個選項(身份證照、申請表照)。 - API client
extractWelfareProfileFromImage(file, sourceHint)用 FormData 直接 fetch(繞過原request<T>()預設 JSON header)。 - 結果卡片 + 後續填表流程完全沿用 rc2 邏輯(同一個
extractedProfilestate → 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。
指令:用戶 round-5 對 v0.4.0-rc1 做細部 QA 後給出兩個方向:
- CSSA/CCSV/SSA 仍有「英文/數字欄位字符過寬、HKID 與 DOB 互撞、SSA 性別 X 落錯框、SSA DOB 散落」幾處漂移
- 新增功能:「讀取幾分長者資料,丟給 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_textfallback。_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_*.pdf5 個模板 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。
指令:用戶 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-fieldfont_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新增(座標探測工具)
bug:v0.4.0-beta 上線後 QA round-3 用戶回報 OALA PDF「很多內容飄了,且有兩個莫名其妙『㎏』漂浮在半空中」。
根因分析:
- 「㎏」幽靈字符:
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",跨環境穩定。 - OALA 全欄位 y 飄移:alpha 版我憑印象標座標,把
write_rect標在標籤行(如「姓名(中文)」y≈325)而不是底線行(underscore y≈338)。修法:用 PyMuPDF 把 PDF 所有_____underscore span 的 bbox 跑出來當 ground truth,重排所有 14 個欄位的 rect。 - HKID 寬度溢出:
A123456(7)在 fontsize=10 渲出 ~100 pt,但底線寫入區只有 67 pt(x=148–215),結果文字壓到性別格上。加font_sizeper-field override,HKID/DOB 縮到 7 pt。 - 申請日期位置錯:把寫入區放在「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-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 內嵌簽名圖片
進展定位:γ 路線第一階段(「先提取格式、標準化、寫到預設套組」)。本版只交付模板載入,PDF 實際回填留給 v0.4.0-beta。
PDF 探勘結果(PyMuPDF 1.27.2,A4 portrait 595×842 pt)
| 頁數 | 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_pagefill_strategy:"acroform"或"coord_anchor"status:"ready"或"pending_coord_mapping"elder_profile_keys:本表需要的長者欄位(給前端做完整性檢查)fields[]:每欄附key / label_zh / label_en / type / elder_profile_path / fillfill對 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)
services.welfare_form_filler.fill_pdf(template_id, elder_profile, output_path)— 真正寫 PDF(AcroForm 直填 / 座標寫文字 + 勾選符號)POST /api/welfare-form/fill→ 回填 PDF + 給 download URL- 前端
WelfareForm.tsx:選表 → 預覽 mock elder 對應 → 觸發回填 → PDF 預覽 iframe → 下載 - (選用)DeepSeek 把 elder profile + 自由問答 → 自動建議
marital_status等欄位 - 補
ssa_307/cssa/ccsv三份的座標標註
為什麼新增
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 又快又穩,這版把主備角色互換。
做了什麼
-
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。
-
Qwen client 變得「快敗」:
vision_timeout_seconds60 → 45 秒;vision_max_retries1 → 0(不再多一輪 corrective prompt,最壞單張 90 秒就交棒);- 這兩條只影響 Qwen client,Azure 仍維持自家 timeout(read=
vision_timeout_seconds仍 45s × 2 attempts,即 90s 上限)。
-
「刪此頁」功能 — 用戶實測時遇到「夾在批次裡的格式不對的紙條(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.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)。
做了什麼
-
新增
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。
-
新增設定(
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
-
觸發策略(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 行為。
-
前端
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.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 → 實測等價於「無限期等」。
做了什麼
- 拿掉
.with_options(timeout=float):直接用get_client()已配置的httpx.Timeout(connect=10, read=60, write=60, pool=10),不再 override。 - 加 timeout 重試:
review_volunteer_extraction內部for attempt in range(2),只對timeout / Connection*類錯誤做 1 次 retry;若 4xx / 5xx 等業務錯誤直接 break,不浪費 quota。 - 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.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 沒東西可補」。
做了什麼
-
影像預處理(
vision._preprocess_image_bytes):- 任何 >800 KB 或長邊 >1600 px 的圖都會先用 Pillow 縮 + 重壓為 JPEG@85;
- 透明 PNG 自動鋪白底(避免 base64 黑塊);
- 失敗時降級回原檔,不擋主流程。
- 實測:2.5 MB PNG → 295 KB JPEG(89 % 減量),4 KB 小 JPG 完全跳過。
-
EXTRACTION_PARALLELISM 4 → 2:減少同時打 DashScope 的併發數,避免大 base64 payload 互相擠壓帶寬。
-
顯式
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。 -
連續空白標記(
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 連續 3 次 attempt 全回空 dict → 在
設計取捨
- 不對 Qwen 連續空白做 DeepSeek-V 文字 fallback(DeepSeek 沒視覺、亂回比缺更糟);
- 不自動 OCR fallback 到其他模型(避免帳單擴張);
- 預處理永遠走 in-memory,不改寫原檔(保留人工複核時可以看到原圖)。
為什麼新增
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):
- 修正運算優先順序:
list((comp.get("partial_fields") or {}).keys()); - 把整段 DeepSeek 審查邏輯抽成獨立函式
_run_deepseek_review(session, pending),在run_extraction內用try/except包住,任何例外都會打review_phase_error事件到vision.log(含error_type+error[:300]),不再被BackgroundTasks默默吞掉; - 新增
review_phase_start事件,方便確認「審查階段確實有被進入」; - 對既有 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.3 後回報:
现在出现了「邀 加中心活」这种断断续续的话,而且没有被高光。我需要你每次把千问的输出直接传给 deepseek 审查,然后自动补全,然后高亮补全过的栏目,默认补全,可手动撤回。
兩個問題並存:
- 碎片偵測有死角 — 局部模糊正則只認「??」「〇〇」「__」「XX」「…」等固定符號,對「漢字 + 空格 + 漢字」這種 OCR 漏字產生的句子(例如「邀 加中心活」原本應為「邀請參加中心活動」)完全沒反應。
- 沒有跨模型交叉校驗 — 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 看過了但也沒辦法」,避免用戶誤以為沒做事。
從 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標記。
- 若上一次 attempt 的
前端「一鍵 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+ historybatch_detail都新增suggestions+suggestion_confidence。
設計取捨
- Suggestions 永遠跑 → 上傳完成後背景補全照單全收,使用者刷新頁面就會看到。代價:每張不完整照片多 ~30–60s 背景延遲,但與抽取並行不阻塞使用者。
- 把
auto_complete旗標的語義從「跑/不跑補全」改為「跑完後自動套用還是只列為建議」。
回應使用者實測痛點
- 「兩份檔案處理 12 分鐘還只跑一半」→ 加 timestamp JSONL 日誌、收緊 timeout/retries、自動補全並行化。
- 「上次的問題一個也沒解決」→ 把每一次 VLM 呼叫的開始 / 嘗試 / 成功 / 失敗都寫進
backend/logs/vision.log,使用者下次實測完直接傳檔給 AI 分析。 - 「資訊不完整不只指『欄位空白』,也包括『深水??街』這種局部模糊」→ 新增字元級偵測 + 黃底高亮。
性能修補
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)XXxx(≥2)__(≥2)…......(≥3 點)- 字面「無法辨識」「看不清」「不清楚」「模糊」
assess_completeness()新返回partial_fields: {key: [[start,end], ...]};任一欄位有 partial →is_complete=False。- API
RecordOut+ Historybatch_detail都帶partial_fields。
前端高亮
VolunteerReview.tsx:- 欄位列:若該欄位 AI 原讀有 partial span → label 旁加「局部模糊」黃章;input 下方多一行小字「AI 原讀:深水??街 1 號」,模糊字符以黃底
<mark>包住。 - 「信息不完整」標頭分兩列:缺失欄位(空白)+ 局部模糊(部分字符)。
- 欄位列:若該欄位 AI 原讀有 partial span → label 旁加「局部模糊」黃章;input 下方多一行小字「AI 原讀:深水??街 1 號」,模糊字符以黃底
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。
使用者下一步測試流程
- 上傳 2 ~ 5 張照片觀察狀態流轉時間。
- 完成後查看
backend/logs/vision.log看每張照片實際耗時。 - 觀察 review 頁有局部模糊的欄位是否正確高亮。
修復
- 間隔失準 bug:原
vision.pySYSTEM_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/ ASRfun-asr)。- 上傳 20 張預計 6 ~ 12 秒完成抽取(mock 模式 < 1s)。
整體
- 用 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(待打包)。
前端(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)。
整合來源
- 合作夥伴在
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才會落筆。
設計語言重塑 — 編輯室 / 檔案夾風
- 目標:去除典型 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:DataclassTemplateInfo,manifestdata/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改用 PydanticExportCombinedPayload(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 錯誤。