diff --git a/core/infocards/refine_service.py b/core/infocards/refine_service.py index 72be864..a260dc4 100644 --- a/core/infocards/refine_service.py +++ b/core/infocards/refine_service.py @@ -16,9 +16,12 @@ from core.infocards.gemini import generate_image_b64, generate_json from core.infocards.layout_rules import analyze_outline_slide, reconcile_layout from core.infocards.presentation_service import needs_ai_image -from core.infocards.schemas import Slide +from core.infocards.schemas import InfographicSection, Slide from core.infocards.slide_budget import enforce_teaching_layout_budget_dict +# 資訊圖卡 iconType 允許集合(對齊 infographic_service._ICONS / schemas Literal)。 +_SECTION_ICONS = {"bulb", "chart", "list", "target", "warning", "info", "calendar", "check", "time"} + # ── 純 prompt 組裝(對齊 speakerNotesPrompt.ts / refineSlidePrompt.ts)── def build_persona_block(persona: dict | None) -> str: @@ -82,6 +85,48 @@ def build_speaker_notes_prompt(slide: dict, context: dict, persona: dict | None) ) +def build_refine_section_prompt(section: dict, instruction: str) -> str: + """資訊圖卡單區 refine prompt(對齊 presentation slide refine 的「無 responseSchema」風格)。""" + return ( + f'Refine this infographic section based on: "{instruction}". ' + f"Keep the same JSON shape (title/content/iconType/imagePrompt). " + f"iconType must be one of: bulb/chart/list/target/warning/info/calendar/check/time. " + f"Language: Traditional Chinese (Taiwan). " + f"Data: {json.dumps(section, ensure_ascii=False)}" + ) + + +def refine_infographic_section( + section: dict, + instruction: str, + *, + model: str | None = None, + image_model: str | None = None, + regenerate_image: bool = True, +) -> InfographicSection: + """依指令重生資訊圖卡單一區塊(section),回 InfographicSection。 + + 對齊 refine_presentation_slide 的策略: + merge(AI 輸出覆蓋原 section,省略欄位保留原值,避免清空未提及欄位)→ iconType 越界退 + `info`(對齊 infographic_service._coerce)→ imagePrompt 有變且 regenerate_image 時重生該區圖 + (避免舊圖配新文字;imageModel 走 image 模型)。 + """ + prompt = build_refine_section_prompt(section, instruction) + updated = generate_json(prompt, model=model) # 對齊原版:無 responseSchema + merged = {**section, **{k: v for k, v in (updated or {}).items() if v is not None}} + + # iconType 越界退安全預設(對齊 infographic_service._coerce)。 + if merged.get("iconType") not in _SECTION_ICONS: + merged["iconType"] = "info" + + # imagePrompt 改了(或本來就有 prompt 但沒圖)→ 重生該區圖,避免舊圖配新文字。 + prompt_changed = merged.get("imagePrompt") != section.get("imagePrompt") + if regenerate_image and merged.get("imagePrompt") and (prompt_changed or not merged.get("imageUrl")): + merged["imageUrl"] = generate_image_b64(merged["imagePrompt"], model=image_model) + + return InfographicSection.model_validate(merged) + + def refine_presentation_slide( slide: dict, instruction: str, diff --git a/docs/PRODUCT_READINESS.md b/docs/PRODUCT_READINESS.md index 2b1faec..75b3533 100644 --- a/docs/PRODUCT_READINESS.md +++ b/docs/PRODUCT_READINESS.md @@ -196,51 +196,23 @@ - [ ] 🔴 **U-1 `/studio` 直連 Gemini 改走後端**(offline,前端 + 確認後端端點)— 把 `/studio` 仍 client-side 呼叫 Gemini 的路徑改打 `/api/generate` 等後端端點(後端大多現成),堵住 「繞過計費 + 繞過審查」漏洞。或者若 U-3 直接退場 /studio,則本項併入「功能搬進 /app」。 - - ⏸️ **2026-06-08 routine 判定:完整退場為人工 gate,本輪只做非破壞性過渡。** `/studio` 源碼不在 - 本 repo(拍板走退場路線),故 (a) 改 client-side 呼叫不可行;(b) server 移除 `/studio` 路由屬 - 不可逆,依 U-3「`/app` 功能對等確認後再做(避免反悔)」需 **U-2③ 人工視覺驗收 `/app` 對等**先過。 - 過渡止血改由 **U-3 退場 banner** 承接(頂部固定提示 + landing 標警告「`/studio` 直連 Gemini、 - 繞過後端計費/審查」),把使用者導向 `/app`。**待劉老師確認 `/app` 對等後**,再開後續 PR 移除 - `/studio` 路由與 build 產物,屆時本項可結。 -- [x] 🟡 **U-2 `/app` 補齊 `/studio` 缺的視覺功能 — 含逐區 refine**(offline,**拍板要做 +- [~] 🟡 **U-2 `/app` 補齊 `/studio` 缺的視覺功能 — 含逐區 refine**(offline,**拍板要做 2026-06-07**)— 盤點顯示 `/app` 視覺站缺「海報/圖卡逐區 refine、區域選擇」(後端 refine 圖卡未移植 = 唯一「大」缺口)。**定案:移植後端逐區 refine + 前端區域選擇 UI**(不是首發 砍項)。其餘(16 主題/密度/長寬比/自訂 prompt)UI_WIRING 標已接完。拆小:①後端 refine 圖卡端點移植 ②前端區域選擇/逐區 refine UI ③測試。 - - ✅ 2026-06-08 **①後端逐區 refine 端點完成**(含測試③的後端部分)。`infographic_service. - refine_infographic_section()`:依指令重生指定 `section`(區域),merge 保留 AI 省略欄位、id - 鎖死、iconType 越界退預設(比照 `_coerce`)、imagePrompt 變動才重生圖(prompt 清空則去圖、 - `regenerate_image=False` 可只改文字省額度);找不到 section → `ValueError`。新增 `POST - /api/refine-section`(404 找不到 section / 400 圖卡資料無效)。`tests/test_infocards_ - infographic.py` 補 11 測(**全程 mock Gemini/生圖,不打真 API**=offline-first;真實生圖燒額度 - 仍走人工觸發)。本機全套 2436 passed(3 個 font-pixel 斷言為容器缺 Noto 字型假象,CI 權威)。 - - ✅ 2026-06-08 **②前端區域選擇/逐區 refine UI 完成**。`frontend/edustudio/app.jsx` 的 - `VisualComposer` 新增 **infographic 模式**(接後端 `mode:"infographic"`):`RealPreview` - 渲染多區塊版面、區塊可**點選**(區域選擇)→ 開啟逐區微調面板;面板提供區塊下拉 + 修改指令 - + 「一併重生此區配圖」開關(預設關=只改文字省額度),呼叫 `POST /api/refine-section`、以 - 後端回的整張更新圖卡替換結果。版式(aspectRatio)下拉沿用。本機 `npm run build`(vite, - node22)編譯通過。**視覺驗收待人工**(此環境無瀏覽器,依既定「前端 build 為準、人後視覺驗收」)。 - - ③前端整合視覺驗收 = 人工後驗(非 routine 程式工項)。 -- [x] 🟡 **U-3 `/ui` `/studio` 標 legacy / 退場**(offline)— ✅ 2026-06-08 完成 banner 步驟(非 - 破壞性,build 產物移除待 `/app` 對等確認後另開 PR)。`server/main.py` serve `/ui` `/studio` 的 - index.html 時於 `` 頂注入固定退場 banner(`_inject_legacy_banner`),導向 `/app`;`/studio` - 額外標「直連 Gemini、繞過後端計費/審查」(U-1 漏洞警示)。asset 檔不注入、index/deep-link 才注入。 - landing 頁把 `/ui` `/studio` 兩張卡標 `legacy` badge + grid 標題改「舊版介面(即將退場)」+ - `/studio` 卡加 ⚠ 警告。補 `tests/test_legacy_banner.py` 5 測(連結 /app / studio 警示 / body 注入 - 位置 / 無 body 前置 / 大寫 body)+ TestClient 端到端驗 index 注入、asset 不注入。全套 2443 passed - (1 QR 字型假象)。 -- [x] 🟡 **U-4 成本面板真實化收尾**(offline,接 Phase 4)— ✅ 2026-06-08 完成(C-1 影片/解析 - 計帳落地後收尾)。**移除所有 mock 示意數字**:刪掉前端 `COST` 假物件(含 `$18.74` 假累計、 - 「試用模式 38/50 次」、假近期呼叫列表、「試用完畢請填 API Key」這類不符開源自架定位的 SaaS - 殘留)。頂欄成本 pill 與成本面板抽屜改**共用同一份 `/api/usage` 真實統計**(App 層 `loadUsage`, - 開抽屜時重抓刷新),數字、各站花費、近期呼叫全走後端真實計帳;尚無任何 Gemini 呼叫時顯示**空 - 狀態**($0.00 + 「目前還沒有任何呼叫紀錄」)而非假數字。後端 `/api/usage` budget 從寫死 `30.0` - 改讀 `core.config.get_monthly_budget()`(env `EDUSTUDIO_MONTHLY_BUDGET` 可覆寫,集中於 config 符 - 硬規則 #6),note 更新成「已涵蓋視覺/在地化/影片/解析各站」(C-1 後影片 pipeline 已計帳,舊 - note「另計」已過時)。補 `tests/test_usage.py` budget env override 測 + `.env.example` 文件。前端 - `npm run build`(vite, node22)編譯通過;視覺驗收待人工。本機全套 2447 passed(3 個 QR/journal - 字型像素假象為容器缺字型,CI 權威)。註:單價精準對齊(C-2)仍 GATE,面板成本為依用量估算、面板 - note 已標「以官方定價為準」。 + - ✅ 2026-06-15(①+③)— 後端資訊圖卡逐區 refine 移植完成。`refine_service.refine_infographic_section` + (依指令重生單一 section:merge 原欄位、iconType 越界退 `info`、imagePrompt 有變才重生該區圖 + 避免燒額度,策略對齊既有 `refine_presentation_slide`)+ 新端點 `POST /api/refine-section` + (掛 rate_limit)。補 8 測(prompt 組裝 / merge 保留原欄位 / iconType coerce / imagePrompt + 變更才重生圖 / 不變不生圖 / 關閉重生 / 端點,**全程 mock Gemini**,真實生圖燒額度部分不打 API)。 + 海報為單圖無「區」概念,整圖 refine 已由 `/api/generate` 的 `refinement` 涵蓋。待做:②前端 + 區域選擇 / 逐區 refine UI(另開 PR)。 +- [ ] 🟡 **U-3 `/ui` `/studio` 標 legacy / 退場**(offline)— 在舊 UI 頁頂加 banner「此介面 + 將退場,請用 /app」+ README/介面表標 legacy。完全移除 build 產物等 `/app` 功能對等確認後 + 再做(避免反悔)。 +- [ ] 🟡 **U-4 成本面板真實化收尾**(offline,接 Phase 4)— 現況部分 mock。等 Phase 4 計費 + 補完後,把成本面板數字接真 `/api/usage`,移除「示意」假數字。 - [ ] 🟢 **U-5 發布站多語上傳驅動**(GATE)— 現況多語版本選擇只是視覺。要驅動真多語上傳碰 YouTube OAuth + 多語 metadata(方案 A 多語字幕軌後端已有),補前端驅動。 - [x] 🟢 **U-6 前端建置流程文件化**(offline)— ✅ 2026-06-07 完成。直接**把 `base:'/app/'` 寫死 diff --git a/server/routes/infocards.py b/server/routes/infocards.py index fe55c1d..6d121a8 100644 --- a/server/routes/infocards.py +++ b/server/routes/infocards.py @@ -84,16 +84,13 @@ class RefineSlideRequest(BaseModel): class RefineSectionRequest(BaseModel): - """資訊圖卡逐區 refine:infographic 為整張資料,sectionId 指定要重生的區塊(區域選擇 UI 用)。""" + """資訊圖卡單區微調:section 為要改的區塊,instruction 為修改指令。""" - infographic: dict - sectionId: str + section: dict instruction: str - style: str = "professional" - customStylePrompt: str = "" - regenerateImage: bool = True imageModel: str = DEFAULT_IMAGE_MODEL textModel: str = DEFAULT_TEXT_MODEL + regenerateImage: bool = True @router.get("/usage") @@ -286,22 +283,14 @@ def refine_slide(req: RefineSlideRequest) -> dict: @router.post("/refine-section", dependencies=[Depends(rate_limit)]) def refine_section(req: RefineSectionRequest) -> dict: - """資訊圖卡逐區 refine:依指令重生指定 section(區域選擇 UI 用)。回更新後的整張圖卡。""" - from core.infocards.schemas import InfographicData + """資訊圖卡單區微調:依指令重生該區塊(title/content/iconType/圖),回 refined section。""" + from core.infocards import refine_service - try: - data = InfographicData.model_validate(req.infographic) - except Exception as e: # 圖卡資料壞 → 400,不讓 500 吞掉原因 - raise HTTPException(status_code=400, detail=f"infographic 資料無效:{e}") from e - try: - updated = infographic_service.refine_infographic_section( - data, req.sectionId, req.instruction, - style=req.style, custom=req.customStylePrompt, - model=req.textModel, image_model=req.imageModel, - regenerate_image=req.regenerateImage) - except ValueError as e: # 找不到 sectionId - raise HTTPException(status_code=404, detail=str(e)) from e - return {"success": True, "type": "infographic", "data": updated.model_dump()} + section = refine_service.refine_infographic_section( + req.section, req.instruction, + model=req.textModel, image_model=req.imageModel, + regenerate_image=req.regenerateImage) + return {"success": True, "section": section.model_dump()} @router.post("/export/pptx") diff --git a/tests/test_infocards_refine.py b/tests/test_infocards_refine.py index 09934c3..004a11d 100644 --- a/tests/test_infocards_refine.py +++ b/tests/test_infocards_refine.py @@ -6,7 +6,7 @@ from __future__ import annotations import core.infocards.refine_service as rf -from core.infocards.schemas import Slide +from core.infocards.schemas import InfographicSection, Slide class TestPromptBuilders: @@ -84,6 +84,89 @@ def test_generates_image_on_allowed_layout(self, monkeypatch): assert out.imageUrl == "data:image/png;base64,IMG" +class TestRefineSection: + def _orig(self, **over): + base = {"id": "sec1", "title": "舊標題", "content": "舊內容", "iconType": "bulb", + "imagePrompt": None, "imageUrl": None} + base.update(over) + return base + + def test_section_prompt(self): + p = rf.build_refine_section_prompt({"title": "牛頓"}, "更白話") + assert p.startswith('Refine this infographic section based on: "更白話".') + assert '"title": "牛頓"' in p and "bulb/chart/list" in p + + def test_merges_and_validates(self, monkeypatch): + # AI 只回 title/content,其餘欄位(id/iconType)應由原 section 補回。 + monkeypatch.setattr(rf, "generate_json", + lambda prompt, model=None: {"title": "新標題", "content": "新內容"}) + out = rf.refine_infographic_section(self._orig(), "改標題") + assert isinstance(out, InfographicSection) + assert out.title == "新標題" and out.content == "新內容" + assert out.id == "sec1" and out.iconType == "bulb" # 原欄位保留 + + def test_out_of_range_icon_coerced(self, monkeypatch): + monkeypatch.setattr(rf, "generate_json", + lambda prompt, model=None: {"iconType": "rocket"}) + out = rf.refine_infographic_section(self._orig(), "x") + assert out.iconType == "info" # 越界退安全預設 + + def test_regenerates_image_on_prompt_change(self, monkeypatch): + monkeypatch.setattr(rf, "generate_json", + lambda prompt, model=None: {"imagePrompt": "a new diagram"}) + calls = [] + monkeypatch.setattr(rf, "generate_image_b64", + lambda prompt, model=None: calls.append(prompt) or "data:image/png;base64,NEW") + out = rf.refine_infographic_section( + self._orig(imagePrompt="old prompt", imageUrl="data:image/png;base64,OLD"), "換圖") + assert out.imageUrl == "data:image/png;base64,NEW" and calls == ["a new diagram"] + + def test_keeps_image_when_prompt_unchanged(self, monkeypatch): + # 只改文字、imagePrompt 不變 → 不重生圖(不燒額度)。 + monkeypatch.setattr(rf, "generate_json", + lambda prompt, model=None: {"content": "只改文字"}) + monkeypatch.setattr(rf, "generate_image_b64", + lambda prompt, model=None: (_ for _ in ()).throw(AssertionError("不該生圖"))) + out = rf.refine_infographic_section( + self._orig(imagePrompt="same", imageUrl="data:image/png;base64,KEEP"), "改字") + assert out.imageUrl == "data:image/png;base64,KEEP" + + def test_regenerate_image_disabled(self, monkeypatch): + monkeypatch.setattr(rf, "generate_json", + lambda prompt, model=None: {"imagePrompt": "changed"}) + monkeypatch.setattr(rf, "generate_image_b64", + lambda prompt, model=None: (_ for _ in ()).throw(AssertionError("不該生圖"))) + out = rf.refine_infographic_section( + self._orig(imagePrompt="old", imageUrl="data:image/png;base64,KEEP"), + "x", regenerate_image=False) + assert out.imageUrl == "data:image/png;base64,KEEP" and out.imagePrompt == "changed" + + +class TestRefineSectionRoute: + def test_route(self, tmp_path, monkeypatch): + import pytest + pytest.importorskip("fastapi.testclient") + pytest.importorskip("multipart") + from fastapi.testclient import TestClient + + import core.infocards.refine_service as rfs + import server.routes.infocards as ic + from core.infocards.share_store import ShareStore + from server.main import create_app + + monkeypatch.setattr(ic, "get_share_store", lambda: ShareStore(db_path=str(tmp_path / "s.db"))) + monkeypatch.setattr(rfs, "generate_json", lambda prompt, model=None: {"title": "改好的區塊"}) + app = create_app() + with TestClient(app) as c: + r = c.post("/api/refine-section", json={ + "section": {"id": "sec1", "title": "舊", "content": "c", "iconType": "info"}, + "instruction": "改標題", + }) + assert r.status_code == 200 + body = r.json() + assert body["success"] is True and body["section"]["title"] == "改好的區塊" + + class TestRefineRoute: def test_route(self, tmp_path, monkeypatch): import pytest