From e151683f3ea3792de395a359a00fcdaf13ed65c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 15:13:05 +0000 Subject: [PATCH 1/2] =?UTF-8?q?U-2=E2=91=A0:=20=E5=BE=8C=E7=AB=AF=E8=B3=87?= =?UTF-8?q?=E8=A8=8A=E5=9C=96=E5=8D=A1=E9=80=90=E5=8D=80=20refine=20?= =?UTF-8?q?=E7=AB=AF=E9=BB=9E=E7=A7=BB=E6=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移植 /studio 缺的「圖卡逐區 refine」到後端(offline-first,全程 mock 測): - refine_service.refine_infographic_section:依指令重生單一 section,merge 原欄位、 iconType 越界退 info、imagePrompt 有變才重生該區圖(不燒額度),策略對齊既有 refine_presentation_slide。 - 新端點 POST /api/refine-section(掛 rate_limit)。 - 補 8 測(mock Gemini,涵蓋 merge/coerce/條件重生圖/端點)。 海報為單圖無區概念,整圖 refine 已由 /api/generate 的 refinement 涵蓋。 前端區域選擇 UI(U-2②)另開 PR。 --- core/infocards/refine_service.py | 47 +++++++++++++++++- docs/PRODUCT_READINESS.md | 9 +++- server/routes/infocards.py | 22 +++++++++ tests/test_infocards_refine.py | 85 +++++++++++++++++++++++++++++++- 4 files changed, 160 insertions(+), 3 deletions(-) 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 400c08b..a597dbc 100644 --- a/docs/PRODUCT_READINESS.md +++ b/docs/PRODUCT_READINESS.md @@ -185,11 +185,18 @@ - [ ] 🔴 **U-1 `/studio` 直連 Gemini 改走後端**(offline,前端 + 確認後端端點)— 把 `/studio` 仍 client-side 呼叫 Gemini 的路徑改打 `/api/generate` 等後端端點(後端大多現成),堵住 「繞過計費 + 繞過審查」漏洞。或者若 U-3 直接退場 /studio,則本項併入「功能搬進 /app」。 -- [ ] 🟡 **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-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` 功能對等確認後 再做(避免反悔)。 diff --git a/server/routes/infocards.py b/server/routes/infocards.py index b024b46..96c7a59 100644 --- a/server/routes/infocards.py +++ b/server/routes/infocards.py @@ -83,6 +83,16 @@ class RefineSlideRequest(BaseModel): textModel: str = DEFAULT_TEXT_MODEL +class RefineSectionRequest(BaseModel): + """資訊圖卡單區微調:section 為要改的區塊,instruction 為修改指令。""" + + section: dict + instruction: str + imageModel: str = DEFAULT_IMAGE_MODEL + textModel: str = DEFAULT_TEXT_MODEL + regenerateImage: bool = True + + @router.get("/usage") def usage_summary() -> dict: """Gemini 用量真實統計(成本面板)。涵蓋視覺站 + 在地化的呼叫;budget 為設定值。""" @@ -265,6 +275,18 @@ def refine_slide(req: RefineSlideRequest) -> dict: return {"success": True, "slide": slide.model_dump()} +@router.post("/refine-section", dependencies=[Depends(rate_limit)]) +def refine_section(req: RefineSectionRequest) -> dict: + """資訊圖卡單區微調:依指令重生該區塊(title/content/iconType/圖),回 refined section。""" + from core.infocards import refine_service + + 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") def export_pptx(req: ExportPptxRequest) -> Response: """簡報 PresentationData → .pptx 下載(python-pptx,座標對齊 slideMasters)。""" 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 From 0a1e7e76d699abab1b3800018ebb5c6e4f004d3e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 15:17:01 +0000 Subject: [PATCH 2/2] ci: re-trigger workflow