Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion core/infocards/refine_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 13 additions & 41 deletions docs/PRODUCT_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 時於 `<body>` 頂注入固定退場 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/'` 寫死
Expand Down
31 changes: 10 additions & 21 deletions server/routes/infocards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
85 changes: 84 additions & 1 deletion tests/test_infocards_refine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading