14개 테이블 + ER 다이어그램
1-1. ER 다이어그램
4개 도메인 14 테이블의 관계. 1:N (사용자→아이→기록), N:M (식단↔식재료, 레시피↔식재료).
1-2. 도메인별 14 테이블 상세 스키마
👤 도메인 A — 사용자/아이/성장
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | UUID | PK | 사용자 고유 ID (uuid_generate_v4) |
| TEXT | UK, NOT NULL | 이메일 (OAuth provider에서 받음) | |
| parent_type | ENUM | DEFAULT 'mom' | 'mom' | 'dad' | 'other' — 인사말 어조용 |
| oauth_provider | ENUM | NOT NULL | 'kakao' | 'google' | 'apple' |
| push_token | TEXT | FCM/APNS 토큰 (알림용) | |
| created_at | TIMESTAMPTZ | DEFAULT now() | 가입 시각 |
| deleted_at | TIMESTAMPTZ | soft delete (30일 보존 후 hard delete) |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | UUID | PK | 아이 고유 ID |
| user_id | UUID | FK→users.id, NOT NULL | 보호자 FK |
| nickname | TEXT | NOT NULL | 별명 (실명 X — 프라이버시) |
| birth_date | DATE | NOT NULL | 생년월일 (월·년만 사용) |
| sex | ENUM | NOT NULL | 'M' | 'F' — KDC 성장도표 percentile 계산용 |
| allergens | JSONB | DEFAULT '[]' | ['난류','우유','땅콩'] — 17 대 알레르겐 |
| care_type | ENUM | '어린이집' | '유치원' | '가정' | '없음' | |
| created_at | TIMESTAMPTZ | DEFAULT now() |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| child_id | UUID | FK→children.id | 아이 FK |
| recorded_at | DATE | PK(child_id,date) | 측정 일자 |
| height_cm | DECIMAL(5,1) | NOT NULL | 키 cm |
| weight_kg | DECIMAL(4,1) | NOT NULL | 몸무게 kg |
| bmi | DECIMAL(4,1) | GENERATED | weight_kg / (height_cm/100)² 자동 계산 |
| pct_height | INT | CHECK 0-100 | KDC 성장도표 키 백분위 |
| pct_weight | INT | CHECK 0-100 | KDC 몸무게 백분위 |
| pct_bmi | INT | CHECK 0-100 | KDC BMI 백분위 (5/85/95) |
📝 도메인 B — 식단 기록
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | UUID | PK | |
| child_id | UUID | FK→children.id | INDEX(child_id, eaten_at DESC) |
| eaten_at | DATE | NOT NULL | 먹은 날짜 (소급 입력 가능) |
| meal_type | ENUM | NOT NULL | '아침' | '점심' | '저녁' | '간식' |
| approx_hour | INT | CHECK 0-23 | 대략 몇 시 (정확한 시간 X) |
| duration_min | INT | 먹은 시간 분 (10/15/20/30+) | |
| place | ENUM | '집' | '어린이집' | '외식' | '이동' | |
| photo_url | TEXT | Supabase Storage URL (90일 TTL) | |
| photo_hash | TEXT | INDEX | sha256 — OCR 캐시 키 |
| texture_level | ENUM | '미음·페이스트·매시·다진·큐브·스틱·통째' | |
| autonomy | ENUM | '떠먹여줌·반자율·스스로' | |
| reaction | ENUM | NOT NULL | '거부·남김·잘먹음·또달라' |
| note | TEXT | 특이사항 메모 | |
| created_at | TIMESTAMPTZ | DEFAULT now() | 기록 시각 (소급 입력 추적) |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| meal_log_id | UUID | FK, PK | 끼니 FK |
| ingredient_id | UUID | FK, PK | 식재료 FK |
| source | ENUM | NOT NULL | 'ai_detected' | 'manual' | 'autocomplete' |
| confidence | DECIMAL(3,2) | AI 인식 신뢰도 0.00-1.00 | |
| estimated_g | INT | 추정 분량 (LLM 또는 카테고리 평균) |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | UUID | PK | |
| child_id | UUID | FK | |
| evaluated_at | TIMESTAMPTZ | DEFAULT now() | |
| source_image_url | TEXT | Supabase Storage (24h TTL — pg_cron 자동 삭제) | |
| image_hash | TEXT | FK→ocr_cache | OCR 캐시 키 |
| expires_at | TIMESTAMPTZ | DEFAULT now()+24h | 자동 삭제 트리거 |
| ocr_extracted | JSONB | {days:[{date,menus,allergens}], footer} | |
| scores | JSONB | {diversity:88, texture:55, ...} | |
| total_score | INT | CHECK 0-100 | |
| grade | ENUM | 'S·A·B·C·D' |
🗂 도메인 C — 마스터 데이터
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | UUID | PK | |
| name | TEXT | UK | 정규화 이름 (예: '시금치') |
| raw_aliases | TEXT[] | ['시금치, 잎, 생것', '시금치_생것'] 변형들 | |
| category | TEXT | INDEX | 16 카테고리 (잎채소·뿌리·생선...) |
| emoji | TEXT | UI 표시용 | |
| chosung | TEXT | INDEX | 초성 (자동완성용 — 'ㅅㄱㅊ') |
| nutri_per_100g | JSONB | {iron_mg:2.7, calcium_mg:81, ...} 36 영양 | |
| allergens | TEXT[] | 매핑된 알레르겐 | |
| exposure_target | INT | DEFAULT 30 | 권장 노출 횟수 (Solid Starts) |
| status | ENUM | DEFAULT 'estimated' | 'verified' | 'ai_estimated' | 'pending_review' |
| source | TEXT | '농진청·KDRI·LLM_estimated' |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | UUID | PK | |
| name | TEXT | NOT NULL | 메뉴명 |
| base_type | TEXT | INDEX | 15분류 (죽·계란찜·부침개·국·볶음...) |
| texture_level | TEXT | INDEX | 7단계 |
| steps | TEXT[] | 조리 순서 (배열) | |
| tip | TEXT | 조리 팁 | |
| age_group | TEXT | INDEX | '6-11m·1-2y·3-5y·6-11y' |
| allergens | TEXT[] | 자동 추출 | |
| cook_minutes | INT | 조리 시간 | |
| difficulty | ENUM | 'easy·medium·hard' | |
| nutri_total | JSONB | 계산된 영양 합계 36종 | |
| nutrient_highlights | TEXT[] | GIN INDEX | 30%+ RNI 기여 영양소 (Phase 4 매칭) |
| concealment | JSONB | {시금치:'high', 가지:'mid'} 가림 정도 | |
| source | TEXT | '영아기·유아기·아동기·월별식단' |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| recipe_id | UUID | FK, PK | |
| ingredient_id | UUID | FK, PK | |
| amount_g | DECIMAL(5,1) | 분량 (g) | |
| form | ENUM | '즙·다진·매시·큐브·스틱·통째·페이스트·가루' | |
| is_main | BOOLEAN | DEFAULT false | 주재료 vs 조미료 |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| age_band | TEXT | PK(band,code) | '0-5m·6-11m·1-2y·3-5y·6-11y...' |
| nutrient_code | TEXT | PK(band,code) | 'iron·calcium·choline·vit_d...' |
| rni | DECIMAL | 권장섭취량 (있는 영양소만) | |
| ai | DECIMAL | 충분섭취량 (RNI 없을 때) | |
| ul | DECIMAL | 상한섭취량 | |
| unit | TEXT | 'mg·μg·g·kcal' | |
| priority | INT | 표시 우선순위 (높을수록 빨강 임계 우선) |
🗄 도메인 D — 캐시
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| child_id | UUID | INDEX | |
| type | ENUM | INDEX | '3day_plan·single_recipe·ingredient_followup' |
| profile_hash | TEXT | INDEX | sha256(프로파일 요약) — 캐시 hit 판정 |
| result | JSONB | LLM 응답 그대로 | |
| generated_at | TIMESTAMPTZ | DEFAULT now() | |
| expires_at | TIMESTAMPTZ | DEFAULT now()+24h |
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| image_hash | TEXT | PK | sha256(이미지 바이트) |
| extracted | JSONB | Vision API 결과 | |
| confidence | DECIMAL | 전체 OCR 신뢰도 | |
| model_version | TEXT | 'claude-sonnet-4-7' — 모델 교체 시 invalidate | |
| created_at | TIMESTAMPTZ | DEFAULT now() |
1-3. 인덱스 + RLS (Row Level Security) 정책
-- 핵심 인덱스 CREATE INDEX idx_meal_logs_child_date ON meal_logs (child_id, eaten_at DESC); CREATE INDEX idx_recipes_highlights ON recipes USING GIN (nutrient_highlights); CREATE INDEX idx_ingredients_chosung ON ingredients (chosung text_pattern_ops); CREATE INDEX idx_reco_lookup ON recommendations (child_id, type, profile_hash); -- RLS: 사용자는 자기 데이터만 ALTER TABLE children ENABLE ROW LEVEL SECURITY; CREATE POLICY "own children" ON children FOR ALL USING (user_id = auth.uid()); CREATE POLICY "own meal_logs" ON meal_logs FOR ALL USING (child_id IN (SELECT id FROM children WHERE user_id = auth.uid())); -- 24h TTL 자동 삭제 (pg_cron) SELECT cron.schedule('expire-daycare-evals', '0 * * * *', 'DELETE FROM daycare_evals WHERE expires_at < NOW()');
37개 유즈케이스 — 고객은 무엇을 할 수 있나?
2-1. Actor 다이어그램
주 사용자)) K((👶 아이
대상)) N((🧑🔬 영양사
운영)) P -->|온보딩 1-4| OB[온보딩] P -->|기록 5-13| REC[식단 기록] P -->|평가 14-22| EVAL[평가 보기] P -->|레시피 23-28| RCP[레시피] P -->|도감 29-32| DEX[도감] P -->|공유 33| SHR[공유] N -->|운영 34| ADM[운영] classDef actor fill:#FFE8D1,stroke:#E89244,color:#1F2D3D classDef cat fill:#E3F2FD,stroke:#3498DB,color:#1F2D3D class P,K,N actor class OB,REC,EVAL,RCP,DEX,SHR,ADM cat
2-2. 카테고리 A — 온보딩 (UC-01 ~ UC-04)
회원가입 / 로그인
고객은 카카오·구글·애플 OAuth로 빠르게 가입할 수 있다.
전제스마트폰 + 해당 SNS 계정 보유아이 프로필 등록
고객은 별명·생년월일·성별·알레르겐·돌봄 유형(어린이집/유치원/가정)을 한 화면에서 등록할 수 있다.
POST /api/children → KDC 성장도표 기반 적정 키·몸무게 범위 초기 계산child_id 발급 + 홈 화면 이동
키·몸무게 입력 (주 1회)
고객은 주 1회 키·몸무게를 기록하고 BMI percentile을 즉시 확인할 수 있다.
BMI = w/(h/100)² 자동 계산 → KDC percentile lookup → 결과 표시 (정상/저체중/과체중)스트릭 🔥 + 데일리 미션
고객은 상단 바의 🔥 N일 뱃지와 홈 상단 데일리 미션 카드("오늘 한 컷 기록하기 · 30초만 · 12일 연속 🔥")로 매일 식단 기록 동기를 받는다.
2-3. 카테고리 B — 식단 기록 (UC-05 ~ UC-13)
날짜 선택 + 소급 입력
고객은 오늘/어제/그저께 chip 또는 캘린더로 임의 날짜의 기록을 입력·수정할 수 있다.
식사 종류 선택 (간식 포함)
고객은 아침·점심·저녁·간식 중 한 종류를 선택해 기록할 수 있다.
meal_type 값 저장사진 업로드 + AI 식재료 자동 인식
고객은 음식 사진 1장만 올리면 AI가 식재료를 자동 인식해 해시태그로 미리 채워준다.
POST /api/ocr-food → Claude Vision → 식재료 리스트 추출 → ingredient_pool 매칭 → 해시태그 chip 자동 추가 (✨ AI 마크) → 부모 수정 가능해시태그 식재료 입력 + 한글 자동완성
고객은 식재료를 텍스트로 입력하면 첫글자·초성으로 자동완성 받을 수 있다.
식감 단계 입력
고객은 죽·다진·핑거푸드·테이블푸드 중 하나를 선택해 식감 단계를 기록할 수 있다.
자율성 입력
고객은 떠먹여줌·도와줌·스스로 중 하나로 자율성을 기록할 수 있다.
식사 반응 입력
고객은 거부·남김·잘먹음·또달라 중 하나로 아이 반응을 기록할 수 있다.
메모 + 퀵노트
고객은 자유 메모 또는 quick chip ("김 가루로 덮으니 먹음")으로 특이사항을 기록할 수 있다.
월간 PDF 리포트 다운로드
고객은 한 달 식습관 PDF를 다운로드받아 소아과·어린이집에 공유할 수 있다.
2-4. 카테고리 C — 평가 (UC-14 ~ UC-22b)
영양 점수 카드 (등급·스케일·진척)
고객은 홈에서 영양 점수를 4단계 시각화로 확인한다: ① 60점 큰 숫자 + B 보통 뱃지 + 지난주 대비 변화(+8), ② 5등급 가로 스케일 바 (D→C→B→A→S, 현재 위치 화살표), ③ 이번 주 먹은 식재료 진척도 (18/30종, 다음 등급까지 N종), ④ 🍳 레시피 추천받기 CTA → 레시피 탭으로.
36종 영양 신호등
고객은 KDRI 2025 36 영양소를 잘챙김/조금부족/결핍위험 3색으로 한눈에 볼 수 있다.
신호등 자세히 보기 (게이지바)
고객은 신호등 카드 클릭으로 36종 가로 게이지바 + 빈도 자연어("거의 매일"/"드물게"/"못 만남")를 볼 수 있다.
탄·단·지 + BMI 종합
고객은 빈도 + BMI percentile 조합으로 다량영양소가 과한지 부족한지 정밀 평가를 받을 수 있다.
결핍 영양 → 보충 식재료 보기
고객은 빨강 영양소(예: 콜린) 아래에 자동 추천된 보충 식재료 카드를 클릭해 도감 진입할 수 있다.
3일 식단 진단 (8축)
고객은 식품군다양성·식감단계·KDRI 36영양·반복도·알레르겐·가공식품(초가공/일반)·제철·조리스타일 8축을 한 카드에서 자연어로 받을 수 있다.
식단표 평가받기 (어린이집·유치원·가정 식단)
고객은 종이 식단표 사진 1장 업로드로 한 달치 8축 평가를 30초 안에 받을 수 있다.
식단표 결과 → 보충 레시피 받기
고객은 평가받은 결과의 부족 영양소를 채우는 레시피를 받기 위해 도감 앱으로 자연스럽게 진입한다.
/dogam.html?source=daycare&grade=B+&deficits=콜린,비D,EPA-DHA → 환영 배너 + AI 식단 모달 자동 오픈오늘 만난 영양 가족 (8 식품군)
고객은 WHO MDD 8 식품군 카테고리 그리드(곡물·콩·유제품·고기생선·계란·진한채소·기타채소·과일)로 오늘 만난 그룹을 한눈에 본다. 만난 식품군은 회색→컬러로 활성화, 카운트 "5 / 8 충족" 표시.
식감/메뉴 반복 narrative 알림
고객은 8축 진단의 warn 항목(식감 단계·메뉴 반복)을 별도 narrative 카드로 받고, 클릭으로 레시피 탭 자동 이동한다.
2-5. 카테고리 D — 레시피 (UC-23 ~ UC-28)
레시피 추천받기
고객은 홈 점수 카드 CTA를 눌러 4원칙 기반 맞춤 레시피 6-8개를 즉시 받을 수 있다.
레시피 클릭 → 상세 보기
고객은 레시피 카드 클릭 시 재료(1인분 g)·조리 순서·우리 아이 적용 팁·매칭 정보 통합 모달을 본다.
renderRecipeDetailHTML()AI 3일 식단 받기
고객은 "AI에게 3일 식단 받기" 클릭으로 일자별 4끼(아침·점심·간식·저녁) × 3일 = 12끼 맞춤 식단을 받는다.
레시피 → 오늘 식단 기록
고객은 레시피 상세 모달 하단의 "📝 오늘 식단으로 기록" 버튼으로 즉시 meal_log 생성한다.
레시피 → 내일 식단 예약
고객은 레시피 상세에서 "📥 내일 식단에 추가" 버튼으로 미래 끼니 예약 + 장보기 리스트 생성 가능.
레시피 탭 4원칙별 보기
고객은 레시피 탭에서 4원칙(선호+도전·원물·가림·영양보강) 섹션별로 분류된 추천을 본다.
2-6. 카테고리 E — 도감 (UC-29 ~ UC-32)
도감 (식재료 친해지기 훈련)
고객은 식재료별 노출·먹은 횟수, 마지막 만난 시점, 친해지기 진행도를 본다.
식재료 클릭 → 영양 특성 + 패턴 맞춤 요리
고객은 식재료 클릭 시 KDRI 영양 특성·흡수율 팁·우리 아이 패턴 분석·맞춤 요리 5종(원칙별 분류)을 받는다.
이번 주 시도해볼 TOP 10
고객은 오래 못 만난·거부도 높은 식재료 TOP 10을 빈도순 랭킹으로 본다.
친해지기 SOS 키트 추천
고객은 22번 노출에도 8번만 먹는 식재료 발견 시 SOS 6단계 키트 추천 알림을 받는다.
2-7. 카테고리 F — 공유·학습·알림·운영 (UC-33 ~ UC-34)
공식 블로그 후킹 카드 (가로 스크롤)
고객은 홈 하단 보라색 그라데이션 카드 가로 스크롤로 최신 블로그 8편의 후킹된 제목(예: "단맛 좋아하는 우리 아이 유전일까?", "편식 2년 골든타임", "NHS 의사 거부 한 마디 비법")을 본다. 우상단 "전체 357편 →" 버튼으로 인덱스 진입. 각 카드 클릭 = 해당 글 새 창.
평가 결과 공유 (카드 PNG · 친구)
고객은 daycare-eval 결과 카드를 PNG로 저장하거나 카카오·인스타로 공유한다. 공유 시 출처(어린이집·유치원 이름) 자동 제거 — 점수와 보충 가이드만 노출.
알림 (top-bar 🔔)
고객은 상단 알림 아이콘 (빨간 dot) 클릭으로 미확인 알림(식단 미기록·신호등 변화·키트 추천·새 블로그)을 본다.
운영자 — 신규 식재료 검수
영양사는 부모가 입력한 신규 식재료 목록을 검토하고 영양 정보·알레르겐을 확정해 마스터 DB에 승격할 수 있다.
평가 엔진 — Pure Rule (5 알고리즘 상세)
3-0. 엔진 아키텍처
meal_logs (최근 3-7일)
+ child profile (age·sex·allergens)
+ growth_stats (최신 BMI)"] PRE["🔧 전처리
1. 식재료 정규화 (alias → id)
2. 끼니별 영양 합산
3. accept_rate 계산"] A1["ALG-EVAL-01
8축 식단 진단"] A2["ALG-EVAL-02
36 영양 신호등
(빈도 → 충족률)"] A3["ALG-EVAL-03
BMI percentile
(KDC lookup)"] A4["ALG-EVAL-04
탄·단·지 + BMI
(빈도 × BMI 매트릭스)"] A5["ALG-EVAL-05
OCR 식단표 평가
(8축 변형)"] POST["📤 출력
scores JSONB + grade + 자연어 진단"] IN --> PRE PRE --> A1 & A2 & A3 & A5 A3 --> A4 A2 --> A4 A1 & A2 & A4 & A5 --> POST classDef in fill:#E3F2FD,stroke:#3498DB,color:#1F2D3D classDef proc fill:#E8F5E9,stroke:#16A085,color:#1F2D3D classDef out fill:#FFF8E1,stroke:#F9A825,color:#1F2D3D class IN in class PRE,A1,A2,A3,A4,A5 proc class POST out
3-1. 5개 평가 알고리즘 상세
8축 식단 진단
WHO·HabEat·Satter·CEBQ·Solid Starts 기반 학술 8축 평가.
축별 계산식
- ① 식품군 다양성 (WHO MDD): 8 식품군(곡물·콩·유제품·고기생선·계란·진한채소·기타채소·과일) 중 등장 종류 → pct = (등장/8) × 100
- ② 식감 단계 (HabEat): texture_level 분포 vs 연령 권장 분포 → 0-100
- ③ KDRI 36 영양 (보건복지부): 36종 대비 식재료 커버 비율 (ALG-EVAL-02)
- ④ 메뉴 반복도: 동일 메뉴 등장 횟수 — 밥·김치·우유 등 주식 제외, 적을수록 A
- ⑤ 알레르겐 표기 (식약처): 17대 알레르겐 표기·안전
- ⑥ 가공식품 (NOVA): 초가공(강한 감점) + 일반 가공(약한 0.3 가중) 2단계 — 카레·돈가스 등 집밥은 제외
- ⑦ 제철 식재료 (농진청): 해당 월 제철 활용도
- ⑧ 조리 스타일 다양성: 한식·양식·일식·중식 등 cuisine 다양성
- ※ 식사 환경·식욕 응답·새 시도·자율성(Satter DOR·CEBQ) 등 정성 신호는 8축 점수가 아니라 AI 코치 엔진(정성 시계열)에서 별도로 다룸 — 점수=룰, 정성=LLM
def axis_diversity(logs): families_met = set() for log in logs: for ing in log.ingredients: families_met.add(FOOD_FAMILY_MAP[ing.category]) pct = len(families_met) / 8 * 100 return { 'pct': pct, 'status': 'green' if pct >= 80 else 'orange' if pct >= 50 else 'red', 'detail': f'{len(families_met)}/8 식품군', 'narrative': generate_diversity_narrative(families_met, missing=ALL_FAMILIES - families_met) }
{pct: 62.5, status: 'orange', detail: '5/8 식품군', narrative: '곡물·콩·유제품·고기·과일은 충분히 만났어요. 진한 채소·기타 채소·계란 3가지가 빠져있어요.'}36종 KDRI 신호등 (빈도 → 충족률 추정)
그램 단위 정확 측정 불가 → 끼니 등장 빈도 기반 추정. KDRI 2025 만 1-2세 RNI/AI 기준.
단계
- 각 끼니의 식재료별 영양 기여도 계산:
contribution_pct = (nutri_per_100g × estimated_g/100) / KDRI_RNI[age_band][nutrient] - 30% 이상 기여하면 "이 끼니에 등장" 카운트 → nutrient_meals[n] += 1
- 주간 권장 빈도와 비교:
freq_pct = nutrient_meals / (days × 2) × 100 - status 분류: ≥80 green / 50-80 orange / <50 red
- 자연어 빈도 라벨: 90+ "거의 매일", 75-90 "주 4-5회", ... 15< "거의 못 만남"
def signal_36_nutrients(child_id, days=7): logs = get_meal_logs(child_id, days=days) age_band = get_age_band(child_id) # '1-2y' nutrient_meals = defaultdict(int) for log in logs: contributions = defaultdict(float) for ing in log.ingredients: est_g = ing.estimated_g or CATEGORY_AVG_G[ing.category] for nutrient, amt_per_100g in ing.nutri_per_100g.items(): rni = KDRI_RNI[age_band].get(nutrient) if not rni: continue contributions[nutrient] += (amt_per_100g * est_g / 100) / rni for n, c in contributions.items(): if c >= 0.3: nutrient_meals[n] += 1 weekly_target = days * 2 return {n: { 'pct': nutrient_meals[n] / weekly_target * 100, 'status': classify(nutrient_meals[n] / weekly_target), 'freq_label': freq_to_label(nutrient_meals[n] / weekly_target * 100) } for n in KDRI_36_NUTRIENTS}
BMI percentile (KDC 표준성장도표)
한국질병관리청 2017 소아청소년 성장도표 LMS 파라미터로 percentile 정확 계산.
- BMI 계산:
BMI = weight_kg / (height_cm/100)² - 월령 기반 LMS 파라미터 lookup:
L, M, S = KDC_LMS[sex][age_months]['bmi'] - Z-score 계산:
Z = ((BMI/M)^L - 1) / (L·S)(L≠0) - Z-score → percentile (정규분포 cumulative):
pct = round(scipy.stats.norm.cdf(Z) * 100) - 분류: <5p 저체중 / 5-85p 정상 / 85-95p 과체중 / 95p+ 비만
탄·단·지 + BMI 종합
빈도(매일·가끔·드물게) × BMI percentile 매트릭스로 다량영양소 양 평가.
MATRIX = {
('daily', 'normal'): {'carb': 'ok', 'protein': 'ok', 'fat': 'ok'},
('daily', 'underweight'):{'carb': 'ok', 'protein': 'boost', 'fat': 'warn_low'},
('daily', 'overweight'): {'carb': 'warn_high', 'protein': 'ok', 'fat': 'warn_high'},
('rare', 'underweight'): {'carb': 'warn_low', 'protein': 'warn_low', 'fat': 'warn_low'},
# ... 모든 조합
}
MESSAGES = {
'warn_low': '양 부족 — 견과·아보카도·EPA+DHA 보강',
'warn_high': '양 조절 — 잡곡 비중↑, 간식 줄이기',
'boost': '성장기 필요 — 충분히 OK',
}
식단표 OCR 평가 (8축 변형)
한 달치 식단표 한 장에서 OCR로 추출한 메뉴를 8축으로 평가. 가정 기록과 다른 점: 가공식품 비율·계절성 추가.
- OCR 결과 → recipes 매칭 (메뉴명 fuzzy match) → 영양 합계 계산
- 다양성·식감·반복·KDRI 36종 (ALG-EVAL-01~02 재사용)
- 알레르겐 표시 안전: 알레르겐 번호 표기 비율
- 가공식품 비율: 가공식품 카테고리 등장 비율
- 계절성·국산: 4월=봄나물 매핑 + 원산지 표기 점수
- 총점 가중평균 → 5등급
추천 엔진 — Hybrid (7 알고리즘 상세)
4-0. 엔진 아키텍처
POST /api/recipe-suggest
{child_id, type, context}"] CACHE{"💾 캐시
profile_hash 일치?"} P0["ALG-RECO-00
식습관 프로파일 자동 집계"] P1A["원칙 1
선호 + 도전"] P1B["원칙 2
오랜만 → 원물"] P1C["원칙 3
가림 형태"] P1D["원칙 4
영양 보강"] RANK["ALG-RECO-05
후보 랭킹
(가중합)"] P2["ALG-RECO-06
LLM 정제
Claude Sonnet 4.7"] P3["💾 캐시 저장
24h TTL"] OUT["📤 응답
6-8 레시피 + 자연어"] REQ --> CACHE CACHE -->|HIT| OUT CACHE -->|MISS| P0 P0 --> P1A & P1B & P1C & P1D P1A & P1B & P1C & P1D --> RANK RANK --> P2 P2 --> P3 P3 --> OUT classDef io fill:#FFF8E1,stroke:#F9A825,color:#1F2D3D classDef proc fill:#E8F5E9,stroke:#16A085,color:#1F2D3D classDef llm fill:#F3E5F5,stroke:#9C27B0,color:#1F2D3D class REQ,OUT,CACHE,P3 io class P0,P1A,P1B,P1C,P1D,RANK proc class P2 llm
Phase 0 — 식습관 프로파일 자동 집계
profile = {
'ingredient_stats': {
'시금치': {
'exposure_count': 22,
'eat_count': 8,
'accept_rate': 0.36,
'last_seen_days_ago': 3,
'preferred_forms': ['즙', '다진'],
'rejected_forms': ['통째']
}, ...
},
'base_stats': {
'계란찜': {'eat_count': 43, 'preference_score': 0.95}, ...
},
'texture_distribution': {'죽': 0.65, '매시': 0.25, ...},
'nutrient_status': {'iron': 'red', 'choline': 'red', ...}
}
원칙 1: 선호 베이스 × 도전 식재료
WITH preferred_bases AS ( SELECT r.base_type, SUM(CASE WHEN ml.reaction IN ('잘먹음','또달라') THEN 1 ELSE 0) AS cnt FROM meal_logs ml JOIN meal_log_ingredients mli ON ml.id = mli.meal_log_id JOIN recipes r ON (...메뉴명 매칭...) WHERE ml.child_id = $1 AND ml.eaten_at > NOW() - INTERVAL '30 days' GROUP BY r.base_type ORDER BY cnt DESC LIMIT 5 ), challenge_ings AS ( SELECT id FROM ingredients WHERE id IN (SELECT ing_id FROM low_accept_view WHERE child_id = $1) LIMIT 10 ) SELECT r.*, base_score(r.base_type) * concealment_score(r.id) AS score FROM recipes r WHERE r.base_type IN (SELECT base_type FROM preferred_bases) AND EXISTS ( SELECT 1 FROM recipe_ingredients ri WHERE ri.recipe_id = r.id AND ri.ingredient_id IN (SELECT id FROM challenge_ings) AND ri.form IN ('즙','다진','매시') ) ORDER BY score DESC LIMIT 50;
원칙 2: 잘먹지만 오랜만 → 원물 노출
- 대상 식재료:
accept_rate ≥ 0.7 AND last_seen_days_ago > 30 AND 모든 preferred_forms이 가린 형태('즙','다진','매시') - 레시피 필터: 해당 재료를 통째·스틱·큐브 형태로 사용하는 레시피
- texture_level > 현재 사용자 최대 texture (점진 단계 ↑)
- 랭킹:
age_appropriateness(texture, age_months) × loved_score(ing)
원칙 3: 극도 거부 → 형태 안 보이게
- 대상:
accept_rate < 0.3 AND exposure_count ≥ 5 - 레시피 필터:
concealment[ing] == 'high'(즙·페이스트) - 추가: base_type ∈ preferred_bases (베이스도 친숙)
- 랭킹:
concealment_score × base_preference_score
원칙 4: 부족 영양 보강
WITH red_nutrients AS ( SELECT nutrient FROM signal_36_view WHERE child_id = $1 AND status = 'red' ORDER BY pct ASC LIMIT 5 ) SELECT r.*, ( SELECT SUM( (r.nutri_total->>n.nutrient)::DECIMAL / (SELECT rni FROM kdri_rni WHERE age_band = $2 AND nutrient_code = n.nutrient) ) FROM red_nutrients n ) AS boost_score FROM recipes r WHERE r.nutrient_highlights && ARRAY(SELECT nutrient FROM red_nutrients) ORDER BY boost_score DESC LIMIT 80;
후보 랭킹 (가중합 score)
score = (
base_pref_weight * base_preference_score(recipe.base_type, profile.base_stats) +
concealment_weight * concealment_score(recipe, challenge_ings) +
nutri_weight * nutrient_boost_score(recipe, red_nutrients) +
novelty_weight * (1 if recipe not in recent_30days else 0) +
age_fit_weight * age_appropriateness(recipe.age_group, child.age_months)
)
# 기본 가중치 (튜닝 가능)
DEFAULTS = {'base_pref':0.3, 'concealment':0.2, 'nutri':0.3, 'novelty':0.1, 'age_fit':0.1}
Phase 2 — LLM 정제 (Claude Sonnet 4.7)
Phase 1 결과 190 후보 → Claude에 전달 → 원칙별 최적 + 자연어 이유 생성.
def refine_with_llm(profile, candidates_190): system = """당신은 만 0-5세 영유아 식습관 데이터를 분석해...""" user = f""" ## 프로파일 {profile_to_natural_language(profile)} ## 190 후보 (Phase 1) {json.dumps(candidates_190, ensure_ascii=False)} ## 출력 형식 (JSON Schema) { "principle_1": [{"recipe_id": "uuid", "reason": "3-4문장 자연어"}, ...], "principle_2": [...], "principle_3": [...], "principle_4": [...] } """ response = anthropic.messages.create( model="claude-sonnet-4-7", max_tokens=4000, system=[{"type":"text","text":system,"cache_control":{"type":"ephemeral"}}], messages=[{"role":"user","content":user}] ) return parse_and_validate(response.content)
캐싱 — profile_hash 기반
def compute_profile_hash(profile): # 변동성 낮은 핵심만 해싱 → 캐시 hit rate ↑ key_fields = { 'top5_bases': sorted(top_5(profile.base_stats))[:5], 'red_nutrients': sorted(profile.red_nutrients)[:5], 'challenge_ings': sorted(top_10_challenge(profile))[:10], 'age_band': profile.age_band, } return sha256(json.dumps(key_fields, sort_keys=True)).hexdigest()
OCR 엔진 — CLOVA(식단표 전사) + Claude Vision(기록용 음식 사진) (6 알고리즘 상세)
5-0. 엔진 아키텍처
이미지 전처리
- EXIF orientation 자동 회전
- 해상도 최적화: 긴 변 1600px 이하로 리사이즈 (Vision API 토큰 절감)
- 대비/밝기 자동 보정 (Pillow ImageOps.autocontrast)
- JPEG quality=85로 재인코딩
- sha256 해시 생성
Claude Vision 프롬프트
VISION_PROMPT = """
이 한국 어린이집·유치원·가정 식단표 이미지를 분석하여
다음 JSON 스키마로 출력해주세요. 부정확한 부분은 confidence<0.7로 표기.
{
"year_month": "YYYY-MM",
"facility_type_hint": "어린이집|유치원|가정|기타",
"days": [
{
"date": "M/D",
"weekday": "월|화|...",
"is_event_day": false,
"meals": {
"오전간식": [{"name":"메뉴", "allergen_numbers":[1,5]}],
"점심": [...],
"오후간식": [...]
}
}
],
"footer": {
"nutritionist": "이름 or null",
"allergen_legend": [{"num":1, "name":"난류"}, ...],
"origin": {"쌀":"국내산", "쇠고기":"호주산", ...}
},
"confidence": 0.0-1.0
}
규칙:
- 메뉴명에서 알레르겐 번호 ⓞⓘⓢ는 allergen_numbers 배열로 분리
- 날짜는 YYYY-MM-DD 풀 포맷 X, M/D만
- 식재료 단순 나열이 아닌 메뉴 단위로 분리 (예: '잡곡밥/된장국/시금치나물' → 3개)
"""
메뉴 정규화 (메뉴명 → recipe_id)
- 메뉴명 텍스트 클렌징: 알레르겐 번호·괄호·특수문자 제거
- recipes.name LIKE 매칭 (fuzzy: trigram similarity)
- top-3 후보 → similarity ≥ 0.7 이면 매칭 성공
- 매칭 실패 시 LLM에 "이 메뉴는 어떤 base_type·texture? 식재료는?" 추가 호출
- recipe_id 또는 ad-hoc recipe 생성
알레르겐 매핑
- footer.allergen_legend → 숫자↔이름 매핑 테이블 구축
- 각 메뉴의 allergen_numbers → 한글 알레르겐 변환
- 아이 프로필 allergens와 교집합 → 위험 메뉴 자동 플래그
- UI: "⚠️ ○○ 메뉴에 우유 포함" 알림
신뢰도 스코어링 + 사용자 수정
- 전체 confidence = Vision의 self-report × 매칭 success rate
- 0.85+ : 자동 진행
- 0.7-0.85: 사용자에게 확인 UI ("이 메뉴 맞나요?")
- 0.7-: 수동 입력 모드 전환
- 사용자 수정 사항 → 다음 OCR 학습 데이터로 누적
식사 사진 OCR (식단 기록용)
daycare-eval과 별도 — 부모가 매일 음식 사진 올릴 때 식재료 자동 추출.
FOOD_VISION_PROMPT = """
이 음식 사진을 보고 식재료를 추출하세요.
하나의 메뉴에 들어간 모든 식재료를 나열 (소스·향신 포함).
{
"menu_hint": "추정 메뉴명 (예: '시금치 두부 죽')",
"ingredients": [
{"name": "시금치", "confidence": 0.95},
{"name": "두부", "confidence": 0.92},
{"name": "쌀", "confidence": 0.88}
],
"cooking_method_hint": "죽|볶음|국|...",
"texture_hint": "매시|핑거|...",
"overall_confidence": 0.0-1.0
}
"""