📦 이 문서는 v1 이전 자료(deprecated)입니다 — 최신 정보는 문서 허브를 보세요
🔬 ENGINE DEEP DIVE · v2 (2026-05-25)

밀프레드 편식도감
핵심 엔진 5종 설계 명세

평가 · 추천 · OCR · AI 코치 · 입력 전처리(LLM Normalizer) — 알고리즘·자료구조·SQL·LLM 프롬프트·테스트 케이스까지 전부

⚙️ 30 알고리즘 상세 📐 TypeScript 인터페이스 🧪 테스트 케이스 💬 LLM 프롬프트 전문 🛡 prompt injection 방어

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' 플래그
}
ALG-PREP-01

자유 텍스트 → MealLog 구조화 (LLM 핵심)

입력
rawText + context
출력
MealLog · zod-validated
LLM
Haiku 4.5 → Sonnet 4.7 fallback
p95 지연
800ms (Haiku) · 2.5s (Sonnet)
캐시
정확 일치 7일
프롬프트 (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;
}
ALG-PREP-02

식재료 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 };
}
ALG-PREP-03

재질문 흐름 (Clarification Loop)

LLM confidence < 0.7 또는 필수 필드 누락 시 사용자에게 1-2개 짧은 질문으로 보강. 3회 이상 누락 시 강제 저장 (low_confidence 플래그).

케이스재질문 예시
식재료 모호"오늘 '국' 드셨다고 적어주셨는데, 어떤 국이었나요? (예: 미역국·된장국·소고기뭇국)"
reaction 없음"잘 먹었는지 살짝 알려주세요 (👍 잘 먹음 / 😐 보통 / 👎 거부)"
meal_chip 모호"이번 식사는 언제 드셨나요? (아침·점심·간식·저녁)"
autonomy 추정"아이가 스스로 먹었나요, 부모님이 떠먹여주셨나요?"
ALG-PREP-04

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.
ALG-PREP-05

비용·캐시 정책

항목정책
모델 우선순위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. 평가·추천 엔진과의 인터페이스

flowchart LR USER[사용자 자유 입력
'시금치 죽 줬는데 거부'] 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. 모니터링 지표 (필수)

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 신규 (예: 콜린)
}
ALG-EVAL-01

8축 식단 진단

입력
EvalInput
출력
AxisResult[7]
복잡도
O(N·M) N=끼니, M=재료
의존
ingredients.category, AGE_TEXTURE_TABLE
실행 빈도
실시간 + 5분 캐시
축 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
━ TEST CASE ━
Input
9 끼니 (3일), 8 식품군 중 5개 등장, 모든 끼니 텍스처='죽', 연령=28m
Expected Output
다양성: 62.5% orange "5/8 식품군 — 콩·유제품·과일 비어요" / 식감: 45% red "28개월에는 핑거푸드 단계 권장"
ALG-EVAL-02

36종 KDRI 영양 신호등 (빈도 → 충족률)

핵심 가설

그램 단위 정확 섭취량 측정 불가 (부모가 g 안 잼) → "끼니별 등장 빈도"로 KDRI 충족률 추정.

임계 contribution: 한 끼에서 30% 이상 RNI 기여하면 "이 끼니에 등장"으로 카운트.

핵심 공식
contributioni,n = (nutri_per_100gi,n × estimated_gi / 100) / KDRI_RNI[band][n]

mealCountn = count(meals where Σ contributioni,n ≥ 0.3)

freqPctn = (mealCountn / (days × 2)) × 100

statusn = freqPct ≥ 80 ? green : freqPct ≥ 50 ? orange : red
가중치 — 영양소별 임계 조정

일부 영양소는 한 끼에 30% 기여 어려움 → 임계 조정:

영양소임계 (한 끼 기여)이유
비타민 D15%식이 공급 자체 어려움 (햇볕·강화식품)
EPA+DHA20%등푸른 생선 외 공급원 적음
철 (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거의 못 만남
ALG-EVAL-03

BMI percentile (KDC 2017 LMS)

학술 출처: 한국질병관리청 2017 소아청소년 표준성장도표

LMS (Box-Cox 모수) 방법으로 정확한 percentile 계산.

LMS 공식
Z = ((BMI / M)L - 1) / (L × S) // L ≠ 0
Z = ln(BMI / M) / S // L = 0
percentile = Φ(Z) × 100 // 정규분포 누적
샘플 LMS 테이블 (만 28개월, 여)
지표LMS
BMI-0.85315.990.0710
키 (cm)1.00091.420.0392
몸무게 (kg)-0.31013.210.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 };
}
━ TEST CASE — 지우 (만 28m, 여, 88.5cm/12.4kg) ━
BMI
12.4 / (0.885)² = 15.83
LMS
L=-0.853, M=15.99, S=0.0710
Z-score
((15.83/15.99)^-0.853 - 1) / (-0.853 × 0.0710) = -0.133
Percentile
Φ(-0.133) × 100 ≈ 45%
Status
normal (5-85p)
ALG-EVAL-04

탄·단·지 + 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 자동 생성. 자세히 보기 모달에 자연어로 표시.

ALG-EVAL-06 ★ NEW

시도 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 / attemptCount10번 시도 / 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로 가정하면 다양성·영양 과대 평가 위험
ALG-EVAL-07 ★ NEW

