diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c182a16 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Git +.git +.gitignore + +# Flutter 앱 (서버 이미지에 불필요) +app/ +android/ +ios/ +macos/ + +# 문서 +docs/ +*.md +LICENSE + +# Python 캐시 +**/__pycache__ +**/*.pyc +**/*.pyo +.venv/ +*.egg-info/ + +# 개발 환경 +.vscode/ +.idea/ +*.bat + +# ChromaDB (볼륨으로 관리) +ai/rag_engine/chroma_db/ + +# 데이터셋 (용량 큼) +data/dataset/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d50db47 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# NutrAI 서버 환경 변수 +# 이 파일을 .env 로 복사 후 값을 수정하세요. +# cp .env.example .env + +# 사용할 LLM 모델 +NUTRAI_LLM_MODEL=qwen3:8b + +# LLM 응답 타임아웃 (초) +NUTRAI_LLM_TIMEOUT=120 + +# 모델 메모리 유지 시간 +NUTRAI_LLM_KEEP_ALIVE=1h diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34be234 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# ── 1단계: 빌드 ─────────────────────────────────────────────────────────────── +FROM python:3.11-slim AS builder + +WORKDIR /app + +# 시스템 의존성 (ultralytics 빌드에 필요) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ libglib2.0-0 libgl1 \ + && rm -rf /var/lib/apt/lists/* + +COPY server/requirements.txt . +RUN pip install --no-cache-dir --user -r requirements.txt + + +# ── 2단계: 런타임 ───────────────────────────────────────────────────────────── +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libglib2.0-0 libgl1 \ + && rm -rf /var/lib/apt/lists/* + +# 빌드 단계 패키지 복사 +COPY --from=builder /root/.local /root/.local +ENV PATH=/root/.local/bin:$PATH + +# 소스 복사 (모델 파일 포함) +COPY server/ ./server/ +COPY ai/ ./ai/ + +# 포트 노출 +EXPOSE 8000 + +# 헬스체크 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + +CMD ["python", "-m", "uvicorn", "server.main:app", \ + "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git "a/SQL/NutrAi_DB \354\264\210\354\225\210.docx" b/SQL/NutrAi_DB #Ucd08#Uc548.docx similarity index 100% rename from "SQL/NutrAi_DB \354\264\210\354\225\210.docx" rename to SQL/NutrAi_DB #Ucd08#Uc548.docx diff --git a/ai/scripts/db/add_franchise_data.py b/ai/scripts/db/add_franchise_data.py index 8258600..7d06895 100644 --- a/ai/scripts/db/add_franchise_data.py +++ b/ai/scripts/db/add_franchise_data.py @@ -1 +1 @@ -""" 프랜차이즈/편의점 영양 데이터를 가공식품 CSV에서 추출하여 ChromaDB에 추가하는 스크립트. 기존 음식 DB(247,770건)에 프랜차이즈 메뉴 데이터를 보강한다. """ import csv import sys from pathlib import Path import chromadb from sentence_transformers import SentenceTransformer # ── 설정 ── BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent CHROMA_DIR = BASE_DIR / "ai" / "rag_engine" / "chroma_db" CSV_PATH = BASE_DIR / "data" / "nutrition_db" / "식품의약품안전처_통합식품영양성분정보(가공식품)_20260402.csv" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" # 프랜차이즈 키워드 (제조사명 or 식품명에서 매칭) FRANCHISE_KEYWORDS = [ # 패스트푸드 "맥도날드", "버거킹", "롯데리아", "맘스터치", "서브웨이", "KFC", # 카페 "스타벅스", "투썸", "메가커피", "컴포즈", "이디야", "배스킨", "던킨", "빽다방", "할리스", "카페베네", "폴바셋", "블루보틀", # 치킨 "BBQ", "교촌", "BHC", "굽네", "네네", "호식이", "페리카나", "처갓집", # 피자 "도미노", "피자헛", "파파존스", "미스터피자", "피자알볼로", # 편의점 "CU", "GS25", "세븐일레븐", "이마트24", "미니스톱", # 분식/기타 "김밥천국", "본죽", "놀부", "한솥", "명랑핫도그", # 배달/외식 "떡볶이", "족발", "보쌈", "찜닭", "삼겹살", "김치찌개", "된장찌개", "비빔밥", "불고기", "갈비탕", "설렁탕", "냉면", "짜장면", "짬뽕", "탕수육", "볶음밥", "제육볶음", "돈까스", "라멘", "우동", "초밥", "카레", "쌀국수", ] def is_franchise(row: dict) -> bool: """프랜차이즈/배달 관련 데이터인지 판별""" text = ( row.get("식품명", "") + " " + row.get("제조사명", "") + " " + row.get("유통업체명", "") + " " + row.get("식품대분류명", "") ) return any(kw in text for kw in FRANCHISE_KEYWORDS) def safe_float(val: str) -> float | None: """안전한 float 변환""" try: return float(val) if val and val.strip() else None except ValueError: return None def build_document(row: dict) -> str: """ChromaDB에 저장할 텍스트 문서 생성""" name = row.get("식품명", "").strip() category = row.get("식품대분류명", "").strip() maker = row.get("제조사명", "").strip() serving = row.get("1회 섭취참고량", "").strip() base_amount = row.get("영양성분함량기준량", "").strip() parts = [name] if maker: parts[0] = f"{maker} {name}" if category: parts.append(f"분류: {category}") kcal = safe_float(row.get("에너지(kcal)", "")) carb = safe_float(row.get("탄수화물(g)", "")) protein = safe_float(row.get("단백질(g)", "")) fat = safe_float(row.get("지방(g)", "")) sodium = safe_float(row.get("나트륨(mg)", "")) sugar = safe_float(row.get("당류(g)", "")) sat_fat = safe_float(row.get("포화지방산(g)", "")) cholesterol = safe_float(row.get("콜레스테롤(mg)", "")) if kcal is not None: parts.append(f"칼로리 {kcal}kcal") if carb is not None: parts.append(f"탄수화물 {carb}g") if protein is not None: parts.append(f"단백질 {protein}g") if fat is not None: parts.append(f"지방 {fat}g") if sodium is not None: parts.append(f"나트륨 {sodium}mg") if sugar is not None: parts.append(f"당류 {sugar}g") if sat_fat is not None: parts.append(f"포화지방산 {sat_fat}g") if cholesterol is not None: parts.append(f"콜레스테롤 {cholesterol}mg") basis = serving or base_amount if basis: parts.append(f"(기준: {basis})") return " , ".join(parts) # gemma4 호환: | 대신 , 사용 def main(): sys.stdout.reconfigure(encoding="utf-8") print("📂 가공식품 CSV에서 프랜차이즈/배달 데이터 추출 중...") rows = [] with open(CSV_PATH, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: if is_franchise(row): doc = build_document(row) if doc: rows.append((row.get("식품코드", ""), doc)) print(f"✅ 추출 완료: {len(rows)}건") if not rows: print("추가할 데이터가 없습니다.") return # 임베딩 모델 로드 print("🔄 임베딩 모델 로드 중...") import torch device = "cuda" if torch.cuda.is_available() else "cpu" model = SentenceTransformer(EMBED_MODEL, device=device) print(f" → 디바이스: {device}") # ChromaDB 연결 client = chromadb.PersistentClient(path=str(CHROMA_DIR)) collection = client.get_collection("nutrition") existing_count = collection.count() print(f"📊 현재 ChromaDB 문서 수: {existing_count}") # 배치 임베딩 및 추가 BATCH_SIZE = 2000 added = 0 for i in range(0, len(rows), BATCH_SIZE): batch = rows[i : i + BATCH_SIZE] ids = [f"franchise_{r[0]}_{i+j}" for j, r in enumerate(batch)] documents = [r[1] for r in batch] embeddings = model.encode(documents, show_progress_bar=False, convert_to_numpy=True) collection.add( ids=ids, documents=documents, embeddings=embeddings.tolist(), ) added += len(batch) print(f" → {added}/{len(rows)} 추가 완료") final_count = collection.count() print(f"\n🎉 완료! ChromaDB 문서 수: {existing_count} → {final_count} (+{final_count - existing_count})") if __name__ == "__main__": main() \ No newline at end of file +""" 프랜차이즈/편의점 영양 데이터를 가공식품 CSV에서 추출하여 ChromaDB에 추가하는 스크립트. 기존 음식 DB(247,770건)에 프랜차이즈 메뉴 데이터를 보강한다. """ import csv import sys from pathlib import Path import chromadb from sentence_transformers import SentenceTransformer # ── 설정 ── BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent CHROMA_DIR = BASE_DIR / "ai" / "rag_engine" / "chroma_db" CSV_PATH = BASE_DIR / "data" / "nutrition_db" / "식품의약품안전처_통합식품영양성분정보(가공식품)_20260402.csv" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" # 프랜차이즈 키워드 (제조사명 or 식품명에서 매칭) FRANCHISE_KEYWORDS = [ # 패스트푸드 "맥도날드", "버거킹", "롯데리아", "맘스터치", "서브웨이", "KFC", # 카페 "스타벅스", "투썸", "메가커피", "컴포즈", "이디야", "배스킨", "던킨", "빽다방", "할리스", "카페베네", "폴바셋", "블루보틀", # 치킨 "BBQ", "교촌", "BHC", "굽네", "네네", "호식이", "페리카나", "처갓집", # 피자 "도미노", "피자헛", "파파존스", "미스터피자", "피자알볼로", # 편의점 "CU", "GS25", "세븐일레븐", "이마트24", "미니스톱", # 분식/기타 "김밥천국", "본죽", "놀부", "한솥", "명랑핫도그", # 배달/외식 "떡볶이", "족발", "보쌈", "찜닭", "삼겹살", "김치찌개", "된장찌개", "비빔밥", "불고기", "갈비탕", "설렁탕", "냉면", "짜장면", "짬뽕", "탕수육", "볶음밥", "제육볶음", "돈까스", "라멘", "우동", "초밥", "카레", "쌀국수", ] def is_franchise(row: dict) -> bool: """프랜차이즈/배달 관련 데이터인지 판별""" text = ( row.get("식품명", "") + " " + row.get("제조사명", "") + " " + row.get("유통업체명", "") + " " + row.get("식품대분류명", "") ) return any(kw in text for kw in FRANCHISE_KEYWORDS) def safe_float(val: str) -> float | None: """안전한 float 변환""" try: return float(val) if val and val.strip() else None except ValueError: return None def build_document(row: dict) -> str: """ChromaDB에 저장할 텍스트 문서 생성""" name = row.get("식품명", "").strip() category = row.get("식품대분류명", "").strip() maker = row.get("제조사명", "").strip() serving = row.get("1회 섭취참고량", "").strip() base_amount = row.get("영양성분함량기준량", "").strip() parts = [name] if maker: parts[0] = f"{maker} {name}" if category: parts.append(f"분류: {category}") kcal = safe_float(row.get("에너지(kcal)", "")) carb = safe_float(row.get("탄수화물(g)", "")) protein = safe_float(row.get("단백질(g)", "")) fat = safe_float(row.get("지방(g)", "")) sodium = safe_float(row.get("나트륨(mg)", "")) sugar = safe_float(row.get("당류(g)", "")) sat_fat = safe_float(row.get("포화지방산(g)", "")) cholesterol = safe_float(row.get("콜레스테롤(mg)", "")) if kcal is not None: parts.append(f"칼로리 {kcal}kcal") if carb is not None: parts.append(f"탄수화물 {carb}g") if protein is not None: parts.append(f"단백질 {protein}g") if fat is not None: parts.append(f"지방 {fat}g") if sodium is not None: parts.append(f"나트륨 {sodium}mg") if sugar is not None: parts.append(f"당류 {sugar}g") if sat_fat is not None: parts.append(f"포화지방산 {sat_fat}g") if cholesterol is not None: parts.append(f"콜레스테롤 {cholesterol}mg") basis = serving or base_amount if basis: parts.append(f"(기준: {basis})") return " , ".join(parts) # gemma4 호환: | 대신 , 사용 def main(): sys.stdout.reconfigure(encoding="utf-8") print("📂 가공식품 CSV에서 프랜차이즈/배달 데이터 추출 중...") rows = [] with open(CSV_PATH, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: if is_franchise(row): doc = build_document(row) if doc: rows.append((row.get("식품코드", ""), doc)) print(f"✅ 추출 완료: {len(rows)}건") if not rows: print("추가할 데이터가 없습니다.") return # 임베딩 모델 로드 print("🔄 임베딩 모델 로드 중...") import torch device = "cuda" if torch.cuda.is_available() else "cpu" model = SentenceTransformer(EMBED_MODEL, device=device) print(f" → 디바이스: {device}") # ChromaDB 연결 client = chromadb.PersistentClient(path=str(CHROMA_DIR)) collection = client.get_collection("nutrition") existing_count = collection.count() print(f"📊 현재 ChromaDB 문서 수: {existing_count}") # 배치 임베딩 및 추가 BATCH_SIZE = 2000 added = 0 for i in range(0, len(rows), BATCH_SIZE): batch = rows[i : i + BATCH_SIZE] ids = [f"franchise_{r[0]}_{i+j}" for j, r in enumerate(batch)] documents = [r[1] for r in batch] embeddings = model.encode(documents, show_progress_bar=False, convert_to_numpy=True) collection.add( ids=ids, documents=documents, embeddings=embeddings.tolist(), ) added += len(batch) print(f" → {added}/{len(rows)} 추가 완료") final_count = collection.count() print(f"\n🎉 완료! ChromaDB 문서 수: {existing_count} → {final_count} (+{final_count - existing_count})") if __name__ == "__main__": main() \ No newline at end of file diff --git a/ai/scripts/db/add_health_supplement_db.py b/ai/scripts/db/add_health_supplement_db.py index 8809860..ed222fa 100644 --- a/ai/scripts/db/add_health_supplement_db.py +++ b/ai/scripts/db/add_health_supplement_db.py @@ -1 +1 @@ -""" 건강기능식품 DB → ChromaDB 추가 스크립트 기존 ChromaDB를 유지하면서 건강기능식품 데이터만 추가합니다. 실행: .venv\Scripts\python ai\scripts\add_health_supplement_db.py """ import time import pandas as pd from pathlib import Path REPO_ROOT = Path(__file__).parent.parent.parent.parent DATA_DIR = REPO_ROOT / "data" / "nutrition_db" CHROMA_DIR = REPO_ROOT / "ai" / "rag_engine" / "chroma_db" XLSX_FILE = DATA_DIR / "20251230_건강기능식품DB_4380건 (3).xlsx" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" BATCH_SIZE = 500 ID_PREFIX = "health_" def safe(val, unit=""): if pd.isna(val): return "" try: return f"{round(float(val), 1)}{unit}" except (ValueError, TypeError): return "" def row_to_doc(row) -> str: name = str(row.get("식품명", "")).strip() cat = str(row.get("식품대분류명", "건강기능식품")).strip() kcal = safe(row.get("에너지(kcal)"), "kcal") carb = safe(row.get("탄수화물(g)"), "g") prot = safe(row.get("단백질(g)"), "g") fat = safe(row.get("지방(g)"), "g") sod = safe(row.get("나트륨(mg)"), "mg") fiber = safe(row.get("식이섬유(g)"), "g") sugar = safe(row.get("당류(g)"), "g") calc = safe(row.get("칼슘(mg)"), "mg") vitc = safe(row.get("비타민 C(mg)"), "mg") ref = row.get("영양성분제공단위량", "") parts = [name, f"분류: 건강기능식품/{cat}"] if kcal: parts.append(f"칼로리 {kcal}") if carb: parts.append(f"탄수화물 {carb}") if prot: parts.append(f"단백질 {prot}") if fat: parts.append(f"지방 {fat}") if sod: parts.append(f"나트륨 {sod}") if fiber: parts.append(f"식이섬유 {fiber}") if sugar: parts.append(f"당류 {sugar}") if calc: parts.append(f"칼슘 {calc}") if vitc: parts.append(f"비타민C {vitc}") if ref: parts.append(f"(기준: {ref})") return " | ".join(parts) def main(): import torch import chromadb from sentence_transformers import SentenceTransformer device = "cuda" if torch.cuda.is_available() else "cpu" print(f"디바이스: {device.upper()}") if not CHROMA_DIR.exists(): print("오류: ChromaDB가 없습니다. build_nutrition_db.py를 먼저 실행하세요.") return # xlsx 로드 print(f"건강기능식품 데이터 로드 중: {XLSX_FILE.name}") df = pd.read_excel(XLSX_FILE, engine="openpyxl") df = df.dropna(subset=["식품명"]).drop_duplicates(subset=["식품명"]) print(f" → {len(df)}건") texts, metas = [], [] for _, row in df.iterrows(): cat = str(row.get("식품대분류명", "건강기능식품")).strip() texts.append(row_to_doc(row)) metas.append({ "source": "건강기능식품DB", "category": f"건강기능식품/{cat}", "name": str(row.get("식품명", "")), }) # 기존 ChromaDB에서 중복 ID 확인 client = chromadb.PersistentClient(path=str(CHROMA_DIR)) collection = client.get_or_create_collection( name="nutrition", metadata={"hnsw:space": "cosine"}, ) # 이미 추가된 건강기능식품 제거 (재실행 시) existing = collection.get(where={"source": "건강기능식품DB"}) if existing["ids"]: print(f"기존 건강기능식품 {len(existing['ids'])}건 삭제 후 재추가") collection.delete(ids=existing["ids"]) print(f"\n임베딩 시작 (총 {len(texts)}건, 배치 {BATCH_SIZE}건씩)") model = SentenceTransformer(EMBED_MODEL, device=device) start = time.time() for i in range(0, len(texts), BATCH_SIZE): batch_texts = texts[i:i + BATCH_SIZE] batch_metas = metas[i:i + BATCH_SIZE] batch_ids = [f"{ID_PREFIX}{i + j}" for j in range(len(batch_texts))] embeddings = model.encode( batch_texts, batch_size=128, show_progress_bar=False, convert_to_numpy=True, ).tolist() collection.add( ids=batch_ids, embeddings=embeddings, documents=batch_texts, metadatas=batch_metas, ) done = i + len(batch_texts) elapsed = time.time() - start pct = done * 100 // len(texts) print(f" [{pct:3d}%] {done}/{len(texts)}건 경과: {elapsed:.1f}초", flush=True) total_min = (time.time() - start) / 60 print(f"\n완료! 소요: {total_min:.1f}분") print(f"총 DB 항목 수: {collection.count()}") print("서버를 재시작하면 건강기능식품 추천이 활성화됩니다.") if __name__ == "__main__": main() \ No newline at end of file +""" 건강기능식품 DB → ChromaDB 추가 스크립트 기존 ChromaDB를 유지하면서 건강기능식품 데이터만 추가합니다. 실행: .venv\Scripts\python ai\scripts\add_health_supplement_db.py """ import time import pandas as pd from pathlib import Path REPO_ROOT = Path(__file__).parent.parent.parent.parent DATA_DIR = REPO_ROOT / "data" / "nutrition_db" CHROMA_DIR = REPO_ROOT / "ai" / "rag_engine" / "chroma_db" XLSX_FILE = DATA_DIR / "20251230_건강기능식품DB_4380건 (3).xlsx" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" BATCH_SIZE = 500 ID_PREFIX = "health_" def safe(val, unit=""): if pd.isna(val): return "" try: return f"{round(float(val), 1)}{unit}" except (ValueError, TypeError): return "" def row_to_doc(row) -> str: name = str(row.get("식품명", "")).strip() cat = str(row.get("식품대분류명", "건강기능식품")).strip() kcal = safe(row.get("에너지(kcal)"), "kcal") carb = safe(row.get("탄수화물(g)"), "g") prot = safe(row.get("단백질(g)"), "g") fat = safe(row.get("지방(g)"), "g") sod = safe(row.get("나트륨(mg)"), "mg") fiber = safe(row.get("식이섬유(g)"), "g") sugar = safe(row.get("당류(g)"), "g") calc = safe(row.get("칼슘(mg)"), "mg") vitc = safe(row.get("비타민 C(mg)"), "mg") ref = row.get("영양성분제공단위량", "") parts = [name, f"분류: 건강기능식품/{cat}"] if kcal: parts.append(f"칼로리 {kcal}") if carb: parts.append(f"탄수화물 {carb}") if prot: parts.append(f"단백질 {prot}") if fat: parts.append(f"지방 {fat}") if sod: parts.append(f"나트륨 {sod}") if fiber: parts.append(f"식이섬유 {fiber}") if sugar: parts.append(f"당류 {sugar}") if calc: parts.append(f"칼슘 {calc}") if vitc: parts.append(f"비타민C {vitc}") if ref: parts.append(f"(기준: {ref})") return " | ".join(parts) def main(): import torch import chromadb from sentence_transformers import SentenceTransformer device = "cuda" if torch.cuda.is_available() else "cpu" print(f"디바이스: {device.upper()}") if not CHROMA_DIR.exists(): print("오류: ChromaDB가 없습니다. build_nutrition_db.py를 먼저 실행하세요.") return # xlsx 로드 print(f"건강기능식품 데이터 로드 중: {XLSX_FILE.name}") df = pd.read_excel(XLSX_FILE, engine="openpyxl") df = df.dropna(subset=["식품명"]).drop_duplicates(subset=["식품명"]) print(f" → {len(df)}건") texts, metas = [], [] for _, row in df.iterrows(): cat = str(row.get("식품대분류명", "건강기능식품")).strip() texts.append(row_to_doc(row)) metas.append({ "source": "건강기능식품DB", "category": f"건강기능식품/{cat}", "name": str(row.get("식품명", "")), }) # 기존 ChromaDB에서 중복 ID 확인 client = chromadb.PersistentClient(path=str(CHROMA_DIR)) collection = client.get_or_create_collection( name="nutrition", metadata={"hnsw:space": "cosine"}, ) # 이미 추가된 건강기능식품 제거 (재실행 시) existing = collection.get(where={"source": "건강기능식품DB"}) if existing["ids"]: print(f"기존 건강기능식품 {len(existing['ids'])}건 삭제 후 재추가") collection.delete(ids=existing["ids"]) print(f"\n임베딩 시작 (총 {len(texts)}건, 배치 {BATCH_SIZE}건씩)") model = SentenceTransformer(EMBED_MODEL, device=device) start = time.time() for i in range(0, len(texts), BATCH_SIZE): batch_texts = texts[i:i + BATCH_SIZE] batch_metas = metas[i:i + BATCH_SIZE] batch_ids = [f"{ID_PREFIX}{i + j}" for j in range(len(batch_texts))] embeddings = model.encode( batch_texts, batch_size=128, show_progress_bar=False, convert_to_numpy=True, ).tolist() collection.add( ids=batch_ids, embeddings=embeddings, documents=batch_texts, metadatas=batch_metas, ) done = i + len(batch_texts) elapsed = time.time() - start pct = done * 100 // len(texts) print(f" [{pct:3d}%] {done}/{len(texts)}건 경과: {elapsed:.1f}초", flush=True) total_min = (time.time() - start) / 60 print(f"\n완료! 소요: {total_min:.1f}분") print(f"총 DB 항목 수: {collection.count()}") print("서버를 재시작하면 건강기능식품 추천이 활성화됩니다.") if __name__ == "__main__": main() \ No newline at end of file diff --git a/ai/scripts/db/add_manual_franchise.py b/ai/scripts/db/add_manual_franchise.py index cf549e3..97cdc37 100644 --- a/ai/scripts/db/add_manual_franchise.py +++ b/ai/scripts/db/add_manual_franchise.py @@ -1 +1 @@ -"""수동 작성한 프랜차이즈 메뉴 CSV를 ChromaDB에 추가""" import csv import sys from pathlib import Path import chromadb from sentence_transformers import SentenceTransformer BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent CHROMA_DIR = BASE_DIR / "ai" / "rag_engine" / "chroma_db" CSV_PATH = BASE_DIR / "data" / "nutrition_db" / "franchise_manual.csv" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" def main(): sys.stdout.reconfigure(encoding="utf-8") rows = [] with open(CSV_PATH, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: brand = row["브랜드"].strip() menu = row["메뉴명"].strip() parts = [f"{brand} {menu}"] for col, unit in [ ("칼로리(kcal)", "kcal"), ("탄수화물(g)", "g"), ("단백질(g)", "g"), ("지방(g)", "g"), ("나트륨(mg)", "mg"), ("당류(g)", "g"), ]: label = col.split("(")[0] val = row.get(col, "").strip() if val: parts.append(f"{label} {val}{unit}") serving = row.get("1회제공량", "").strip() if serving: parts.append(f"(기준: {serving})") doc = " , ".join(parts) rows.append((f"manual_{brand}_{menu}", doc)) print(f"📂 수동 프랜차이즈 데이터: {len(rows)}건") import torch device = "cuda" if torch.cuda.is_available() else "cpu" model = SentenceTransformer(EMBED_MODEL, device=device) client = chromadb.PersistentClient(path=str(CHROMA_DIR)) collection = client.get_collection("nutrition") before = collection.count() documents = [r[1] for r in rows] ids = [r[0] for r in rows] embeddings = model.encode(documents, convert_to_numpy=True) collection.add(ids=ids, documents=documents, embeddings=embeddings.tolist()) after = collection.count() print(f"✅ ChromaDB: {before} → {after} (+{after - before})") if __name__ == "__main__": main() \ No newline at end of file +"""수동 작성한 프랜차이즈 메뉴 CSV를 ChromaDB에 추가""" import csv import sys from pathlib import Path import chromadb from sentence_transformers import SentenceTransformer BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent CHROMA_DIR = BASE_DIR / "ai" / "rag_engine" / "chroma_db" CSV_PATH = BASE_DIR / "data" / "nutrition_db" / "franchise_manual.csv" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" def main(): sys.stdout.reconfigure(encoding="utf-8") rows = [] with open(CSV_PATH, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: brand = row["브랜드"].strip() menu = row["메뉴명"].strip() parts = [f"{brand} {menu}"] for col, unit in [ ("칼로리(kcal)", "kcal"), ("탄수화물(g)", "g"), ("단백질(g)", "g"), ("지방(g)", "g"), ("나트륨(mg)", "mg"), ("당류(g)", "g"), ]: label = col.split("(")[0] val = row.get(col, "").strip() if val: parts.append(f"{label} {val}{unit}") serving = row.get("1회제공량", "").strip() if serving: parts.append(f"(기준: {serving})") doc = " , ".join(parts) rows.append((f"manual_{brand}_{menu}", doc)) print(f"📂 수동 프랜차이즈 데이터: {len(rows)}건") import torch device = "cuda" if torch.cuda.is_available() else "cpu" model = SentenceTransformer(EMBED_MODEL, device=device) client = chromadb.PersistentClient(path=str(CHROMA_DIR)) collection = client.get_collection("nutrition") before = collection.count() documents = [r[1] for r in rows] ids = [r[0] for r in rows] embeddings = model.encode(documents, convert_to_numpy=True) collection.add(ids=ids, documents=documents, embeddings=embeddings.tolist()) after = collection.count() print(f"✅ ChromaDB: {before} → {after} (+{after - before})") if __name__ == "__main__": main() \ No newline at end of file diff --git a/ai/scripts/db/build_guidelines_db.py b/ai/scripts/db/build_guidelines_db.py index 04db813..163b3da 100644 --- a/ai/scripts/db/build_guidelines_db.py +++ b/ai/scripts/db/build_guidelines_db.py @@ -1 +1 @@ -""" 의학 가이드라인 → ChromaDB 추가 스크립트 기존 nutrition_collection에 가이드라인 문서를 추가한다. (별도 컬렉션 없이 동일 컬렉션에 넣어 RAG 검색에 통합) 실행: .venv\Scripts\python ai\scripts\build_guidelines_db.py """ import sys import uuid from pathlib import Path REPO_ROOT = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(REPO_ROOT)) import chromadb from sentence_transformers import SentenceTransformer CHROMA_DIR = REPO_ROOT / "ai" / "rag_engine" / "chroma_db" GUIDE_DIR = REPO_ROOT / "data" / "guidelines" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" COLLECTION = "nutrition" # ── 가이드라인 파일 메타데이터 정의 ────────────────────────────── GUIDELINE_FILES = [ { "file": "diabetes_kda.txt", "source": "대한당뇨병학회", "condition": "당뇨", "is_diabetes": True, "is_hypertension": False, "is_diet": False, }, { "file": "hypertension_ksh.txt", "source": "대한고혈압학회", "condition": "고혈압", "is_diabetes": False, "is_hypertension": True, "is_diet": False, }, { "file": "kdri_2025.txt", "source": "보건복지부/한국영양학회", "condition": "일반", "is_diabetes": False, "is_hypertension": False, "is_diet": True, }, # ── 영양사도우미 (kdclub.com) 식사요법 ── { "file": "kdclub_general_diet.txt", "source": "영양사도우미(kdclub.com)", "condition": "일반치료식", "is_diabetes": False, "is_hypertension": False, "is_diet": True, }, { "file": "kdclub_gastric.txt", "source": "영양사도우미(kdclub.com)", "condition": "소화기", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_liver.txt", "source": "영양사도우미(kdclub.com)", "condition": "간질환", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_cardiovascular.txt", "source": "영양사도우미(kdclub.com)", "condition": "심혈관", "is_diabetes": False, "is_hypertension": True, "is_diet": False, }, { "file": "kdclub_kidney.txt", "source": "영양사도우미(kdclub.com)", "condition": "신장질환", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_neuro.txt", "source": "영양사도우미(kdclub.com)", "condition": "뇌신경", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_eating_disorder.txt", "source": "영양사도우미(kdclub.com)", "condition": "섭식장애", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_cancer.txt", "source": "영양사도우미(kdclub.com)", "condition": "암", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, ] def chunk_text(text: str, max_chars: int = 400) -> list[str]: """단락 단위로 청킹. 빈 줄 기준으로 분리 후 max_chars 초과 시 추가 분할.""" paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks = [] for para in paragraphs: # 헤더 줄(# 으로 시작하는 줄)을 제거하고 내용만 추출 lines = para.splitlines() content_lines = [ln for ln in lines if not ln.startswith("#")] para = "\n".join(content_lines).strip() if not para: continue if len(para) <= max_chars: chunks.append(para) else: # 문장(마침표/개행) 단위로 추가 분할 sentences = [s.strip() for s in para.replace(". ", ".\n").split("\n") if s.strip()] buf = "" for sent in sentences: if len(buf) + len(sent) + 1 <= max_chars: buf = (buf + " " + sent).strip() else: if buf: chunks.append(buf) buf = sent if buf: chunks.append(buf) return chunks def load_guideline_docs(cfg: dict) -> tuple[list[str], list[dict], list[str]]: path = GUIDE_DIR / cfg["file"] text = path.read_text(encoding="utf-8") chunks = chunk_text(text) docs, metas, ids = [], [], [] for i, chunk in enumerate(chunks): doc_id = f"guide_{cfg['condition']}_{i:04d}" meta = { "category": "가이드라인", "source": cfg["source"], "condition": cfg["condition"], "is_diet": cfg["is_diet"], "is_diabetes": cfg["is_diabetes"], "is_hypertension": cfg["is_hypertension"], "is_morning": False, "is_lunch": False, "is_dinner": False, "is_snack": False, "is_supplement": False, } docs.append(chunk) metas.append(meta) ids.append(doc_id) return docs, metas, ids def main() -> None: print(f"\n{'='*60}") print(" NutrAI 의학 가이드라인 → ChromaDB 추가") print(f"{'='*60}\n") print("모델 로딩 중...") model = SentenceTransformer(EMBED_MODEL) client = chromadb.PersistentClient(path=str(CHROMA_DIR)) # 잘못 생성된 nutrition_collection 정리 try: client.delete_collection("nutrition_collection") print("nutrition_collection (오생성) 삭제") except Exception: pass collection = client.get_or_create_collection( name=COLLECTION, metadata={"hnsw:space": "l2"}, ) total_added = 0 for cfg in GUIDELINE_FILES: cond = cfg["condition"] print(f"\n[{cond}] {cfg['file']} 처리 중...") docs, metas, ids = load_guideline_docs(cfg) # 기존 가이드라인 문서 삭제 후 재삽입 existing = collection.get( where={"$and": [{"category": {"$eq": "가이드라인"}}, {"condition": {"$eq": cond}}]}, include=[], ) if existing["ids"]: collection.delete(ids=existing["ids"]) print(f" 기존 {len(existing['ids'])}개 삭제") # 임베딩 생성 embeddings = model.encode(docs, batch_size=64, show_progress_bar=False).tolist() collection.add(documents=docs, embeddings=embeddings, metadatas=metas, ids=ids) print(f" {len(docs)}개 청크 추가 완료") total_added += len(docs) total = collection.count() print(f"\n총 {total_added}개 가이드라인 청크 추가") print(f"컬렉션 전체 문서 수: {total:,}건") print(f"\n{'='*60}\n") if __name__ == "__main__": main() \ No newline at end of file +""" 의학 가이드라인 → ChromaDB 추가 스크립트 기존 nutrition_collection에 가이드라인 문서를 추가한다. (별도 컬렉션 없이 동일 컬렉션에 넣어 RAG 검색에 통합) 실행: .venv\Scripts\python ai\scripts\build_guidelines_db.py """ import sys import uuid from pathlib import Path REPO_ROOT = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(REPO_ROOT)) import chromadb from sentence_transformers import SentenceTransformer CHROMA_DIR = REPO_ROOT / "ai" / "rag_engine" / "chroma_db" GUIDE_DIR = REPO_ROOT / "data" / "guidelines" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" COLLECTION = "nutrition" # ── 가이드라인 파일 메타데이터 정의 ────────────────────────────── GUIDELINE_FILES = [ { "file": "diabetes_kda.txt", "source": "대한당뇨병학회", "condition": "당뇨", "is_diabetes": True, "is_hypertension": False, "is_diet": False, }, { "file": "hypertension_ksh.txt", "source": "대한고혈압학회", "condition": "고혈압", "is_diabetes": False, "is_hypertension": True, "is_diet": False, }, { "file": "kdri_2025.txt", "source": "보건복지부/한국영양학회", "condition": "일반", "is_diabetes": False, "is_hypertension": False, "is_diet": True, }, # ── 영양사도우미 (kdclub.com) 식사요법 ── { "file": "kdclub_general_diet.txt", "source": "영양사도우미(kdclub.com)", "condition": "일반치료식", "is_diabetes": False, "is_hypertension": False, "is_diet": True, }, { "file": "kdclub_gastric.txt", "source": "영양사도우미(kdclub.com)", "condition": "소화기", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_liver.txt", "source": "영양사도우미(kdclub.com)", "condition": "간질환", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_cardiovascular.txt", "source": "영양사도우미(kdclub.com)", "condition": "심혈관", "is_diabetes": False, "is_hypertension": True, "is_diet": False, }, { "file": "kdclub_kidney.txt", "source": "영양사도우미(kdclub.com)", "condition": "신장질환", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_neuro.txt", "source": "영양사도우미(kdclub.com)", "condition": "뇌신경", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_eating_disorder.txt", "source": "영양사도우미(kdclub.com)", "condition": "섭식장애", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, { "file": "kdclub_cancer.txt", "source": "영양사도우미(kdclub.com)", "condition": "암", "is_diabetes": False, "is_hypertension": False, "is_diet": False, }, ] def chunk_text(text: str, max_chars: int = 400) -> list[str]: """단락 단위로 청킹. 빈 줄 기준으로 분리 후 max_chars 초과 시 추가 분할.""" paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks = [] for para in paragraphs: # 헤더 줄(# 으로 시작하는 줄)을 제거하고 내용만 추출 lines = para.splitlines() content_lines = [ln for ln in lines if not ln.startswith("#")] para = "\n".join(content_lines).strip() if not para: continue if len(para) <= max_chars: chunks.append(para) else: # 문장(마침표/개행) 단위로 추가 분할 sentences = [s.strip() for s in para.replace(". ", ".\n").split("\n") if s.strip()] buf = "" for sent in sentences: if len(buf) + len(sent) + 1 <= max_chars: buf = (buf + " " + sent).strip() else: if buf: chunks.append(buf) buf = sent if buf: chunks.append(buf) return chunks def load_guideline_docs(cfg: dict) -> tuple[list[str], list[dict], list[str]]: path = GUIDE_DIR / cfg["file"] text = path.read_text(encoding="utf-8") chunks = chunk_text(text) docs, metas, ids = [], [], [] for i, chunk in enumerate(chunks): doc_id = f"guide_{cfg['condition']}_{i:04d}" meta = { "category": "가이드라인", "source": cfg["source"], "condition": cfg["condition"], "is_diet": cfg["is_diet"], "is_diabetes": cfg["is_diabetes"], "is_hypertension": cfg["is_hypertension"], "is_morning": False, "is_lunch": False, "is_dinner": False, "is_snack": False, "is_supplement": False, } docs.append(chunk) metas.append(meta) ids.append(doc_id) return docs, metas, ids def main() -> None: print(f"\n{'='*60}") print(" NutrAI 의학 가이드라인 → ChromaDB 추가") print(f"{'='*60}\n") print("모델 로딩 중...") model = SentenceTransformer(EMBED_MODEL) client = chromadb.PersistentClient(path=str(CHROMA_DIR)) # 잘못 생성된 nutrition_collection 정리 try: client.delete_collection("nutrition_collection") print("nutrition_collection (오생성) 삭제") except Exception: pass collection = client.get_or_create_collection( name=COLLECTION, metadata={"hnsw:space": "l2"}, ) total_added = 0 for cfg in GUIDELINE_FILES: cond = cfg["condition"] print(f"\n[{cond}] {cfg['file']} 처리 중...") docs, metas, ids = load_guideline_docs(cfg) # 기존 가이드라인 문서 삭제 후 재삽입 existing = collection.get( where={"$and": [{"category": {"$eq": "가이드라인"}}, {"condition": {"$eq": cond}}]}, include=[], ) if existing["ids"]: collection.delete(ids=existing["ids"]) print(f" 기존 {len(existing['ids'])}개 삭제") # 임베딩 생성 embeddings = model.encode(docs, batch_size=64, show_progress_bar=False).tolist() collection.add(documents=docs, embeddings=embeddings, metadatas=metas, ids=ids) print(f" {len(docs)}개 청크 추가 완료") total_added += len(docs) total = collection.count() print(f"\n총 {total_added}개 가이드라인 청크 추가") print(f"컬렉션 전체 문서 수: {total:,}건") print(f"\n{'='*60}\n") if __name__ == "__main__": main() \ No newline at end of file diff --git a/ai/scripts/db/build_nutrition_db.py b/ai/scripts/db/build_nutrition_db.py index 67483a8..1d07966 100644 --- a/ai/scripts/db/build_nutrition_db.py +++ b/ai/scripts/db/build_nutrition_db.py @@ -1 +1,202 @@ -""" 식품영양성분 xlsx → ChromaDB 벡터 DB 구축 스크립트 데이터 출처: 한글: 식품영양성분 데이터베이스 영문: Korean Food Composition Database system (K-FCDB) 실행: .venv\Scripts\python ai\scripts\build_nutrition_db.py [--all] 옵션: (없음) 음식DB만 빌드 (~19,495건, 5~10분) --all 음식DB + 가공식품DB 모두 빌드 (~296,000건, 1시간+) 특징: - GPU 자동 사용 (CUDA) - 체크포인트 지원 (끊겨도 이어서 시작) - 카테고리·영양소 기반 boolean 메타데이터 자동 태깅 """ import sys import time import json import pandas as pd from pathlib import Path from sentence_transformers import SentenceTransformer import chromadb REPO_ROOT = Path(__file__).parent.parent.parent.parent DATA_DIR = REPO_ROOT / "data" / "nutrition_db" CHROMA_DIR = REPO_ROOT / "ai" / "rag_engine" / "chroma_db" CHECKPOINT = REPO_ROOT / "ai" / "rag_engine" / "build_checkpoint.json" FOOD_XLSX = DATA_DIR / "20251229_음식DB 19495건.xlsx" PROC_XLSX = DATA_DIR / "20260429_가공식품_277074건.xlsx" EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" BATCH_SIZE = 2000 SOURCE_KR = "식품영양성분 데이터베이스" SOURCE_EN = "Korean Food Composition Database system (K-FCDB)" # ── 메타데이터 태깅 규칙 ────────────────────────────────────────── # 식사 시간대 카테고리 매핑 _MORNING_CATS = {"죽 및 스프류", "과일류", "음료 및 차류", "빵 및 과자류", "유제품류 및 빙과류", "밥류"} _LUNCH_CATS = {"밥류", "면 및 만두류", "국 및 탕류", "찌개 및 전골류", "구이류", "볶음류", "찜류", "전·적 및 부침류", "조림류", "튀김류", "나물·숙채류", "생채·무침류", "수·조·어·육류"} _DINNER_CATS = {"밥류", "국 및 탕류", "찌개 및 전골류", "구이류", "볶음류", "찜류", "수·조·어·육류", "면 및 만두류"} _SNACK_CATS = {"빵 및 과자류", "과일류", "음료 및 차류", "두류, 견과 및 종실류"} # 질환식 제외 카테고리 (염분·당류 높은 것들) _HIGH_SODIUM_CATS = {"젓갈류", "김치류", "장류, 양념류", "장아찌·절임류"} _HIGH_SUGAR_CATS = {"음료 및 차류", "빵 및 과자류", "유제품류 및 빙과류", "과일류", "장류, 양념류"} def _fval(row, col: str, default: float = 0.0) -> float: try: v = row.get(col) return float(v) if v is not None and not pd.isna(v) else default except (TypeError, ValueError): return default def tag_row(row, category: str, source: str = "food") -> dict: """카테고리·영양소 기반 boolean 메타데이터 태그 계산.""" kcal = _fval(row, "에너지(kcal)") protein = _fval(row, "단백질(g)") fat = _fval(row, "지방(g)") sodium = _fval(row, "나트륨(mg)") sugar = _fval(row, "당류(g)") carb = _fval(row, "탄수화물(g)") fiber = _fval(row, "식이섬유(g)") # 식사 시간대 is_morning = category in _MORNING_CATS is_lunch = category in _LUNCH_CATS is_dinner = category in _DINNER_CATS is_snack = category in _SNACK_CATS # 다이어트 적합: 저칼로리(≤300) + 비다이어트 부적합 카테고리 제외 # OR 고단백 저지방(단백질≥15g, 지방≤8g, kcal≤350) non_diet_cats = {"빵 및 과자류", "유제품류 및 빙과류", "튀김류", "장류, 양념류"} is_diet = ( (0 < kcal <= 300 and category not in non_diet_cats) or (protein >= 15 and fat <= 8 and 0 < kcal <= 350) ) # 당뇨 적합: 당류≤5g AND 탄수화물≤30g, 고당 카테고리 제외 is_diabetes = ( sugar <= 5 and carb <= 30 and kcal > 0 and category not in _HIGH_SUGAR_CATS ) # 고혈압 적합: 나트륨≤300mg, 고염 카테고리 제외 is_hypertension = ( 0 < sodium <= 300 and category not in _HIGH_SODIUM_CATS ) # 건강기능식품: 별도 DB 소스에서만 True is_supplement = source == "supplement" return { "is_morning": is_morning, "is_lunch": is_lunch, "is_dinner": is_dinner, "is_snack": is_snack, "is_diet": is_diet, "is_diabetes": is_diabetes, "is_hypertension": is_hypertension, "is_supplement": is_supplement, } def safe(val, unit="") -> str: try: if pd.isna(val): return "" return f"{round(float(val), 1)}{unit}" except (TypeError, ValueError): return "" def row_to_doc(row, category: str = "") -> str: name = str(row.get("식품명", "")).strip() kcal = safe(row.get("에너지(kcal)"), "kcal") carb = safe(row.get("탄수화물(g)"), "g") prot = safe(row.get("단백질(g)"), "g") fat = safe(row.get("지방(g)"), "g") sod = safe(row.get("나트륨(mg)"), "mg") fiber = safe(row.get("식이섬유(g)"), "g") sugar = safe(row.get("당류(g)"), "g") calc = safe(row.get("칼슘(mg)"), "mg") vitc = safe(row.get("비타민 C(mg)"), "mg") parts = [name] if category: parts.append(f"분류: {category}") if kcal: parts.append(f"칼로리 {kcal}") if carb: parts.append(f"탄수화물 {carb}") if prot: parts.append(f"단백질 {prot}") if fat: parts.append(f"지방 {fat}") if sod: parts.append(f"나트륨 {sod}") if fiber: parts.append(f"식이섬유 {fiber}") if sugar: parts.append(f"당류 {sugar}") if calc: parts.append(f"칼슘 {calc}") if vitc: parts.append(f"비타민C {vitc}") ref = row.get("영양성분함량기준량", "100g") parts.append(f"(기준: {ref})") parts.append(f"(출처: {SOURCE_EN})") return " | ".join(parts) def load_food_docs() -> tuple[list[str], list[dict]]: """음식DB xlsx 로드""" print(f"음식DB 로드 중: {FOOD_XLSX.name}") df = pd.read_excel(FOOD_XLSX, engine="openpyxl") df = df.dropna(subset=["식품명", "에너지(kcal)"]).drop_duplicates(subset=["식품명"]) print(f" → {len(df):,}건") texts, metas = [], [] for _, row in df.iterrows(): cat = str(row.get("식품대분류명", "")) texts.append(row_to_doc(row, cat)) metas.append({ "source": "K-FCDB_음식DB", "source_kr": SOURCE_KR, "source_en": SOURCE_EN, "category": cat, "name": str(row.get("식품명", "")), **tag_row(row, cat, source="food"), }) return texts, metas def load_proc_docs() -> tuple[list[str], list[dict]]: """가공식품DB xlsx 로드""" print(f"가공식품DB 로드 중: {PROC_XLSX.name}") df = pd.read_excel(PROC_XLSX, engine="openpyxl") df = df.dropna(subset=["식품명", "에너지(kcal)"]).drop_duplicates(subset=["식품명"]) print(f" → {len(df):,}건") texts, metas = [], [] for _, row in df.iterrows(): cat = str(row.get("식품대분류명", row.get("식품기원명", ""))) texts.append(row_to_doc(row, cat)) metas.append({ "source": "K-FCDB_가공식품DB", "source_kr": SOURCE_KR, "source_en": SOURCE_EN, "category": cat, "name": str(row.get("식품명", "")), **tag_row(row, cat, source="food"), }) return texts, metas def load_checkpoint() -> int: if CHECKPOINT.exists(): data = json.loads(CHECKPOINT.read_text()) return data.get("done", 0) return 0 def save_checkpoint(done: int): CHECKPOINT.write_text(json.dumps({"done": done})) def main(): sys.stdout.reconfigure(encoding="utf-8") include_proc = "--all" in sys.argv import torch device = "cuda" if torch.cuda.is_available() else "cpu" print(f"디바이스: {device.upper()}") print(f"모드: {'음식DB + 가공식품DB' if include_proc else '음식DB만'}") print(f"출처: {SOURCE_KR} ({SOURCE_EN})\n") start_from = load_checkpoint() if start_from > 0: print(f"체크포인트 발견: {start_from:,}건부터 이어서 시작") else: if CHROMA_DIR.exists(): import shutil print("기존 ChromaDB 삭제 중...") shutil.rmtree(CHROMA_DIR) texts, metas = load_food_docs() if include_proc: t2, m2 = load_proc_docs() texts += t2 metas += m2 total = len(texts) print(f"\n총 {total:,}개 문서") print(f"임베딩 모델 로드 중: {EMBED_MODEL}") model = SentenceTransformer(EMBED_MODEL, device=device) CHROMA_DIR.mkdir(parents=True, exist_ok=True) client = chromadb.PersistentClient(path=str(CHROMA_DIR)) collection = client.get_or_create_collection( name="nutrition", metadata={"hnsw:space": "l2"}, ) print(f"\nEmbedding 시작 (배치 {BATCH_SIZE:,}건씩)\n") start = time.time() for i in range(start_from, total, BATCH_SIZE): batch_texts = texts[i : i + BATCH_SIZE] batch_metas = metas[i : i + BATCH_SIZE] batch_ids = [f"doc_{j}" for j in range(i, i + len(batch_texts))] embeddings = model.encode( batch_texts, batch_size=256, show_progress_bar=False, convert_to_numpy=True, ).tolist() collection.add( ids=batch_ids, embeddings=embeddings, documents=batch_texts, metadatas=batch_metas, ) done = i + len(batch_texts) save_checkpoint(done) elapsed = time.time() - start speed = (done - start_from) / elapsed if elapsed > 0 else 0 eta = (total - done) / speed if speed > 0 else 0 pct = done * 100 // total print( f" [{pct:3d}%] {done:,}/{total:,}건 " f"경과: {elapsed/60:.1f}분 남은시간: {eta/60:.1f}분 " f"속도: {speed:.0f}건/초", flush=True, ) CHECKPOINT.unlink(missing_ok=True) total_min = (time.time() - start) / 60 print(f"\n완료! 총 소요: {total_min:.1f}분") print(f"ChromaDB 저장 위치: {CHROMA_DIR}") print(f"총 문서 수: {collection.count():,}건") print("서버를 재시작하면 새 데이터가 적용됩니다.") if __name__ == "__main__": main() \ No newline at end of file +""" +음식분류 AI 데이터 영양DB xlsx → ChromaDB 벡터 DB 구축 스크립트 + +데이터: 음식분류_AI_데이터_영양DB.xlsx (400개 외식메뉴) + +실행: + python -m ai.scripts.db.build_nutrition_db + +특징: + - GPU 자동 사용 (CUDA) + - 체크포인트 지원 (끊겨도 이어서 시작) +""" + +import sys +import time +import json +import pandas as pd +from pathlib import Path +from sentence_transformers import SentenceTransformer +import chromadb + +REPO_ROOT = Path(__file__).parent.parent.parent.parent +DATA_DIR = REPO_ROOT / "data" / "nutrition_db" +CHROMA_DIR = REPO_ROOT / "ai" / "rag_engine" / "chroma_db" +CHECKPOINT = REPO_ROOT / "ai" / "rag_engine" / "build_checkpoint.json" + +# 업로드된 파일명 +FOOD_XLSX = DATA_DIR / "음식분류_AI_데이터_영양DB.xlsx" + +EMBED_MODEL = "snunlp/KR-SBERT-V40K-klueNLI-augSTS" +BATCH_SIZE = 256 + +SOURCE = "음식분류 AI 데이터 영양DB" + + +def _fval(row, col: str, default: float = 0.0) -> float: + try: + v = row.get(col) + return float(v) if v is not None and not pd.isna(v) else default + except (TypeError, ValueError): + return default + + +def tag_row(row) -> dict: + """영양소 기반 boolean 메타데이터 태그 계산.""" + kcal = _fval(row, "에너지(kcal)") + protein = _fval(row, "단백질(g)") + fat = _fval(row, "지방(g)") + sodium = _fval(row, "나트륨(mg)") + sugar = _fval(row, "당류(g)") + carb = _fval(row, "탄수화물(g)") + + is_diet = ( + (0 < kcal <= 400) + or (protein >= 15 and fat <= 8 and 0 < kcal <= 500) + ) + is_diabetes = sugar <= 5 and carb <= 40 and kcal > 0 + is_hypertension = 0 < sodium <= 400 + + return { + "is_diet": is_diet, + "is_diabetes": is_diabetes, + "is_hypertension": is_hypertension, + } + + +def row_to_doc(row) -> str: + name = str(row.get("음 식 명", "")).strip() + weight = _fval(row, "중량(g)") + kcal = _fval(row, "에너지(kcal)") + carb = _fval(row, "탄수화물(g)") + sugar = _fval(row, "당류(g)") + fat = _fval(row, "지방(g)") + prot = _fval(row, "단백질(g)") + calc = _fval(row, "칼슘(mg)") + sodium = _fval(row, "나트륨(mg)") + chol = _fval(row, "콜레스테롤(mg)") + + parts = [name] + if weight: parts.append(f"중량 {round(weight,1)}g") + if kcal: parts.append(f"칼로리 {round(kcal,1)}kcal") + if carb: parts.append(f"탄수화물 {round(carb,1)}g") + if sugar: parts.append(f"당류 {round(sugar,1)}g") + if fat: parts.append(f"지방 {round(fat,1)}g") + if prot: parts.append(f"단백질 {round(prot,1)}g") + if calc: parts.append(f"칼슘 {round(calc,1)}mg") + if sodium: parts.append(f"나트륨 {round(sodium,1)}mg") + if chol: parts.append(f"콜레스테롤 {round(chol,1)}mg") + parts.append(f"(출처: {SOURCE})") + + return " | ".join(parts) + + +def load_food_docs() -> tuple[list[str], list[dict]]: + print(f"영양DB 로드 중: {FOOD_XLSX.name}") + if not FOOD_XLSX.exists(): + raise FileNotFoundError( + f"파일을 찾을 수 없습니다: {FOOD_XLSX}\n" + f"다음 경로에 파일을 복사하세요: {DATA_DIR}" + ) + df = pd.read_excel(FOOD_XLSX, engine="openpyxl") + df = df.dropna(subset=["음 식 명", "에너지(kcal)"]) + df = df.drop_duplicates(subset=["음 식 명"]) + print(f" → {len(df):,}건") + + texts, metas = [], [] + for _, row in df.iterrows(): + texts.append(row_to_doc(row)) + metas.append({ + "source": SOURCE, + "name": str(row.get("음 식 명", "")).strip(), + "kcal": _fval(row, "에너지(kcal)"), + "protein": _fval(row, "단백질(g)"), + "fat": _fval(row, "지방(g)"), + "sodium": _fval(row, "나트륨(mg)"), + **tag_row(row), + }) + return texts, metas + + +def load_checkpoint() -> int: + if CHECKPOINT.exists(): + data = json.loads(CHECKPOINT.read_text(encoding="utf-8")) + return data.get("done", 0) + return 0 + + +def save_checkpoint(done: int): + CHECKPOINT.write_text(json.dumps({"done": done}), encoding="utf-8") + + +def main(): + sys.stdout.reconfigure(encoding="utf-8") + + import torch + device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"디바이스: {device.upper()}") + print(f"출처: {SOURCE}\n") + + start_from = load_checkpoint() + if start_from > 0: + print(f"체크포인트 발견: {start_from:,}건부터 이어서 시작") + else: + if CHROMA_DIR.exists(): + import shutil + print("기존 ChromaDB 삭제 중...") + shutil.rmtree(CHROMA_DIR) + + texts, metas = load_food_docs() + total = len(texts) + print(f"\n총 {total:,}개 문서") + + print(f"임베딩 모델 로드 중: {EMBED_MODEL}") + model = SentenceTransformer(EMBED_MODEL, device=device) + + CHROMA_DIR.mkdir(parents=True, exist_ok=True) + client = chromadb.PersistentClient(path=str(CHROMA_DIR)) + collection = client.get_or_create_collection( + name="nutrition", + metadata={"hnsw:space": "l2"}, + ) + + print(f"\nEmbedding 시작 (배치 {BATCH_SIZE}건씩)\n") + start = time.time() + + for i in range(start_from, total, BATCH_SIZE): + batch_texts = texts[i : i + BATCH_SIZE] + batch_metas = metas[i : i + BATCH_SIZE] + batch_ids = [f"doc_{j}" for j in range(i, i + len(batch_texts))] + + embeddings = model.encode( + batch_texts, + batch_size=64, + show_progress_bar=False, + convert_to_numpy=True, + ).tolist() + + collection.add( + ids=batch_ids, + embeddings=embeddings, + documents=batch_texts, + metadatas=batch_metas, + ) + + done = i + len(batch_texts) + save_checkpoint(done) + + elapsed = time.time() - start + speed = (done - start_from) / elapsed if elapsed > 0 else 0 + pct = done * 100 // total + print(f" [{pct:3d}%] {done}/{total}건 경과: {elapsed:.1f}초 속도: {speed:.0f}건/초", flush=True) + + CHECKPOINT.unlink(missing_ok=True) + total_sec = time.time() - start + print(f"\n✅ 완료! 총 소요: {total_sec:.1f}초") + print(f"ChromaDB 저장 위치: {CHROMA_DIR}") + print(f"총 문서 수: {collection.count():,}건") + print("서버를 재시작하면 새 데이터가 적용됩니다.") + + +if __name__ == "__main__": + main() diff --git a/app/.flutter-plugins b/app/.flutter-plugins deleted file mode 100644 index c8689a0..0000000 --- a/app/.flutter-plugins +++ /dev/null @@ -1,15 +0,0 @@ -# This is a generated file; do not edit or check into version control. -file_selector_linux=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\file_selector_linux-0.9.3+2\\ -file_selector_macos=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\file_selector_macos-0.9.4+4\\ -file_selector_windows=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\file_selector_windows-0.9.3+4\\ -flutter_plugin_android_lifecycle=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\flutter_plugin_android_lifecycle-2.0.31\\ -image_picker=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\image_picker-1.2.1\\ -image_picker_android=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\image_picker_android-0.8.13+1\\ -image_picker_for_web=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\image_picker_for_web-3.1.0\\ -image_picker_ios=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\image_picker_ios-0.8.13\\ -image_picker_linux=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\image_picker_linux-0.2.2\\ -image_picker_macos=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\image_picker_macos-0.2.2\\ -image_picker_windows=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\image_picker_windows-0.2.2\\ -sqflite=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\sqflite-2.4.2\\ -sqflite_android=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\sqflite_android-2.4.1\\ -sqflite_darwin=C:\\Users\\user\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\sqflite_darwin-2.4.2\\ diff --git a/app/devtools_options.yaml b/app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f7c037c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" + +services: + # ── FastAPI 백엔드 ────────────────────────────────────────────────────────── + nutrai-server: + build: + context: . + dockerfile: Dockerfile + container_name: nutrai-server + restart: unless-stopped + ports: + - "8001:8000" # 포트 8000은 portainer가 사용 중이므로 8001로 변경 + volumes: + - chroma_data:/app/ai/rag_engine/chroma_db + - ./ai/models:/app/ai/models:ro + environment: + # 기존 ollama 컨테이너 주소 (host 네트워크로 접근) + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + - NUTRAI_LLM_MODEL=${NUTRAI_LLM_MODEL:-qwen3:8b} + - NUTRAI_LLM_TIMEOUT=${NUTRAI_LLM_TIMEOUT:-120} + - NUTRAI_LLM_KEEP_ALIVE=${NUTRAI_LLM_KEEP_ALIVE:-1h} + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + +volumes: + chroma_data: diff --git "a/docs/04-20_\355\224\274\353\223\234\353\260\261.md" b/docs/04-20_#Ud53c#Ub4dc#Ubc31.md similarity index 100% rename from "docs/04-20_\355\224\274\353\223\234\353\260\261.md" rename to docs/04-20_#Ud53c#Ub4dc#Ubc31.md diff --git "a/docs/05-07_\355\224\274\353\223\234\353\260\261.md" b/docs/05-07_#Ud53c#Ub4dc#Ubc31.md similarity index 100% rename from "docs/05-07_\355\224\274\353\223\234\353\260\261.md" rename to docs/05-07_#Ud53c#Ub4dc#Ubc31.md diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md new file mode 100644 index 0000000..1002ef7 --- /dev/null +++ b/docs/docker-deployment.md @@ -0,0 +1,83 @@ +# NutrAI Docker 배포 가이드 + +## 구성 + +``` +[Android 앱] ──── HTTP ────▶ [nutrai-server :8000] + │ + ┌───────┴────────┐ + YOLOv11m FastAPI + │ + [ollama :11434] + qwen3:8b + (같은 compose) +``` + +모든 서비스가 Docker 안에서 동작합니다. 호스트에 별도로 설치할 것이 없습니다. + +--- + +## 1. Portainer에서 배포 + +**Stacks → Add stack → Web editor**에 `docker-compose.yml` 내용을 붙여넣고 **Deploy** 클릭. + +시작 순서는 자동으로 처리됩니다: +1. `ollama` 컨테이너 기동 +2. `ollama-pull`이 `qwen3:8b` 자동 다운로드 (~5GB, 최초 1회만) +3. `nutrai-server` 기동 + +> **GPU 없는 서버라면** `docker-compose.yml`의 `deploy.resources` 블록을 주석처리하세요. + +--- + +## 2. 일반 서버 (CLI) + +```bash +cp .env.example .env + +docker compose up -d --build + +# 로그 확인 (모델 다운로드 진행 상황) +docker compose logs -f ollama-pull +docker compose logs -f nutrai-server +``` + +--- + +## 3. Android 앱 빌드 + +```bash +# 서버 IP로 APK 빌드 +flutter build apk --dart-define=API_BASE_URL=http://<서버IP>:8000 + +# USB 연결 (adb reverse 터널) +adb reverse tcp:8000 tcp:8000 +flutter run +``` + +--- + +## 4. 자주 쓰는 명령어 + +```bash +# 재시작 +docker compose restart + +# 중지 +docker compose down + +# 코드 변경 후 서버만 재빌드 +docker compose up -d --build nutrai-server + +# 모델/DB 포함 전체 초기화 +docker compose down -v +``` + +--- + +## 볼륨 용량 참고 + +| 볼륨 | 내용 | 용량 | +|------|------|------| +| `ollama_data` | qwen3:8b 모델 | ~5GB | +| `chroma_data` | ChromaDB 벡터 DB | 수백MB | diff --git a/server/api/routes_detect.py b/server/api/routes_detect.py index 0690809..457d939 100644 --- a/server/api/routes_detect.py +++ b/server/api/routes_detect.py @@ -1,18 +1,74 @@ -from fastapi import APIRouter, File, UploadFile +""" +routes_detect.py +POST /detect — 이미지를 업로드하면 YOLOv11m 모델로 음식을 탐지합니다. +""" + +import logging + +from fastapi import APIRouter, File, HTTPException, UploadFile from server.api.schemas import DetectResponse, DetectionItem +from server.services.detect_service import detect_foods + +logger = logging.getLogger(__name__) router = APIRouter(tags=["detect"]) +# 허용 MIME 타입 +_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/bmp"} + + +@router.post("/detect", response_model=DetectResponse, summary="음식 이미지 탐지") +async def post_detect( + image: UploadFile = File(..., description="탐지할 음식 이미지 (JPEG / PNG / WebP)"), +) -> DetectResponse: + """ + 업로드된 이미지에서 음식을 탐지하고 결과를 반환합니다. + + - **food_name**: 탐지된 음식 이름 (한국어) + - **confidence**: 탐지 신뢰도 (0.0 ~ 1.0) + - **bbox**: 바운딩 박스 [x1, y1, x2, y2] + - **count**: 동일 음식 탐지 횟수 + - **inference_ms**: 추론 소요 시간 (밀리초) + """ + # Content-Type 검증 + if image.content_type and image.content_type not in _ALLOWED_CONTENT_TYPES: + raise HTTPException( + status_code=415, + detail=( + f"지원하지 않는 이미지 형식입니다: {image.content_type}. " + f"허용 형식: {', '.join(_ALLOWED_CONTENT_TYPES)}" + ), + ) + + image_bytes = await image.read() + + if not image_bytes: + raise HTTPException(status_code=400, detail="빈 이미지 파일입니다.") + + try: + result = detect_foods(image_bytes) + except FileNotFoundError as e: + logger.error("모델 파일 없음: %s", e) + raise HTTPException(status_code=503, detail=str(e)) + except RuntimeError as e: + logger.error("모델 로드 실패: %s", e) + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + logger.exception("음식 탐지 중 예상치 못한 오류: %s", e) + raise HTTPException(status_code=500, detail="음식 탐지 처리 중 오류가 발생했습니다.") + + detections = [ + DetectionItem( + food_name=d["food_name"], + confidence=round(d["confidence"], 4), + bbox=d["bbox"], + count=d["count"], + ) + for d in result["detections"] + ] -@router.post("/detect", response_model=DetectResponse) -async def post_detect(image: UploadFile = File(...)) -> DetectResponse: - # TODO: YOLO 모델 통합 전 mock 응답 - await image.read() return DetectResponse( - detections=[ - DetectionItem(food_name="김치찌개", confidence=0.91, bbox=[120, 80, 300, 260], count=1), - DetectionItem(food_name="쌀밥", confidence=0.95, bbox=[320, 110, 510, 300], count=1), - ], - inference_ms=820, + detections=detections, + inference_ms=result["inference_ms"], ) diff --git a/server/requirements.txt b/server/requirements.txt index 79eca34..1d5203a 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -15,3 +15,13 @@ chromadb==1.0.4 # Data pydantic==2.11.4 pandas==2.2.0 +openpyxl>=3.1.0 +pyarrow>=14.0.0 + +# Food Detection (YOLOv11m) +ultralytics>=8.0.0 +pillow>=10.0.0 +numpy>=1.24.0 + +# Embedding (KR-SBERT) +sentence-transformers>=3.0.0 diff --git a/server/services/detect_service.py b/server/services/detect_service.py new file mode 100644 index 0000000..fe4fdbc --- /dev/null +++ b/server/services/detect_service.py @@ -0,0 +1,149 @@ +""" +food_detect_service.py +YOLOv11m 기반 음식 탐지 서비스 +- ai/models/best.pt (YOLOv11m, 800 클래스) 모델을 싱글톤으로 로드 +- 이미지 바이트를 받아 DetectionItem 리스트 반환 +""" + +from __future__ import annotations + +import io +import logging +import time +from pathlib import Path +from typing import Optional + +import numpy as np +from PIL import Image + +logger = logging.getLogger(__name__) + +# 모델 파일 경로 (프로젝트 루트 기준) +_MODEL_PATH = Path(__file__).resolve().parents[2] / "ai" / "models" / "best.pt" + +# 싱글톤 모델 인스턴스 +_model = None + + +def _load_model(): + """YOLO 모델을 싱글톤으로 로드합니다.""" + global _model + if _model is not None: + return _model + + try: + from ultralytics import YOLO # type: ignore + except ImportError as e: + raise RuntimeError( + "ultralytics 패키지가 설치되어 있지 않습니다. " + "`pip install ultralytics` 를 실행하세요." + ) from e + + if not _MODEL_PATH.exists(): + raise FileNotFoundError( + f"YOLO 모델 파일을 찾을 수 없습니다: {_MODEL_PATH}\n" + "ai/models/best.pt 경로에 모델 파일을 배치하세요." + ) + + logger.info("YOLOv11m 모델 로드 중: %s", _MODEL_PATH) + _model = YOLO(str(_MODEL_PATH)) + logger.info("YOLOv11m 모델 로드 완료 — 클래스 수: %d", len(_model.names)) + return _model + + +def detect_foods( + image_bytes: bytes, + conf_threshold: float = 0.25, + iou_threshold: float = 0.45, + imgsz: int = 640, +) -> dict: + """ + 이미지 바이트에서 음식을 탐지합니다. + + Parameters + ---------- + image_bytes : bytes + 업로드된 이미지 파일의 원시 바이트 + conf_threshold : float + 신뢰도 임계값 (기본값: 0.25) + iou_threshold : float + NMS IoU 임계값 (기본값: 0.45) + imgsz : int + 추론 이미지 크기 (기본값: 640) + + Returns + ------- + dict + { + "detections": [ + {"food_name": str, "confidence": float, "bbox": [x1,y1,x2,y2], "count": int}, + ... + ], + "inference_ms": int + } + """ + model = _load_model() + + # 바이트 → PIL Image → numpy array + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + img_array = np.array(image) + + # 추론 + t0 = time.perf_counter() + results = model.predict( + source=img_array, + conf=conf_threshold, + iou=iou_threshold, + imgsz=imgsz, + verbose=False, + ) + inference_ms = int((time.perf_counter() - t0) * 1000) + + # 결과 파싱 + detections: list[dict] = [] + if results and len(results) > 0: + result = results[0] + boxes = result.boxes + + if boxes is not None and len(boxes) > 0: + # 음식 이름별로 집계 (중복 탐지 처리) + food_map: dict[str, dict] = {} + + for box in boxes: + cls_id = int(box.cls[0].item()) + conf = float(box.conf[0].item()) + xyxy = box.xyxy[0].tolist() + bbox = [int(v) for v in xyxy] # [x1, y1, x2, y2] + food_name = model.names[cls_id] + + if food_name not in food_map: + food_map[food_name] = { + "food_name": food_name, + "confidence": conf, + "bbox": bbox, + "count": 1, + } + else: + # 같은 음식이 여러 번 탐지되면 count 증가, 최고 confidence 유지 + food_map[food_name]["count"] += 1 + if conf > food_map[food_name]["confidence"]: + food_map[food_name]["confidence"] = conf + food_map[food_name]["bbox"] = bbox + + # confidence 내림차순 정렬 + detections = sorted( + food_map.values(), + key=lambda x: x["confidence"], + reverse=True, + ) + + logger.info( + "탐지 완료 — 음식 %d 종류, 추론 시간: %d ms", + len(detections), + inference_ms, + ) + + return { + "detections": detections, + "inference_ms": inference_ms, + }