비개발자용 — 이 모듈은 '무엇을 왜' 하나
밀프레드 코칭은 머리(무엇을 말할지 결정)와 손(그걸 따뜻한 편지로 풀어 쓰기)으로 나뉩니다. 이 모듈이 바로 '손'입니다. 머리(②추천엔진·③사실거울·④영양평가·⑤진척·⑧주간계획)가 미리 정해 넘긴 재료 — 오늘 다룰 단 하나의 타깃(예: 부족한 콩류), 오늘 권할 구체적인 음식 하나(예: '단호박찜'), 영양 거울 한 줄, 검증된 추천 음식 목록, 이번 주 커리큘럼 단계 — 를 받아서, AI에게 '이 사실 안에서만, 이런 규칙을 지켜서 부모에게 가는 편지를 써라'라고 시킵니다. 손은 새로운 사실을 만들지 않습니다. 머리가 준 재료를 따뜻한 한국어 문장으로 옮기는 일만 합니다.
핵심은 '작문은 AI가 자유롭게, 그러나 안전벨트는 코드가 단단히' 입니다. AI가 글을 쓴 뒤 곧바로 발행하지 않고, 세 겹의 검사를 통과해야만 부모에게 갑니다. (1) 정규식 안전망: '지난달·N개월 전' 같은 없는 시점, 김치에 다른 재료 섞기, 바나나에 콩가루 뿌리기(괴식), 튀김·돈까스 권유, 짠맛 칭찬 같은 위험 표현이 들어 있으면 즉시 다시 쓰게 합니다. (2) 어휘 반복 검사: 어제·최근 편지와 도입부나 전체가 너무 비슷하면(잔소리가 되니까) 행동 방식을 바꿔 다시 씁니다. (3) AI 검증자: 또 다른 AI가 '이 편지의 사실 주장이 진짜 데이터에 근거하나, 행동을 2개 시키지 않나, 체중·점수·진단 같은 금지어가 없나'를 의미 수준에서 검사하고, 걸리면 위반 목록을 돌려줘 1회 더 고쳐 씁니다.
2026-06 들어 손이 새로 맡은 세 가지 '봉합'이 있습니다. ① slotMandate 봉합(F-18) — 머리(⑧주간계획)가 "오늘은 단호박찜"이라고 구체 음식을 정해 줬는데도 손이 자꾸 익숙한 '두부'로 돌아가던 버릇이 있었습니다. 이제 손은 편지를 쓴 뒤 '본문에 진짜 단호박이 들어갔나'를 확인하고, 빠졌으면 그 음식을 강제로 넣어 다시 씁니다. ② 거울 쿨다운 anti-leak(#5) — '어린이집 덕에 영양은 잘 채워져요'라는 안심 한 줄이 거의 매일 박혀(24통 중 17통) 앵무새가 됐습니다. 이제 최근 2일 안에 그 줄이 나왔으면 오늘은 빼고, 손이 습관적으로 그 줄을 도로 붙이면 잘라냅니다. ③ 도입 소재 회전(#6) — 모든 편지가 '부모님,' 또는 '요즘…'으로 똑같이 시작하던 것을, 날짜 시드로 '관찰/변화/질문/장면/일상/공감' 6가지 여는 방식을 매일 돌려 씁니다.
이 모듈은 영양·결핍을 스스로 '판정'하지 않습니다. 부족 영양소·부족 식품군은 머리(분석)가 계산해 넘겨준 우리 데이터만 쓰고, 손은 그걸 편지에 옮기기만 합니다(특정 식재료를 'OO는 고단백'이라 단정 금지). 처리 규칙: ①결핍 제시는 받은 값만 — 영양거울 한 구절을 코드가 미리 문장으로 만들어 '이걸 반드시 녹여라'로 손에 직접 전달. ②반복 노출=거부는 정상 — 단 '벌써 N번째' 같은 카운트는 절대 지어내지 못하게 막음. ③괴식 차단 — 바나나에 콩가루, 김치에 다른 재료 섞기, 해조류(미역·김)를 생선으로 혼동, 밀·쌀 날곡물 금지. ④강요 금지(Satter 역할분담) — 부모는 무엇·언제만, 행동은 한 번에 하나만. ⑤저염·연령 안전 — 짠맛 칭찬·매운 음식·튀김 금지, 질식 표시(반으로잘라·익혀) 보존. ⑥만성질환은 식이 '방향'만 한 구절, 진단·처방·정량·체중은 전부 금지.
구현된 내용 — 코드에 실제로 있는 함수·흐름·I/O
전부 web/lib/coach.ts(1190줄) + 호출부 web/app/api/cron/coach/route.ts 실측. 함수명·타입·file:line은 코드 그대로입니다.
핵심 함수 (file:line)
| 함수 · 타입 | 위치 | 역할 (실측) |
|---|---|---|
composeLetter | coach.ts:923-1066 | 통합 작성기(크론·온디맨드 공유). {base, precomputed, detInput, detForbid, deadlineMs, daySeed, cidHash} 입력 → 전환사실 누수 차단 → generateLetter → det 재생성(≤2) → 유사도 재생성(≤2) → verifyLetter 의미검증 재생성(1) → 5개 must-weave 포함검증(거울·거울누수절단·성장·F-18 슬롯·F-17 step) → {letter, oneliner, plan, scenarioId, scenarioLabel, coachRegen, verify, modelUsed} 반환 |
buildLetterUser(b: LetterInput) | coach.ts:595-808 | LetterInput → USER 프롬프트 문자열 조립. history·ctx·factsBlock·mirrorBlock·growthBlock·scenarioBlock·planTargetBlock·slotMandateBlock·openBlock·actionBlock·arcBlock·chronicBlock·snackBlock·moveBlock·regen/fix/reengage/safety 블록 결합. EPIC C byte-동일 회귀용 export |
generateLetter | coach.ts:831-837 | callLLM(700토큰) 편지 생성 → polishKo(결정론 어법) + proofreadLetter(LLM 퇴고 1콜) → {letter, oneliner} |
callLLM | coach.ts:74-159 | DeepSeek 1차(DEEPINFRA/OPENROUTER 키·sonnet→V4-Pro·haiku→V4-Flash)→실패 시 dsDead 서킷브레이커로 Claude 폴백. 최대 5회 재시도(깨진 JSON 회복)·usage 적재(_usageLog)·system 캐싱 |
letterDeterministicBad | coach.ts:576-592 | 발행 전 결정론 정규식 가드 — FORBID_TIME·SYMPTOM_RE·섞기누수(NO_MIX_SCEN)·김치괴식·튀김초가공·N가지칭찬·해조류↔생선·fruitSaltyMix·fruitMealPairing·날곡물·짠맛칭찬·김치칭찬 → true면 재생성 |
verifyLetter / verifyFacts | coach.ts:886-914 | LLM 의미 검증자 1콜(SYSTEM_VERIFY) — ①사실근거 ②행동1개(noFoodAction) ③금지표현 ④진단재서술. verifyFacts가 '실제 받은 입력'을 압축해 전달. 장애 시 fail-open({ok:true}) |
mirrorCooldownDue(recentShown, opts) | coach.ts:299-303 | #5 신규 순수함수 — 최근 cooldownDays(=2)일 안에 거울 노출 있으면 true(오늘 생략). severe(전체결핍≥2)면 면제. 출현 1회/3일로 수렴 |
mirrorReassureLeak(letter) | coach.ts:307-316 | #5 신규 거울 생략 날인데 손이 '어린이집/기관 급식 덕에 영양 채워진다' 안심줄을 자체로 붙였는지 탐지(역 must-weave 트리거). 영양어 동반 require로 재노출 언급 과탐 방지 |
stripMirrorReassure(letter) | coach.ts:323-347 | #5 신규 재생성으로도 누수 남으면 결정론 절단 — 순수 daycare-praise 문장은 통째, 행동/집-결핍 섞인 문장은 선행 praise 절(쉼표)만 제거. 과절단이면 원본 유지 |
pickOpenMaterial(seed) / OPEN_MATERIALS | coach.ts:389-403 | #6 신규 daySeed로 도입 '여는 방식' 6종(observe·change·question·scene·daily·empathy) 중 1개. SCEN_OPEN(각도 내용)과 직교 합성. 풀 비면 null 폴백 |
planFor / buildCoachPlan / targetPoolForScenario | coach.ts:533·484·473 | 시나리오 선택 + 결정론 plan(frame·target·moveKey·signature) 산출. forceScenarioId(두뇌)면 그 시나리오로 잠금. composeLetter 인자 precomputed로 주입 |
chronicGuidanceText / structuredTip / metaInputNudge | coach.ts(별·248·266) | 만성질환→식이 방향 1구절 · 구조화 입력(식감·자율성·환경·식사시간)→개선 팁 · 초기 1~3주 메타 입력 초대(#4b) |
generateOnboardingLetter / generateQuestion / icfqForDate | coach.ts:845·(이하)·1081 | 기록<3일 온보딩 편지 · 오늘의 질문(결정론 주제 회전) · ICFQ 위험 스크리너(10일 회전) |
이번 세션 신규 — 손이 받는 입력·실행하는 봉합
| 기능 | 손의 입력/코드 (file:line) | 손이 실제로 하는 일 |
|---|---|---|
| F-18 슬롯 본문 봉합 라이브 | LetterInput.slotFood / slotDish / slotTrack / slotPairLiked(coach.ts:212-215). 봉합 검증 = composeLetter:1040-1047. 주입 = route.ts:864-865·917-918 | slotMandateBlock(buildLetterUser:660-666)이 '오늘 권할 음식은 slotDish 하나, 본문에 반드시 직조' + '두부·콩류로 대체 금지'(slotMandateCommon). 생성 후 slotWoven(slotDish·slotFood·핵심명사 중 하나라도 본문에) 검사 → 누락 시 fixNotes로 1회 재생성. track='challenge'면 "감자 잘 먹으니 닮은 단호박" 톤, supply면 부족 채움 톤(프레이밍 분기) |
| #5 거울 출현빈도 쿨다운 + anti-leak 라이브 | mirrorCooldown / mirrorLine / mirrorPlanned(coach.ts:209-211). 누수 절단 = composeLetter:1014-1029. 쿨다운 판정 = route.ts:894·896 | route가 mirrorCooldownDue로 최근 2일 노출 시 mirrorLineSel=null. mirrorBlock(coach.ts:743-786)이 라인 없으면 '안심 문구 금지' 가드만 주입. 발행 후 intendMirror=false인데 mirrorReassureLeak 매치면 → ①재생성 1회 → ②stripMirrorReassure 결정론 절단. K-03 must-weave(998-1013)는 결핍 분기에서만 거울 누락 시 보강(쿨다운 날 면제) |
| #6 도입 소재 회전 라이브 | OPEN_MATERIALS·pickOpenMaterial(coach.ts:389-403). 합성 = buildLetterUser:643-648 | openMat=pickOpenMaterial(daySeed) → openRotate 문장을 openBlock에 합성. SCEN_OPEN(각도)은 그대로 두고 '여는 형태'만 회전. 양 분기(varyOpener true/false) 모두 "첫 어절을 '부모님'(호명)으로 시작 금지" 명문(:647-648) |
| #7 macro 성장 거울 라이브(route 주도) | LetterInput.growthMirror(coach.ts:207). 손 봉합 = E-06 composeLetter:1031-1037 | 손은 growthMirror가 오면 본문에 한 구절(숫자·BMI·체중·살 금지). E-06 must-weave가 growthWoven 누락 시 재생성. 단 macro 트랙 활성화·성장데이터 nudge 빈도(저체중→고기류↑)는 route.ts:229-230(매 3일·hasGrowth면 중단)이 결정 — 손은 받은 phrase를 녹이는 역할 |
| F-17 step 누적 서사 라이브 | weeklyArc.stepStory(coach.ts:196). 봉합 = composeLetter:1048-1063 | arcBlock(688-703)이 advance/celebrate/길/연속감 한 구절 지시. 손이 거의 항상 생략(메타 주입만으론 23통 중 1통)해 '자연스러운 진도 순간'(intro·advance·celebrate)에만 storyWoven must-weave 재생성 |
| 선호계량화 / hardStall 피벗 / P0-D 프로브 / A 추천근거 상류 모듈 — 손은 결과만 수신 | — | 이번 세션 신규지만 코드상 손(coach.ts)에는 직접 존재하지 않음: 선호계량화=lib/preferenceQuantification.ts(②/route), hardStall 피벗=⑤커리큘럼(route.ts:671 fallbackPivot), P0-D child_daily_state=신호입력(미구현), A 추천근거(reason/weeklyEstFreq/level)=②추천엔진/어드민. 손은 이들이 산출한 slotFood·target·bridgeFacts·arc만 받아 작문. (환각 금지 — 이 모듈 문서엔 손이 받는 인터페이스로만 표기) |
weeklyArc.stage==='intro'(주 첫 진단)만 'claude-sonnet-4-6' 역할키(→DeepSeek V4-Pro 폴백 Sonnet), 평소엔 undefined→HAND_MODEL='claude-haiku-4-5-20251001' 역할키(→V4-Flash 폴백 Haiku). 1차는 항상 DeepSeek. modelUsed로 letterCtx에 기록.설계 아키텍처 시각화 — 입력 테이블 → 가공 함수 → 출력 컨텍스트키
데이터 파이프라인 그림. 왼쪽 입력(머리·테이블이 넘긴 재료) → 가운데 가공 함수(손) → 오른쪽 출력(coach_letters 행 + letterCtx). 점선 박스는 발행 전 가드 루프.
slotFood·slotDish·slotTrack·slotPairLiked(F-18) · mirrorLine·mirrorPlanned·mirrorCooldown(#5) · growthMirror(#7) · weeklyArc.stepStory(F-17) · daySeed·cidHash
scenario · plan(frame·target·moveKey·signature) · varyOpener
letterCtx(context JSON): plan · scenarioId · scenarioLabel · coachRegen · verify · simToPrev · repeatAlert · modelUsed · mirrorShown(#5 쿨다운 이력) · planSlot(slotIndex·dish·track·mirror·macro) · brain · curriculum · recoIng/recoPool/recoMode
mirrorShown·plan.signature·letter 본문이 다음 날 입력으로 되돌아온다: route.ts:167-187이 최근 편지에서 recentMirrorShown(쿨다운)·recentPlans(시그니처 회피)·pastLetters(유사도)를 적재. 손은 이 이력으로 '어제와 다르게' 쓴다.작동 예시 — 아린(5세) 실데이터 before/after
실제 케이스: 슬롯이 '단호박찜'(challenge·감자 사촌)을 정했는데 손이 결핍군 대표 '두부'로 회귀하던 F-18 천장. 같은 입력으로 봉합 전/후 본문 비교.
{
"childName": "아린", "ageBand": "5y",
"favoriteFreq": ["밥(16회)","소고기(9회)","계란말이(7회)","감자(4회)"],
"missing": ["콩류","녹황색채소"],
"homeMissing": ["콩류","녹황색채소"],
"attendsDaycare": true,
"slotFood": "찐 단호박",
"slotDish": "단호박찜",
"slotTrack": "challenge",
"slotPairLiked": "감자",
"mirrorLine": "어린이집 덕에 여러 식품군을 두루 잘 챙기고 있어요. 집에선 단호박찜 같은 음식도 가끔 식탁에 올리면 다양성에 좋아요",
"mirrorPlanned": true,
"mirrorCooldown": false,
"weeklyArc": {"stage":"how","behaviorGoal":"저녁 첫 숟갈 전 거부 채소 한 조각 올리기"},
"daySeed": 20259 // pickOpenMaterial → 'scene'(식탁 장면)
}slotMandateBlock(challenge·:664):
"오늘 권할 음식은 '단호박찜'(찐 단호박) 하나 —
'감자 잘 먹으니 닮은 단호박찜도 한 번'처럼
잘 먹는 음식에서 이어 권하라. ❌ 두부·콩류로
대체 금지. ❌ 부족 프레이밍 금지(다른 식품군)"
planTargetBlock(:656 강등):
"[ℹ️ 콩류는 영양거울에서 가볍게 비추기만, 음식
추천을 콩류로 끌어오지 마라 — 슬롯이 단일 타깃]"
openBlock(:644 #6):
"오늘 첫 문장은 '아이가 식탁에서 보이는 모습을
그리듯' 방식으로 열어라. ❌ 첫 어절 '부모님' 금지"
F-18 검증(:1042): slotWoven('단호박' in 본문)?
→ 1차 생성에 누락 시 fixNotes 재생성 1회메타(planSlot)='단호박찜'인데 본문 음식명 0회 — '메타=단호박, 본문=두부' 이중장부. 부모는 단호박을 영영 못 봄.
slotDish/slotFood 본문 직조 · challenge 톤(감자→단호박) · 도입은 #6 'scene'(식탁 장면, '부모님' 호명 없음). 콩류는 거울에만.
recentMirrorShown[아린]=[true,…]. 오늘 mirrorCooldownDue([true],{cooldownDays:2})=true → route가 mirrorLineSel=null → mirrorBlock(750)이 "오늘은 영양거울 생략 — '어린이집 덕에…' 안심 문구 금지" 가드만 주입. 그래도 Haiku가 습관적으로 그 줄을 붙이면 → intendMirror=false && mirrorReassureLeak=true(1018) → 재생성 1회 → 남으면 stripMirrorReassure로 그 문장만 절단. 결과: 안심줄 출현이 매일→3일 1회로 격일화.남은 문제점 · 해야할 일 — 랄프위검 47점(연속성·2026-06-20) 실측 천장
아린 24통(가입 1일차~오늘) 연속성 감사 결과를 이 모듈(손) 관점에서 정직하게. 봉합된 것(EARNED)도 사실이면 명시하되, 남은 천장은 미화 없이 증상→근본원인→해야할일(owner)로.
| 증상 (날짜·증거) | 근본원인 (이 모듈 관점) | 해야할 일 (owner) | 등급 |
|---|---|---|---|
| 천장2 — F-18 봉합이 environment 분기를 우회 슬롯음식(두부/단호박/검은콩/달걀)이 메타(planSlot)엔 있는데 본문 음식명 0회. 06-11·12·13·18·19 5연속. 부모가 '오늘 뭘 먹여보라는 건지' 못 읽음 |
F-18 슬롯 강제(slotFood/slotDish)가 lever:food 분기에만 걸린다. route.ts:864-865는 !_noFood 조건이라 환경(brainPick.useFood===false) 날은 slotFood=null → slotMandateBlock·slotWoven 봉합이 통째로 풀림. _foodScen(home-daycare-gap·nutrient-gap·repeat-menu)만 _noFood를 끄므로(route.ts:859-860), 환경 레버로 잡힌 음식-있는 날은 슬롯이 본문에 안 들어감 |
environment 분기에도 slotDish must-weave 봉합 — '슬롯에 음식 걸린 모든 날 본문에 음식명 최소 1회'. mirrorBlock의 환경-날 소프트 노출(route.ts:886-887 _foodClause)이 거울에만 있고 본문 검증(slotWoven)은 _noFood면 skip(:1040)이라, 환경 날 본문 봉합 경로를 신설하거나 거울 소프트노출을 검증 대상에 포함. owner: ⑥손(slotWoven 게이트) + ⑧주간계획(슬롯↔레버 정합) |
높음 |
| 천장5 — 도입 새 수렴(#6이 옮긴 자리) '부모님,' 호명은 0/24(차단 성공처럼 보임). 대신 '요즘/며칠째/오늘 저녁 6시+식탁/화면'이 후반부 점령. 06-07≈06-08 near-duplicate |
OPEN_MATERIALS 6종(observe·change·question·scene·daily·empathy)이 daySeed 단일 시드로만 회전(coach.ts:398-402)하는데, 실제 첫 문장 수사는 SCEN_OPEN(시나리오 각도)에 종속된다. 같은 시나리오가 며칠 연속이면(상류 닻 종속) 같은 각도 × 인접 daySeed → 도입 재수렴. '부모님' 차단 baseline 자체가 측정 위생 문제: 0/24가 진짜 차단인지 검증 baseline이 wf.js args 버그로 stale(아래 ⚠️) |
openBlock 수사장치를 시나리오와 직교화 — pickOpenMaterial 시드에 scenarioId 해시를 섞어 같은 각도라도 다른 도입 장치 강제 + 인접 N일 도입 중복 억제(pastLetters 첫 문장 25자 자카드를 openBlock 단계에서 별도 검사, 현 simBadness는 전체/도입 통합이라 도입 단독 회귀를 못 잡음). owner: ⑥손 |
중 |
| 천장6 — 본문 클로징·위로·안심 템플릿 회전 부재 도입만 손봄. modeling-line '부모님이 먼저 맛있게 드시는' ~11통, plateau-comfort '거부 없이 평온함' 3통이 거의 동일 |
#6 회전은 첫 문장(openBlock)에만 적용됐다. 본문 중간·클로징의 상투 슬롯(modeling-line·plateau-comfort·곁들임 권유)은 SYSTEM_COACH(coach.ts:40 '부모가 먼저 맛있게 먹는 모습') + SCEN_ACT가 매 편지 같은 슬롯으로 수렴시키는데 회전 장치가 없다. simBadness는 3-gram 자카드라 modeling-line처럼 짧은 verbatim 상투구는 전체 유사도를 0.45 밑으로 통과(어휘 통과) | modeling/plateau 본문 템플릿 풀 회전 추가 — MOVE_MENU(model 무브)·plateau 칭찬 문구를 daySeed 변형 풀로(SCEN_MOVES 환경 무브가 이미 풀-회전이듯). 추가로 구조 골격 유사도 측정자(상투 슬롯 시그니처를 직전 N편과 비교)를 simBadness에 가산해 '단어만 바꾼 붕어빵'까지 재생성 트리거. owner: ⑥손 | 중 |
| ⚠️ 측정 위생 — wf.js args 미전달로 baseline stale 천장5의 '0/24' 같은 판정이 직전 라운드와 정확히 비교되지 않음 |
scripts/arin-continuity-review.wf.js가 Workflow({scriptPath, args})에서 args를 런타임에 전달받지 못해 스크립트 기본값(loop·file·baseline=44, :16-18)으로 매 라운드 고정 실행. 그래서 'baseline 대비 올랐나'의 baseline이 갱신 안 됨 → 봉합 효과 측정이 흐려짐 |
우선 args 미전달 버그 수정 — 매 라운드 loop/file/baseline 기본값을 직접 갱신하거나 Workflow args 주입 경로를 고침. 그 뒤에야 위 천장들의 봉합 효과를 신뢰성 있게 측정 가능. owner: 측정 인프라(wf.js) | 선행 |
✅ EARNED (이번 세션 실측 봉합 — 사실) ① F-18 food 분기 슬롯 봉합: 실측 비-콩류 슬롯 12일에서 본문 두부 누수 0(food 레버 날 한정). ② #5 거울 쿨다운: 안심줄 출현 17→8(3일 1회)로 격일화, stripMirrorReassure로 Haiku 습관 누수까지 결정론 절단. ③ #6 도입: '부모님,' 호명 24→0(단, 수사가 다른 자리로 이동 = 천장5). ④ F-17 step: 메타 주입만일 때 23통 중 1통 → must-weave로 진도 순간엔 본문 직조. tsc 0 · vitest 464 통과. |
|||
손은 '글은 LLM이 자유롭게, 안전벨트는 코드가 단단히'의 마지막 관문이고, 이번 세션에 거울 anti-leak·도입 회전·슬롯 봉합을 EARNED했다. 그러나 셋 다 '한 자리'만 봉합했다: 슬롯은 food 분기만(environment 우회), 회전은 첫 문장만(본문 클로징 미적용), 측정은 stale baseline. 남은 일은 봉합을 분기 전체·본문 전체로 확장하고, '단어만 바꾼 붕어빵'을 잡는 구조 골격 유사도를 어휘 자카드 위에 얹는 것이다. ⚠️ 상류(②/⑧이 슬롯·레버를 정함)가 환경 날 음식 슬롯을 어떻게 본문에 흘릴지 정하지 않으면 손의 봉합만으론 천장2를 못 막는다(손은 2차 방어층).