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