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."""
|
||||
|
||||
@@ -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[])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<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) => {
|
||||
if (sortKey === key) {
|
||||
setSortAsc(!sortAsc);
|
||||
@@ -697,6 +744,13 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
>
|
||||
{remappingCuisine ? "음식분류 중..." : "음식종류 재분류"}
|
||||
</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 && (
|
||||
<span className="text-sm text-gray-600">{processResult}</span>
|
||||
@@ -908,6 +962,24 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</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 && (
|
||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||
|
||||
@@ -63,8 +63,23 @@ export default function RestaurantList({
|
||||
{r.region && (
|
||||
<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">
|
||||
{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) => (
|
||||
<span
|
||||
key={ch}
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface Restaurant {
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
channels?: string[];
|
||||
foods_mentioned?: string[];
|
||||
}
|
||||
|
||||
export interface VideoLink {
|
||||
|
||||
Reference in New Issue
Block a user