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")
@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."""