From 54d21afd5265df625f7da8c8ff45f480da39672a Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 9 Mar 2026 11:21:05 +0900 Subject: [PATCH] Add food tag remap feature and show menu tags in restaurant cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLM extraction prompt: foods_mentioned max 10, Korean only, prioritized - New /remap-foods API endpoint for bulk LLM re-extraction - Admin UI: "메뉴태그 재생성" button with SSE progress bar - Backend: attach foods_mentioned to restaurant list API response - Restaurant cards: display food tags (orange, max 5 visible) Co-Authored-By: Claude Opus 4.6 --- backend/api/routes/videos.py | 124 +++++++++++++++++++++ backend/core/extractor.py | 2 +- backend/core/restaurant.py | 23 ++++ frontend/src/app/admin/page.tsx | 72 ++++++++++++ frontend/src/components/RestaurantList.tsx | 17 ++- frontend/src/lib/api.ts | 1 + 6 files changed, 237 insertions(+), 2 deletions(-) diff --git a/backend/api/routes/videos.py b/backend/api/routes/videos.py index 2853c91..4493ffa 100644 --- a/backend/api/routes/videos.py +++ b/backend/api/routes/videos.py @@ -307,6 +307,130 @@ def remap_cuisine(_admin: dict = Depends(get_admin_user)): return StreamingResponse(generate(), media_type="text/event-stream") +@router.post("/remap-foods") +def remap_foods(_admin: dict = Depends(get_admin_user)): + """Re-extract foods_mentioned for all video_restaurants using LLM. SSE progress.""" + from core.extractor import _llm, _parse_json + + BATCH = 15 + + _FOODS_PROMPT = """\ +아래 식당들의 대표 메뉴 태그를 다시 만들어주세요. + +규칙: +- 반드시 한글로 작성 +- 각 식당당 최대 10개의 대표 메뉴/음식 태그 +- 우선순위: 시그니처 메뉴 > 자주 언급된 메뉴 > 일반 메뉴 +- 너무 일반적인 태그(밥, 반찬 등)는 제외 +- 모든 식당에 대해 빠짐없이 결과 반환 (총 {count}개) +- JSON 배열만 반환, 설명 없음 +- 형식: [{{"id": "식당ID", "foods": ["메뉴1", "메뉴2", ...]}}] + +식당 목록: +{restaurants} + +JSON 배열:""" + + def _apply_batch(batch: list[dict]) -> tuple[int, list[dict]]: + items = [{"id": b["id"], "name": b["name"], "current_foods": b["foods"], "cuisine_type": b.get("cuisine_type")} for b in batch] + prompt = _FOODS_PROMPT.format( + restaurants=_json.dumps(items, ensure_ascii=False), + count=len(items), + ) + raw = _llm(prompt, max_tokens=4096) + results = _parse_json(raw) + if not isinstance(results, list): + return 0, batch + + result_map: dict[str, list[str]] = {} + for item in results: + if isinstance(item, dict) and "id" in item and "foods" in item: + foods = item["foods"] + if isinstance(foods, list): + # Ensure Korean, max 10 + foods = [str(f) for f in foods[:10]] + result_map[item["id"]] = foods + + updated = 0 + missed = [] + for b in batch: + bid = b["id"] + new_foods = result_map.get(bid) + if new_foods is None: + missed.append(b) + continue + with db_conn() as c: + c.cursor().execute( + "UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id", + {"foods": _json.dumps(new_foods, ensure_ascii=False), "id": bid}, + ) + updated += 1 + return updated, missed + + def generate(): + # Fetch all video_restaurants with context + sql = """ + SELECT vr.id, r.name, r.cuisine_type, + vr.foods_mentioned, v.title + FROM video_restaurants vr + JOIN restaurants r ON r.id = vr.restaurant_id + JOIN videos v ON v.id = vr.video_id + ORDER BY r.name + """ + with db_conn() as c: + cur = c.cursor() + cur.execute(sql) + rows = [] + for row in cur.fetchall(): + foods_raw = row[3].read() if hasattr(row[3], "read") else (row[3] or "[]") + try: + foods = _json.loads(foods_raw) if isinstance(foods_raw, str) else foods_raw + except Exception: + foods = [] + rows.append({ + "id": row[0], + "name": row[1], + "cuisine_type": row[2], + "foods": foods if isinstance(foods, list) else [], + "video_title": row[4], + }) + + total = len(rows) + yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n" + + updated = 0 + all_missed: list[dict] = [] + + for i in range(0, total, BATCH): + batch = rows[i : i + BATCH] + yield f"data: {_json.dumps({'type': 'processing', 'current': min(i + BATCH, total), 'total': total})}\n\n" + try: + cnt, missed = _apply_batch(batch) + updated += cnt + all_missed.extend(missed) + yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + BATCH, total), 'total': total, 'updated': updated})}\n\n" + except Exception as e: + logger.error("Remap foods error at %d: %s", i, e, exc_info=True) + all_missed.extend(batch) + yield f"data: {_json.dumps({'type': 'error', 'message': str(e), 'current': i})}\n\n" + + # Retry missed + if all_missed: + yield f"data: {_json.dumps({'type': 'retry', 'missed': len(all_missed)})}\n\n" + for i in range(0, len(all_missed), 10): + batch = all_missed[i : i + 10] + try: + cnt, _ = _apply_batch(batch) + updated += cnt + except Exception as e: + logger.error("Remap foods retry error: %s", e) + + cache.flush() + yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'updated': updated})}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + @router.post("/rebuild-vectors") def rebuild_vectors(_admin: dict = Depends(get_admin_user)): """Rebuild all restaurant vector embeddings. Streams SSE progress.""" diff --git a/backend/core/extractor.py b/backend/core/extractor.py index 600f7c0..1c96090 100644 --- a/backend/core/extractor.py +++ b/backend/core/extractor.py @@ -110,7 +110,7 @@ _EXTRACT_PROMPT = """\ - cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용: {cuisine_types} - price_range: 가격대 (예: 1만원대, 2-3만원) (string | null) -- foods_mentioned: 언급된 메뉴들 (string[]) +- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성) - evaluation: 평가 내용 (string | null) - guests: 함께한 게스트 (string[]) diff --git a/backend/core/restaurant.py b/backend/core/restaurant.py index b3a0814..977dcba 100644 --- a/backend/core/restaurant.py +++ b/backend/core/restaurant.py @@ -314,8 +314,31 @@ def get_all( for row in cur.fetchall(): ch_map.setdefault(row[0], []).append(row[1]) + # Attach aggregated foods_mentioned for each restaurant + foods_sql = f""" + SELECT vr.restaurant_id, vr.foods_mentioned + FROM video_restaurants vr + WHERE vr.restaurant_id IN ({placeholders}) + """ + foods_map: dict[str, list[str]] = {} + with conn() as c: + cur = c.cursor() + cur.execute(foods_sql, ch_params) + for row in cur.fetchall(): + raw = row[1].read() if hasattr(row[1], "read") else row[1] + if raw: + try: + items = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(items, list): + for f in items: + if isinstance(f, str) and f not in foods_map.get(row[0], []): + foods_map.setdefault(row[0], []).append(f) + except Exception: + pass + for r in restaurants: r["channels"] = ch_map.get(r["id"], []) + r["foods_mentioned"] = foods_map.get(r["id"], [])[:10] return restaurants diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 14dd6f2..2a4644e 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -282,6 +282,8 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { const [vectorProgress, setVectorProgress] = useState<{ phase: string; current: number; total: number; name?: string } | null>(null); const [remappingCuisine, setRemappingCuisine] = useState(false); const [remapProgress, setRemapProgress] = useState<{ current: number; total: number; updated: number } | null>(null); + const [remappingFoods, setRemappingFoods] = useState(false); + const [foodsProgress, setFoodsProgress] = useState<{ current: number; total: number; updated: number } | null>(null); const [bulkProgress, setBulkProgress] = useState<{ label: string; total: number; @@ -556,6 +558,51 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { } }; + const startRemapFoods = async () => { + if (!confirm("전체 식당의 메뉴 태그를 LLM으로 재생성합니다 (한글, 최대 10개). 진행하시겠습니까?")) return; + setRemappingFoods(true); + setFoodsProgress(null); + try { + const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; + const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null; + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + const resp = await fetch(`${apiBase}/api/videos/remap-foods`, { method: "POST", headers }); + if (!resp.ok) { + alert(`메뉴 태그 재생성 실패: ${resp.status}`); + setRemappingFoods(false); + return; + } + const reader = resp.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) { setRemappingFoods(false); return; } + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const ev = JSON.parse(line.slice(6)); + if (ev.type === "processing" || ev.type === "batch_done") { + setFoodsProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 }); + } else if (ev.type === "complete") { + setFoodsProgress({ current: ev.total, total: ev.total, updated: ev.updated }); + } else if (ev.type === "error") { + alert(`메뉴 태그 재생성 오류: ${ev.message}`); + } + } catch { /* ignore */ } + } + } + setRemappingFoods(false); + } catch { + setRemappingFoods(false); + } + }; + const handleSort = (key: VideoSortKey) => { if (sortKey === key) { setSortAsc(!sortAsc); @@ -697,6 +744,13 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { > {remappingCuisine ? "음식분류 중..." : "음식종류 재분류"} + } {processResult && ( {processResult} @@ -908,6 +962,24 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { )} + {/* 메뉴태그 재생성 진행 */} + {foodsProgress && ( +
+

+ 메뉴태그 재생성 {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"} +

+
+
+
+

+ {foodsProgress.current}/{foodsProgress.total} — {foodsProgress.updated}개 업데이트 +

+
+ )} + {/* 벡터 재생성 진행 */} {vectorProgress && (
diff --git a/frontend/src/components/RestaurantList.tsx b/frontend/src/components/RestaurantList.tsx index 163037e..8e78fd2 100644 --- a/frontend/src/components/RestaurantList.tsx +++ b/frontend/src/components/RestaurantList.tsx @@ -63,8 +63,23 @@ export default function RestaurantList({ {r.region && (

{r.region}

)} - {r.channels && r.channels.length > 0 && ( + {r.foods_mentioned && r.foods_mentioned.length > 0 && (
+ {r.foods_mentioned.slice(0, 5).map((f, i) => ( + + {f} + + ))} + {r.foods_mentioned.length > 5 && ( + +{r.foods_mentioned.length - 5} + )} +
+ )} + {r.channels && r.channels.length > 0 && ( +
{r.channels.map((ch) => (