Add food tag remap feature and show menu tags in restaurant cards

- 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 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-09 11:21:05 +09:00
parent a5b3598f8a
commit 54d21afd52
6 changed files with 237 additions and 2 deletions

View File

@@ -307,6 +307,130 @@ def remap_cuisine(_admin: dict = Depends(get_admin_user)):
return StreamingResponse(generate(), media_type="text/event-stream") 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") @router.post("/rebuild-vectors")
def rebuild_vectors(_admin: dict = Depends(get_admin_user)): def rebuild_vectors(_admin: dict = Depends(get_admin_user)):
"""Rebuild all restaurant vector embeddings. Streams SSE progress.""" """Rebuild all restaurant vector embeddings. Streams SSE progress."""

View File

@@ -110,7 +110,7 @@ _EXTRACT_PROMPT = """\
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용: - cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
{cuisine_types} {cuisine_types}
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null) - price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
- foods_mentioned: 언급된 메뉴 (string[]) - foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
- evaluation: 평가 내용 (string | null) - evaluation: 평가 내용 (string | null)
- guests: 함께한 게스트 (string[]) - guests: 함께한 게스트 (string[])

View File

@@ -314,8 +314,31 @@ def get_all(
for row in cur.fetchall(): for row in cur.fetchall():
ch_map.setdefault(row[0], []).append(row[1]) 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: for r in restaurants:
r["channels"] = ch_map.get(r["id"], []) r["channels"] = ch_map.get(r["id"], [])
r["foods_mentioned"] = foods_map.get(r["id"], [])[:10]
return restaurants return restaurants

View File

@@ -282,6 +282,8 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
const [vectorProgress, setVectorProgress] = useState<{ phase: string; current: number; total: number; name?: string } | null>(null); const [vectorProgress, setVectorProgress] = useState<{ phase: string; current: number; total: number; name?: string } | null>(null);
const [remappingCuisine, setRemappingCuisine] = useState(false); const [remappingCuisine, setRemappingCuisine] = useState(false);
const [remapProgress, setRemapProgress] = useState<{ current: number; total: number; updated: number } | null>(null); 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<{ const [bulkProgress, setBulkProgress] = useState<{
label: string; label: string;
total: number; 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<string, string> = {};
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) => { const handleSort = (key: VideoSortKey) => {
if (sortKey === key) { if (sortKey === key) {
setSortAsc(!sortAsc); setSortAsc(!sortAsc);
@@ -697,6 +744,13 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
> >
{remappingCuisine ? "음식분류 중..." : "음식종류 재분류"} {remappingCuisine ? "음식분류 중..." : "음식종류 재분류"}
</button> </button>
<button
onClick={startRemapFoods}
disabled={remappingFoods || bulkExtracting || bulkTranscripting || rebuildingVectors || remappingCuisine}
className="bg-orange-600 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 disabled:opacity-50"
>
{remappingFoods ? "메뉴태그 재생성 중..." : "메뉴태그 재생성"}
</button>
</>} </>}
{processResult && ( {processResult && (
<span className="text-sm text-gray-600">{processResult}</span> <span className="text-sm text-gray-600">{processResult}</span>
@@ -908,6 +962,24 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
</div> </div>
)} )}
{/* 메뉴태그 재생성 진행 */}
{foodsProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2">
{foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
</h4>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all"
style={{ width: `${foodsProgress.total ? (foodsProgress.current / foodsProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{foodsProgress.current}/{foodsProgress.total} {foodsProgress.updated}
</p>
</div>
)}
{/* 벡터 재생성 진행 */} {/* 벡터 재생성 진행 */}
{vectorProgress && ( {vectorProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4"> <div className="mt-4 bg-white rounded-lg shadow p-4">

View File

@@ -63,8 +63,23 @@ export default function RestaurantList({
{r.region && ( {r.region && (
<p className="mt-1 text-xs text-gray-400 truncate">{r.region}</p> <p className="mt-1 text-xs text-gray-400 truncate">{r.region}</p>
)} )}
{r.channels && r.channels.length > 0 && ( {r.foods_mentioned && r.foods_mentioned.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5"> <div className="flex flex-wrap gap-1 mt-1.5">
{r.foods_mentioned.slice(0, 5).map((f, i) => (
<span
key={i}
className="px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded text-[10px]"
>
{f}
</span>
))}
{r.foods_mentioned.length > 5 && (
<span className="text-[10px] text-gray-400">+{r.foods_mentioned.length - 5}</span>
)}
</div>
)}
{r.channels && r.channels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{r.channels.map((ch) => ( {r.channels.map((ch) => (
<span <span
key={ch} key={ch}

View File

@@ -48,6 +48,7 @@ export interface Restaurant {
phone: string | null; phone: string | null;
website: string | null; website: string | null;
channels?: string[]; channels?: string[];
foods_mentioned?: string[];
} }
export interface VideoLink { export interface VideoLink {