코칭 plan 엔진 — 편지 반복 근본수정 + 추천 네트워크 아키텍처

구현·배포 보고서 · 2026-06-08 · 출발점: 반복 진단·재귀개선 감사가 가리킨 근본원인을 실제 프로덕션 수정으로. 시나리오 엔진의 후속.

⚠️ 2026-06-10 이후 변경 — 위에 주간 작전층이 얹힘: 주간 케이던스(정본 ③)가 타깃을 한 주 잠그고, 본 문서의 '이틀 연속 같은 타깃 금지'는 '채근(push) 주 1회 캡'으로 공식 역전됐다(food 주는 planFromWeekly가 본 엔진의 buildCoachPlan 회전을 그대로 태움). 또한 06-11 복붙 사고 핫픽스로 구조 프레임(환경·자율성·식감)은 전용 무브 메뉴 SCEN_MOVESbuildCoachPlan이 회전한다(시그니처 dedup 복원·포화 시 plateau). 본문 §1의 "어휘 가드는 의미 중복 미측정" 진단(지지 0.90)은 06-11 사고로 실증됨 — 상세는 주간 정본 부록.
📌 현행 편지 파이프라인 정본 — 2026-06-11 최종. 본 절이 본문·타 문서와 상충하면 본 절이 우선한다(같은 날 수차례 개정된 혼선의 단일 진실).
  1. 주간 닻(작전) — 일요일 Sonnet 종합이 레버(food/환경/자율성/식감)·타깃·behavior_goal 잠금. healAnchor가 구버전 닻 보충, upsert 에러 시 컬럼 빼고 재시도.
  2. 계획 = 코드 — frame(레버 잠금 or 시나리오 선택) + 무브 회전(MOVE_MENU 음식 7종 / SCEN_MOVES 환경5·자율성4·식감4) + 시그니처 dedup(직전 3일) + 포화 시 plateau + 아크 단계(intro→how→obstacle→observe→reinforce·주 첫 편지만 진단 허용·reinforce 연속 금지).
  3. 사실 컴파일('1번')lib/coachFacts.compileFacts가 기존 컬럼(log_date 등·스키마 변경 0)만으로 시계열 라벨 사실 카드(끼니 커버리지·환경 통계+어제 사실·자주 오르는 음식 횟수·거부 단발/간헐/반복·메모 날짜 분류)와 동적 금지(detForbid)를 계산. 편지의 사실 주장은 카드 안에서만·라벨대로(단발을 패턴처럼 금지). 메모 2일 컷은 철회 — 자르지 않고 분류.
  4. 작문 = LLM — 일간 Haiku, intro만 Sonnet 4.6. 행동(ⓒ)은 계획된 무브 1개. 구조(환경·자율성) 편지엔 간식 스왑·새 식재료 제안 미주입('한 번에 하나').
  5. 퇴고polishKo(결정론 어법) + proofreadLetter(LLM). ⚠️ 스타일 전용: "내용·정보·길이·문장 수 불변, 새 정보 추가·삭제 금지" 계약 — 다양성·패러프레이징 담당이 아니다. 다양성은 2단계(계획 회전·아크)가 구조로 만든다.
  6. 결정론 가드(detBad) — 정적 블랙리스트 13종(letterDeterministicBad: 시점 환각 / 입력에 없는 증상 단정 / NO_MIX 프레임 섞기·'으깨N섞'·'섞어 함께' / 김치에 섞기 / 까스·너겟·튀김·소시지·어묵 권유 / 'N가지' 가짓수 칭찬 / 해조류↔생선 혼동 / 단 과일+짠 재료 괴식 / 과일↔끼니 페어링 / 밀가루 날것 / 쌀 날것 / 짠맛=입맛 발달 칭찬 / 김치류 칭찬) + 동적 detForbid(그날 데이터로 계산: 점심 기록 3일+면 '점심 거르/건너/안먹/굶' 금지 · 단발 이벤트 단어[뷔페·외식·여행·파티·잔치·캠핑·행사]가 최근 2일 메모에 없으면 언급 금지) → 위반 시 재생성 ≤2회.
  7. 어휘 유사도 가드 — 직전 5통 대비 전체≥0.45/도입25자≥0.40이면 무브 회전 재생성 ≤2회(구조 프레임은 SCEN_MOVES 안에서 회전).
  8. 의미 검증자 — Haiku 1콜: 사실 근거(시계열 라벨 위반 포함)·행동 1개·금지 표현(체중/점수/미션/진단·처방)·진단 재서술 검사 → 위반 시 fixNotes 주입 1회 재작성. fail-open(발행 차단 안 함) + 위반 발행 시 cron issues 경보. 허용 채널 명시(간식 평가·미입력 권유=오탐 방지).
  9. 발행 + 자가 측정context에 plan 시그니처·아크·simToPrev·repeatAlert·verify·model 기록 → 어드민 칩 + cron_runs.issues 경보. 발행 후에도 3일 연속 시그니처/유사도 0.6+면 반복경보.

변경 이력(06-11 하루): 사고 진단 → SCEN_MOVES·아크 5단계·healAnchor·반복경보(52811ba) → 점심 사실+detForbid(c8a3834) → 검증자+intro Sonnet(186f015) → 메모 2일 컷(f981933, 이후 철회) → 간식 분리·허용 채널(13c8306) → 사실 컴파일러로 통합·2일 컷 철회(최종).

🎯 한눈에

1. 진단 — 스캐폴드는 있으나 하네스는 아니었다

편지 생성은 이미 다단 파이프라인이다: 신호추출 → 시나리오 라우터(13종) → 프롬프트 조립 → 생성(haiku) → 안전가드 재생성 → 어휘 유사도 재생성 → 퇴고. 느슨한 의미의 스캐폴드는 맞다. 그러나 다양성을 보장하는 관절이 약했다 — 9개 에이전트가 6개 가설을 코드에 대해 반증 시도한 결과:

근본원인내용판정
시나리오 수렴만성 집공백 등원아는 home-daycare-gap/nutrient-gap만 점화 → 각도가 '집 공백'으로 수렴지지 0.82
타깃 비대칭코칭 무브는 결정론 회전인데 타깃(과일·콩류)은 LLM 재량 → 부족 1순위 고정이면 매일 같은 타깃지지 0.92
유사도 가드가 어휘적문자 3-gram 자카드 + 도입 첫 25자. "집…" vs "어린이집…"은 통과하나 사람 눈엔 같은 처방. 의미 중복 미측정지지 0.90
상태 원장 부재'최근 다룬 (타깃·무브·프레임)'을 구조화 저장·환류하는 게 없음. 비중복은 과거 편지 본문을 LLM에 호소지지 0.90
재생성 비탈출임계 초과해도 같은 계획 재호출(무브 텍스트만 변경) → 근접 중복 발행 가능지지 0.95
집-기관 프레임"어린이집은 충분히"는 home-daycare-gap 도입에서만. '충분'은 OCR 실측이지 추정 아님(부분 정정)부분 0.84

가장 근본: source_hash = 정렬식재료+거부+reds+notes날짜·무브·시나리오가 빠짐. 편식 식단은 며칠씩 비슷한 게 정상이라 해시가 자주 일치 → 어제 편지를 글자 그대로 재사용하며 모든 다양성 장치를 실행조차 안 함.

DB 확증: 6/8 nutrient-gap · 6/7 home-daycare-gap · 6/6 nutrient-gap — 시나리오 ID는 달랐는데 출력이 전부 '과일/어린이집 충분'으로 수렴.

2. 해법 — 계획=코드, 작문=LLM (하네스화)

핵심 전환: '무엇을 다룰지(계획)'를 LLM 손에서 빼내 코드가 결정론적으로 확정하고, LLM은 그 계획을 따뜻하게 풀어 쓰기만 한다. 계획은 구조화된 객체로 저장돼 다음날 자기 자신을 검증한다.

BEFORE · 프롬프트 적층 스캐폴드

  • 타깃 = LLM이 부족 목록에서 자유 선택
  • 검증 = 정규식 + 문자 3-gram (표면)
  • 상태 = 과거 편지 텍스트를 다시 먹여 "겹치지 마라" 호소
  • 재생성 = 같은 입력 재호출, 덜 나쁜 것 채택
  • source_hash 동결 = 다양성 장치 우회

AFTER · plan 엔진 (닫힌 루프)

  • 타깃 = 코드가 최근 안 쓴 결핍부터 결정론 회전
  • 검증 = 시그니처 frame|target|move 중복회피(생성 전)
  • 상태 = context.plan 구조화 원장(다음날 읽음)
  • 재생성 = 포화 시 정체기로 에스컬레이트
  • source_hash에 today 포함 → 매일 새 계획
# lib/coach.ts — 결정론 계획 + 공유 작성기 planFor(signals, recentScenarioIds, recentPlans, daySeed, cidHash) ├ selectScenario() 프레임 선택 + 최근 3일 dedupbuildCoachPlan() 타깃 회전 · 무브 회전 · 시그니처 중복회피 │ └ 포화 → escalate → plateau (같은 처방 반복 대신 쉬어가기) └ varyOpener 프레임 반복 시 "어린이집은 충분히" 도입 억제 │ ▼ composeLetter() 크론·온디맨드 공유 → 생성(haiku) → 안전가드 재생성 → 어휘 유사도 재생성 → 반환 │ ▼ coach_letters.context.plan ← 상태 원장(다음날 planFor가 읽음)

주요 구성: CoachPlan{frame,target,move,signature} · buildCoachPlan(타깃 결정론 회전 + 시그니처 중복회피 + escalate) · planFor · composeLetter(공유 가드) · planTargetBlock(타깃 코드 확정) · varyOpener · srcHash에 today · ?date= QA 오버라이드.

e0d676a plan 엔진e2d2f93 원장은 과거 편지만

3. 이사님 피드백 반영

피드백구현커밋
이틀 연속 같은 결핍 채근 금지
"다른 시나리오로 환기 — 엄마가 다음에 결심할 여유"
어제와 같은 타깃을 또 짚게 되면(대체 결핍 없음) gap 시나리오를 빼고 환기 각도(정체기)로 전환. 단일 결핍이면 gap/plateau 교대.62d1b93
밥과 과일은 따로 · 과일 추천은 간식으로SNACK_CHANNEL={과일} — 무브를 snack으로 고정, 끼니에 곁들이지 말고 "간식으로 따로" 프레이밍.62d1b93
과일↔끼니 페어링 차단결정론 가드 fruitMealPairing — 한 문장에 과일+끼니음식+곁들/옆에/섞 동사면 재생성(backstop).62d1b93
간식 멘트가 5일 중 4일 반복
"과자 대신 과일·요거트·계란"
① 쿨다운(context.snackShown 원장, 최근 2일 노출 시 생략) ② 과일이 그날 타깃이면 중복 제거 ③ daySeed 예시 로테이션. 신호는 며칠 간격으로 재환기.8a6ada5

원인은 모두 동일했다 — 메인 코칭이든 간식이든 윈도우를 매일 재계산하면서 이력(원장)이 없어 같은 결론으로 수렴. 해법도 같다: 구조화 원장 + 결정론 회전 + 의미 중복회피.

4. 검증 — 아린 6/4~6/8 재생성

?date= QA 오버라이드로 5일을 정방향 재생성(원장이 앞으로 쌓이도록). 정상 운영 모드는 byte-identical(무회귀).

날짜프레임타깃무브비고
6/4home-daycare-gap콩류cook-together간식 멘트 노출(예시 회전: 고구마·방울토마토)
6/5plateau콩류 채근 대신 태도 칭찬(환기) · 간식 쿨다운
6/6home-daycare-gap생선·해산물sensory감각 사다리(향·만지기)
6/7home-daycare-gap과일snack"오후 간식에 딸기·귤 따로"(밥 옆 아님)
6/8nutrient-gap콩류beside된장국에 두부

이틀 연속 같은 타깃 0건 · 과일↔끼니 페어링 위반 0건 · 5/5 고유 시그니처 · 간식 멘트 4/5 → 1/5.

Before(제보 이미지): 과일/과일/콩류 전부 "어린이집 충분 + 좋아하는 음식 옆에 놓아두기" → After: 4개 다른 타깃·각도, 채근 0. (검증: tsc 0 · next build 0 · 실함수 7일 시뮬 인접 3일 시그니처 충돌 0)

5. 추천을 네트워크 테이블로 근거화 ✅ Phase 1 배포

편지가 타깃 식재료를 어떤 음식·궁합으로 줄지를 LLM이 지어내던 것("콩밥·된장국")을, 검증된 네트워크 테이블에서 뽑아 사실로 주입하도록 바꿨다(plan 엔진과 같은 철학).

