비개발자용 — 무엇을 왜 하는가
이 모듈은 코칭 편지가 "아이에 대해 말해도 되는 사실"을 코드가 미리 다 계산해서, 깔끔한 사실 카드로 만들어 AI에게 넘기는 부분입니다. 비유하자면, AI(LLM)가 글솜씨 좋은 작가라면 이 모듈은 옆에서 "팩트만 정리해주는 꼼꼼한 기자"예요. 작가가 분위기 살린답시고 없는 얘기를 지어내거나("매일 점심을 거른대요"), 하루 외식을 보고 "이 집은 늘 뷔페만 다녀요" 식으로 과장하지 못하게, 기자가 미리 검증된 팩트와 "이건 한 번뿐인 일/가끔 있는 일/자주 있는 일"이라는 빈도 도장을 찍어 넘깁니다. 즉, 숫자와 판단은 코드가 하고, AI는 그 사실을 따뜻한 문장으로 옮기기만 합니다.
가장 핵심은 "식단 거울"입니다. 편지 안에, 아이가 실제로 먹은 것과 영양이 어디가 채워지고 어디가 비었는지를 비춰주는 한 구절을 얹어요. "코치가 우리 애가 뭘 먹는지 보고 있구나"를 부모가 느끼게 하려는 장치죠. 거울은 (1) 어제 컨디션이 안 좋았으면 그냥 공감만 하고 끝, (2) 최근 7일 영양을 보고 잘 챙긴 식품군(강점)과 가장 부족한 식품군(결핍)을 골라 "노랑채소는 잘 챙겼는데 콩류가 좀 아쉬워요" 같은 해석 한 문장, (3) 그 결핍을 아이가 잘 먹는 음식에 슬쩍 섞는 구체 행동을 만듭니다. "어제 뭐뭐 먹었어요" 식의 단순 나열은 엄마에게 도움이 안 되니 일부러 뺐습니다.
집/기관을 정직하게 나눠 비추는 것도 이 모듈의 일입니다(P10). 어린이집·유치원 급식은 부모가 못 바꾸는 끼니라, 전체 영양이 괜찮아도 그게 기관 급식 덕이면 "어린이집 덕에 전체 영양은 잘 채워지고 있어요"라고 솔직히 밝히고, 집에서 비는 식품군에 한 걸음을 둡니다. "전반적으로 잘하고 계세요"처럼 기관 덕을 부모 공으로 돌리는 뭉뚱그린 칭찬은 금지합니다.
#5 거울 빈도 쿨다운(2026-06-20 신규) — 가장 최근에 고친 부분입니다. 예전에는 매일 편지 끝에 "어린이집 덕에 영양 잘 채워지고 있어요" 같은 안심 문구가 거의 매일 박혀(아린이 24통 중 17통) 부모가 보기에 "또 그 소리" 앵무새처럼 들렸습니다. 그래서 이 거울 안심 문구의 '출현 자체를 격일화'했습니다 — 최근 2일 안에 거울 안심이 한 번이라도 나왔으면 오늘은 통째로 쉽니다. 단, 집·기관 양쪽에서 부족한 게 2개 이상이면(심한 결핍) 쿨다운을 면제해 반드시 알립니다. 결과: 안심줄이 17통 → 6~8통으로 줄었습니다.
결핍 판정: 최근 7일 동안 각 식품군(곡물·노랑채소·채소·과일·유제품·고기달걀·생선해산물·콩류)이 며칠 등장했는지로 신호등(green/yellow/red)을 매깁니다(④ 영양평가 모듈과 동일 로직). 그중 가장 시급한 결핍 하나만 골라(
argmin) 코칭합니다 — red 먼저, 만성적으로 잘 비는 군(노랑채소·과일)에 가중치(-5).같은 잔소리 반복 차단: 거울 문장은 최근에 쓴 표현과 안 겹치게 고릅니다(
pickFresh+recentMirrors). 더해 #5 쿨다운으로 거울 안심줄 자체를 격일로 쉽니다.집/기관 분리(P10): 어린이집 급식은 "차려져서 먹은 것"이라 "이 아이가 잘 먹는다"는 칭찬 근거로 쓰지 않습니다. 영양 충족(다양성)만 집+기관 전체로 보고, 칭찬·평가·선호 판단은 집 끼니 기준입니다.
괴식 차단: 결핍을 섞을 때는 끼니 음식에만 섞고, 과일·우유·간식류는 섞기 대상에서 제외합니다(바나나에 시금치 같은 괴식 방지). 추천 음식도 실제 식단에서 검증된 궁합·사촌 테이블만 씁니다.
강요 금지: 컨디션이 안 좋은 날(
SICK_RE 감지)은 칭찬도 행동도 빼고 공감만 합니다. 재노출도 "부담 없이 다시 만나기 좋은 때"처럼 부드럽게 권하고, 어제오늘 막 준 음식은 권태를 막으려 제외합니다.구현된 내용 — 함수·데이터흐름·입출력 (실측)
코드에 실제로 존재하는 함수·타입·데이터흐름입니다(file:line 실측, 환각 없음). 4개 파일에 걸쳐 산다: lib/coachFacts.ts(거울 직조·사실 카드), lib/reexposure.ts(재노출 판정), lib/coach.ts(쿨다운·누수 탐지·mirrorBlock·역 must-weave), app/api/cron/coach/route.ts(이력 적재·거울 선택·배선).
핵심 함수
| 함수 · file:line | 역할 (실측) |
|---|---|
compileFactCardscoachFacts.ts:190 | { rows, today, recentMirrors?, name? } → { cards: FactCard[], noteCards, forbidParts, mirror }. 시계열 라벨이 붙은 사실 카드(끼니·환경 env-week:235·거부 refuse:k:278)·메모 분류·금지 정규식·식단 거울을 만드는 메인 컴파일러. mirror는 buildMealMirror 호출(:292). |
compileFactscoachFacts.ts:297 | { rows, today } → cards를 c.text 문자열로 평탄화한 어댑터. route.ts:423이 실제로 호출하는 진입점(const fc = compileFacts({ rows: rs, today })). |
buildMealMirrorcoachFacts.ts:73 | 어제 컨디션 공감(sick:104) + 최근 7일 신호등 argmin 결핍(red→만성-5→daily→weeklyEst:117) + 잘 먹는 음식/요리에 결핍 섞는 행동(:136,150,157,175) 합성. recent로 pickFresh(:65) 쿨다운. 반환 string|null. |
recurrenceLabelcoachFacts.ts:26 | count → '단발 1회'/'간헐 N회'/'반복 N회' 추세 라벨. 모든 사실이 거쳐야 하는 과일반화 가드 핵심. 거부는 행 수가 아닌 '거부한 날 수'(v.days.size:278)로 셈(M6). |
reexposurePickreexposure.ts:26 | (refs, offerCount, offerDaysAgo) → ReexpoVerdict|null. 0~1일 제외, 2~7일=due, 8일+=overdue(:41). due에서 많이 만난 것 우선(:36). 8회+면 친해질 때 격려(nearAccept:42). 숫자 박힌 fact 한 줄(:43-45)을 timeseries로(route.ts:457-458). |
mirrorCooldownDue ⭐신규coach.ts:299 | #5 거울 빈도 쿨다운(2026-06-20). 순수함수 (recentShown: boolean[], { cooldownDays=2, severe }) → boolean. 최근 n일 안에 거울 출현 있으면 true(오늘 생략). severe(전체 결핍 심함)면 면제(:300). 이력 부족이면 false(초기 보존). |
mirrorReassureLeak ⭐신규coach.ts:307 | 거울 생략한 날인데 손이 '어린이집/기관 급식 덕에 영양 채워진다' 안심줄을 자체로 붙였는지 탐지(역 must-weave 트리거). (a) 데이크어 귀속 안심(:310) (b) '어린이집' 단어 빼고도 '전체 영양 괜찮다' 변종(:313) (c) '안심하셔도/안심되네요'(:314). |
stripMirrorReassure ⭐신규coach.ts:323 | 재생성으로도 누수 남으면 결정론 절단 v2. 순수 daycare-praise 문장=통째 절단(:331), 행동(_ENDIMP:321)·집-결핍(_HOMEDEF:322)이 섞이면 선행 praise 절(쉼표 경계)만 제거하고 행동/결핍 절 보존(:332-340). 못 자르면 원본 유지. |
route.ts — 이력 적재 · 거울 선택 · 쿨다운 배선 (실측)
| 단계 · line | 실제 코드 동작 |
|---|---|
| 거울 이력 적재 route.ts:167,187 | recentMirrorShown[cid]: boolean[]를 최근 3일 편지 context.mirrorShown에서 적재(최신부터). 슬롯 유무 무관히 매 과거 편지 1엔트리(쿨다운 격일화의 분모). |
| 거울 라인 변주 route.ts:876-889 | 등원 여부(attends)별 positive 안심 3변형 풀(_posV:877-879)을 daySeed로 회전(_posBase:880). 음식 액션 날(!_noFood+결핍)이면 음식 없는 generic, 환경 날(_noFood)이면 거울에 그날 슬롯 음식 소프트 노출(_foodClause:881-887, F-18). |
| #5 쿨다운 판정 route.ts:893-896 | _mirrorSevere = gMissing.length >= 2(전체 결핍 2개+). _cooldownDue = mirrorCooldownDue(recentMirrorShown[cid]||[], { cooldownDays:2, severe:_mirrorSevere }). 슬롯 날 쿨다운이면 mirrorLineSel = null(라인 생략·mirrorPlanned=true 보존, :896). |
| 거울 노출 이력 기록 route.ts:899 | mirrorShownCtx = planSlotCtx ? !!mirrorLineSel : (_hasDeficitDay || !_cooldownDue). 슬롯 날=라인 선택 여부 · 무슬롯=결핍 있거나 쿨다운 아니면 표시. → context.mirrorShown(:953)으로 저장돼 다음날 분모가 됨. |
| base로 배선 route.ts:910-922 | mirrorLine: mirrorLineSel, mirrorPlanned: !!planSlotCtx, mirrorCooldown: _cooldownDue(:920). 그 외 covered/missing/homeMissing(영양평가), notes/factCards(=fc.noteCards/fc.cards, :911), growthMirror: planSlotCtx?.macroPhrase ?? growthMirrorCtx(:916 — 주간계획 macro 슬롯 우선, E-04). |
mirrorPlanned=true): mirrorLineSel이 라인 또는 null(쿨다운). 무슬롯 날(저데이터·plateau): mirrorBlock IIFE(coach.ts:743)가 mirrorCooldown + 결핍 유무로 폴백 처리. 두 경로 모두 동일 _cooldownDue를 base로 받아 쿨다운이 일관 적용된다.coach.ts mirrorBlock — 프롬프트 직조 + 역 must-weave (실측)
| 분기 · line | 실제 직조 결과 |
|---|---|
| 슬롯 날 · 쿨다운 coach.ts:750 | mirrorPlanned && !mirrorLine → 빈 문자열 아니라 '안심 문구 금지' 가드 주입: "오늘은 영양거울 생략 — 어린이집/기관 덕에 영양 채워진다는 안심 문구를 어떤 표현으로도 넣지 마라". LLM이 습관적 기본 클로징을 못 붙이게. |
| 슬롯 날 · 라인 있음 coach.ts:751 | "이 평가를 한 구절로 반드시 자연스럽게 녹여라(P10 톤·강요/점수/등급 금지)" + mirrorLine + 충족 식품군 cov. |
| 무슬롯 · 쿨다운 + 순수positive coach.ts:758-759 | mirrorCooldown && _purePositive(결핍 없음) → 안심 문구 금지 가드. 결핍 있으면 actionable이라 항상 표시(degrade-safe). |
| 무슬롯 · 결핍 코칭 coach.ts:764-784 | 전체 결핍(allMiss)·집 결핍(homeMiss)·순수 칭찬 3분기, 각 daySeed 3변형(pick:762). 집 결핍 분기가 P10 "어린이집 덕에 전체는 채워지고 집엔 ○○가 드무니"(:774). |
| 역 must-weave(누수 절단) coach.ts:1014-1028 | intendMirror(:1017) 거짓인데 mirrorReassureLeak이면: (1) LLM 1회 재생성(:1021) → (2) 그래도 남으면 stripMirrorReassure 결정론 절단(:1026). 출현빈도 격일화의 마지막 빗장. fail-open. |
| 거울 must-weave 검증 coach.ts:1004-1012 | mirrorGroups(쿨다운 날은 빈 배열 →강제 안 함). 라인 있으면 본문에 그 군/포괄어 포함 검증, 없으면 1회 재생성(F-18 generic-positive 감지 :1008). |
mirrorCooldownDue·mirrorReassureLeak·stripMirrorReassure 신규, route.ts:167/187/893-899 배선). ✅ F-18 거울↔슬롯 봉합(거울 음식=항상 그날 슬롯 음식, deficitDishFor 두부 디폴트 제거, route.ts:870-889). ✅ #7 macro(growthMirror: planSlotCtx?.macroPhrase ?? growthMirrorCtx :916 — 저체중/성장더딤 탄단지 우선). ☐ 아래는 이 모듈 밖이라 여기서 다루지 않음: 선호계량화(① 신호선택기·preferenceQuantification.ts), hardStall 피벗·#6 도입회전·A 어드민 근거(⑤ 진척·⑥ 작문), P0-D child_daily_state 프로브(① 입력).설계 아키텍처 — 데이터 파이프라인 시각화
입력 테이블 → 가공 함수 → 출력 컨텍스트키 파이프라인입니다.
log_date·slot·menus·ingredients·refused·note·environment·place·ate_well
offerCount·offerDaysAgo (재노출용)
context.mirrorShown[] (쿨다운 이력)
recentMirrors (문구 쿨다운)
nutrition.ts 군별 주간목표 상수
planSlot·macroPhrase
→ recurrenceLabel 추세도장
sick공감→argmin결핍→섞기행동
pickFresh+recent 쿨다운
due/overdue·nearAccept fact
recentShown[0..2] → 라인 생략
+ stripMirrorReassure ⭐
역 must-weave 누수 절단
#5 거울 빈도 쿨다운 — 폐루프 (2026-06-20)
[어제·그제 편지] context.mirrorShown ──┐
▼
route.ts:187 recentMirrorShown[cid] = [t/f, t/f, ...] (최신부터)
│
▼
route.ts:894 _cooldownDue = mirrorCooldownDue(recentShown, {cooldownDays:2, severe:gMissing≥2})
│
┌───────────────────────────┼───────────────────────────┐
severe(결핍2+) cooldownDue=true cooldownDue=false
면제 → 라인 노출 라인 생략(mirrorLineSel=null) 라인 노출
│ │ │
▼ ▼ ▼
coach.ts:751 라인 직조 coach.ts:750 '안심금지' 가드 coach.ts:751 라인 직조
│
▼
LLM이 습관적 안심줄 붙이면? → coach.ts:1018 mirrorReassureLeak
│ true
┌─────────────┴─────────────┐
:1021 LLM 재생성 1회 :1026 stripMirrorReassure(결정론 절단)
│
▼
route.ts:899 mirrorShownCtx 기록 → 다음날 분모로 환류 ↺작동 예시 — 아린 실데이터 before/after
아린(5세)이 집에서 소고기·계란·미역국 잘 먹고 콩류/채소(당근) 결핍, 어린이집 등원. compileFactCards 통과 I/O와, #5 쿨다운 적용 전/후 편지 말미 비교.
{
"today": "2026-06-16",
"name": "아린",
"recentMirrors": [
"최근 고기·달걀은 잘 챙겼는데
노랑·주황 채소가 좀 아쉬워요. ..."
],
"rows": [
{ "log_date":"2026-06-15","slot":"dinner",
"menus":["미역국","소고기무국","잡곡밥"],
"refused":"당근","note":"당근은 골라냈어요",
"environment":"table","place":"home",
"ate_well":true,
"ingredients":["미역","소고기","쌀","무"] },
{ "log_date":"2026-06-15","slot":"lunch",
"menus":["김치","닭갈비"],"refused":null,
"environment":"table","place":"daycare",
"ate_well":true,
"ingredients":["닭고기","배추"] }
]
}{
"cards": [
{ "key":"refuse:당근",
"text":"거부: 당근 — 단발 1회(마지막 어제
· 횟수=거부한 날 수)",
"kind":"diagnosis" }
],
"noteCards": ["[어제·하루 관찰] 당근은 골라냈어요"],
"forbidParts": [
"점심(을|은)?[^.。\\n]{0,6}(거르|건너|굶)|..."
],
"mirror":
"최근 고기·달걀은 잘 챙겼는데 콩류가 좀
아쉬워요. 아린이가 잘 먹는 소고기무국에
당근을 잘게 썰어 넣어 보면 어떨까요."
}…아린이가 잘 먹는 소고기무국에 당근을 한 번 곁들여 보면 어때요. 어린이집 덕에 전체 영양은 잘 채워지고 있으니 집에서는 부담 내려놓으셔도 괜찮아요.
→ 같은 '어린이집 덕에 안심' 클로징이 24통 중 17통에 verbatim에 가깝게 박힘. 부모 체감 = "또 그 소리" 앵무새.
…아린이가 잘 먹는 소고기무국에 당근을 잘게 썰어 넣어 보면 어떨까요. 오늘 한 걸음이면 충분해요.
→ recentMirrorShown[아린]=[true,…] → _cooldownDue=true → mirrorLineSel=null → coach.ts:750이 '안심 금지' 가드 주입 → LLM이 안심줄 없이 행동·마무리로 닫음. 만약 LLM이 습관적으로 안심줄을 붙였다면 coach.ts:1018 mirrorReassureLeak이 잡아 재생성/절단.
LetterInput.factCards(사실 주장은 이 안에서만), noteCards→notes, forbidParts→detForbid 정규식(위반 시 재생성), mirror→buildMealMirror가 직접·또는 route의 변주 라인→mirrorLine. reexposurePick.fact는 timeseries 사실로 편지가 'N번·M일·적기'를 인용. 거울 노출 여부는 context.mirrorShown으로 저장돼 다음날 쿨다운 분모가 됨.남은 문제점 · 해야할 일 (랄프위검 연속성 47점, 2026-06-20)
| 증상 | 근본원인 | 해야할 일 (owner) |
|---|---|---|
| 천장6 일부 — 본문 '어린이집 안심' 6통+ 반복 0610≈0613 본문 안심줄이 거의 복붙 | 쿨다운의 적용 범위가 거울줄에만 걸린다. mirrorCooldownDue가 끄는 것은 ③이 직접 만든 mirrorLine이고, LLM이 본문 산문 중간에 자체로 짜 넣는 '어린이집 덕에 전체 영양 안심' 문장에는 빈도 가드가 없다. mirrorReassureLeak(coach.ts:307)은 '오늘 거울을 안 의도한 날'(!intendMirror, :1018)에만 발동하므로, 거울을 의도한 날 본문에 끼어든 추가 안심 산문은 통과한다. | #5 쿨다운을 본문 '어린이집 안심' 절까지 확대(거울줄 밖). intendMirror=true인 날에도, 최근 N일 본문에 daycare-praise가 이미 났으면 본문 안심 산문을 1회로 제한하는 빈도 가드 추가. mirrorReassureLeak 탐지기를 '거울 의도 여부와 무관한 빈도 카운터'로 승격. (owner: ③ 코칭엔진) |
| 인접 N일 본문 또래근거 슬롯절 dedup 미적용 | 인접일 dedup이 거울줄·시그니처엔 걸리나, '또래 급식에 자주 나오는 재료'(coach.ts:799) 같은 또래근거 슬롯 산문은 표현 변주(매번 다른 어미)만 요구하고 출현 빈도 자체는 가드 없음. 변주 풀이 마르면 의미가 매일 반복. | 인접 N일 본문 dedup을 또래근거 슬롯절까지 확대. 거울 쿨다운과 동형으로 '또래근거 노출 여부'를 context에 기록해 격일화. (owner: ③+⑥ 작문기 공동) |
| 안심줄 카피 변주 풀 협소 현재 단일 의미 템플릿 | _posV(route.ts:877-879)·mirrorBlock 분기(coach.ts:766-784)가 각 3변형뿐 — 어미·구조는 다르나 의미는 단일('전체 영양 채워지고 있다'). 쿨다운으로 빈도는 줄였으나, 나오는 날의 안심줄은 의미적으로 동일. | 안심줄 카피 변주 풀 확장 — 의미 축을 다양화(전체-충족 안심 외에 '오늘의 작은 성공'·'한 걸음 인정'·'리듬 관찰' 등 비-안심 클로징 풀). pickFresh가 의미 클러스터 단위로 회전하게. (owner: ③+⑥) |