0. 입력 전처리 엔진 — Input Preprocessing (LLM Normalizer)
모든 사용자 입력의 첫 관문. 자유 텍스트("닭볶음탕에 시금치 좀 줬는데 거의 안 먹음")를 정형 schema(MealLog)로 변환해 평가·추천 엔진에 전달. LLM 필수 사용 (Claude Haiku 4.5 + Sonnet 4.7 fallback). 출력은 zod로 100% 스키마 검증.
🚨 왜 별도 엔진인가?
평가 엔진은 결정론적 Rule + Schema 입력 전제. 부정확·자유로운 사용자 입력을 직접 받으면 룰이 깨짐. 따라서 LLM은 평가 엔진 안에 두지 않고 전처리 레이어에 격리. 평가 엔진은 항상 검증된 깨끗한 데이터만 받음 (관심사 분리).
0-0. 엔진 책임·경계
책임 (Does)
- 자유 텍스트 → 구조화: "오전에 시금치 죽 줬는데 한 숟갈 먹고 거부" → MealLog 객체
- 식재료 fuzzy 매칭: "시금치"·"시그치"·"sigeum" → ingredient_id (147 풀 + ai_estimated)
- meal_chip 추론: 시간 컨텍스트 + 단어("아침에"·"새벽") → 6 chip 매핑
- reaction·texture·autonomy 추론: 자연어 → 0-5 정량 점수
- Schema validation: zod로 100% 검증, 실패 시 재질문 흐름 트리거
- 보안 가드: prompt injection 방어, PII 자동 마스킹
책임 아님 (Doesn't)
- 영양·다양성 평가 (→ 평가 엔진)
- 레시피 추천 (→ 추천 엔진)
- 이미지 → 텍스트 (→ OCR 엔진)
- 편지·답장 생성 (→ AI 코치)
0-1. 입출력 명세
// packages/prep-engine/types.ts export interface PrepInput { childId: string; ageMonths: number; rawText: string; // 사용자가 입력한 자유 텍스트 imageUrls?: string[]; // 첨부 사진 (Vision 분석) contextTimestamp: Date; // 입력 시각 (chip 추론용) recentMeals: MealLog[]; // 최근 3끼 컨텍스트 knownIngredients: string[]; // 자녀 잘 먹는 베이스 (학습됨) } export interface PrepOutput { status: 'ok' | 'needs_clarification' | 'rejected'; mealLog?: MealLog; // status='ok' 시 — 평가 엔진에 그대로 전달 가능 clarification?: { // 'needs_clarification' 시 questions: string[]; // 사용자에게 다시 물을 질문 (1-2개) suggestedFields: string[]; // 어떤 필드가 비었는지 }; rejectReason?: string; // 'rejected' (prompt injection 등) 시 llmMeta: { model: 'haiku' | 'sonnet'; costKrw: number; tokensIn: number; tokensOut: number; latencyMs: number; cached: boolean; }; confidence: number; // 0-1, < 0.7 시 평가 결과에 'low confidence' 플래그 }
자유 텍스트 → MealLog 구조화 (LLM 핵심)
프롬프트 (system) — v3 (served ≠ consumed 분리 명시)
너는 한국 영유아 식사 기록 전처리 어시스턴트야. 부모가 입력한 자유 텍스트를
구조화된 JSON으로 변환한다. **추가 정보를 추정하거나 만들어내지 말 것.**
명시되지 않은 필드는 null 또는 'unknown'으로 둘 것.
⚠️ 가장 중요: 메뉴에 들어간 식재료(served)와 실제 먹은 식재료(consumed)를
반드시 분리해야 한다. "소고기 무국 먹음" ≠ 소고기를 먹었음.
부모가 명시적으로 골라냄·남김·한 입만·향만 등 표현하면 state='rejected'로.
스키마:
{
"mealChip": "아침"|"오전간식"|"점심"|"오후간식"|"저녁"|"야간"|null,
"ingredients": [
{
"name": string,
"state": "consumed"|"partial"|"rejected"|"unknown",
// consumed = 다 먹음 / partial = 일부 / rejected = 한 입도 X / unknown = 미상
"rejectionTag": string|null, // "골라냄"·"한 입 시도"·"향만 핥음" 같은 자유 표현
"confidence": 0-1
}
],
"overallReaction": { "score": 0-5, "evidence": string }|null,
"textureLevel": "puree"|"mashed"|"chopped"|"finger"|"family"|null,
"autonomy": "fed_by_parent"|"helped"|"self"|null,
"approxHour": number|null,
"note": string|null,
"needsClarification": [string],
"confidence": 0-1
}
규칙:
1. 메뉴명 자체를 식재료로 등록 X ("소고기 무국" → 소고기+무+파+간장 분해 후 각각 state)
2. 명시적 거부 표현 시 state='rejected', rejectionTag에 부모 표현 그대로 보존
예: "소고기 골라냄" → {name:'소고기', state:'rejected', rejectionTag:'골라냄'}
3. 명시 없으면 unknown으로 두고 needsClarification에 "혹시 안 먹은 식재료 있어요?" 질문 제안
4. 양 정보 없으면 servedG/consumedG는 null. 평가 엔진이 카테고리 평균치로 추정.
5. 부모 감정·의견은 note에 별도 보존.
6. raw_text는 가공·요약 금지 — 그대로 raw_note 테이블에 저장됨 (다른 엔진이 사용)
실제 호출 코드
async function parseMealText(input: PrepInput): Promise<PrepOutput> { // 1. 캐시 확인 (rawText hash + ageMonths) const cacheKey = sha256(input.rawText + input.ageMonths); const cached = await redis.get(`prep:${cacheKey}`); if (cached) return { ...cached, llmMeta: { ...cached.llmMeta, cached: true } }; // 2. 보안 가드 (prompt injection 차단) const sanitized = sanitizeUserInput(input.rawText); if (detectInjection(sanitized)) return { status: 'rejected', rejectReason: 'injection_detected' }; // 3. Haiku 우선 (저비용·빠름) let result = await anthropic.messages.create({ model: 'claude-haiku-4-5-20251001', system: PREP_SYSTEM_PROMPT, messages: [{ role: 'user', content: buildContextMessage(input, sanitized) }], max_tokens: 800 }); // 4. Schema validation (zod) let parsed = MealLogSchema.safeParse(extractJSON(result.content[0].text)); // 5. 실패 시 Sonnet fallback (복잡 케이스) if (!parsed.success || parsed.data.confidence < 0.5) { result = await anthropic.messages.create({ model: 'claude-sonnet-4-6', system: PREP_SYSTEM_PROMPT + EXAMPLES_FEW_SHOT, messages: [{ role: 'user', content: buildContextMessage(input, sanitized) }], max_tokens: 1200 }); parsed = MealLogSchema.safeParse(extractJSON(result.content[0].text)); } // 6. 그래도 실패 시 재질문 요청 if (!parsed.success) return { status: 'needs_clarification', ... }; // 7. 캐시 저장 + 반환 const output = { status: 'ok', mealLog: parsed.data, llmMeta: {...}, confidence: parsed.data.confidence }; await redis.setex(`prep:${cacheKey}`, 604800, output); return output; }
식재료 fuzzy 매칭 (147 풀 → ingredient_id)
LLM이 추출한 식재료명을 우리 도감 147종 (확장 가능)에 매핑. 매칭 실패 시 ai_estimated 상태로 저장.
function resolveIngredient(name: string): IngredientMatch { // 1. 정확 일치 (정규화: 공백·괄호·이형 제거) const exact = INGREDIENT_POOL.find(i => normalize(i.name) === normalize(name)); if (exact) return { id: exact.id, confidence: 1.0, source: 'pool' }; // 2. 별칭 매칭 (시그치 → 시금치, 토마토 = 도마도) const alias = ALIAS_MAP[normalize(name)]; if (alias) return { id: alias, confidence: 0.95, source: 'alias' }; // 3. Levenshtein 거리 ≤ 2 + 카테고리 일치 const fuzzy = INGREDIENT_POOL .map(i => ({ id: i.id, dist: levenshtein(normalize(i.name), normalize(name)) })) .filter(x => x.dist <= 2) .sort((a, b) => a.dist - b.dist)[0]; if (fuzzy) return { id: fuzzy.id, confidence: 0.8 - fuzzy.dist * 0.1, source: 'fuzzy' }; // 4. 미매칭 — ai_estimated로 enrich queue에 추가 await enrich_queue.insert({ name, source: 'user_input', status: 'pending' }); return { id: null, confidence: 0.3, source: 'ai_estimated', rawName: name }; }
재질문 흐름 (Clarification Loop)
LLM confidence < 0.7 또는 필수 필드 누락 시 사용자에게 1-2개 짧은 질문으로 보강. 3회 이상 누락 시 강제 저장 (low_confidence 플래그).
| 케이스 | 재질문 예시 |
|---|---|
| 식재료 모호 | "오늘 '국' 드셨다고 적어주셨는데, 어떤 국이었나요? (예: 미역국·된장국·소고기뭇국)" |
| reaction 없음 | "잘 먹었는지 살짝 알려주세요 (👍 잘 먹음 / 😐 보통 / 👎 거부)" |
| meal_chip 모호 | "이번 식사는 언제 드셨나요? (아침·점심·간식·저녁)" |
| autonomy 추정 | "아이가 스스로 먹었나요, 부모님이 떠먹여주셨나요?" |
Prompt Injection·PII 방어
사용자 입력은 항상 untrusted로 취급. LLM 시스템 지시 변조·민감 정보 탈취·우리 비밀 키 노출 시도 차단.
function detectInjection(text: string): boolean { const patterns = [ /ignore\s+(previous|above|all)\s+(instructions?|prompts?)/i, /you\s+are\s+now\s+(a|an)\s+/i, /system\s*[::]\s*/i, /\\n*system\s*:/i, /<\|.*?\|>/, // special tokens /api[_-]?key|secret|password/i, // 비밀 정보 요청 ]; return patterns.some(p => p.test(text)); } function sanitizeUserInput(text: string): string { return text .slice(0, 2000) // 최대 2000자 .replace(/<[^>]+>/g, '') // HTML 제거 .replace(/\\u200b/g, '') // zero-width .replace(/[\\x00-\\x1F]/g, ' ') // 제어 문자 .trim(); } // 사용자 입력은 user 메시지에 격리. system 프롬프트와 절대 섞지 않음. // LLM 응답은 항상 zod schema로 통과 — 자유 응답을 그대로 사용 X.
비용·캐시 정책
| 항목 | 정책 |
|---|---|
| 모델 우선순위 | Haiku 4.5 (95% 케이스) → Sonnet 4.7 (5% 복잡) |
| 건당 평균 비용 | Haiku ₩4 · Sonnet ₩25 (입출력 합산) |
| 캐시 | (rawText + ageMonths) hash 키 · Redis 7일 |
| 일일 사용자 한도 | 500 호출/사용자/일 (영유아 5끼 × 100배 마진) |
| 일일 시스템 한도 | $50/day → 초과 시 Haiku만 사용, Sonnet fallback 차단 |
| 실패 fallback | 전처리 실패 시 → 사용자에게 chip+자동완성 UI 강제 (LLM 우회) |
| p95 비용 목표 | MAU 1만 시 월 ₩300k 이내 |
0-2. 평가·추천 엔진과의 인터페이스
'시금치 죽 줬는데 거부'] PREP[🧹 전처리 엔진
LLM Normalizer
Haiku/Sonnet] SCHEMA{Schema OK?} RETRY[재질문 흐름] MEAL[(MealLog
검증된 객체)] EVAL[📊 평가 엔진
결정론적 Rule] RECO[🍳 추천 엔진
Hybrid] RESULT[결과 + 신호등 + 레시피] USER --> PREP --> SCHEMA SCHEMA -->|fail| RETRY --> USER SCHEMA -->|ok| MEAL MEAL --> EVAL --> RESULT MEAL --> RECO --> RESULT classDef llm fill:#FFF8E1,stroke:#F9A825,color:#1F2D3D classDef rule fill:#E8F5E9,stroke:#16A085,color:#1F2D3D classDef hybrid fill:#F3E5F5,stroke:#9C27B0,color:#1F2D3D class PREP llm class EVAL rule class RECO hybrid
0-3. 모니터링 지표 (필수)
- status=ok 비율 ≥ 85% (첫 호출 성공)
- 재질문 트리거율 ≤ 12%
- rejected (injection 감지) 0.1% 이내
- 평균 confidence ≥ 0.82
- Haiku → Sonnet escalation ≤ 5%
- Schema validation 실패 ≤ 3% (LLM 출력 형식 오류)
- 캐시 hit rate ≥ 30% (반복 끼니 자연 캐시)
1. 평가 엔진 — Evaluation Engine
평가 엔진 자체는 결정론적 Rule 기반. LLM 미사용. 빠르고(p95 < 50ms) 재현 가능하며 학술적으로 검증 가능.
📌 단, 사용자의 자유 텍스트 입력은 §0 전처리 엔진(LLM Normalizer)에서 검증된 schema로 정규화한 뒤 본 엔진에 전달됨. 본 엔진은 항상 깨끗한 MealLog 객체만 받는다는 전제. LLM의 비결정성은 전처리 레이어에 격리되어 평가의 재현성·감사 가능성을 보장.
1-0. 엔진 책임·경계
책임 (Does)
- 식단 기록 → 8축 점수·36 영양 신호등·BMI 진단·종합 등급 산출
- 학술 출처 명시 (KDRI 2025·WHO MDD·HabEat·CEBQ·Satter)
- 자연어 진단 문장 생성 (LLM 호출 X, 룰 기반 템플릿)
- OCR 식단표 평가 (가정 기록과 별도 변형)
책임 아님 (Doesn't)
- 레시피 추천 (→ 추천 엔진)
- 이미지 OCR (→ OCR 엔진)
- 편지·동적 질문 (→ AI 코치 시스템)
- 의료 진단 (앱 disclaimer)
1-1. 입력 명세 (Interface)
// packages/eval-engine/types.ts export interface EvalInput { childId: string; // UUID ageMonths: number; // 0~71 (만 0-5세) sex: 'M' | 'F'; ageBand: AgeBand; // '0-5m'·'6-11m'·'1-2y'·'3-5y' windowDays: number; // 3·7·30 (집계 윈도우) mealLogs: MealLog[]; growthLatest: GrowthStat | null; childAllergens: string[]; } export interface MealLog { id: string; eatenAt: Date; mealType: '아침' | '점심' | '저녁' | '간식'; approxHour: number | null; durationMin: number | null; place: Place | null; textureLevel: TextureLevel | null; autonomy: Autonomy | null; reaction: Reaction; ingredients: IngredientUse[]; } export interface IngredientUse { id: string; name: string; category: IngredientCategory; nutriPer100g: NutrientMap; // ★ v3 핵심 — '제공 ≠ 섭취' 분리 (소고기 무국에서 소고기 골라냄 케이스) servedG: number; // 메뉴에 들어간 양 (제공) consumedG: number; // 실제 먹은 양 (0이면 완전 거부) state: 'consumed' | 'partial' | 'rejected' | 'unknown'; // consumed = 다 먹음 / partial = 일부 / rejected = 한 입도 X / unknown = 미상 (LLM이 확신 X 시) rejectionTag: string | null; // 부모 자유 입력 ("골라냄", "향만 핥음", "한 입 시도") attemptCount: number; // 평생 누적 시도 횟수 (서버 집계) successCount: number; // 평생 누적 섭취 횟수 (서버 집계) } /* ──────────────────────────────────────────────────────────────── ⚠️ CRITICAL — 평가 엔진은 반드시 state를 구분해서 카운팅 ──────────────────────────────────────────────────────────────── ❌ 잘못: "소고기 무국 먹음" → 소고기·무 모두 +1 (consumed 카운트) ✅ 옳음: "소고기 무국 먹음, 소고기 골라냄" → 소고기: state='rejected', consumedG=0, attemptCount+1 → 무: state='consumed', consumedG=>0, successCount+1 - 다양성·영양·KDRI 평가 = consumed/partial 만 카운트 (rejected X) - Toomey SOS 시도 횟수 = 모든 served 카운트 (rejected 포함) - 친해지기 진전도 = successCount / attemptCount ────────────────────────────────────────────────────────────────*/ export interface EvalOutput { sevenAxes: AxisResult[]; // 7개 signal36: NutrientSignal[]; // 36개 bmi: BMIResult | null; macroBMI: MacroBMIResult; totalScore: number; // 0-100 grade: 'S' | 'A' | 'B' | 'C' | 'D'; narratives: Narrative[]; // 자연어 진단 (홈 카드용) }
1-2. 핵심 자료구조
export interface AxisResult { axisId: 'diversity' | 'texture' | 'repeat' | 'environment' | 'appetite' | 'novelty' | 'autonomy'; pct: number; // 0-100 status: 'green' | 'orange' | 'red'; detail: string; // '5/8 식품군' narrative: string; // 자연어 진단 evidence: { source: string; // 'WHO MDD 2017' 등 rawData: unknown; }; } export interface NutrientSignal { nutrient: NutrientCode; rni: number; unit: string; contributingMeals: number; // 30%+ 기여 끼니 수 weeklyTarget: number; freqPct: number; // 0-100+ freqLabel: string; // '주 3-4회' status: 'green' | 'orange' | 'red'; topSources: string[]; // ['시금치', '들깨'] age2025: boolean; // 2025 신규 (예: 콜린) }
8축 식단 진단
축 1 — 다양성 (WHO MDD)
학술 출처: WHO Minimum Dietary Diversity for Children 6-23 Months (2010, revised 2017)
8 식품군: 곡물·콩/콩제품·유제품·고기생선·계란·비타민A 풍부 채소·기타 채소·과일
function axisDiversity(logs: MealLog[]): AxisResult { const familiesMet = new Set<FoodFamily>(); for (const log of logs) { for (const ing of log.ingredients) { const fam = CATEGORY_TO_FAMILY[ing.category]; if (fam) familiesMet.add(fam); } } const pct = (familiesMet.size / 8) * 100; const missing = ALL_FAMILIES.filter(f => !familiesMet.has(f)); return { axisId: 'diversity', pct, status: classify(pct, [80, 50]), detail: `${familiesMet.size}/8 식품군`, narrative: buildDiversityNarrative(familiesMet, missing), evidence: { source: 'WHO MDD 2017', rawData: { met: [...familiesMet], missing } } }; }
축 2 — 식감 단계 (HabEat)
학술 출처: HabEat Project (EU FP7, 2010-2014) · Schwartz et al. 2014
알고리즘: 연령별 권장 식감 분포 vs 실제 분포의 KL-divergence 기반 점수
const AGE_TEXTURE_TABLE: Record<AgeBand, TextureDistribution> = { '6-11m': { 미음:0.20, 페이스트:0.40, 매시:0.30, 다진:0.10, 큐브:0, 스틱:0, 통째:0 }, '1-2y': { 미음:0.05, 페이스트:0.15, 매시:0.30, 다진:0.30, 큐브:0.10, 스틱:0.05, 통째:0.05 }, '3-5y': { 미음:0, 페이스트:0.05, 매시:0.10, 다진:0.20, 큐브:0.25, 스틱:0.20, 통째:0.20 }, }; function axisTexture(logs: MealLog[], band: AgeBand): AxisResult { const actual = computeDistribution(logs, l => l.textureLevel); const expected = AGE_TEXTURE_TABLE[band]; const divergence = klDivergence(actual, expected); // 0~∞ const pct = Math.max(0, 100 - divergence * 100); // invert + clamp return { ... }; }
축 3-7 (요약)
| 축 | 계산식 핵심 | 임계치 |
|---|---|---|
| 3. 메뉴 반복 | 4주 내 동일 메뉴명 max 등장 횟수 | 0=A+ / 1-2=B / 3-4=C / 5+=D |
| 4. 식사 환경 (Satter) | avg(duration_min) + place 분산 | ≤20분=A / 20-30=B / 30+=C |
| 5. 식욕 응답 (CEBQ) | (잘먹음+또달라)/전체 × 100 | ≥75=green / 50-75=orange / <50=red |
| 6. 새 시도 | 7일 내 신규 식재료 개수 (이전 30일 미만남) | ≥2=green / 1=orange / 0=red |
| 7. 자율성 (Satter DOR) | '스스로' 비율 (연령 가중) | 24m+: ≥50%=green |
36종 KDRI 영양 신호등 (빈도 → 충족률)
핵심 가설
그램 단위 정확 섭취량 측정 불가 (부모가 g 안 잼) → "끼니별 등장 빈도"로 KDRI 충족률 추정.
임계 contribution: 한 끼에서 30% 이상 RNI 기여하면 "이 끼니에 등장"으로 카운트.
핵심 공식
mealCountn = count(meals where Σ contributioni,n ≥ 0.3)
freqPctn = (mealCountn / (days × 2)) × 100
statusn = freqPct ≥ 80 ? green : freqPct ≥ 50 ? orange : red
가중치 — 영양소별 임계 조정
일부 영양소는 한 끼에 30% 기여 어려움 → 임계 조정:
| 영양소 | 임계 (한 끼 기여) | 이유 |
|---|---|---|
| 비타민 D | 15% | 식이 공급 자체 어려움 (햇볕·강화식품) |
| EPA+DHA | 20% | 등푸른 생선 외 공급원 적음 |
| 철 (heme) | 30% (기본) | 흡수율 차이 — 비타민C 동시 섭취 시 ×3 계수 |
| 콜린 (2025 신규) | 25% | 달걀 노른자 의존, 한국 식단 자주 어려움 |
| 나머지 32종 | 30% | 기본값 |
구현 (TypeScript)
function signal36(logs: MealLog[], band: AgeBand, days: number): NutrientSignal[] { const nutrientMeals: Record<NutrientCode, number> = {}; const topSources: Record<NutrientCode, Map<string, number>> = {}; for (const log of logs) { const contributions: Record<NutrientCode, number> = {}; for (const ing of log.ingredients) { const g = ing.estimatedG ?? CATEGORY_AVG_G[ing.category]; for (const [n, amt] of Object.entries(ing.nutriPer100g)) { const rni = KDRI_RNI[band][n]; if (!rni) continue; const contrib = (amt * g / 100) / rni; // 비타민C 보강 시 철 흡수 ×3 const boost = (n === 'iron' && hasVitC(log)) ? 3 : 1; contributions[n] = (contributions[n] ?? 0) + contrib * boost; // top source 추적 topSources[n] = topSources[n] ?? new Map(); topSources[n].set(ing.name, (topSources[n].get(ing.name) ?? 0) + contrib); } } for (const [n, total] of Object.entries(contributions)) { if (total >= NUTRIENT_THRESHOLD[n]) { nutrientMeals[n] = (nutrientMeals[n] ?? 0) + 1; } } } const weeklyTarget = days * 2; return KDRI_36_NUTRIENTS.map(n => ({ nutrient: n, rni: KDRI_RNI[band][n], unit: KDRI_UNITS[n], contributingMeals: nutrientMeals[n] ?? 0, weeklyTarget, freqPct: ((nutrientMeals[n] ?? 0) / weeklyTarget) * 100, freqLabel: freqToLabel(...), status: classify(...), topSources: topN(topSources[n], 3), age2025: n === 'choline', })); }
freqToLabel 매핑 (사용자 노출용)
| freqPct 범위 | 라벨 |
|---|---|
| ≥ 90 | 거의 매일 |
| 75-89 | 주 4-5회 |
| 60-74 | 주 3회 |
| 50-59 | 주 2회 |
| 40-49 | 주 1-2회 |
| 30-39 | 주 1회 |
| 15-29 | 드물게 |
| < 15 | 거의 못 만남 |
BMI percentile (KDC 2017 LMS)
학술 출처: 한국질병관리청 2017 소아청소년 표준성장도표
LMS (Box-Cox 모수) 방법으로 정확한 percentile 계산.
LMS 공식
Z = ln(BMI / M) / S // L = 0
percentile = Φ(Z) × 100 // 정규분포 누적
샘플 LMS 테이블 (만 28개월, 여)
| 지표 | L | M | S |
|---|---|---|---|
| BMI | -0.853 | 15.99 | 0.0710 |
| 키 (cm) | 1.000 | 91.42 | 0.0392 |
| 몸무게 (kg) | -0.310 | 13.21 | 0.1126 |
구현
import { cumulativeNormal } from './math'; function computePercentile(value: number, lms: LMS): number { const { L, M, S } = lms; const z = Math.abs(L) > 1e-7 ? (Math.pow(value / M, L) - 1) / (L * S) : Math.log(value / M) / S; return Math.round(cumulativeNormal(z) * 100); } function evalBMI(growth: GrowthStat, sex: 'M'|'F', ageMonths: number): BMIResult { const bmi = growth.weightKg / Math.pow(growth.heightCm/100, 2); const lms = KDC_LMS[sex][ageMonths]['bmi']; const pct = computePercentile(bmi, lms); const status = pct < 5 ? 'underweight' : pct < 85 ? 'normal' : pct < 95 ? 'overweight' : 'obese'; return { bmi, percentile: pct, status }; }
탄·단·지 + BMI 종합 매트릭스
9 케이스 전체 매트릭스
| BMI 위치 | 탄수화물 | 단백질 | 지방 |
|---|---|---|---|
| 저체중 (<5p) | warn_low — "양 늘리기" | boost — "성장기 더 OK" | warn_low — "견과·아보카도·EPA" |
| 정상 (5-85p) | ok — "적정" | ok — "적정" | ok — "적정" |
| 과체중 (85-95p) | warn_high — "잡곡 비중↑·간식↓" | ok — "유지" | warn_high — "튀김·기름↓" |
각 케이스에 따라 3종 메시지 + CTA 자동 생성. 자세히 보기 모달에 자연어로 표시.
시도 vs 섭취 분리 카운팅 + 친해지기 진전도 (Toomey 노출)
핵심: 평가 엔진의 모든 식재료 카운트는 IngredientUse.state를 반드시 분리. "소고기 무국에서 소고기 골라냄" = 다양성 카운트에는 안 들어가지만, Toomey SOS 시도 횟수 +1.
축별 state 사용 규칙
| 평가 축 | 카운트 대상 | 예시 |
|---|---|---|
| 다양성 (WHO MDD·14 sub) | consumed + partial | 골라낸 소고기는 식품군 카운트 X |
| KDRI 36 영양 신호등 | consumed만 (partial은 50% 환산) | 소고기 골라냄 → 철 섭취 0 |
| 메뉴 반복도 | served 전체 (먹었든 안 먹었든) | 닭죽 5회 제공 = 반복 5회 |
| Toomey SOS 노출 횟수 | served 전체 | 시금치 거부 10회도 노출 10회 (Cooke 반복 노출) |
| 친해지기 진전도 | successCount / attemptCount | 10번 시도 / 1번 먹음 = 0.1 (낮음 → 집중 키트 추천) |
| 가공식품 비율 | served 전체 | 제공 시 표시 (실제 섭취 무관) |
| 알레르겐 표시 | served 전체 | 제공된 알레르겐 표기 의무 |
구현 — 핵심 함수
function filterByState(logs: MealLog[], states: State[]): IngredientUse[] { return logs.flatMap(l => l.ingredients.filter(i => states.includes(i.state))); } // 다양성: consumed/partial 만 (rejected 제외) const diversityPool = filterByState(logs, ['consumed', 'partial']); // KDRI 영양 (partial은 50% 가중) function nutrientSum(logs: MealLog[], nutrient: string): number { return logs.flatMap(l => l.ingredients).reduce((sum, i) => { const weight = i.state === 'consumed' ? 1.0 : i.state === 'partial' ? 0.5 : 0; // rejected/unknown = 0 return sum + (i.consumedG ?? i.servedG * weight) * i.nutriPer100g[nutrient] / 100; }, 0); } // 친해지기 진전도 (Toomey + Cooke) function familiarityScore(ingredientId: string, childId: string): FamiliarityResult { const stats = await db.ingredient_attempts.aggregate({ where: { ingredientId, childId }, sum: { attemptCount: true, successCount: true } }); const rate = stats.successCount / stats.attemptCount; const sosStage = stats.attemptCount === 0 ? 0 // 미노출 : rate === 0 && stats.attemptCount < 3 ? 1 // 보기 : rate === 0 ? 2 // 거부 지속 — 만지기·냄새 단계 : rate < 0.3 ? 3 // 핥기·한 입 시도 : rate < 0.7 ? 4 // 씹기 : 5; // 삼키기·반복 섭취 return { rate, sosStage, attemptCount: stats.attemptCount, successCount: stats.successCount }; }
DB schema 영향
-- 식재료별 시도·섭취 누적 (자녀 평생 통계) CREATE TABLE ingredient_attempts ( child_id UUID REFERENCES children(id), ingredient_id UUID REFERENCES ingredients(id), attempt_count INT DEFAULT 0, -- served 누적 success_count INT DEFAULT 0, -- consumed/partial 누적 last_attempted_at TIMESTAMPTZ, last_consumed_at TIMESTAMPTZ, current_sos_stage INT, -- 0-5 자동 갱신 rejection_tags TEXT[], -- 누적된 거부 표현 ['골라냄', '향만 핥음'] PRIMARY KEY(child_id, ingredient_id) ); -- meal_log → ingredient_attempts 자동 누적 트리거 CREATE TRIGGER update_attempts AFTER INSERT ON meal_log_ingredients FOR EACH ROW EXECUTE update_ingredient_attempts();
UI 영향 — 입력 화면 (해시태그 분리)
━ 오늘 식사 ━
[ 소고기 무국 ] ← 메뉴 자유 입력 (LLM 자동 분해)
━ 자동 분해된 식재료 ━ (chip 각각 토글)
[ ✓ 소고기 ] ← 길게 누르면 '✗ 남김' 전환
[ ✓ 무 ]
[ ✓ 파 ]
[ ✓ 간장 ] (조미료 — 평가 X)
━ 부모 메모 (선택, raw_note 저장) ━
[ "소고기 골라냈음. 무만 다 먹음" ]
↓ LLM 전처리
{
ingredients: [
{ name:'소고기', state:'rejected', rejectionTag:'골라냄' },
{ name:'무', state:'consumed' },
{ name:'파', state:'unknown' }
],
needsClarification: ["파는 어땠어요?"]
}
왜 'unknown' 상태가 필요한가
- "소고기 무국 먹음"만 적었으면 무·파는 unknown (확신 X)
- unknown은 평가에 반영 X (소고기 먹음 + 무·파 unknown)
- 다음 입력 시 LLM이 "어제 무국에서 무·파는 어땠어요?" 역질문 (정성 체이닝 §4와 연결)
- 강제로 consumed로 가정하면 다양성·영양 과대 평가 위험
식단표 역분석 → 도감 자동 enrich 파이프라인
피드백: '어머니들이 업로드한 식단표 = 가장 정확한 한국 영유아 실식단 시그널'. 역으로 분석해 자주 등장하는 새 식재료를 도감 enrich_queue에 자동 push.
흐름 (M2~M4 통합)
async function harvestFromEvaluation(evalText: string, evalResult: EvalOutput) { // 1. 메뉴 텍스트에서 추출된 식재료 (147 풀과 매칭된 것 + 미매칭된 것) const { matched, unmatched } = extractIngredients(evalText); // 2. 미매칭 식재료 카운터 증가 (어머니 식단표 누적) for (const name of unmatched) { await supabase.from('unmatched_ingredient_signals').upsert({ name, sighting_count: { increment: 1 }, first_seen_at: new Date(), last_seen_at: new Date(), source: 'daycare-eval', }, { onConflict: 'name' }); } // 3. 임계치 도달 시 enrich_queue로 자동 push const { data: ready } = await supabase .from('unmatched_ingredient_signals') .select('*') .gte('sighting_count', 5) // 5회 이상 등장 .is('promoted_to_queue_at', null); for (const sig of ready || []) { await supabase.from('enrich_queue').insert({ name: sig.name, source_db: 'daycare-eval 시그널 (어머니 식단표)', scheduled_for: new Date(), status: 'pending', }); await supabase.from('unmatched_ingredient_signals').update({ promoted_to_queue_at: new Date(), }).eq('id', sig.id); } }
DB schema 추가
CREATE TABLE unmatched_ingredient_signals ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name TEXT UNIQUE NOT NULL, -- 어머니 식단표에서 추출된 미매칭 식재료명 sighting_count INT DEFAULT 1, -- 누적 등장 횟수 source TEXT, -- 'daycare-eval'·'sns'·'comment' 등 first_seen_at TIMESTAMPTZ DEFAULT NOW(), last_seen_at TIMESTAMPTZ DEFAULT NOW(), promoted_to_queue_at TIMESTAMPTZ, -- enrich_queue로 push된 시점 enriched_ingredient_id UUID REFERENCES ingredients(id), notes TEXT ); CREATE INDEX idx_unmatched_ready ON unmatched_ingredient_signals(sighting_count DESC) WHERE promoted_to_queue_at IS NULL;
가드레일
- 익명 보장: name만 추출, 사용자·기관 식별 정보 X
- 임계치 5회: 한 명만 입력한 식재료(오타·고유명사)는 도감 진입 X
- 운영자 큐 검토: enrich_queue 진입 후에도 ai_enriched 상태로 표시 → 운영자 verified 승급 필요
- 금지 리스트: '알약·영양제·인공감미료' 등 비식재료는 자동 차단
예상 효과 (3개월 후)
- 월 500명 부모 × 평균 식단표 1건 = 500 식단표/월 자동 수집
- 식단표당 미매칭 식재료 평균 3개 → 월 1,500 시그널
- 임계치 5회 도달 신규 식재료 = 월 ~30종 자동 도감 진입
- "한국 어머니가 실제로 먹이는 식재료" 도감 — 농진청 표준 식단보다 정확한 현장 데이터
OCR 식단표 평가 (가정 기록과의 변형)
한 달 식단표는 가정 기록보다 2 축 추가 + 일부 축은 가중치 다름.
| 축 | 가정 기록 (UC-14~22b) | 식단표 OCR (UC-20) |
|---|---|---|
| 다양성 | 3일 8 식품군 | 한 달 8 식품군 (더 엄격) |
| 식감 | 로그 텍스처 분포 | 메뉴명 텍스처 추정 (LLM) |
| KDRI 36종 | 실시간 빈도 | 한 달 평균 빈도 |
| 메뉴 반복 | 4주 내 | 한 달 내 동일 |
| 알레르겐 표시 | X (가정 무관) | NEW — 명시 비율 |
| 가공식품 비율 | X | NEW — 25% 이하 권장 |
| 계절성·국산 | X | NEW — 제철 매핑 |
1-3. 종합 점수 + 등급 계산
const AXIS_WEIGHTS: Record<AxisId, number> = { diversity: 0.20, texture: 0.15, repeat: 0.10, environment: 0.10, appetite: 0.15, novelty: 0.10, autonomy: 0.10, // + signal36 가중치 0.10 (별도 계산) }; function computeTotalScore(axes: AxisResult[], signal: NutrientSignal[]): number { const axisScore = axes.reduce((s, a) => s + a.pct * AXIS_WEIGHTS[a.axisId], 0); const greenCount = signal.filter(s => s.status === 'green').length; const signalScore = (greenCount / 36) * 100 * 0.10; return Math.round(axisScore * 0.90 + signalScore); } function scoreToGrade(score: number): 'S'|'A'|'B'|'C'|'D' { return score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D'; }
1-4. API 응답 형식
// GET /api/eval/score?child_id=&days=3 { "ok": true, "data": { "totalScore": 60, "grade": "B", "sevenAxes": [ { "axisId": "diversity", "pct": 62.5, "status": "orange", "detail": "5/8 식품군", "narrative": "콩·유제품·과일이 비어요" }, ... ], "signal36": [ { "nutrient": "iron", "freqPct": 38, "status": "red", "freqLabel": "주 1회", "topSources": ["시금치", "들깨"] }, ... ], "bmi": { "bmi": 15.83, "percentile": 45, "status": "normal" }, "macroBMI": { "carb": "ok", "protein": "ok", "fat": "warn_low", "messages": {...} }, "narratives": [...] }, "meta": { "cached": false, "latencyMs": 23, "version": "1.0.0" } }
1-5. 에러 처리 + Fallback
- 데이터 부족 (logs.length < 3): "데이터 부족 — 3끼 이상 입력해주세요" 메시지 + null 반환
- BMI 계산 불가 (growth 없음): macroBMI 생략 + "키·몸무게 입력 시 정밀 평가 가능"
- 식재료 미매칭: AI 추정 데이터(status='ai_estimated')로 보강 + 결과에 `confidence: 'low'` 표시
- KDRI lookup 실패: 영양소 신호등에서 제외 + 운영팀 알림
1-6. 성능 최적화
- 인덱스:
(child_id, eaten_at DESC)covering index - 캐싱: 결과를 Redis에 5분 캐시 (key:
eval:{child_id}:{date}) - 배치: 매일 03:00 KST에 전 사용자 평가 사전 계산 → 홈 진입 시 즉시 응답
- p95 목표: 50ms (캐시 hit) / 300ms (cold)
2. 추천 엔진 — Recommendation Engine
하이브리드 (Rule 필터 + LLM 정제). 4,432 레시피 풀 → 6-8 최종 추천. Phase 0~4 단계 명확.
2-0. 엔진 책임
- 식습관 프로파일 자동 집계 (Phase 0)
- 4가지 원칙별 후보 추출 (Phase 1 Rule)
- 후보 랭킹 (Phase 1.5 가중합)
- LLM 정제 + 자연어 이유 생성 (Phase 2)
- 캐싱 + 응답 (Phase 3)
- 후처리 안전 필터 (Phase 4 — 알레르겐 검증)
2-1. 핵심 자료구조
export interface ChildProfile { childId: string; ageBand: AgeBand; allergens: string[]; // Phase 0 집계 결과 ingredientStats: Record<string, IngredientStat>; baseStats: Record<BaseType, BaseStat>; textureDistribution: Record<TextureLevel, number>; nutrientStatus: Record<NutrientCode, Status>; recentMenus: string[]; // 최근 7일 메뉴명 profileHash: string; // 캐시 키 } export interface IngredientStat { exposureCount: number; eatCount: number; acceptRate: number; // 0~1 lastSeenDaysAgo: number; preferredForms: Form[]; // 먹은 형태 rejectedForms: Form[]; } export interface RecipeCandidate { recipe: Recipe; principleId: 1 | 2 | 3 | 4; rawScore: number; factors: { basePreference: number; concealment: number; nutrientBoost: number; novelty: number; ageFit: number; }; }
2-2. Phase 0 — 프로파일 자동 집계
식습관 프로파일 집계
시간 윈도우 가중치: 최근 7일 ×1.0 / 8-30일 ×0.5 / 31일+ 제외
async function buildProfile(childId: string): Promise<ChildProfile> { const logs = await fetchLogs(childId, { days: 30 }); const child = await fetchChild(childId); // 식재료별 stat (시간 가중치 적용) const ingStats: Record<string, IngredientStat> = {}; for (const log of logs) { const daysAgo = daysDiff(Date.now(), log.eatenAt); const weight = daysAgo <= 7 ? 1.0 : 0.5; for (const ing of log.ingredients) { const stat = ingStats[ing.id] ??= emptyStat(); stat.exposureCount += weight; if (['잘먹음', '또달라'].includes(log.reaction)) { stat.eatCount += weight; stat.preferredForms.push(inferForm(log, ing)); } else if (log.reaction === '거부') { stat.rejectedForms.push(inferForm(log, ing)); } stat.lastSeenDaysAgo = Math.min(stat.lastSeenDaysAgo, daysAgo); } } for (const stat of Object.values(ingStats)) { stat.acceptRate = stat.exposureCount > 0 ? stat.eatCount / stat.exposureCount : 0; } // 베이스별 stat (메뉴명 → base_type 매핑) const baseStats = aggregateBases(logs); // 식감 분포 const textureDist = computeDistribution(logs, l => l.textureLevel); // KDRI 신호등 (eval 엔진 호출) const nutrientStatus = await fetchSignal36(childId); const profile = { childId, ageBand: child.ageBand, allergens: child.allergens, ingredientStats: ingStats, baseStats, textureDistribution: textureDist, nutrientStatus, recentMenus: logs.slice(0, 21).map(l => l.menuName) }; profile.profileHash = computeProfileHash(profile); return profile; } // 캐시 hit rate 위해 변동성 낮은 키만 사용 function computeProfileHash(p: ChildProfile): string { const key = { ageBand: p.ageBand, top5Bases: Object.entries(p.baseStats) .sort((a, b) => b[1].eatCount - a[1].eatCount) .slice(0, 5).map(e => e[0]), redNutrients: Object.entries(p.nutrientStatus) .filter(([_, s]) => s === 'red').map(e => e[0]).sort(), top10Challenge: getTop10Challenge(p.ingredientStats).map(i => i.id).sort(), }; return sha256(JSON.stringify(key)); }
2-3. Phase 1 — 4 원칙별 Rule SQL
원칙 1: 선호 베이스 × 도전 식재료
WITH preferred_bases AS ( SELECT base_type, eat_count FROM base_stats_view WHERE child_id = $1 AND eat_count >= 5 ORDER BY eat_count DESC LIMIT 5 ), challenge_ings AS ( SELECT ingredient_id FROM ingredient_stats_view WHERE child_id = $1 AND (accept_rate < 0.5 OR last_seen_days_ago > 60) ORDER BY challenge_priority(accept_rate, last_seen_days_ago) DESC LIMIT 10 ), candidates AS ( SELECT r.*, base_preference_score(r.base_type, $1) AS base_score, concealment_score(r.id, $1) AS conceal_score, (SELECT COUNT(*) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id AND ri.ingredient_id IN (SELECT ingredient_id FROM challenge_ings) AND ri.form IN ('즙', '다진', '매시') ) AS challenge_ing_count FROM recipes r WHERE r.base_type IN (SELECT base_type FROM preferred_bases) AND r.age_group_compatible(SELECT age_band FROM children WHERE id = $1) AND NOT r.allergens && (SELECT allergens FROM children WHERE id = $1) ) SELECT *, (base_score * 0.4 + conceal_score * 0.3 + challenge_ing_count * 0.3) AS total_score FROM candidates WHERE challenge_ing_count > 0 ORDER BY total_score DESC LIMIT 50;
SQL 함수 (커스텀)
CREATE FUNCTION base_preference_score(p_base TEXT, p_child_id UUID) RETURNS NUMERIC AS $$ SELECT COALESCE( LEAST(1.0, (eat_count :: NUMERIC) / 10), -- normalize 0.0 ) FROM base_stats_view WHERE base_type = p_base AND child_id = p_child_id; $$ LANGUAGE SQL IMMUTABLE; CREATE FUNCTION concealment_score(p_recipe_id UUID, p_child_id UUID) RETURNS NUMERIC AS $$ -- 도전 식재료가 가린 형태(즙·다진·매시)일수록 점수 ↑ SELECT COALESCE( AVG( CASE ri.form WHEN '즙' THEN 1.0 WHEN '다진' THEN 0.8 WHEN '매시' THEN 0.6 ELSE 0.2 END ), 0.5 ) FROM recipe_ingredients ri WHERE ri.recipe_id = p_recipe_id AND ri.ingredient_id IN (SELECT ingredient_id FROM challenge_ings WHERE child_id = p_child_id); $$ LANGUAGE SQL STABLE;
원칙 2: 잘먹지만 오랜만 → 원물 노출
WITH loved_but_distant AS ( SELECT ingredient_id FROM ingredient_stats_view WHERE child_id = $1 AND accept_rate >= 0.7 AND last_seen_days_ago > 30 AND NOT (preferred_forms && ARRAY['스틱', '통째', '큐브']) -- 원물 형태 안 만남 LIMIT 5 ), current_max_texture AS ( SELECT texture_max(texture_distribution) AS max_level FROM profile_view WHERE child_id = $1 ) SELECT r.* FROM recipes r JOIN recipe_ingredients ri ON ri.recipe_id = r.id WHERE ri.ingredient_id IN (SELECT ingredient_id FROM loved_but_distant) AND ri.form IN ('스틱', '통째', '큐브') AND texture_level_index(r.texture_level) > (SELECT max_level FROM current_max_texture) ORDER BY age_appropriateness(r.texture_level, $2) * loved_score(ri.ingredient_id, $1) DESC LIMIT 30;
원칙 3: 극도 거부 → 가림 형태
WITH highly_rejected AS ( SELECT ingredient_id FROM ingredient_stats_view WHERE child_id = $1 AND accept_rate < 0.3 AND exposure_count >= 5 LIMIT 5 ) SELECT r.*, concealment_score(r.id, $1) AS conceal FROM recipes r WHERE EXISTS ( SELECT 1 FROM recipe_ingredients ri WHERE ri.recipe_id = r.id AND ri.ingredient_id IN (SELECT ingredient_id FROM highly_rejected) AND (r.concealment->>ri.ingredient_id) = 'high' -- 즙·페이스트 ) AND r.base_type IN (SELECT base_type FROM preferred_bases_view WHERE child_id = $1) ORDER BY conceal * base_preference_score(r.base_type, $1) DESC LIMIT 30;
원칙 4: 부족 영양 보강
WITH red_nutrients AS ( SELECT nutrient FROM signal_36_view WHERE child_id = $1 AND status = 'red' ORDER BY freq_pct ASC LIMIT 5 ) SELECT r.*, ( SELECT SUM( (r.nutri_total->>n.nutrient)::NUMERIC / (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) AND r.base_type IN (SELECT base_type FROM preferred_bases_view WHERE child_id = $1) ORDER BY boost_score DESC LIMIT 80;
2-4. Phase 1.5 — 후보 랭킹 (가중합)
가중합 점수 함수
function rankCandidates(candidates: RecipeCandidate[], profile: ChildProfile): RecipeCandidate[] { const weights = { basePref: 0.30, concealment: 0.20, nutri: 0.30, novelty: 0.10, ageFit: 0.10 }; return candidates.map(c => { const factors = { basePreference: basePrefScore(c.recipe.baseType, profile), concealment: concealmentScore(c.recipe, profile), nutrientBoost: nutrientBoostScore(c.recipe, profile), novelty: profile.recentMenus.includes(c.recipe.name) ? 0 : 1, ageFit: ageAppropriateness(c.recipe.ageGroup, profile.ageBand), }; const rawScore = weights.basePref * factors.basePreference + weights.concealment * factors.concealment + weights.nutri * factors.nutrientBoost + weights.novelty * factors.novelty + weights.ageFit * factors.ageFit; return { ...c, rawScore, factors }; }).sort((a, b) => b.rawScore - a.rawScore); }
각 factor 함수 디테일
- basePreference: eat_count 정규화 (min(eat_count, 10) / 10)
- concealment: 도전 식재료가 가린 형태인 비율 (0-1)
- nutrientBoost: Σ(레시피 영양/RNI) for 빨강 영양소들 (clamp 1)
- novelty: 최근 21끼니에 없으면 1, 있으면 0
- ageFit: age_group 정확 매칭=1.0 / 한 단계 차이=0.7 / 두 단계 이상=0.3
2-5. Phase 2 — LLM 정제 (Claude Sonnet 4.7)
LLM 호출 + 프롬프트 전문
System Prompt (캐싱 대상)
const SYSTEM_PROMPT = `당신은 만 0-5세 영유아의 식습관 데이터를 분석해 4가지 원칙에 따라 레시피를 추천하는 영양 전문가입니다. # 4가지 원칙 1. 선호 베이스 + 도전 식재료 (가림 형태로 거부감 ↓) 2. 잘먹지만 오랜만 → 원물 노출 (식감 단계 ↑) 3. 극도 거부 → 형태 안 보이게 (즙·페이스트로 노출 누적) 4. 부족 영양 보강 (KDRI 신호등 빨강) # 출력 규칙 - 각 원칙별 1-2개 레시피 선택 (총 6-8개) - 각 레시피마다 "왜 우리 아이에게?" 자연어 이유 (3-4문장) - 이유에는 반드시 다음 포함: ① 선호/거부 데이터 인용 ② 학술 근거 ③ 구체 행동 제안 - 부모 친화 톤 (강요 X · 권유 O · 죄책감 유발 X) - "영양사" "의사" 같은 권위 표현 X - 학술 출처는 자연어로 ("연구에 따르면" / "권장은") # 안전 - 알레르겐 위배 시 절대 추천 금지 (input에서 제거됨) - 의료 진단·치료 X — 정보·교육 목적 # JSON Schema { "principle_1": [{"recipe_id": "uuid", "reason": "3-4 문장"}, ...], "principle_2": [...], "principle_3": [...], "principle_4": [...] }\`;
User Prompt (동적)
function buildUserPrompt(profile: ChildProfile, candidates: RecipeCandidate[]): string { return `## 우리 아이 프로파일 - 만 ${profile.ageMonths}개월 · ${profile.sex === 'M' ? '남' : '여'} - 잘먹는 베이스 TOP 5: ${formatBases(profile.baseStats)} - 거부·도전 식재료: ${formatChallenge(profile.ingredientStats)} - 식감 분포: ${formatTexture(profile.textureDistribution)} - 신호등 빨강 영양소: ${formatRedNutrients(profile.nutrientStatus)} - 최근 21끼 메뉴 (중복 회피용): ${profile.recentMenus.join(', ')} - 알레르겐: ${profile.allergens.join(', ') || '없음'} ## ${candidates.length}개 후보 (Phase 1 Rule 통과) \`\`\`json ${JSON.stringify(candidates.map(c => ({ id: c.recipe.id, name: c.recipe.name, base_type: c.recipe.baseType, texture: c.recipe.textureLevel, highlights: c.recipe.nutrientHighlights, ingredients: c.recipe.ingredients.map(i => i.name).slice(0, 5), principle: c.principleId, factors: c.factors, })), null, 2)} \`\`\` 원칙별 최적 1-2개 선택 + 자연어 이유.\`; }
호출 코드 (Prompt Caching 적용)
import Anthropic from '@anthropic-ai/sdk'; async function refineWithLLM(profile: ChildProfile, candidates: RecipeCandidate[]) { const client = new Anthropic(); const response = await client.messages.create({ model: 'claude-sonnet-4-7', max_tokens: 4000, system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } // 1시간 캐시 — 비용 ↓ 50% }], messages: [{ role: 'user', content: buildUserPrompt(profile, candidates) }], }); return parseAndValidate(response.content[0].text); }
출력 검증 (안전 필터)
function parseAndValidate(rawText: string): LLMOutput { const json = extractJSON(rawText); const parsed = LLMOutputSchema.parse(json); // zod // 추가 안전 체크 for (const principle of [1, 2, 3, 4]) { for (const rec of parsed[`principle_${principle}`]) { // 알레르겐 위배 검증 (이중 안전) const recipe = findRecipe(rec.recipe_id); if (recipe.allergens.some(a => profile.allergens.includes(a))) { throw new Error('ALLERGEN_VIOLATION'); } // 자연어 길이 (3-4 문장 강제) if (sentenceCount(rec.reason) < 3 || sentenceCount(rec.reason) > 5) { rec.reason = trimSentences(rec.reason, 3, 4); } // 금지 표현 ("영양사", "의사" 등) for (const word of FORBIDDEN_WORDS) { if (rec.reason.includes(word)) { rec.reason = rec.reason.replaceAll(word, SAFE_REPLACEMENTS[word]); } } } } return parsed; }
2-6. Phase 3 — 캐싱 + Invalidation
캐싱 전략
| 레이어 | 키 | TTL | Invalidate 조건 |
|---|---|---|---|
| Redis 추천 캐시 | reco:{child_id}:{profile_hash} | 24h | profile_hash 변경 / 사용자 강제 새로고침 |
| Anthropic Prompt Cache | system prompt (자동) | 1h | system prompt 변경 |
| DB candidate 캐시 | candidates:{child_id}:{phase} | 1h | 새 meal_log 입력 |
Invalidation 트리거
// Supabase Realtime trigger async function onMealLogInsert(log: MealLog) { await redis.del(`reco:${log.childId}:*`); // 추천 캐시 무효화 await redis.del(`candidates:${log.childId}:*`); await redis.del(`eval:${log.childId}:*`); }
2-7. 비용 추적 + Fallback
- 일일 LLM 비용 한도: $20/day → 초과 시 Phase 1 룰 결과만 반환 (자연어 이유 X)
- 호출 실패 (timeout/rate limit): 3회 재시도 (1s, 2s, 4s) → 실패 시 Phase 1 결과 + 정형 메시지
- 모델 비교 A/B: 10% 트래픽은 Gemini Flash로 라우팅 → 만족도 측정
3. OCR 엔진 — CLOVA(식단표 전사) + Claude(메뉴→식재료 분해)
3-0. 엔진 책임
- 이미지 전처리 (회전·압축·해시)
- CLOVA OCR로 식단표 전사(표 셀 인식, 미지원 도메인은 일반 OCR 폴백) → Claude가 메뉴·식재료 분해
- 메뉴 → recipe_id 매핑 (fuzzy match + LLM fallback)
- 알레르겐 매핑 (식약처 17대)
- 신뢰도 스코어링 + 사용자 수정 인터페이스
- 식사 사진 OCR (음식 → 식재료 추정)
이미지 전처리
import sharp from 'sharp'; import crypto from 'crypto'; async function preprocessImage(buffer: Buffer): Promise<{ buffer: Buffer, hash: string }> { const processed = await sharp(buffer) .rotate() // EXIF 자동 회전 .resize({ width: 1600, withoutEnlargement: true }) .normalize() // 대비 자동 보정 .jpeg({ quality: 85, mozjpeg: true }) .toBuffer(); const hash = crypto.createHash('sha256').update(processed).digest('hex'); return { buffer: processed, hash }; }
Claude Vision 프롬프트 + JSON Schema
const VISION_PROMPT = `이 한국 어린이집·유치원·가정 식단표 이미지를 분석하여 다음 JSON 스키마로 출력해주세요. 부정확한 부분은 confidence<0.7로 표기. # JSON Schema { "year_month": "YYYY-MM", "facility_type_hint": "어린이집|유치원|가정|기타", "days": [ { "date": "M/D", "weekday": "월|화|수|목|금|토|일", "is_event_day": boolean, "event_name": string|null, "meals": { "오전간식": [{"name": "메뉴명", "allergen_numbers": [int]}], "점심": [...], "오후간식": [...] } } ], "footer": { "nutritionist": string|null, "allergen_legend": [{"num": int, "name": "한글명"}], "origin": {"식재료명": "국산|수입국명"} }, "confidence": float (0.0-1.0) } # 규칙 - 메뉴명에서 알레르겐 번호 (5), (5.6), ⓞⓘⓢ 등은 allergen_numbers 배열로 분리 - '잡곡밥/된장국/시금치나물' 처럼 슬래시 분리된 항목은 각각 별도 메뉴로 추출 - 날짜는 M/D 포맷 (한 자리는 0 padding X) - footer.allergen_legend는 페이지 어딘가에 명시된 번호 매핑 (1=난류, 2=우유 등) - 이벤트일 (어린이날·재량휴원 등)은 is_event_day=true, event_name 명시 - 빈 칸은 빈 배열 [] 사용 \`; async function callVision(imageBuffer: Buffer): Promise<OCRResult> { const base64 = imageBuffer.toString('base64'); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-7', max_tokens: 8000, messages: [{ role: 'user', content: [ { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } }, { type: 'text', text: VISION_PROMPT }, ] }], }); return parseJSON(response.content[0].text); }
메뉴 정규화 — recipe_id 매핑
import { fuzzyScore } from './trigram'; async function matchMenuToRecipe(menuName: string): Promise<RecipeMatch> { // 1. 텍스트 클렌징 const cleaned = menuName.replace(/\([0-9.]+\)/g, '') // (5.6) 제거 .replace(/[ⓞⓘⓢ]+/g, '').trim(); // 2. Trigram similarity (Postgres pg_trgm) const { rows } = await db.query(` SELECT id, name, similarity(name, $1) AS sim FROM recipes WHERE name % $1 -- pg_trgm operator ORDER BY sim DESC LIMIT 3 `, [cleaned]); // 3. 임계값 ≥ 0.7 이면 매칭 성공 if (rows[0]?.sim >= 0.7) { return { recipeId: rows[0].id, confidence: rows[0].sim, source: 'fuzzy' }; } // 4. Fallback: LLM에 base_type·texture·ingredients 추정 const llmResult = await inferRecipeFromName(cleaned); const adHocId = await createAdHocRecipe(llmResult); return { recipeId: adHocId, confidence: 0.5, source: 'llm_inferred' }; }
알레르겐 매핑 + 아이 위험 알림
const ALLERGEN_17: Record<number, string> = { 1: '난류', 2: '우유', 3: '메밀', 4: '땅콩', 5: '대두', 6: '밀', 7: '고등어', 8: '게', 9: '새우', 10: '돼지고기', 11: '복숭아', 12: '토마토', 13: '아황산류', 14: '호두', 15: '닭고기', 16: '쇠고기', 17: '오징어' }; function checkAllergenRisk(ocrResult: OCRResult, childAllergens: string[]): RiskAlert[] { const alerts: RiskAlert[] = []; const legend = ocrResult.footer.allergen_legend ? Object.fromEntries(ocrResult.footer.allergen_legend.map(a => [a.num, a.name])) : ALLERGEN_17; // fallback for (const day of ocrResult.days) { for (const mealType of ['오전간식', '점심', '오후간식']) { for (const menu of day.meals[mealType] ?? []) { const menuAllergens = menu.allergen_numbers.map(n => legend[n]).filter(Boolean); const risk = menuAllergens.filter(a => childAllergens.includes(a)); if (risk.length > 0) { alerts.push({ date: day.date, mealType, menu: menu.name, allergens: risk }); } } } } return alerts; }
신뢰도 스코어링 + 사용자 수정 UI
function computeOverallConfidence(ocr: OCRResult, matches: RecipeMatch[]): number { const visionConfidence = ocr.confidence; // 0.0-1.0 const matchRate = matches.filter(m => m.source === 'fuzzy').length / matches.length; const avgMatchConfidence = matches.reduce((s, m) => s + m.confidence, 0) / matches.length; return visionConfidence * 0.4 + matchRate * 0.3 + avgMatchConfidence * 0.3; } // 사용자 노출 UI 분기 if (overall >= 0.85) { // 자동 진행 (수정 UI 안 보임) } else if (overall >= 0.70) { // "이 메뉴 맞나요?" 확인 UI 노출 (5-10개 핵심만) } else { // 전체 수동 입력 모드 전환 }
식사 사진 OCR (식단 기록용 — 식단표와 다름)
const FOOD_VISION_PROMPT = `이 음식 사진을 보고 식재료를 추출하세요. 하나의 메뉴에 들어간 모든 식재료를 나열 (소스·향신 포함). { "menu_hint": "추정 메뉴명", "ingredients": [ {"name": "시금치", "confidence": 0.0-1.0}, {"name": "두부", "confidence": 0.92} ], "cooking_method_hint": "죽|볶음|국|구이|찜|...", "texture_hint": "미음|페이스트|매시|다진|큐브|스틱|통째", "overall_confidence": 0.0-1.0 }\`; // 결과는 자동완성 풀(INGREDIENT_POOL 147종)과 매칭 function mapToPool(ocrIngredients: OCRIngredient[]): IngredientId[] { return ocrIngredients.flatMap(o => { const match = INGREDIENT_POOL.find(p => p.name === o.name || p.aliases?.includes(o.name)); return match ? [match.id] : []; }); }
3-1. 비용 최적화 (image_hash 캐시)
// 같은 식단표 사진 재분석 비용 0 (영구 캐시) async function ocrWithCache(buffer: Buffer): Promise<OCRResult> { const { buffer: processed, hash } = await preprocessImage(buffer); const cached = await db.query('SELECT extracted FROM ocr_cache WHERE image_hash = $1', [hash]); if (cached.rows[0]) return cached.rows[0].extracted; const result = await callVision(processed); await db.query('INSERT INTO ocr_cache (image_hash, extracted) VALUES ($1, $2)', [hash, result]); return result; }
4. AI 코치 시스템 (정성 데이터 시계열 체이닝 + 동적 질문 + 편지)
편지 답장 + 동적 질문 + SOS 6단계 + 주간 인사이트 + 정성 데이터 시계열 LLM 분석 + 양방향 체이닝 — UI 증가 0, 정보 수집은 시계열로 깊어지는 코칭 레이어.
🧠 핵심 철학 — 정성 데이터는 정량화하지 않고 시계열로 보존
"오늘 시금치 한 입 먹었는데 평소보다 잘 먹었어요" 같은 부모의 자연어 메모를 강제로 0-5 reaction 점수로만 정규화하면 맥락·뉘앙스·고민·관찰이 모두 사라짐. 정량 데이터는 평가 엔진용, 정성 raw_note는 별도 보존해서 시계열 LLM 분석 + 다음 입력 시 역질문 체이닝에 활용. 이게 개인화 데이터의 핵심.
정성 데이터 메모리 + 양방향 체이닝 (NEW v3)
핵심 데이터 흐름 — "체이닝"
'시금치 죽 한 입 먹고 거부
근데 평소보다 덜 화냄'"] M2["2주차 입력
'시금치 으깬 거 살짝 핥음
처음으로 토 안 함'"] M3["3주차 입력
'시금치 들어간 계란찜 다 먹음!
몰랐던 듯'"] THREAD[("📜 qualitative_threads
식재료별 시계열 raw_note")] ANALYSIS["🧠 주간 LLM 분석 (Sonnet)
'시금치 거부도 점진적 ↓ · Food Chaining 효과
다음 단계: 시금치 누들·시금치 빵 시도'"] QUESTION["💬 다음 입력 시 (Haiku)
'지난주 계란찜 잘 먹었던데,
오늘 시금치 좀 다르게 시도해보셨나요?'"] PARENT[("👩 부모
또 다른 raw_note 입력")] M1 & M2 & M3 --> THREAD THREAD --> ANALYSIS ANALYSIS --> QUESTION QUESTION --> PARENT PARENT --> M3 classDef in fill:#FFF8E1,stroke:#F9A825 classDef ai fill:#F3E5F5,stroke:#9C27B0 classDef out fill:#E8F5E9,stroke:#16A085 class M1,M2,M3,PARENT in class ANALYSIS,QUESTION ai class THREAD out
왜 정량화만으로는 안 되는가
| 정량만 저장 | 정량 + 정성 시계열 (우리 방식) |
|---|---|
| reaction: 2/5 | reaction: 2/5 + raw: "한 입 먹고 거부, 근데 평소보다 덜 화냄" |
| 다음 추천: 다른 식재료 | 다음 추천: 시금치 식감만 살짝 변경 (LLM이 '덜 화냄'을 진전 신호로 인식) |
| 다음 질문: 동일 회전 질문 | 다음 질문: "지난번 덜 화냈는데, 오늘은 어땠어요?" (체이닝) |
| 3주 후: 데이터 빈약 | 3주 후: 시금치 거부 진전 곡선 + 부모 관찰 패턴 완전 매핑 |
저장 schema (정량 + 정성 분리)
-- 정량 (평가 엔진용) CREATE TABLE meal_logs ( id, child_id, eaten_at, meal_chip, reaction_score, autonomy, texture_level, ... ); -- 정성 (코치 엔진용, 시계열 LLM 입력) CREATE TABLE qualitative_notes ( id UUID PRIMARY KEY, child_id UUID REFERENCES children(id), meal_log_id UUID REFERENCES meal_logs(id), -- 연결 raw_text TEXT NOT NULL, -- 부모 자유 입력 원본 — 절대 가공·정규화 X contextual_question TEXT, -- 우리가 던진 동적 질문 (있으면) parent_emotion TEXT, -- LLM 추정 (joy·worry·frustration) 별도 컬럼 llm_insights JSONB, -- 주 1회 Sonnet 분석 결과 캐시 thread_id UUID, -- 같은 식재료/주제 grouping created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_notes_child_thread ON qualitative_notes(child_id, thread_id, created_at); -- 시계열 thread (식재료·고민·주제별 grouping) CREATE TABLE qualitative_threads ( id UUID PRIMARY KEY, child_id UUID, topic TEXT, -- '시금치 친해지기'·'식감 단계 진전'·'밥 양 감소' 등 topic_type TEXT, -- 'ingredient'·'behavior'·'concern' status TEXT, -- 'active'·'resolved'·'archived' llm_summary TEXT, -- 주 1회 갱신되는 thread 요약 next_question TEXT, -- 다음 입력 시 던질 질문 (LLM 생성) next_question_chips TEXT[], -- 빠른 선택 chip started_at TIMESTAMPTZ, last_updated TIMESTAMPTZ );
주간 LLM 분석 프롬프트 (Sonnet, 매주 월요일 03:00 KST)
// system 너는 영유아 식습관 코치다. 한 자녀의 지난 주 정성 기록(부모 자유 텍스트)을 시계열로 읽고 다음을 추출한다: 1. 활성 thread 식별 (식재료별·고민별·진전별) 2. thread 별 진전 곡선 ("거부 → 핥기 → 한 입" 등) 3. Toomey SOS 단계 평가 (보기·만지기·냄새·핥기·씹기·삼키기 중 어디?) 4. Food Chaining 기회 ("X 잘먹음 + Y 거부 → X와 비슷한 Z로 우회") 5. 부모 감정 변화 (좌절 → 희망 / 자신감 → 걱정) 6. **다음 1주일 동안 던질 동적 질문 1-2개** (raw_text 맥락 그대로 인용) 규칙: - 부모 raw_text를 그대로 인용 ("지난주 '시금치 들어간 계란찜 다 먹음' 하셨던데,...") - 강요·평가 X. "성취 인정 + 다음 시도 제안" - 학술 용어 (Toomey SOS, Cooke 반복) 부모에겐 자연어 번역 // user (시계열 raw_text 5-10 entry) { child_age_months: 32, threads: [ { topic: '시금치 친해지기', notes: [ { date: '2026-05-04', raw: '시금치 죽 한 입 먹고 거부, 평소보다 덜 화냄' }, { date: '2026-05-11', raw: '시금치 으깬 거 살짝 핥음, 토 안 함' }, { date: '2026-05-18', raw: '시금치 들어간 계란찜 다 먹음! 모르고 먹은듯' } ]} ] } // 출력 schema { thread_id: ..., summary: "시금치 거부도 점진적 ↓. Toomey SOS 5단계(씹기) 도달. Food Chaining 효과 입증.", evidence_quotes: ["'평소보다 덜 화냄'", "'토 안 함'", "'다 먹음!'"], next_question: "지난번 계란찜 잘 먹었던데, 오늘은 시금치를 어떻게 시도해보셨어요?", next_question_chips: ["계란찜에 또", "다진 시금치 누들", "시금치 빵·과자", "아직 시도 X"], parent_emotion_trend: "frustration → cautious_hope", weekly_letter: { // 홈 카드용 title: "지우 어머니, 3주 만에 시금치 한 입!", body: "5/4엔 '덜 화냄', 5/11엔 '토 안 함', 5/18엔 '다 먹음!'..." } }
매 입력 시 Haiku 호출 (다음 질문 동적 생성)
async function generateNextQuestion(childId: string) { // 1. 활성 thread 중 가장 최근 업데이트된 1-2개 가져옴 const activeThreads = await db.threads.find({ child_id: childId, status: 'active' }).orderBy('last_updated', 'desc').limit(2); // 2. thread.next_question 이미 있으면 그대로 사용 (Sonnet 주간 분석이 미리 생성) if (activeThreads[0]?.next_question) return activeThreads[0]; // 3. 없으면 Haiku로 즉석 생성 (저비용·빠름) const recentNotes = await db.notes.find({ child_id: childId }).orderBy('created_at', 'desc').limit(5); return await haiku.generate(NEXT_Q_PROMPT, recentNotes); }
구체 흐름 — 부모 입장
- 1주차 월요일: 평소처럼 끼니 입력. 자유 메모 칸에 "시금치 죽 한 입 먹고 거부, 평소보다 덜 화냄" 자유롭게 적음.
- 1주차 토요일: 평소처럼 입력. 저장 직전 질문 카드: "지난번 '평소보다 덜 화냄'이라고 적으셨던데, 이번 주 시금치 어땠어요?" + chip [더 잘 먹음 · 비슷 · 다시 거부].
- 2주차 월요일: 시스템이 자동 편지: "지우 어머니, 지난 주 시금치 진전 곡선이에요. 3주 만에 한 입 먹은 거예요! 다음 단계는..."
- 4주차: thread.status = 'resolved' (다 먹음 확인), 다른 thread 활성화.
비용·캐시 정책
| 호출 | 모델 | 빈도 | 건당 |
|---|---|---|---|
| 매 입력 시 다음 질문 생성 | Haiku 4.5 | 1회/입력 | ~₩4 |
| 주간 thread 심층 분석 | Sonnet 4.7 | 1회/주/자녀 | ~₩40 |
| 편지 답장 (특별 이벤트) | Sonnet 4.7 | 1회/2주/자녀 | ~₩50 |
| MAU 1만 시 월 예상 | — | — | ~₩800k |
정성 데이터 보안·PII·삭제 권리
- PII 자동 마스킹: raw_text에 전화번호·이름·주소 자동 탐지·마스킹 (저장 전)
- 자녀 별명만 LLM 전달: 실명 → "지우" 같은 별명만 LLM 컨텍스트에 포함
- 부모 삭제 권리: thread 단위 또는 전체 raw_note 즉시 삭제 + LLM 캐시 무효화
- 외부 학습 X: Anthropic API 호출 시 always_log=false, 운영 외 보관 X
- 5년 보관 후 자동 익명화: child_id 제거, 학술 통계 용도만 보존
4-0. 시스템 컴포넌트
매일 다른 동적 질문
(UI 동일, placeholder만 회전)"] DB[("🗄 시계열 DB
+ 차원별 데이터 누적")] WK["📊 주간 분석
월요일 자동 트리거"] LT["✉️ 편지 답장
홈 카드 + 모달"] SOS["🎯 SOS 6단계
거부 식재료 대상"] IN["💡 주간 인사이트
홈 진단 카드 통합"] REC --> DB DB --> WK WK --> LT & IN LT --> SOS IN --> SOS classDef io fill:#FFF8E1,stroke:#F9A825,color:#1F2D3D classDef data fill:#F3E5F5,stroke:#9C27B0,color:#1F2D3D classDef out fill:#E8F5E9,stroke:#16A085,color:#1F2D3D class REC,LT,SOS,IN out class DB data class WK io
동적 질문 시스템 (시계열 데이터 수집)
핵심 가치: 부모는 매일 똑같은 루틴을 하는데, 시스템은 매일 다른 차원의 데이터를 수집.
7 질문 시리즈 (요일 회전)
const DAILY_QUESTIONS: Question[] = [ { id: 'rejection', label: '거부 반응', dimension: 'cebq', placeholder: '어떤 식재료가 가장 싫다고?', chips: ['한 입도 X', '골라냄', '향만', '친구따라', '평소보다 많이'] }, { id: 'environment', label: '식사 환경', dimension: 'satter_env', placeholder: '분위기는 어땠어요?', chips: ['TV', '가족 대화', '조용', '짜증', '신나서'] }, { id: 'novelty', label: '새 식재료', dimension: 'novelty_response', placeholder: '처음 본 식재료 반응?', chips: ['호기심', '무덤덤', '거부', '흥미', '-'] }, // ... 7개 총 ]; function getTodayQuestion(today: Date): Question { const dayOfYear = Math.floor((today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) / 86400000); return DAILY_QUESTIONS[dayOfYear % DAILY_QUESTIONS.length]; }
데이터 저장 — meal_logs.dimension_data JSONB
// 매 끼니 기록에 차원별 데이터 누적 ALTER TABLE meal_logs ADD COLUMN dimension_data JSONB DEFAULT '{}'; // 예시 저장 (다양한 시점) { "rejection": "한 입도 X", // 월요일 "environment": "TV", // 화요일 "autonomy_quiz": "손으로", // 일요일 ... } // 30일 동안 7 차원 모두 수집 → 종합 프로파일 // 각 차원은 4-5번씩 데이터 누적 (주 1회 회전 × 30일)
시계열 분석 (주간 인사이트용)
async function analyzeWeeklyDimensions(childId: string): Promise<DimensionInsight[]> { const logs = await fetchLogs(childId, { days: 14 }); // 2주 비교 const insights: DimensionInsight[] = []; // 식사 환경 차원 (Satter) const envData = logs.map(l => l.dimension_data?.environment).filter(Boolean); const tvRatio = envData.filter(e => e === 'TV').length / envData.length; if (tvRatio > 0.5) { insights.push({ dimension: 'environment', finding: `최근 2주 TV 시청 식사 ${Math.round(tvRatio*100)}%`, severity: 'warn', recommendation: '식사 중 TV OFF로 식사 시간이 줄어들 수 있어요 (Satter 권장)', cta: { type: 'modal', target: 'sosKit' } }); } // 거부 차원 (CEBQ) const rejectionData = logs.map(l => l.dimension_data?.rejection).filter(Boolean); const hardRejection = rejectionData.filter(r => r === '한 입도 X').length; if (hardRejection >= 3) { insights.push({ dimension: 'rejection', finding: `최근 2주 한 입도 안 먹은 케이스 ${hardRejection}회`, severity: 'danger', recommendation: 'SOS 6단계 친해지기 시작 시점입니다', cta: { type: 'modal', target: 'sosKit' } }); } return insights; }
편지 답장 (LLM 기반 개인화)
트리거
- 어제 메모 있는 경우 (note != null)
- 최소 6시간 간격 (스팸 방지)
- 일 1회 최대
프롬프트 구조
const LETTER_PROMPT = `당신은 부모와 함께 아이의 식습관을 지켜보는 친구입니다. 부모가 어제 일지에 남긴 메모를 보고, 데이터 근거와 함께 따뜻한 답장을 작성하세요. # 답장 구조 (4-5 문단) 1. 인사 + 부모 메모 자연어 인용 (큰따옴표) + 공감 2. 우리가 가진 데이터 근거 제시 (수치) 3. 학술 근거 (자연어 — "연구에 따르면" / "권장은") 4. 구체적 다음 행동 제안 (1-2개) 5. (선택) 다른 식재료·이슈로 자연 연결 # 톤 - "지우 엄마," 처럼 친근 호명 - "영양사" "의사" "전문가" 표현 X - 강요 X, 권유 O - 부모 죄책감 유발 X - 데이터는 부드럽게 ("22번 노출했는데 8번만 — 정상 단계예요") # 입력 데이터 ${profileSummary} # 어제 메모 "${yesterdayNote}" # 어제 끼니 데이터 ${yesterdayMeals} 답장 본문만 작성하세요. JSON X.\`;
호출 + 캐싱
async function generateLetter(childId: string): Promise<Letter> { const profile = await buildProfile(childId); const yesterday = await fetchYesterdayLogs(childId); const note = yesterday.map(l => l.note).filter(Boolean).join(' '); if (!note) return null; // 메모 없으면 편지 X const cacheKey = `letter:${childId}:${today()}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-7', max_tokens: 1000, messages: [{ role: 'user', content: buildLetterPrompt(profile, note, yesterday) }], }); const letter = { body: response.content[0].text, generatedAt: new Date() }; await redis.set(cacheKey, JSON.stringify(letter), 'EX', 86400); // 24h return letter; }
안전 검증
- 의료 진단 표현 차단 (정규식 + 사전)
- 다른 아이·다른 가족 언급 차단
- 광고 표현 자동 제거
- 4-5 문단 길이 강제 (trim 또는 재호출)
SOS 6단계 친해지기 (Toomey)
학술 출처: Toomey & Ross, "SOS Approach to Feeding" (2017)
단계별 진행 추적
CREATE TABLE sos_progress ( child_id UUID REFERENCES children(id), ingredient_id UUID REFERENCES ingredients(id), step INT CHECK (step BETWEEN 1 AND 6), completed_at TIMESTAMPTZ DEFAULT NOW(), note TEXT, PRIMARY KEY (child_id, ingredient_id, step) ); // 6단계: 보기 → 만지기 → 냄새 → 핥기 → 씹기 → 삼키기 const SOS_STEPS = [ { id: 1, name: '보기', dur_days_avg: 3, science: '시각 인지 학습 (Cooke 2011)' }, { id: 2, name: '만지기', dur_days_avg: 3, science: '촉각 학습' }, { id: 3, name: '냄새', dur_days_avg: 2, science: '후각 친숙화' }, { id: 4, name: '핥기', dur_days_avg: 3, science: '맛 노출 시작' }, { id: 5, name: '씹기', dur_days_avg: 5, science: '구강 운동' }, { id: 6, name: '삼키기', dur_days_avg: 7, science: '12-30회 반복 권장 (Birch)' }, ]; // 트리거: ingredient_stat.exposure_count ≥ 5 AND accept_rate < 0.3 시 SOS 자동 추천 async function shouldRecommendSOS(childId: string, ingId: string): Promise<boolean> { const stat = await fetchIngStat(childId, ingId); return stat.exposure_count >= 5 && stat.accept_rate < 0.3; }
거부 대처 5가지 (SOS 모달 내 통합)
- 5초 룰 — 거부 후 5초만 기다리기 (다시 권하지 X)
- 표정·말 반응 X — Satter DOR (압박은 거부 강화)
- 모델링 — 부모가 먼저 맛있게 먹는 모습 (Birch 1980)
- 다음 끼니 재시도 — 포기 X · 끼니 단위로 reset
- 12-30회 반복 노출 — Cooke 2011 / Birch & Marlin 1982
주간 인사이트 (월요일 자동)
트리거 + 출력
// Supabase Edge Function (cron: 매주 월요일 06:00 KST) export async function weeklyInsightJob() { const children = await activeChildrenLastWeek(); for (const child of children) { const dimensionInsights = await analyzeWeeklyDimensions(child.id); const trendInsights = await analyzeTrends(child.id); // 점수·식감·자율성 추세 const llmSummary = await generateInsightSummary(child, dimensionInsights, trendInsights); await db.query(` INSERT INTO weekly_insights (child_id, week_start, insights_json, summary, created_at) VALUES ($1, $2, $3, $4, NOW()) `, [child.id, mondayDate(), { dimensions: dimensionInsights, trends: trendInsights }, llmSummary]); // 푸시 알림: "이번 주 인사이트가 도착했어요" await sendPush(child.userId, { title: '이번 주 우리아이 식습관 인사이트', body: llmSummary.split('\n')[0], // 첫 줄만 }); } }
홈에 표시되는 카드
┌─────────────────────────────────┐ │ 📊 이번 주 인사이트 │ │ │ │ 식사 시간 평균 32분 (지난주 28분) │ │ 시금치 3주 연속 거부 — SOS 시작 시점 │ │ TV 시청 식사 60% — 줄여보세요 │ │ │ │ [🎯 시금치 SOS 시작 →] │ │ [✉️ 편지로 자세히 보기 →] │ └─────────────────────────────────┘
인사이트 생성 LLM 프롬프트
const INSIGHT_PROMPT = `당신은 부모에게 이번 주 아이 식습관을 3-4문장으로 요약해주는 코치입니다. # 입력 데이터 - 식습관 프로파일 (지난 주 vs 이번 주 비교) - 7 차원 데이터 (거부·환경·자율성 등) - 8축 진단 점수 변화 - 신호등 색상 변화 # 출력 구조 1. 가장 큰 변화 1개 강조 (점수·식감·환경 중) 2. 데이터 근거 (수치) 3. 다음 주 액션 1-2개 제안 (구체적) 4. (선택) 칭찬 1개 (잘하고 있는 영역) # 톤 - 친구·코치 톤 (영양사 X) - 부드러운 권유 - 강요 X · 죄책감 X\`;
4-1. 알림 빈도 제어
| 알림 | 빈도 | 조건 |
|---|---|---|
| 편지 답장 | 일 1회 최대 | 어제 메모 있음 + 마지막 편지 ≥ 24h |
| 주간 인사이트 | 주 1회 (월요일 06:00) | 지난주 기록 ≥ 5끼 |
| SOS 추천 | 식재료당 1회 | exposure ≥ 5 AND accept_rate < 0.3 |
| 식단 미기록 푸시 | 일 1회 (저녁 8시) | 당일 기록 0건 |