✅ Phase 1 배포완료 (502816c·e065193) — 이사님 결정 반영: 추상 '조리법'을 추천하면 LLM이 조합을 지어내다 괴식 → 검증된 '인기 음식 이름'만 권한다(조합은 그 음식 안에 이미 들어 있음). lib/coachRecos.ts 신설(사촌 + 그 식재료의 가장 많이 쓰이는 실존 음식 + 궁합, 전부 테이블 근거·매운음식 제외). 아린 검증: "또래는 순두부찌개·유부맑은국으로 먹어요"(콩류)·"고등어무조림·구이"(생선) 본문 노출 — 괴식·환각·매운·목록 밖 추천 0건.

데이터 ↔ 동작 매핑 (검증됨)

동작정본 테이블접근자상태
② 잘 먹는 식재료→사촌food-graph bridge(242)neighborsOf().bridge이미 됨
②′ 궁합(같이 넣을 것)food-graph pair(589)neighborsOf().pair이미 됨
조리법 → 인기 음식 이름으로 대체kit-dish-matrix(식재료×조리법 0~3)dishesForIngredient()인기음식 폴백
③′ 조리법+안전 그램cooking-amounts·cuisine-guidecookingGroups()보류(괴식가드 우선)
① 식재료→또래 음식ingredient-recipes(식재료→또래 메뉴)loadFreqRecipes()✅ 구현
음식→비슷한 음식(직접 자산 없음)derive 필요보류(우회)

세 테이블 모두 식재료(노드/키) 단위 — "음식→비슷한 음식"을 직접 줄 자산은 없다(dish 노드 0개). 우회: 잘 먹는 식재료 기반 또래 음식/사촌(데이터 충분) 또는 빌더로 dish 유사도 생성(거침).

제안 — lib/coachRecos.ts 신설

cron/coach 수집: likedIngredients · favoriteMenus · planTarget │ NEW buildRecoFacts() ② 사촌 : neighborsOf(liked).bridge [food-graph] 이미 있음·정식화 ②′궁합 : neighborsOf(liked|target).pair ③ 조리법 : dishesForIngredient(target) 상위2 [kit-matrix] 신규 + cookingGroups(target) 안전 그램·질식 팁 [cooking] ① 또래음식: loadFreqRecipes(target|cousin) 연령대·비매운 [ingredient-recipes] 신규 ▼ RecoFacts(구조화) → letterInput [검증된 추천 — 반드시 이 목록에서만, 밖은 지어내지 마라] · 사촌 고구마→단호박 / 궁합 두부+미역 · 타깃 두부 조리법: 된장국·두부조림(부드럽게) · 또래 인기: 순두부찌개

Phase 1 ✅ 배포완료

  • ② 사촌 + ① 타깃·사촌 식재료의 인기 음식 이름 + 궁합
  • 타깃에도 테이블 근거 → "콩밥·된장국" 환각 제거
  • lib/coachRecos.ts + cron/api/프롬프트 (502816c·e065193)

Phase 2 보류

  • 진짜 "음식→비슷한 음식"(dish 유사도 빌더)
  • 사촌→인기 음식이 사실상 대신하므로 보류
  • 필요 시 kit-matrix 식재료 프로필 코사인으로

이사님 결정 — 종결

결정결론
조리법 정책 완화?완화 불필요 — 추상 '조리법' 대신 검증된 인기 음식 이름으로 권하니 괴식 위험이 근원에서 제거됨. 상세 레시피 금지는 유지(부모 검색).
① 음식→비슷한 음식(a) 우회 채택 — 잘 먹는 식재료 → 사촌 → 그 식재료의 인기 음식이 "비슷한 음식 추천"을 안전하게 대신. Phase 2 빌더 보류.

6. 배포 요약

e0d676a plan 엔진(하네스화) 62d1b93 채근 방지 + 과일 간식 채널 + 페어링 가드 e2d2f93 원장은 과거 편지만 8a6ada5 간식 멘트 쿨다운+로테이션 502816c 추천 근거화 Phase 1(coachRecos) e065193 타깃 인기 음식 본문 노출

main → Vercel mealfred-app. 스키마 변경 없음(기존 context jsonb에 plan·snackShown 키 추가). 오늘 밤 정기 크론부터 전 자녀 적용. 잔여: 며칠 모니터 · 간식 cadence(쿨다운=2) 체감 후 조정 · CRON_SECRET 미설정.