비개발자용 — 이 모듈이 무엇을 왜 하나
이 모듈은 코칭 편지가 부모에게 "오늘은 이걸 먹여보세요"라고 권할 때, AI가 머릿속으로 음식을 상상하지 못하게 막고, 우리가 미리 만들어 둔 '음식 관계도 표'에서만 답을 꺼내오게 하는 안전장치입니다. AI한테 자유롭게 조리법을 시키면 '미역국에 당근 갈아넣기'처럼 아무도 안 먹는 괴식을 그럴듯하게 지어내기 때문입니다. 그래서 우리는 추상 조리법 대신 또래가 실제로 가장 자주 먹는 '음식 이름'만 권합니다 — 조합은 그 음식 안에 이미 검증돼 있으니까요(예: '순두부찌개'를 권하면 두부 조합은 그 안에 있음).
작동은 음식 인맥 지도라고 생각하면 쉽습니다. 식재료마다 두 종류의 친구가 등록돼 있습니다. 하나는 사촌(맛·식감이 닮아 갈아탈 수 있는 친구 — 고구마·단호박·감자)이고, 다른 하나는 궁합(실제 급식 레시피에서 같이 자주 쓰인 짝꿍 — 가다랑어·김치)입니다. 아이가 잘 먹는 음식에서 출발해 → 사촌을 따라가면 → 부족한 영양 식재료까지 자연스럽게 다리(푸드체이닝)를 놓습니다.
핵심 전략은 5단계 우선순위입니다. 아이가 (1) 이미 잘 먹는 식재료를 한 번 더 → (2) 잘 먹는 것에 궁합 좋은 걸 곁들이기 → (3) 잘 먹는 것의 사촌이 부족한 군에 있으면 그걸로 다리 놓기 → (4) 또래 인기 음식으로 도전 → (5) 그냥 그 식재료. 위로 갈수록 성공 확률이 높고, 다섯 단계 모두 실데이터 표에 근거가 있어 괴식이 0건입니다.
가장 큰 보강입니다. 아린처럼 부모가 '완식했어요'를 거의 안 찍어서 아이가 뭘 좋아하는지 확신이 없을 때(완식 신호 2개 미만), 예전엔 추천이 '두부 디폴트'로 무너졌습니다(콩류군 첫 대표가 두부라서). 이제는 ① 신호계량화(①신호선택기 모듈)가 '한 번 완식한 걸 좋아한다고 착각'하지 않게 거르고, ② 콜드스타트 시드 사다리가 출발점을 '집에서 자주 차려진 음식'(Tier2) → 그것도 빈약하면 '영유아 급식에 흔히 오르는 재료'(Tier3 youa 큐레이션)로 보강합니다. 또 추천 음식에 '또래 급식에도 자주 오르는 익숙한 재료'라는 안심 한 마디를 붙이되, 서열·등수·%는 절대 노출하지 않고(이사님 원칙), 흔하지 않은 재료엔 그 절을 아예 빼서 '0회를 흔함으로 위장'하지 않습니다.
구현된 내용 — 코드에 실제로 있는 것
이 모듈의 라이브 산출물은 단 두 함수입니다 — buildRecoFacts(추천 사실 블록 text) + buildIngredientPool(주간 5개 풀). 나머지(popularDishesFor·coldStartSeed·youaRankOf·safeGarnishOf·cookedName·stapleDisplay)는 이 둘과 주간계획(⑧)이 호출하는 결정론 헬퍼입니다. pickFoodReco·weeklyExposureTarget은 존재하지만 라이브 본문 경로에서는 호출되지 않습니다(거울/하위호환 잔존).
핵심 함수 (coachRecos.ts 실측 file:line)
| 함수 · 타입 | 역할 · 입출력 |
|---|---|
buildRecoFactscoachRecos.ts:276 | 추천 사실 블록 단일 소스. 입력 {likedIngredients, target?, targetIngredient?, freqMap?, suppressCousins?} → 출력 RecoFacts{target, cousins[], lines[], text}. (a) 타깃 라인 = targetIngredient(주간 풀 일일회전 식재료) 우선, 없으면 target 그룹 첫 대표 → 그 식재료의 인기 음식 + safeGarnishOf로 거른 '잘 먹는 것 곁들임' + youa 안심절. (b) 잘 먹는 것 → 검증 사촌·궁합. ⭐F-18 신규: suppressCousins=true면 (b) 전체 생략(라인 303 args.suppressCousins ? [] : liked) — 슬롯이 음식 타깃을 정한 날 사촌(감자→두부)이 슬롯과 경쟁해 본문이 두부로 회귀하던 근원 차단 |
buildIngredientPoolcoachRecos.ts:237 | 주간 추천 식재료 5개 풀. 입력 {signals, likedIngredients, freqMap?, max?} → 출력 IngredientPool{pool[], mode, reason}. 끼니 6군(MEAL_GROUPS·과일/유제품 제외) 중 결핍 상위 3군만(흩뿌림 방지). mode 결정: red≥2 → supply(결핍 대표 라운드로빈) / red=0 && nonGreen≤1 → challenge(cousinsOfLiked 푸드체이닝 + 도전) / 그 외 → mixed. reason 예: "supply(red 2·결핍군 3)". ⭐2026-06-21 풀-기아 봉합: 콜드스타트(사촌 0)+단일 결핍군이면 mixed가 군당 1개만 담아 풀이 두부 1개로 굶던 것을 결핍군 대표 라운드로빈으로 백필 → 아린형 풀 1→4종(주간계획·일일 회전 다양성). |
popularDishesForcoachRecos.ts:80 | 식재료 → '가장 많이 쓰이는 실존 음식' 최대 2개. STAPLE_FORMS(밀·쌀)는 빵·면·떡·밥으로, FRUIT는 빈 배열(간식채널). freqMap(ingredient-recipes) freq≥4 우선 → dishesForIngredient(kit-matrix count>0) 폴백. ⭐2026-06-21 DISH_ALIAS(계란→달걀): 표준명 '계란'이 코퍼스 '달걀' 표기라 dishes 통째 누수하던 것 봉합(표시는 계란팟국·계란장조림으로 정규화). cleanDishName(NEIS 접두태그·'&곁들임'·용량괄호 제거)·inappropriateDish(매운·짠지·견과·튀김 차단)로 정제. ⚠️ 날짜seed·노출이력 입력 없음 → 같은 식재료=항상 같은 top-2 |
coldStartSeed :218 · YOUA_TOP :177 | ⭐신규 콜드스타트 시드 사다리. 입력 servedIngredients[] → Tier2(자주 차려진 집 식재료) → Tier3(YOUA_TOP=youa-freq.json 등장률 내림차순·식품군 인식만). 과일(간식채널)·중복 제외, max 6. 확신 liked가 빈약할 때 추천 앵커가 두부 디폴트로 안 무너지게 보강 |
youaRankOf :207 · youaReassuranceFor :213 | ⭐신규 급식 출현 랭킹. YOUA_RANK(youa-freq 등장률→동률안전 순위+상위N%)를 ING_YOUA_ALIAS(요거트→요구르트·콩→콩(대두)·현미→멥쌀 등)로 별칭 해소 후 조회. 미수록=null(0을 꼴등으로 위장 금지). youaReassuranceFor는 등장률 ≥YOUA_COMMON_PCT(50)일 때만 '또래 급식에도 자주 오르는 익숙한 재료' 반환 — 서열·등수·% 노출 0(이사님 원칙). 연어는 의도적 미매핑(고가·수입이라 급식 희소 → 흔함 위장 금지) |
safeGarnishOf :165 | 곁들임 전용 안전 궁합. garnishPairsOf(tray 제외)에서 ①매운/김치류(KIMCHI_EXTRA) ②날곡물(STAPLE_FORMS) ③교차괴식(garnishConflict: 단과일/우유류 ↔ 생선·해산물/콩·두부)을 한 번 더 거름. buildRecoFacts (a)/(b) 곁들임이 이걸 씀 |
cookedName :60 · stapleDisplay :38 | 안전 조리표기. COOK_ADJ(찐 단호박·삶은 달걀·구운 고등어·익힌 당근)로 생물 오인 방지, 주식 곡물은 먹는 형태(밥·빵·면·떡). 두부·치즈 등 가공식품은 접두 없음 |
groupOfIngredient :144 · GROUP_INGREDIENTS :21 | 식재료↔식품군 역색인 + 결핍군→대표 식재료 사전(콩류→두부·검은콩·콩·콩나물). ING_GROUP 멤버 외(치킨·삼겹살 등 운반체명)는 null |
pickFoodReco :103 · weeklyExposureTarget :128 | liked→pair→chain→dish→plain 5단계 via 태그(거울용)·주간 노출 타깃. 현 라이브 본문 경로 미호출(잔존). via 회전 아이디어의 원형 |
route.ts 배선 (실측 file:line)
| 위치 | 무엇을 하나 |
|---|---|
route.ts:368-374 | ⭐콜드스타트 게이트. quantifyPreferences(rs)(①신호선택기) → confidentLiked(state==='liked'만) · dislikedIng(확실 거부). coldStart = confidentLiked.length < 2이면 likedSeed = [...confidentLiked, ...coldStartSeed(servedTop, 6)], 아니면 likedIng(빈도순). dislikedIng는 앵커에서 제외(거부한 걸 또 권하지 않기) |
route.ts:842-848 | buildIngredientPool({signals: computeGroupSignals(homeDays||byDay), likedIngredients: likedSeed, freqMap}) → recoPoolArr·recoMode. 그 다음 풀을 '오늘 타깃군(또는 집 부족군)'에 정렬하고 recentRecoIng[cid].slice(0,5)에 없는 첫 식재료로 일일 회전 → recoIng(같은 식재료 연속 추천 방지) |
route.ts:851-853 | defMature && planDetailCtx면 주간계획(⑧) pickPlanSlot(...)이 우선 → recoIng = slot.ingredient, recoMode = slot.track(콩류 두부 도돌이표 차단·구체 dish 회전). dedup에 recentIngredients 전달 |
route.ts:864-867 | ⭐F-18 슬롯본문봉합. slotDish=슬롯이 정한 구체 dish(단호박찜)·slotFood=슬롯 cookedName. bridgeFacts = _noFood ? '' : buildRecoFacts({likedIngredients: likedSeed, target: _recoTarget, targetIngredient: recoIng, freqMap, suppressCousins: !!slotFood}).text |
route.ts:760-764 | 두뇌(coachBrain) 경로: recoCand = buildRecoFacts({likedIngredients: likedSeed, target: precomputed.plan?.target, freqMap}).text → brainCtx.recoCandidates(시나리오·useFood 판단 재료) |
route.ts:316·326·371 | servedHomeFreq(집·수용도 무관 자주 차려진 식재료) 집계 → servedTop = coldStartSeed Tier2 앵커 |
route.ts:960 | context 저장: recoIng, recoPool: recoPoolArr, recoMode + planSlot{reason·weeklyEstFreq·level·via·track}(:956) — 어드민·다음날 회전이력 |
PlanSlot.reason/weeklyEstFreq/level(coachWeekly.ts:57-68)은 주간계획 모듈의 타입이지만, 그 dishes는 본 엔진의 popularDishesFor가 채우고 reason 문구('결핍군 콩류 보급(주 0.8회·red)'·'잘 먹는 두부의 검증 사촌')의 식재료·사촌 판정은 groupOfIngredient/cookedName이 만듭니다. 어드민은 이 reason을 직접 표시합니다.selectDailyMaterials·buildValidatedCombos·comboMatrix·comboGuard·rankIngredients·rotateRecommendation·deriveLikedIngredients(coachMaterials.ts)는 현재 라이브 추천 경로에서 호출되지 않습니다(테스트만 참조·dormant). 라이브 괴식 차단은 safeGarnishOf + inappropriateDish 정규식 + 그래프 strong/verified 게이트에만 의존합니다(combo matrix 미배선).설계 아키텍처 — 데이터 파이프라인 그림
입력 테이블 → 가공 함수 → 출력 컨텍스트키. 실선=라이브 호출, 붉은 점선=설계됐으나 미배선(휴면).
┌───────────────────── 입력 (route.ts 집계 · 정적 JSON) ─────────────────────┐ │ meal_logs (집 끼니·place≠daycare) lib/youa-freq.json lib/food-graph.json │ │ ├ servedHomeFreq → servedTop (급식 등장률%) (bridge 사촌·pair 궁합) │ │ ├ favIngFreq → likedIng ingredient-recipes lib/kit-dish-matrix │ │ └ acceptance_level/ate_well (freqMap freq≥4) (count>0 폴백) │ └──────────┬──────────────────────────────┬───────────────────┬──────────────────┘ │ │ │ ▼ (①신호계량화) ▼ ▼ quantifyPreferences ───► confidentLiked YOUA_RANK/YOUA_TOP verifiedCousinsOf │ (state==='liked'만) │ youaRankOf garnishPairsOf │ dislikedIng (거부 제외) │ youaReassuranceFor │ ▼ ▼ ▼ coldStart = confidentLiked < 2 ? ┌──── safeGarnishOf ◄── garnishConflict coldStartSeed(servedTop) ───┐ │ (매운/날곡물/교차괴식 제거) : likedIng ─┴► likedSeed │ ▼ signals = computeGroupSignals(homeDays) ┌──────────── buildIngredientPool(signals, likedSeed, freqMap) ────────────┐ │ 결핍 상위3군 · mode = supply / challenge / mixed │ └──────────────────────────┬───────────────────────────────────────────────┘ ▼ recoPoolArr (5개) ─ 타깃군 정렬 ─ recentRecoIng dedup recoIng (일일 회전) │ ┌──────────────┴───────────────┐ ▼ (defMature & planDetail) ▼ (폴백) ⑧ pickPlanSlot → slot.ingredient recoIng 그대로 slotDish / slotFood / track / reason │ ▼ suppressCousins = !!slotFood (F-18) ┌──── buildRecoFacts(likedSeed, target=_recoTarget, targetIngredient=recoIng) ────┐ │ (a) 타깃 라인: popularDishesFor + safeGarnishOf 곁들임 + youa 안심절 │ │ (b) 잘먹는것→사촌·궁합 ← suppressCousins면 생략(F-18) │ └──────────────────────────────────┬───────────────────────────────────────────┘ ▼ .text ┌──────────────── 출력 CoachContext 키 ────────────────┐ │ bridgeFacts (✍️손=작문 재료) recoCandidates (🧠뇌) │ │ slotFood·slotDish·slotTrack recoIng·recoPool·recoMode │ │ planSlot.reason/level/weeklyEstFreq (어드민 근거 A) │ └─────────────────────────────────────────────────────────┘ ┌╌╌╌ 휴면(미배선) ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ ┊ coachMaterials: selectDailyMaterials · buildValidatedCombos ┊ ┊ comboMatrix.scoreCombo · comboGuard ── 라이브 호출 없음 ┊ └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘
작동 예시 — 아린 케이스 before/after
아린(5세) · 콩류 yellow·비타민A채소 red · 완식 신호 빈약(ate_well===true가 4건이나 전부 다른 식재료 각 1회) · 자주 차려진 것=소고기·계란·감자·미역.
{
"// quantifyPreferences 결과": "①신호선택기",
"confidentLiked": [], // 완식 반복+Wilson 통과 0개
"coldStart": true, // < 2 → 콜드스타트 발동
"servedTop": ["소고기","계란","감자","미역"],
"likedSeed": ["소고기","계란","감자","미역", "...youa Tier3"],
"signals(homeDays)": [
{"group":"비타민A채소","level":"red","weeklyEst":0.4},
{"group":"콩류","level":"yellow","weeklyEst":0.8}
],
"freqMap": { "단호박":[{"name":"단호박찜","freq":7}] },
"planDetailCtx.slot": { "ingredient":"단호박",
"cookedName":"찐 단호박", "dishes":["단호박찜"],
"group":"비타민A채소", "track":"supply",
"reason":"결핍군 비타민A채소 보급(주 0.4회·red)" }
}{
"recoIng": "단호박", // 슬롯이 override (line 853)
"recoMode": "supply",
"slotFood": "찐 단호박", "slotDish": "단호박찜",
"// buildRecoFacts(suppressCousins:true)": "F-18",
"bridgeFacts":
"[오늘 타깃 비타민A채소] 찐 단호박 (또래 인기 음식: 단호박찜)",
"// part(b) 사촌 줄 생략": "슬롯과 경쟁 차단",
"planSlot.reason": "결핍군 비타민A채소 보급(주 0.4회·red)"
}BEFORE — 구 누적기가 '완식 true 4건'을 그대로 liked 4개로 오판 → 콜드스타트 미발동. 슬롯은 단호박인데 추천 사실 블록 part(b)가 '
감자 → 사촌 두부'를 동시에 내보내, LLM이 결핍군 대표 두부를 택해 메타=단호박 / 본문=두부 이중장부. 거울줄도 '콩류→두부' 디폴트로 두부 재소환. 결과: 24통 중 두부5·달걀6 복붙.AFTER —
confidentLiked=0이 정확히 콜드스타트를 켜 servedTop 앵커로 풀을 짜고, suppressCousins=!!slotFood가 part(b)를 꺼 사촌 두부 줄이 사라짐. 본문·메타·거울이 모두 '찐 단호박/단호박찜'으로 단일화. 실측: 비-콩류 슬롯 12일 두부누수 0.popularDishesFor("단호박")은 날짜·노출이력을 입력으로 안 받으므로, 다음에 단호박이 또 슬롯이 되면 '단호박찜'이 100% 복붙됩니다. 같은 음식의 메뉴 회전이 없습니다(⑤ 천장 참조).남은 문제점 · 해야할 일 — 랄프위검 실측 천장
랄프위검(연속성 47점·2026-06-20) 실측 기준, 이 모듈 관점에서 정직하게. 증상 → 근본원인 → 해야할일(owner). 미화·과장 금지.
safeGarnishOf 교차괴식·매운·날곡물 + inappropriateDish). ⓑ 콜드스타트 시드·신호계량화로 '완식 true 단발을 liked 오판'하던 두부 디폴트 붕괴를 차단(confidentLiked<2 게이트). ⓒ F-18 suppressCousins로 슬롯-본문 이중장부(메타 단호박/본문 두부) 봉합 — 비-콩류 슬롯 12일 두부누수 0. ⓓ youa 안심절은 서열·% 노출 0·미수록 null로 정직성 유지. 비용 ₩331/자녀·월.coldStartSeed의 Tier3(YOUA_TOP 큐레이션)가 사실상 미확장: servedTop이 채워지면 Tier2에서 max 6이 다 차 Tier3가 잘려나감. 또 GROUP_INGREDIENTS 풀 자체가 협소(콩류=두부·검은콩·콩·콩나물 4종, 그중 그래프·freqMap 커버는 두부에 집중). confidentLiked 확장은 P0-D 신호 입력에 종속(천장1 의존).popularDishesFor(ing, freqMap)(coachRecos.ts:80)이 날짜seed·노출이력을 입력으로 받지 않음 → freqMap freq 내림차순 top-2를 결정론으로 잘라 캐싱. 같은 식재료면 항상 같은 두 메뉴. slotDish는 dishes[0] 단일 선택(coachWeekly:864).popularDishesFor(ing, freqMap, {seed, recentDishes}) 시그니처 확장 → 날짜seed로 freq≥4 후보 중 회전, 노출이력(recentRecoIng처럼 recentDishes 원장)에 있는 메뉴는 후순위. 같은 식재료라도 방문마다 다른 메뉴 노출. owner: 추천엔진recoIng(식재료 단품)과 slotDish/slotFood(메뉴)가 별 경로로 산출돼, 부모가 '오늘의 타깃'을 어느 걸로 봐야 할지 모호. context에 recoIng·recoPool과 planSlot.ingredient·dishes가 병존.buildIngredientPool 회전(route.ts:848)에서, 슬롯은 pickPlanSlot(⑧)에서 나오는 이원 파이프라인. slot이 있으면 recoIng를 덮어쓰지만(853), 슬롯 없는 날엔 식재료 단품만 남아 표현 단위가 들쭉날쭉.recoIng도 popularDishesFor로 메뉴까지 동반) 또는 슬롯/추천 단일 소스화(buildIngredientPool 출력을 slot 형태로 통일). owner: 추천엔진+⑧주간계획acceptance_level≥3 또는 ate_well===true)이 ≥2회 필요한데, 부모가 그 신호를 거의 입력 안 함. child_daily_state(환경신호) 프로브가 라이브 미배선(P0-D)이라 졸업·확신 데이터가 안 쌓임.child_daily_state 신호 입력(care 5단계 실데이터 축적·table-stage 전진)이 선행돼야 confidentLiked가 채워짐. 본 모듈은 그때 likedSeed가 confidentLiked로 자동 전환되도록 게이트만 유지. owner: ①신호선택기/P0-D (이 모듈은 종속)inappropriateDish)와 그래프 strong/verified 게이트에만 의존. 음식+식재료 '자연스러움' 매트릭스 미검증.comboMatrix.scoreCombo/isComboOk·buildValidatedCombos가 coachMaterials(휴면)에만 묶여 있어 라이브 buildRecoFacts/popularDishesFor 어디서도 호출 안 됨. 라이브는 식재료×식재료(safeGarnishOf)만 검증.buildRecoFacts 곁들임 산출에 배선(isComboOk(dish, ing) 통과분만), comboMatrix↔comboGuard 중복 단일화. 단 현재 괴식 0건이라 우선순위는 천장4·메뉴회전 뒤(P1). owner: 추천엔진