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

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

View File

@@ -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}

View File

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