식단표 역분석 → 도감 자동 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종 자동 도감 진입
  • "한국 어머니가 실제로 먹이는 식재료" 도감 — 농진청 표준 식단보다 정확한 현장 데이터
ALG-EVAL-05

OCR 식단표 평가 (가정 기록과의 변형)

한 달 식단표는 가정 기록보다 2 축 추가 + 일부 축은 가중치 다름.

가정 기록 (UC-14~22b)식단표 OCR (UC-20)
다양성3일 8 식품군한 달 8 식품군 (더 엄격)
식감로그 텍스처 분포메뉴명 텍스처 추정 (LLM)
KDRI 36종실시간 빈도한 달 평균 빈도
메뉴 반복4주 내한 달 내 동일
알레르겐 표시X (가정 무관)NEW — 명시 비율
가공식품 비율XNEW — 25% 이하 권장
계절성·국산XNEW — 제철 매핑

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

1-6. 성능 최적화

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 — 프로파일 자동 집계

ALG-RECO-00

식습관 프로파일 집계

시간 윈도우 가중치: 최근 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

ALG-RECO-01

원칙 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;
ALG-RECO-02

원칙 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;
ALG-RECO-03

원칙 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;
ALG-RECO-04

원칙 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 — 후보 랭킹 (가중합)

ALG-RECO-05

가중합 점수 함수

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)

ALG-RECO-06

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

ALG-RECO-07

캐싱 전략

레이어TTLInvalidate 조건
Redis 추천 캐시reco:{child_id}:{profile_hash}24hprofile_hash 변경 / 사용자 강제 새로고침
Anthropic Prompt Cachesystem prompt (자동)1hsystem 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

3. OCR 엔진 — CLOVA(식단표 전사) + Claude(메뉴→식재료 분해)

3-0. 엔진 책임

  • 이미지 전처리 (회전·압축·해시)
  • CLOVA OCR로 식단표 전사(표 셀 인식, 미지원 도메인은 일반 OCR 폴백) → Claude가 메뉴·식재료 분해
  • 메뉴 → recipe_id 매핑 (fuzzy match + LLM fallback)
  • 알레르겐 매핑 (식약처 17대)
  • 신뢰도 스코어링 + 사용자 수정 인터페이스
  • 식사 사진 OCR (음식 → 식재료 추정)
ALG-OCR-01

이미지 전처리

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 };
}
ALG-OCR-02

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);
}
ALG-OCR-03

메뉴 정규화 — 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' };
}
ALG-OCR-04

알레르겐 매핑 + 아이 위험 알림

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;
}
ALG-OCR-05

신뢰도 스코어링 + 사용자 수정 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 {
  // 전체 수동 입력 모드 전환
}
ALG-OCR-06

식사 사진 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 분석 + 다음 입력 시 역질문 체이닝에 활용. 이게 개인화 데이터의 핵심.

ALG-COACH-00 ★

정성 데이터 메모리 + 양방향 체이닝 (NEW v3)

입력
raw_note 시계열 + 평가/추천 결과
출력
통찰 + 다음 질문 1-2개
LLM
Sonnet 4.7 (주 1회·심화) + Haiku (매 입력 시·다음 질문)
갱신
매 입력 시 (Haiku) + 주 1회 (Sonnet)
핵심 데이터 흐름 — "체이닝"
flowchart TB M1["1주차 입력
'시금치 죽 한 입 먹고 거부
근데 평소보다 덜 화냄'"] 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/5reaction: 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.51회/입력~₩4
주간 thread 심층 분석Sonnet 4.71회/주/자녀~₩40
편지 답장 (특별 이벤트)Sonnet 4.71회/2주/자녀~₩50
MAU 1만 시 월 예상~₩800k
ALG-COACH-00b

정성 데이터 보안·PII·삭제 권리

  • PII 자동 마스킹: raw_text에 전화번호·이름·주소 자동 탐지·마스킹 (저장 전)
  • 자녀 별명만 LLM 전달: 실명 → "지우" 같은 별명만 LLM 컨텍스트에 포함
  • 부모 삭제 권리: thread 단위 또는 전체 raw_note 즉시 삭제 + LLM 캐시 무효화
  • 외부 학습 X: Anthropic API 호출 시 always_log=false, 운영 외 보관 X
  • 5년 보관 후 자동 익명화: child_id 제거, 학술 통계 용도만 보존

4-0. 시스템 컴포넌트

flowchart LR REC["📝 기록 화면
매일 다른 동적 질문
(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
ALG-COACH-01

동적 질문 시스템 (시계열 데이터 수집)

핵심 가치: 부모는 매일 똑같은 루틴을 하는데, 시스템은 매일 다른 차원의 데이터를 수집.

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;
}
ALG-COACH-02

편지 답장 (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 또는 재호출)
ALG-COACH-03

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 모달 내 통합)
  1. 5초 룰 — 거부 후 5초만 기다리기 (다시 권하지 X)
  2. 표정·말 반응 X — Satter DOR (압박은 거부 강화)
  3. 모델링 — 부모가 먼저 맛있게 먹는 모습 (Birch 1980)
  4. 다음 끼니 재시도 — 포기 X · 끼니 단위로 reset
  5. 12-30회 반복 노출 — Cooke 2011 / Birch & Marlin 1982
ALG-COACH-04

주간 인사이트 (월요일 자동)

트리거 + 출력
// 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건