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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user