2026-06-13 · 설계 사상 = v3의 두뇌(진단·계획·커리큘럼)를 가이드로 가져오고, 작문은 v2 LLM이 직접. 기술 해부도 = coaching-engine-architecture.html. 본 문서는 그 하이브리드 설계 + 별도 세션 감사 인계서(개선 A~I)를 구현 가능한 원자 단위로 분해한 작업 명세서다. 각 원자는 {목적·요구·구현 명세·유즈케이스·테스트·완료 기준(DoD)·의존·규모}를 갖는다. 원자 ID로 커밋·테스트를 추적한다.
전체 규모 요약 — EPIC 10개 · 원자 111개 · 테스트 893개 · 마일스톤 D1~D5. 신규 파일 36(web/lib/coachMaterials.ts·web/lib/comboMatrix.ts·web/tests/coach-materials.test.ts·web/tests/coach-recos.test.ts·web/tests/combo-matrix.test.ts·web/tests/fixtures/materials.ts·web/lib/coachGuide.ts·web/tests/coach-guide.test.ts·web/lib/coachGrounding.ts·web/tests/coach-grounding.test.ts·web/lib/coachQuality.ts·web/tests/coach-quality.test.ts·web/lib/coachCompare.ts·web/tests/coach-compare.test.ts·web/tests/fixtures/compare-arin.json·web/components/CompareLetterCard.tsx·web/lib/altLetter.ts·web/tests/alt-letter.test.ts·web/tests/compare-card.test.tsx·web/lib/compareVote.ts·web/app/api/feedback/letter/route.ts·web/app/admin/compare/page.tsx·web/app/admin/compare/VoteButtons.tsx·web/tests/compare-vote.test.ts·web/tests/coach-merged.test.ts·web/tests/compare-replay.test.ts·web/tests/fixtures/arin-golden-b.json·web/scripts/build-ingredient-freq.py·web/public/ingredient-freq.json·web/lib/ingredientFreq.ts·web/lib/comboGuard.ts·web/lib/recoEvidence.ts·web/tests/ingredient-freq.test.ts·web/tests/group-ingredients.test.ts·web/tests/combo-guard.test.ts·web/tests/reco-evidence.test.ts) · 수정 16(web/lib/coachRecos.ts·web/lib/coachDaily.ts·web/lib/coachWeekly.ts·web/lib/curriculumUnits.ts·web/lib/coach.ts·web/tests/coach-guards.test.ts·web/app/api/cron/coach/route.ts·web/app/page.tsx·web/app/admin/[childId]/page.tsx·web/app/admin/layout.tsx·web/app/api/cron/coach-selfcheck/route.ts·web/tests/fixtures/real-arin.json·web/lib/replayMetrics.ts·web/lib/replayRunner.ts·web/package.json·web/app/admin/cron/page.tsx) · SQL 2(web/sql/2026-06-13_letter_feedback_variant.sql·web/sql/2026-06-14_letter_feedback_variant.sql).
🧭 핵심 설계 — 두뇌는 v3에서, 손(작문)은 v2 LLM
v3는 결정(진단·계획)과 표현(작문)을 둘 다 결정론으로 묶어(상태기계→고정 블록) "매번 같은 문장"으로 굳었다(수렴성 4/10·문구 반복 18회). 새 설계 = v3의 두뇌만 가져오고 손은 LLM에 푼다. 커리큘럼·실라버스도 고정 블록이 아니라 가이드로 LLM에 주고 LLM이 직접 쓴다.
아린 2통 A/B — 매일 Letter A(기존 v2 그대로·대조군) + Letter B(개선 A~I 전부 적용·처치군)를 동시 발행. ★모든 개선은 B에만, A는 무변경★ → 어드민 피드백(👍/👎/🔁)에서 B가 이기면 전 자녀 승격. 실증: 하이브리드가 v3 조립본을 6:0으로 이김(은유·환각·기간·구체성).
인계서 개선
핵심
착지 EPIC
A 괴식 조합검증
잘먹는음식×결핍식재료를 kit-dish-matrix(0~3)+pair로 점수화 → 통과 조합만 LLM 후보(미역국+당근 차단)
A·H
B 재료 결정론/문장 LLM
추천 식재료는 코드가 매일 회전(3일 무재사용), 문장만 LLM(수렴 방지)
A·C
C 4기준 가중 랭킹
시급도·급식빈도 상위%·궁합·사촌 가중 랭킹 + freqMap 죽은코드 부활 + 근거문구
A·E·I
D 잘먹음 판정
liked=집(≠급식)+2일+거부없음, 거부 후순위
A
E 온보딩<3일
분석 대신 입력안내+즉효 팁(환각 차단)
B·C
F 기간 수치
"최근 7일 중 3일"처럼 수치(모호어 금지)
A·C
G 품질축 검증
은유사전·나열·모호기간·재료밖 음식명 스캔 → 재생성("게이트 그린≠좋은 편지" 봉합)
D·H
H 무검증 채널 차단
거울·추천을 본문 합본해 검증 1패스(슬롯값 포함)
C·D
I 데이터 정합성
GROUP_INGREDIENTS 실측 빈도 정비(단호박 0회 강등)
I·A
📌 개발 지시 프로토콜 — 이 문서가 작업 지시의 단일 진실
지시 방법: "WBS 보고 A-03 구현해" / "EPIC C 진행해" — 원자 ID만으로 지시. 구현자는 해당 원자의 {명세·테스트·DoD}를 그대로 따른다.
완료 절차(4종): ①명세대로 구현 ②해당 테스트 ID 그린(+npm test 전체 그린 — prebuild 게이트) ③커밋 메시지 맨 앞에 원자 ID ④본 문서 상태칩 갱신(⬜→✅ + 커밋 해시 · 진척 대시보드 동시).
명세와 다르게 만들 때: 코드 먼저 금지 — 본 문서 원자 명세를 먼저 수정·커밋(사유 1줄)한 뒤 구현(문서≠코드 드리프트 방지).
엣지 발견 시: J-10 복리 규칙 — fixture+테스트(red→green)+수정+일지 1줄. 새 원자는 해당 EPIC 말미에 ID 추가.
상태칩: ⬜ 대기 · 🔨 진행 중 · ✅ 완료(커밋) · ⏸ 외부 대기(SQL 실행 등) · ❌ 드랍(사유 기입). 현재 A~J 전 원자 ✅ 코드 완료(테스트 815·tsc·build 그린). 외부 게이트만 ⏸ — compare_votes SQL 실행+배포 카나리아(아래 컷오버 절차).
📊 진척 대시보드(원자 완료 시 갱신 — 작성 2026-06-13 · A~J 코드 완료·테스트 815)
EPIC
원자
완료
테스트
마일스톤
상태
A 재료 엔진
12
12
136
D1
✅ 완료 598bd55 · 테스트 94 그린(전체 314)·tsc 클린·Letter A 무변경
B v3 두뇌 가이드 합성
11
11
84
D2
✅ 완료 a464c2e
C merged 작문 모드
14
14
94
D2
✅ 완료 5659dc2
D 품질·무검증 채널 검증 (coach.ts 발행전 가드)
11
11
101
D2
✅ 완료(오탐 수정) a464c2e·85ea9b3
E 크론 2통 발행
11
11
84
D3
✅ 코드 완료 · ⏸ 배포+카나리아 검증 cf52387
F 앱 2카드 노출 (아린 코칭 화면 A/B 비교 UI)
8
8
60
D3
✅ 코드 완료 · ⏸ 배포 육안검증 3ccedb8
G 어드민 A/B 2통 비교 + 변형별 피드백 승자 판정
12
12
78
D3
✅ 코드 완료 · ⏸ compare_votes SQL 실행 3ccedb8
H 검증 하네스·회귀·적대 테스트 (Letter B 품질 게이트)
15
15
114
D4
✅ 완료(괴식·수렴·품질 게이트 0) cf52387
I 데이터 정합성·근거 보강 (급식빈도 상위%·GROUP_INGREDIENTS 정비·괴식 조합 점검·근거 문구)
🚀 EPIC J — A/B 컷오버·운영·롤백 절차 (코드 완료 · 운영 대기 2026-06-13)
활성화(2단계): ① web/sql/2026-06-13_compare_votes.sql 실행(이사님 — 변형별 피드백 저장). ② Vercel env COACH_COMPARE_CHILDREN=43942d34-b339-4bbd-978a-ec3f6a877031(미설정 시 기존 COACH_V3_CHILDREN 폴백) → 다음 새벽 크론부터 아린만 2통(A=기존 v2 대조군 메인 · B=하이브리드 context.altLetter). QA 즉시: ?child=<id>&date=<과거>&force=1.
목적인계서 I: 빈도 가중(C)을 도입하면 급식 0회 식재료(단호박)가 항상 탈락해 대표 식재료가 죽는다. coachRecos.ts의 GROUP_INGREDIENTS 비타민A채소 ['단호박','당근','시금치','근대']를 실측 빈도 순(당근184·시금치13·근대11·단호박0)으로 재배열하고, 빈도 메타를 코드에 명시해 랭킹 엔진이 참조하게 한다.
요구각 식품군 대표 식재료에 실측 급식빈도(learned_menus 1000개 기준)를 부착하고, GROUP_INGREDIENTS는 빈도 내림차순으로 정렬한다. 단호박은 목록 끝(또는 강등)으로. 기존 popularDishesFor/buildRecoFacts(Letter A 경로)는 무영향이어야 한다(상수 정렬만 변경).
'기타채소'는 토마토42>브로콜리25>양배추20 순 정렬, 단 기존 ['브로콜리','양배추','애호박','버섯','토마토'] 유지가 Letter A 회전에 영향 — Letter A는 대조군이므로 GROUP_INGREDIENTS는 상수 정렬 변경이 pickFoodReco(seed%length 회전) 결과를 바꾼다. 따라서 신규 상수 GROUP_INGREDIENTS_RANKED를 coachMaterials.ts에 별도로 두고 빈도 메타({nm,gioFreq,gioPct}) 부착, 기존 GROUP_INGREDIENTS는 무변경(Letter A 보존)
coachMaterials.ts에 const GIO_FREQ: Record<string,{freq:number;pct:number}> 신설 — 당근{184,2}·토마토{42,12}·브로콜리{25,18}·양배추{20,24}·시금치{13,33}·근대{11,39}·단호박{0,100}·치즈{18,27}·요거트{0,100} (인계서 실측표)
목적인계서 D·P10: 현재 likedIng은 favIngFreq(place 무관) 단순 빈도라 '급식에 차려진 것'을 선호로 오인한다. liked = place!=daycare(집) AND 집에서 2일 이상 등장 AND 거부 이력 없음. 거부된 것은 refused로 후순위.
요구recentMeals 배열(food·place·ateWell·refused·daysAgo)을 입력받아 liked(선호)·refused(거부) 식재료를 분리하는 순수 함수. 급식/간식(차려진 것)은 선호 신호에서 제외. 집 2일 미만이면 liked 제외.
목적인계서 A 최우선: '잘먹는음식+결핍식재료' 조합을 kit-dish-matrix.json(음식×식재료 0~3)+food-graph pair로 점수화. 임계 미만 금지. 실증 괴식='미역국+당근'(matrix 미역국·당근=1). OK='볶음밥/카레/덮밥+당근'(=3). LLM이 조합 짓게 두지 않는다.
요구음식(dish)과 식재료(ingredient) 쌍의 정합성을 0~3 점수로 반환하는 순수 함수. matrix scores[dish][ing] 우선, 없으면 cells(동시출현) 또는 pair 그래프 폴백. 임계(기본 2) 미만이면 부적합 판정.
명세
comboMatrix.ts scoreCombo(dish:string, ing:string): {score:number; source:'matrix'|'cells'|'pair'|'none'} — kitGuide.ts와 동일 데이터(kit-dish-matrix.json) 직접 로드 또는 kitGuide 확장
scores[dish]?.[ing]가 있으면 그 값(0~3)·source='matrix'. 미역국·당근=1 검증
matrix에 dish 행 자체가 없으면 food-graph pair strength로 폴백: neighborsOf(ing) 중 kind==='pair' && nm===dish 의 strength(0~3)·source='pair'
목적인계서 F: '요즘/최근/이번주' 모호어 금지. '최근 7일 중 비타민A 채소가 3일'처럼 수치(주간 등장일)와 임계를 코드로 정의해 LLM 재료에 제공. LLM이 모호어 쓰지 않도록 수치 사실을 못 박는다.
요구computeGroupSignals/computeSignals 결과를 입력받아 가장 시급한 결핍의 {nutrient, daysOf7, threshold, windowDays:7} 사실 객체를 반환. weeklyEst를 7일 등장일로 환산하고 임계(green 기준)를 명시.
목적인계서 핵심 산출물. A-02~A-09를 조립해 selectDailyMaterials(args)→{targetGroup, recommendedIng, validatedCombos, reasonPhrases, deficiencyWindow, liked, refused, mode}를 반환하는 결정론 재료 엔진. Letter B 작문가가 받을 사실·재료 묶음 단일 진실.
요구signals·meals·recentRecos·freqMap·온보딩 메타를 입력받아 하나의 재료 객체를 반환. 온보딩 분기 → 결핍 산출 → 랭킹 → 회전 → 조합검증 → 근거문구를 순서대로 호출. 전부 결정론·순수.
recommendedIng은 targetGroup 식재료 — recommendedIng ∈ GROUP_INGREDIENTS_RANKED[targetGroup](staple 형태 변환 고려)
A-10-11
회귀
validatedCombos 전부 score>=threshold — 모든 combo.score >= 2(괴식 0)
A-10-12
회귀
Letter A 미호출 — composeLetter 경로와 독립 — selectDailyMaterials는 planFor/composeLetter import 안 함(B 전용·A 무변경)
DoD
selectDailyMaterials export·DailyMaterials 타입 export·결정론·순수
A-02~A-09 전부 조립·각 분기 통합 테스트
Letter A 경로 미참조(대조군 보존) 박제
npm test 그린(prebuild 게이트)
의존A-02 · A-04 · A-05 · A-06 · A-07 · A-08 · A-09
✅A-11 · freqMap 실배선 — loadFreqMap·route 전달 헬퍼(C 죽은 코드 부활)코드코드 · 0.75h
목적인계서 C: freqMap(ingredient-recipes 급식빈도)이 route.ts에서 로드만 되고 본경로 미전달=죽은 코드. selectDailyMaterials까지 freqMap이 도달하는 로딩·전달 헬퍼를 만들고 형태를 고정한다(통합은 H 에픽이 배선, 여기서는 순수 어댑터·검증).
요구ingredient-recipes 데이터를 FreqMap 형태(coachRecos.ts FreqMap=Record<string,{name,freq}[]>)로 정규화하는 순수 어댑터와, 비어있을 때 kit-matrix 폴백이 동작함을 보장하는 검증.
liked가 미역국이어도 liked 신호 정확 — 미역국이 집 2일+ → liked, 급식만이면 미제외 (D 판정 fixture 검증)
A-12-7
회귀
freqMap 주입/미주입 둘 다 괴식 0 — freqMap={} 와 freqMap 주입 두 경로 모두 괴식 0
A-12-8
속성
결정론 — 같은 날 입력 2회 동일 추천 — 동일 day fixture 2회 → 동일 recommendedIng
DoD
fixtures/materials.ts·coach-materials.test.ts 추가
인계서 6통 괴식·수렴 사례 회귀 박제(red→green)
6일 시뮬 괴식 0·연속3일 무재사용 검증
npm test 그린(prebuild 게이트)
의존A-10
EPIC B — v3 두뇌 가이드 합성 — buildTeachingGuide D2
v3 두뇌(decideDailyV3 일간 진단·계획 + runWeeklyPlanning/impression 주간 종합 + UNITS 12유닛 커리큘럼)를 고정 블록이 아니라 LLM용 '가이드 브리프'로 합성한다. 결정·진단은 결정론으로 못 박고(unit_ko·lever·stepBehavior·arcStage·재서술 금지목록), 표현(문장·도입·톤)만 LLM에 푼다. 새 순수함수 buildTeachingGuide()의 출력은 coachRecos 재료와 합쳐 Letter B 입력(LetterInput)이 되며, 모든 변경은 Letter B에만 적용(A 무변경).
✅B-01 · TeachingGuide 타입·계약 정의 (coachGuide.ts 골격)설계코드 · 0.5h
목적v3 두뇌를 LLM 재료로 전달하는 단일 계약(TeachingGuide)을 박제해 이후 원자(B-02~)와 Letter B 조립(EPIC 외부)이 같은 형태를 공유하게 한다. 고정 슬롯·byte 거울 금지 원칙을 타입 주석에 명시.
요구buildTeachingGuide의 출력 타입 TeachingGuide = {unit_ko, lever, stepBehavior, why, arcStage, weeklyImpressionSoft, doNotRestate, stepN, mode} 를 lib/coachGuide.ts에 선언. 모든 필드는 '코드가 못 박는 사실/가이드'이며 문장 작문은 LLM 몫임을 JSDoc에 명문화.
명세
신규 파일 lib/coachGuide.ts 생성. import: UNITS·type UnitId·type UnitDef from './curriculumUnits', type DailyDecision from './curriculum', type WeeklyAnchor·WeeklyArcStage from './coachWeekly'.
JSDoc: '결정·진단=결정론(이 함수). 문장·도입·톤=LLM. 고정 블록/byte 거울 금지 — guide는 LLM에 주는 *가이드*이지 출력 문장이 아니다.' 명시.
파일 헤더에 'EPIC B · Letter B 전용 · A(planFor+composeLetter)는 이 모듈을 import하지 않는다' 주석.
유즈케이스
크론(EPIC 외부 H)이 decideDailyV3 결과 + 주간 닻을 buildTeachingGuide에 넣어 TeachingGuide를 받고, coachRecos 재료와 합쳐 LetterInput을 만든다.
리플레이/테스트가 TeachingGuide 형태를 단독 검증.
테스트4개
ID
종류
케이스 · 검증(입력→기대)
B-01-1
단위
TeachingGuide 타입 컴파일·필드 완결성 — buildTeachingGuide 더미 호출 결과 객체에 unit_ko·lever·stepBehavior·why·arcStage·weeklyImpressionSoft·doNotRestate·stepN·mode 9키가 모두 존재 → toHaveProperty 9건 통과
B-01-2
단위
lever 값은 UnitDef.lever 유니온 내 — 임의 유닛 가이드의 lever ∈ {food,environment,autonomy,texture,mixed} → 포함 검증 true
B-01-3
단위
doNotRestate는 항상 배열(null 아님) — 가이드.doNotRestate가 Array.isArray=true (빈 배열이라도 배열)
✅B-02 · decideDailyV3 결정 → stepBehavior·unit_ko·lever·stepN 매핑코드코드 · 1h
목적DailyDecision(unit·step·mode)을 UNITS 레지스트리에서 사람이 읽는 가이드 재료(한국어 유닛명·해당 단의 행동 문구·레버·단계번호)로 변환한다. 이것이 '무엇을 가르칠지'를 코드가 못 박는 핵심.
요구buildTeachingGuide가 decision.unit→UNITS[unit].label(unit_ko)·.lever, decision.step→steps[step-1].behavior(stepBehavior)·stepN을 추출. step 범위 밖(0·초과)이면 가장 가까운 유효 단으로 클램프. decision=null이면 lowdata/온보딩 분기(B-07)로 위임.
목적v3 일간 mode와 v2 작문 아크(WeeklyArcStage)를 잇는다. mode가 '오늘 무엇을 할지'라면 arcStage는 '편지를 어떤 각도로 쓸지'. 고정 블록 대신 LLM에 '오늘은 이 단계 톤으로'를 가이드.
요구buildTeachingGuide가 mode + firstOfWeek + lastArcStage + progress(관측됨)를 받아 arcStage를 결정. planFromWeekly의 기존 아크 회전 규칙(intro=주첫·reinforce 이틀연속 금지·how/obstacle/observe 요일회전)을 재사용하되 mode 신호를 우선 반영.
명세
arcStage 결정 우선순위: ① mode='celebrate' → 'reinforce'(졸업 축하=실행 인정 톤) ② firstOfWeek && (mode='advance'||'pivot') → 'intro'(새 유닛/단 도입 — coachDaily.introNeededV3와 정합) ③ mode='maintain' → 'observe'(정체 위안·행동 0) ④ progress관측 && lastArcStage!=='reinforce' → 'reinforce' ⑤ 폴백 = planFromWeekly의 ROT=['how','obstacle','observe'] 요일 회전 재사용.
planFromWeekly(coachWeekly.ts:297-300)의 stage 결정 로직과 동일 규약 — reinforce 이틀 연속 금지(lastArcStage==='reinforce'면 ROT로 환기) 보존.
arcStage 매핑 함수는 순수: arcStageFor(p: {mode; firstOfWeek; lastArcStage; progress; dow}): WeeklyArcStage 로 분리해 단독 테스트.
mode='deepen'은 firstOfWeek 아니면 ROT 회전(같은 유닛 심화=새 도입 아님 → intro 금지).
유즈케이스
주 첫 편지·focus 새 유닛 advance → intro(왜 이 코칭 1회).
mode=maintain(정체) → observe 톤(행동 권유 0·태도 위안).
졸업(celebrate) → reinforce(실행 인정).
어제 reinforce였는데 오늘도 progress → ROT 단계로 환기(reinforce 2연속 차단).
목적주간 닻 impression(Sonnet 내부 소견·부모 비노출)을 일간 가이드의 '배경 맥락 한 줄'로 부드럽게 정제해 LLM 재료로 준다. 진단어·내부개념을 LLM 입력 단계에서 차단(H 무검증 채널 닫기와 정합).
요구buildTeachingGuide가 anchor.impression(coachWeekly WeeklySynthesis.impression)을 받아 weeklyImpressionSoft로 가공. 진단/처방 단어·'미션/과제/진도/단계' 내부 용어 스캔 후 제거 또는 null화. impression 없으면 null.
목적A/B 구조에서 아린의 진도(curriculum_progress·weekly_plans.goals)를 누가 굴리는지 못 박는다. compare 모드는 v3 순수 조립을 대체하므로, 진도 전진(decideDailyV3.updates·goalsAfter)은 Letter B 경로가 책임지고 영속한다(미래 차단·연속성).
요구buildTeachingGuide는 진도를 변경하지 않는 읽기 전용(순수)이고, 진도 영속은 크론(H)이 decideDailyV3의 updates·goalsAfter를 curriculum_progress·anchor.goals에 upsert함을 계약으로 문서화. B는 진도를 굴리고 A는 진도를 굴리지 않음(대조군은 v2 planFor만, 진도 무관).
SQL 실행은 H에 위임(여기선 형태만·SQL 실행확인 없이 다음단계 금지 원칙은 H가 준수)
npm test 그린
의존B-01 · B-08
✅B-10 · 데이터 정합성 I — GROUP_INGREDIENTS 대표 식재료 급식빈도 정비(가이드 노출 정합)코드코드 · 1h
목적개선 I: 단호박(급식 0회)·근대(11회) 같은 빈도 없는/낮은 대표 식재료가 가이드 why·재료에 올라오면 freqMap 가중(C)에서 탈락하므로, exposure-savings/food-bridge 유닛이 가이드에 노출하는 대표 식재료를 급식빈도 있는 것으로 정비한다.
요구가이드의 food 레버 유닛(exposure-savings·food-bridge·link-rhythm)이 참조하는 대표 식재료 목록을 급식빈도(learned_menus 실측) 기준으로 점검·교체. 단호박→토마토/브로콜리 등 빈도 있는 식재료로. 이 정비는 coachRecos.GROUP_INGREDIENTS(EPIC C 소관)와 정합을 맞추되, B 가이드가 직접 식재료를 만들지 않고 C 재료를 받는 경계를 명확히.
명세
★재료 선택은 결정론(코드 회전·EPIC C)이고 B 가이드는 unit/stepBehavior/why만 못 박는 경계 확인 — B는 식재료 이름을 새로 만들지 않는다(괴식·환각 차단).
curriculumUnits.ts의 food 유닛 extract는 foodTarget(외부 주입)을 받음(exposure-savings:182) — 대표 식재료 자체를 가진 GROUP_INGREDIENTS는 coachRecos.ts 소관이므로, B 원자는 '가이드가 빈도 0 식재료를 why/stepBehavior에 하드코딩하지 않음'을 보장하는 가드만 추가.
EPIC C — merged 작문 모드 — composeLetter groundingMode (재료=결정론·문장=LLM·사실=카드) D2
composeLetter에 Letter B 전용 merged(grounding) 모드를 추가한다. 거울·사실카드를 byte 고정 없이 데이터로 주입하고, 검증된 재료(selectDailyMaterials)·근거문구·수치 기간을 LLM 재료로 묶되 문장만 LLM이 자유롭게 쓰게 하며, 두뇌 가이드(buildTeachingGuide)를 주입하고, 기록<3일 온보딩 분기와 raw timeseries 강등(사실 인용은 카드만)을 적용한다. Letter A 경로(planFor+composeLetter 기존)는 분기로 완전 무변경.
✅C-03 · buildLetterUser merged — 거울(mirror) 데이터 주입(byte 고정 금지)코드코드 · 1h
목적compileFactCards가 만든 mirror를 merged 프롬프트에 '데이터'로 주입하되, LLM이 매일 다르게 풀어 쓰도록 지시한다(고정 슬롯·byte 거울 금지 — 이사님 확정). 사실은 묶되 표현은 풀어 유연함 유지.
요구merged 모드에서만 mirror 블록을 끼우고 '이 거울을 그대로 복사하지 말고 너의 문장으로'를 명시. 레거시는 buildLetterUser에 mirror를 안 쓰므로 무영향.
명세
lib/coach.ts buildLetterUser merged 분기에 `b.mirror ? '[식단 거울 — 아이가 실제 먹은 것/영양 신호. ⚠️ 그대로 베끼지 말고 너의 표현으로 1~2문장에 녹여라(byte 복사 금지). 사실(메뉴·결핍군)은 바꾸지 말 것]\n' + b.mirror : ''` 추가.
mirror는 coachFacts.buildMealMirror 산출 — 사실(결핍군·추천)은 코드가 못 박은 것.
mirror 안 추천·결핍군 사실은 materials와 모순되면 안 됨 → 둘 다 selectDailyMaterials 타깃에서 파생(정합 검증=C-09).
유즈케이스
mirror='노랑·주황 채소가 아쉬워요. 짜파게티에 당근을 넣어보세요' → LLM이 '주황빛 채소가 한 발 비어 있더라고요…' 로 재서술.
테스트4개
ID
종류
케이스 · 검증(입력→기대)
C-03-1
단위
merged + mirror → 거울 데이터 + byte 복사 금지 지시 — buildLetterUser({groundingMode:'merged',mirror:'당근 비어요'}) → '당근 비어요'·'그대로 베끼지'·'byte' 또는 '복사 금지' 포함
목적buildTeachingGuide(EPIC B) 산출을 고정 블록이 아니라 '가이드'로 LLM에 주고 LLM이 직접 쓰게 한다(v3의 두뇌만, 손은 v2 LLM). 커리큘럼·실라버스도 가이드로 전달.
요구merged 모드에서만 teachingGuide 블록을 끼우고 '교육 의도를 따르되 문장·도입·톤은 자유'를 명시. teachingGuide 없으면 기존 arcBlock(weeklyArc) 폴백.
명세
lib/coach.ts buildLetterUser merged 분기에 `b.teachingGuide ? '[오늘의 코칭 방향 — 코드/Sonnet가 정한 교육 의도. ⚠️ 의도·초점은 따르되 문장·도입·예시·톤은 자유롭게(고정 문구 복사 금지)]\n' + b.teachingGuide : ''` 추가.
teachingGuide가 있으면 arcBlock(weeklyArc) 생략(merged는 가이드 기반 통일).
행동 1개 원칙(P7) 유지 지시 추가.
유즈케이스
EPIC B가 진단→유닛 매핑→teachingGuide 텍스트 생성 → merged buildLetterUser가 LLM 가이드로 주입, LLM 자유 작문.
테스트5개
ID
종류
케이스 · 검증(입력→기대)
C-04-1
단위
merged + teachingGuide → 가이드 + 자유 작문 지시 — buildLetterUser({groundingMode:'merged',teachingGuide:'당근 푸드체이닝'}) → '당근 푸드체이닝'·'문장·도입'·'자유' 포함
C-04-2
단위
teachingGuide 있으면 arcBlock 생략 — buildLetterUser({groundingMode:'merged',teachingGuide:'G',weeklyArc:{stage:'how',behaviorGoal:'B'}}) → '이번 주 코칭 방향'(arcBlock) 미포함, 'G' 포함
목적기록<3일이면 분석 대신 (1)빠뜨린 입력 안내 + (2)배경 없는 즉효 편식 팁만 쓴다(개선 E). 가입 다음날 '요즘 채소 비어요' 환각 분석을 구조적 차단.
요구merged + onboardingMode=true이면 분석·결핍·시계열 블록을 모두 빼고 온보딩 전용 프롬프트로 분기. Letter A·레거시 무영향.
명세
lib/coach.ts buildLetterUser 안 `if (b.groundingMode==='merged' && b.onboardingMode)` 최우선 분기 → 분석 블록(reds·missing·timeseries·factCards·materials) 전부 생략.
온보딩 프롬프트: '[온보딩 — 기록 ${loggedDaysTotal??0}일뿐이라 분석 안 함. ⚠️ 결핍·패턴·영양 분석 절대 금지(환각). 대신 (1)빠트린 입력 안내(${missingInputHints 또는 기본 끼니·거부·환경) (2)배경 없는 즉효 편식 팁 1개]' + pickTip(daySeed).
가입 다음날 기록 1일 → onboardingMode=true → '거부한 음식이나 환경도 남겨주시면 더 정확히 봐드릴게요' + 즉효 팁.
테스트6개
ID
종류
케이스 · 검증(입력→기대)
C-05-1
단위
merged + onboarding → 분석 블록 전부 생략 — buildLetterUser({groundingMode:'merged',onboardingMode:true,reds:['철분'],missing:['채소'],timeseries:['T']}) → '철분'·'부족 영양소'·'시계열 사실' 미포함
C-05-2
단위
온보딩 → 결핍 분석 금지 지시 + 입력 안내 — 출력에 '분석하지'·'안내'·'팁' 취지 포함, missingInputHints 항목 포함
C-05-3
단위
온보딩 → 즉효 팁 1개 주입 — buildLetterUser({groundingMode:'merged',onboardingMode:true}) → pickTip 결과 문자열 포함(TIPS 존재 시)
C-05-4
회귀
merged이지만 onboardingMode=false → 정상 분석 — buildLetterUser({groundingMode:'merged',onboardingMode:false,reds:['철분']}) → '철분' 포함
C-05-5
회귀
레거시는 onboardingMode 무시 — buildLetterUser({onboardingMode:true,reds:['철분']}) (미지정) → '철분' 포함
C-05-6
단위
missingInputHints 없으면 기본 안내 — buildLetterUser({groundingMode:'merged',onboardingMode:true,missingInputHints:null}) → '끼니'·'거부'·'환경' 중 최소 1개 포함
combos는 score>=1만 포함(C-09 검증 통과분 — 0=괴식 금지). 점수순 정렬, 상한(최대 4) 절단. 빈 combos면 조합 라인 생략.
타깃 식재료명·dish·cousins 외 음식명은 절대 합성 안 함(LLM 지어내기 입구 차단). rationale·periodFact는 입력 그대로 박음(사실 생성 안 함).
buildRecoFacts(coachRecos.ts — 레거시 bridgeFacts)와 역할 분리되어 공존(둘 다 별개 함수).
유즈케이스
selectDailyMaterials → {target:'비타민A채소',targetIngredient:'당근',combos:[{dish:'짜파게티',ingredient:'당근',score:2}],rationale:'급식 상위2%',periodFact:'최근 7일 중 3일'} → serializeMaterials → merged materials 텍스트.
테스트9개
ID
종류
케이스 · 검증(입력→기대)
C-07-1
단위
기본 직렬화 — 타깃·조합·근거·기간 전부 포함 — serializeMaterials({target:'비타민A채소',targetIngredient:'당근',combos:[{dish:'짜파게티',ingredient:'당근',score:2}],rationale:'급식 상위2%',periodFact:'최근 7일 중 3일'}) → '당근'·'짜파게티'·'급식 상위2%'·'최근 7일 중 3일' 전부 포함
✅C-14 · coach-grounding 테스트 스위트 + prebuild 게이트 회귀테스트테스트 · 1.5h
목적EPIC C 순수 함수·분기를 tests/coach-grounding.test.ts로 묶고 Letter A 무변경(byte 동일) 회귀를 coach-guards.test.ts에 추가해 prebuild 게이트(npm test)에 편입. 엣지 발견 시 fixture+테스트(red→green)+수정 복리 원칙 적용.
요구C-01~C-13의 모든 test 케이스를 vitest로 구현하고 기존 220개 통과를 깨지 않는다. Letter A byte 동일 회귀 스냅샷 포함. callClaude 모킹.
명세
tests/coach-grounding.test.ts 신규 — serializeMaterials(C-07)·buildOnboardingDecision(C-08)·verifyComboSafety(C-09)·qualityScan(C-12) 순수 함수 테스트.
tests/coach-guards.test.ts에 buildLetterUser merged 분기(C-02~C-06)·verifyFacts merged(C-11)·byte 동일 회귀(C-01-1) 추가.
composeLetter 통합(C-10·C-13)은 callClaude를 vi.mock으로 고정 응답 모킹 + 전달 user 텍스트를 spy로 단언.
Letter A byte 동일: groundingMode 없는 대표 fixture로 buildLetterUser 출력 스냅샷 → 변경 후 일치.
fixtures/ 기존 디렉터리 재사용. vitest 실행으로 그린 확인 후 머지(불변 원칙 ①).
유즈케이스
npm test(prebuild 게이트) → coach-grounding 신규 + 기존 7파일 전부 그린.
Letter A 회귀 스냅샷이 깨지면 = 대조군 오염 → 즉시 차단.
테스트6개
ID
종류
케이스 · 검증(입력→기대)
C-14-1
회귀
신규 스위트 전부 그린 — npx vitest run tests/coach-grounding.test.ts → 0 fail
C-14-2
회귀
기존 220개 회귀 무파손 — npx vitest run → 기존 7파일 통과 수 >= 220
C-14-3
회귀
Letter A buildLetterUser byte 동일 스냅샷 — groundingMode 없는 fixture → toMatchSnapshot 일치(대조군 보호)
C-14-4
통합
callClaude 모킹으로 composeLetter 통합 결정론 — vi.mock(callClaude) → merged/레거시 둘 다 네트워크 없이 통과
C-14-5
회귀
prebuild 게이트 편입 확인 — package.json prebuild/test 스크립트가 tests/ 전체를 돌림(신규 파일 포함)
C-14-6
적대
엣지 fixture(빈 데이터·온보딩) red→green — 기록 0일 fixture → onboarding 분기 → 분석 블록 0, 입력안내 present(발견 엣지 고정)
DoD
coach-grounding 신규 스위트 + coach-guards 추가 테스트 전부 그린
근본원인 ①②를 닫는다 — (G)LLM 출력에 품질축 결정론 스캔(은유 클리셰·'어제 X 먹었어요' 나열·모호 기간어·재료 밖 음식명)을 추가해 위반 시 재생성하고, (H)거울·추천·슬롯값을 본문과 합본해 검증 1패스로 무검증 채널을 봉합한다. 모든 신규 가드는 Letter B 전용이며 v2 Letter A(planFor+composeLetter)는 무변경 대조군으로 보존한다.
✅D-01 · 은유 클리셰 사전 + metaphorOveruse(L) 검출기코드코드 · 1h
목적v3가 굳어버린 클리셰 은유(통장·적금·문·계단·걸음·무대·디딤돌·사슬·길·풍경)를 과용하는 편지를 결정론으로 검출. '테스트 그린인데 이사님 별로'를 깨는 품질축 첫 조각.
요구은유 사전 상수 METAPHOR_CLICHES와 순수함수 metaphorOveruse(L: string): boolean을 새 파일 lib/coachQuality.ts에 만든다. 단일 은유 1회 등장은 허용(따뜻한 비유는 자산), 같은 은유 반복 또는 서로 다른 클리셰 은유 2종 이상 동시 등장 = 과용으로 true.
명세
신규 파일 lib/coachQuality.ts 생성. lib/coach.ts의 FORBID_TIME/fruitSaltyMix와 동일한 '순수 정규식 검출' 패턴을 따른다(fs/HTTP 불사용).
목적v3 조립본이 '어제 당근 먹었어요. 그제 시금치 먹었어요…'식 데이터 나열(거울 재서술)로 굳는 것을 결정론 검출. 거울은 코드가 못 박되 본문이 그것을 기계적으로 나열하면 편지가 영수증이 된다.
요구lib/coachQuality.ts에 순수함수 mealEnumeration(L: string): boolean 추가. '{시점어}{음식}먹었어요' 류 문장이 2개 이상 연속/반복되면 나열 패턴으로 true. 단발 사실 인용 1회은 허용(품질 좋은 편지의 정상 요소).
명세
시점어 사전 AGO_WORD = /어제|그제|그저께|오늘|아침|점심|저녁|[0-9]+일\s*전/, 동사 ATE = /먹(었|었어요|었네요|음)|드셨|비웠|남겼/.
문장 분리는 coach.ts의 L.split(/[.!?。\n]/) 패턴 재사용. 한 문장에 시점어+ATE 동시 매칭 문장 개수 count. count>=2면 true(임계 ENUM_MAX=1).
추가 패턴: 쉼표/가운뎃점으로 명사 3개+ 나열 + 끝에 ATE 1개('어제 당근, 시금치, 두부를 먹었어요') = 시점어 동반 시 true.
FORBID_TIME(coach.ts)과 역할 분리: FORBID_TIME=환각 기간어, 이건 데이터 나열 패턴. 둘 다 품질 스캐너(D-05)에서 합쳐 호출.
목적인계서 F·G — '요즘/최근/이번주' 같은 수치 없는 모호 기간어를 검출. 코드가 '최근 7일 중 3일'처럼 수치를 재료로 줬는데도 LLM이 모호어로 뭉개면 재생성. coach.ts FORBID_TIME(환각 기간)과 역할이 달라 별도 함수.
요구lib/coachQuality.ts에 vagueTimeWord(L: string): boolean. '요즘/최근/이번 주/얼마 전/한동안/요새/근래' 등 수치 없는 기간어가 등장하면 true. 단, 바로 옆에 수치(N일/N번/N회)가 동반되면 허용(예 '최근 7일'은 OK).
명세
VAGUE_TIME = /요즘|요새|근래|최근|이번\s*주|얼마\s*전|한동안|당분간|며칠째/. 단 직후 12자 내 [0-9]+\s*(일|번|회|가지) 동반 시 면제.
FORBID_TIME(coach.ts:379)은 이미 '지난달·N개월·N일간'을 잡으므로 중복 금지 — vagueTimeWord는 수치 없는 정성 기간어만 담당.
구현: VAGUE_TIME 전체 매칭 위치 직후 12자 윈도우에 수치 단위 없으면 위반 1건. >=1이면 true.
임계 상수 없음(0건 vs 1건+ 이진).
유즈케이스
'요즘 채소를 잘 안 드시네요' → true.
'최근 7일 중 비타민A 채소가 3일이었어요' → false.
'이번 주 중 콩류가 2번 나왔어요' → false.
테스트11개
ID
종류
케이스 · 검증(입력→기대)
D-03-1
단위
수치 없는 '요즘' = true — vagueTimeWord('요즘 채소를 잘 안 드시네요') → true
D-03-2
단위
'최근 7일' 수치 동반 = 허용 — vagueTimeWord('최근 7일 중 비타민A 채소가 3일이었어요') → false
D-03-3
단위
'이번 주 2번' 수치 동반 = 허용 — vagueTimeWord('이번 주 콩류가 2번 나왔어요') → false
목적인계서 G·A — LLM이 '검증된 추천'(bridgeFacts) 목록 밖의 음식명/조합을 지어내면(괴식·환각 입구) 검출. 화이트리스트(코드가 LLM에 준 재료) 대조 방식이라 임의 코퍼스 스캔의 오탐을 피한다.
요구lib/coachQuality.ts에 offMaterialFood(L: string, allowed: string[]): string[] — 편지에서 '~찌개/~국/~볶음/~구이/~조림/~찜/~밥/~죽/~전' 등 조리 음식명 후보를 추출해, allowed(코드가 준 인기음식·궁합·사촌·favoriteFoods·STAPLE_FORMS 표시) 안에 없는 것을 위반 목록으로 반환. 빈 배열=통과.
명세
DISH_SUFFIX = /([가-힣]{1,6}(찌개|국|탕|볶음|구이|조림|찜|밥|죽|전|무침|나물|샐러드|스프|파스타|면|빵|떡))/g 로 토큰 추출.
allowed 정규화: 호출자가 bridgeFacts 텍스트·favoriteFoods·popularDishesFor 결과·SNACK 예시를 모아 넘긴다. allowed 내 부분문자열 포함(includes) 매칭이면 통과.
일반 명사 ALLOW_GENERIC = ['밥','국','죽','반찬','간식','식사','끼니'] 단독은 면제(구체 음식명만 검사).
목적인계서 H — 거울(coachFacts.buildMealMirror)·추천(bridgeFacts)·슬롯값이 발행 직전 가드를 우회하던 무검증 채널을 봉합. 본문+거울+추천을 합본해 품질·det·검증을 한 번에 통과시킨다.
요구lib/coachQuality.ts(또는 coach.ts)에 composeVerifiableText(p: { letter: string; mirror?: string|null; recoText?: string|null }): string — 본문과 거울·추천 문장을 줄바꿈으로 합쳐 단일 검증 텍스트를 만든다. 이 합본을 letterQualityBad·letterDeterministicBad 입력으로 써서 슬롯값도 검사 대상에 포함.
셋 다 깨끗 = 통과 — letterQualityBad(composeVerifiableText({letter:'오늘 저녁 함께 앉아보세요', mirror:'어제 두부를 처음 비웠어요', recoText:'볶음밥'}), {allowedFoods:['볶음밥']}).bad → false
D-06-6
단위
본문만(슬롯 없음) = 본문 그대로 — composeVerifiableText({letter:'A'}) → 'A'
D-06-7
통합
거울의 det 위반(섞기 금지 프레임)도 합본으로 적발 — letterDeterministicBad(composeVerifiableText({letter:'화면을 끄세요', mirror:'두부를 섞어 주세요'}), 'mealtime-atmosphere', '') → true
검증자 LLM 콜은 품질 통과 후에만(콜 절약) — qualityBad.bad일 때 우선 결정론 재작성, verifyLetter LLM 콜이 불필요하게 늘지 않음
D-07-6
회귀
A 경로 무변경(게이트 미적용) — 기존 composeLetter 호출은 품질 게이트 미실행 — 동일 입력 출력 byte 동일
D-07-7
통합
deadline 초과 시 품질 게이트 생략(S7) — deadlineMs 초과면 qualityBad 재작성 루프 skip하고 현재 본문 발행
DoD
allowedFoodsFromBridge 파서 구현·단독 테스트
품질 결정론 게이트가 LLM 콜 전에 수행
A 경로 byte 동일 회귀 확인
D-07-1~7 그린
npm test 그린
의존D-05 · D-06
✅D-08 · composeLetterB — Letter B 통합 작성기(개선 A~I 적용·가드 대칭)코드코드 · 2h
목적Letter B 전용 작성기. 기존 composeLetter(A·v2)는 건드리지 않고, B에서만 품질 게이트(D-05)·합본 검증(D-06)·품질 사유 재작성(D-07)을 det·유사도·의미검증 루프에 대칭으로 추가. 재생성 산출물도 det+quality+sim+verify 전부 재통과해야 채택(S1).
기존 det 가드 케이스 전부 그린 유지 — coach-guards.test.ts 기존 12케이스 통과 불변
D-09-4
통합
품질·det 이중 검출 무해 — 튀김+은유 동시 편지 → detBad=true AND qualityBad=true 둘 다 재생성 트리거
D-09-5
회귀
양질 편지는 det·quality·sim 전부 통과 — letter-v2-good.txt → letterDeterministicBad=false, letterQualityBad.bad=false
D-09-6
E2E
npm test 그린(prebuild 게이트) — npm test → 신규 coach-quality.test.ts 포함 전체 그린
D-09-7
단위
fixture 로딩 안정성 — fixture 파일 읽기 실패 시 테스트가 명확히 실패(빈 문자열로 false 통과 위장 방지)
DoD
tests/coach-quality.test.ts 신규 + fixture 2개 추가
v3=bad, v2 양질=통과(오탐 0) 증명
기존 coach-guards 케이스 불변
신규+기존 전체 npm test 그린(prebuild 게이트)
의존D-01 · D-02 · D-03 · D-04 · D-05 · D-06
✅D-10 · route.ts Letter B 합본·품질 컨텍스트 배선 + altLetter 저장코드코드 · 2h
목적프로젝트 A/B 구조 — 아린 cron 루프에서 Letter A(기존 composeLetter)는 그대로 두고, composeLetterB로 Letter B를 생성해 coach_letters.context.altLetter={letter,oneliner,design,mirror,materials,quality}에 저장. 메인 letter/oneliner=A. 스키마 변경 0.
요구app/api/cron/coach/route.ts에서 비교 코호트(COACH_V3_CHILDREN 재활용 또는 신규 COACH_COMPARE_CHILDREN) 자녀에 대해 A 생성 후 composeLetterB도 호출, altLetter를 finalCtx에 병합 upsert. allowedFoods(favoriteFoods+bridgeFacts 인기음식)·mirror(fc.mirror)를 composeLetterB에 전달.
✅D-11 · 어드민 검증·품질 위반 보고(cron_runs.issues + altLetter.quality)운영코드 · 0.5h
목적근본원인 ① — '게이트 그린≠좋은 편지'를 모니터링으로 닫는다. Letter B의 품질 위반·재작성·합본 검증 결과를 cron_runs.issues와 context.altLetter.quality에 노출해 카나리아 3일 모니터에서 B의 실제 품질을 추적.
요구route.ts에서 composeLetterB의 quality.bad(발행됐으나 품질 위반)·quality.regen·verify 결과를 issues 배열에 push(기존 검증위반 발행 패턴 route.ts:693 재사용). context.altLetter.quality에 기록해 A vs B 비교 가능.
altLetter.quality.regen이면 context에 '품질 재작성 수행' 기록(재작성으로 통과했는지 vs 못 통과하고 발행했는지 구분).
기존 simToPrev·repeatAlert(route.ts:687-691)는 A 기준 유지, B는 altLetter.quality로 별도 노출(대조군 비교).
운영 DoD: 카나리아 3일간 어드민에서 B 품질 위반율·재작성율·A대비 피드백(👍/👎/🔁 letter_feedback) 관찰.
유즈케이스
B가 품질 위반인 채 발행(재작성 실패) → issues에 'B품질위반' → 모니터에서 즉시 포착.
B가 재작성으로 통과 → context에 regen 기록 → 게이트 효과 측정.
어드민에서 A vs B 나란히 비교 → 이사님 피드백 수집.
테스트5개
ID
종류
케이스 · 검증(입력→기대)
D-11-1
통합
B 품질 위반 발행 → issues 기록 — altLetter.quality.bad=true → cron_runs.issues에 'B품질위반' 항목 존재
D-11-2
통합
B 재작성 통과 → 위반 미기록 — altLetter.quality.bad=false(재작성 통과) → issues에 B품질위반 없음, context에 regen=true
D-11-3
회귀
A 기준 repeatAlert 무변경 — 기존 simToPrev/repeatAlert는 메인(A) 편지 기준 그대로
D-11-4
수동
altLetter.quality 어드민 노출 — 어드민 스레드에서 context.altLetter.quality(bad/reasons/regen) 확인 가능
D-11-5
적대
B 생성 실패 시 issues 노이즈 없음 — composeLetterB throw로 altLetter=null이면 B품질 issues push 안 함(null 가드)
DoD
route.ts issues + context.altLetter.quality에 B 품질 결과 노출
A 기준 repeatAlert 무변경
B 생성 실패 시 노이즈 없음(null 가드)
카나리아 3일 모니터 후 전 자녀 승격 판단(운영)
D-11-1~5 그린·npm test 그린
의존D-08 · D-10
EPIC E — 크론 2통 발행 — 아린 compare 분기(Letter A 대조군 v2 + Letter B 처치군 하이브리드) D3
app/api/cron/coach/route.ts에 아린 compare 분기를 심어 매일 편지 2통을 동시 발행한다. Letter A는 기존 v2(planFor+composeLetter) 그대로 무변경 대조군, Letter B는 개선 A~I 전부 적용한 하이브리드(코드가 재료·진단·가이드를 못 박고 LLM이 작문)이며 context.altLetter에 저장(스키마 변경 0). freqMap 죽은코드를 본경로에 배선하고 아린 진도를 영속시키며, compare 모드는 기존 v3 순수 조립을 대체한다. additive — 다른 자녀는 무영향.
대소문자 구분 id 매칭 — compareEnabled({COACH_COMPARE_CHILDREN:'AAA'}, 'aaa') → false
E-01-12
회귀
v3Enabled와 독립 — compare 끔이 v3에 영향 없음 — v3Enabled({COACH_V3_CHILDREN:'aaa'},'aaa')=true 이면서 compareEnabled({},'aaa')=false 동시 성립
DoD
순수 함수로 process.env 미참조(단독 테스트 통과)
E-01 테스트 12개 그린
npm test 전체 그린(prebuild 게이트)
v3Enabled 회귀 없음(기존 coach 테스트 통과)
의존없음
✅E-02 · Letter B 빌더 모듈 lib/coachCompare.ts — buildLetterB 오케스트레이터 시그니처·폴백 골격코드코드 · 1.5h
목적route.ts를 비대하게 만들지 않도록 Letter B 생성 전 과정을 단일 진입점 buildLetterB로 분리한다. 입력은 이미 계산된 신호 묶음, 출력은 altLetter 페이로드. 실패하면 throw 없이 null을 돌려 A만 발행(발행 보장).
요구lib/coachCompare.ts에 buildLetterB(args)를 만든다. 내부에서 selectDailyMaterials(B EPIC) → buildTeachingGuide(가이드 EPIC) → composeLetterB(LLM 작문) → 품질검증(G EPIC) 순으로 호출하고, 성공 시 { letter, oneliner, design, mirror, materials, guide, verify, modelUsed, llmCalls } 반환. 어떤 단계 실패도 catch해 null 반환(A는 별개로 항상 발행).
목적route.ts:101에서 로드만 되고 추천 본경로에 전달되지 않는 freqMap을 Letter B 재료 선택(selectDailyMaterials)에 실배선해 급식빈도 가중 랭킹이 실제로 동작하게 한다.
요구route.ts에서 이미 로드된 freqMap(/ingredient-recipes.json)을 buildLetterB(args.freqMap)로 전달한다. 로드 실패 시 빈 {} 폴백은 기존대로 유지(kit-matrix 폴백). Letter A 경로(buildRecoFacts at 664)는 무변경.
목적compare 대상 자녀에서도 Letter A는 기존 v2 경로(planFor+composeLetter)를 100% 그대로 타게 한다. A가 손상되면 대조군이 무의미해지므로 격리가 핵심.
요구compareEnabled(cid)가 true여도 letter/oneliner(메인 컬럼)는 반드시 기존 v2 composeLetter 산출이 되도록 한다. compare 모드는 v3 순수 조립을 '대체'하므로(아린은 더 이상 v3 블록 조립 안 함), 아린의 경우 v3Enabled 분기(502~614행)를 건너뛰고 항상 레거시 v2 생성 경로(650~694행)로 A를 만든다.
명세
route.ts 502행 if (anchor && v3Enabled(...)) 게이트 앞에 compare 우선 분기: const isCompare = compareEnabled(process.env, cid); if (isCompare) { /* v3 조립 건너뜀 — A는 레거시 v2, B는 buildLetterB */ }
v3Enabled 분기는 isCompare일 때 진입하지 않게(else if 또는 !isCompare 가드) — '아린은 더 이상 v3 블록 조립 안 함'(브리프 ⑧) 구현
결과: isCompare 자녀는 v3Ctx가 null로 남아 650행 if(!v3Ctx) 레거시 v2 생성 경로 진입 → letter=out.letter, oneliner=out.oneliner, verifyCtx=out.verify (A = v2 그대로)
A 경로의 어떤 입력(base·precomputed·detForbid)도 compare 때문에 바뀌지 않음 — B 빌드는 별도 변수(altLetter)에만 영향
compare 자녀 메인 letter = v2 composeLetter 산출 — compare 시뮬 → 최종 letter === composeLetter.letter(레거시)
E-04-3
회귀
non-compare v3 자녀는 기존 v3 조립 유지(회귀) — compareEnabled=false·v3Enabled=true → decideDailyV3 호출됨, v3Ctx 생성
E-04-4
회귀
compare가 A의 base/precomputed 입력 불변 — compare on/off로 composeLetter에 들어가는 base 객체 동일(snapshot)
E-04-5
회귀
non-compare 레거시 자녀 완전 무변경 — compare·v3 모두 off → 분기 진입 0, 기존 경로 byte 동일
E-04-6
적대
compare 자녀 B 빌드 실패해도 A 정상 발행 — buildLetterB→null → letter(A) 여전히 v2 산출로 채워짐
DoD
A 경로가 compare on/off에 무영향(회귀 테스트)
compare 자녀에서 v3 조립 미발동(통합 테스트)
B 실패가 A 발행을 막지 않음
npm test 그린
의존E-01 · E-02
✅E-05 · compare 분기 — Letter B 호출·daySeed/cidHash·재료 회전 입력 준비코드코드 · 1h
목적B EPIC 재료 결정론 회전(B: 3일내 재사용 금지)에 필요한 입력(최근 B 재료 이력·daySeed·cidHash·likedIng·groupSignals)을 route.ts에서 모아 buildLetterB에 전달한다.
요구compare 자녀에 한해 최근 B 편지들의 재료 이력(context.altLetter.materials의 추천 식재료)을 조회해 buildLetterB의 recentBMaterials로 넘긴다. daySeed=Math.floor(Date.parse(today)/86400000)·cidHash(기존 관용구)·likedIng(300행)·computeGroupSignals(byDay,catOf)를 그대로 재사용해 전달.
명세
recentBMaterials 소스: compare 전용으로 최근 3~7일 coach_letters.context.altLetter.materials.food를 추출(B의 3일내 재사용 금지 가드 입력)
daySeed·cidHash는 438~439행과 동일 산식 재사용(중복 계산 피하려면 변수 위로 끌어올림)
likedIng(300행)·favoriteFoods(298행)·reds(302)·fg.missing(303)·computeGroupSignals(byDay,catOf)(531행 패턴) 전달
pastBLetters: 최근 5일 context.altLetter.letter(B 자체의 연속성·반복 회피용 — A의 pastLetters와 분리)
deadlineMs = runStart + TIME_BUDGET_MS - 4000(683행 composeLetter와 동일 기준 — 단 A가 먼저 예산을 쓰므로 잔여 기준으로 재계산, E-08 참조)
B 호출은 A 발행 코드(696~731행 upsert) 전 또는 후 — 순서는 E-06에서 확정(altLetter는 같은 row의 context에 합본)
유즈케이스
B 재료가 최근 3일 B 이력을 보고 회전(당근→치즈→시금치)
B의 작문 연속성은 A가 아니라 과거 B 편지를 참조
테스트7개
ID
종류
케이스 · 검증(입력→기대)
E-05-1
통합
recentBMaterials 추출 — altLetter.materials.food 수집 — 최근 3일 context.altLetter.materials.food=['당근','치즈'] → buildLetterB args.recentBMaterials에 두 식재료 포함
목적compare 모드가 v3 순수 조립을 대체하면서 아린의 진도(curriculum_progress)와 주간 닻(weekly_plans)이 끊기지 않게 한다. B의 두뇌(진단·계획)는 v3 상태기계를 재사용하므로 진도는 계속 굴러야 한다.
요구B 재료·진단 선택이 decideDailyV3/진도를 참조한다면(브리프 '두뇌만 가져온다'), compare 분기에서도 진도 로드·decideDailyV3·진도 upsert·goals 영속(539~548행)을 수행해야 한다. 단 편지 작문(assembleLetter)은 하지 않고 두뇌 출력만 B 재료/가이드에 넘긴다.
명세
compare 분기에서 진도 로드(505행 curriculum_progress select)·decideDailyV3(532행) 호출은 유지 — 결정(unit/mode/step)은 B의 teaching guide 입력
진도 upsert(540~543행)·피벗 영속(546~548행)·사실 원장 승계(550~552행)는 compare에서도 실행 — '진도 영속'
단 assembleLetter(561행)·v3 letter 발행은 compare에선 건너뜀(B는 LLM 작문) — dr.decision/dr.updates만 buildLetterB(guide 입력)로
decideDailyV3 결과를 altLetter.design.decision에 기록(어드민 검증)
decision이 altLetter.design에 기록 — altLetter.design.decision.unit === dr.decision.unit
E-07-7
통합
이튿날 연속성 — 어제 진도를 today가 이어받음 — fixture 2일 연속 실행 → 2일차 progress가 1일차 step을 승계
E-07-8
적대
진도 없는 첫날 콜드스타트 — curriculum_progress 빈 결과 → decideDailyV3 lowData 분기, upsert 신규 행
DoD
compare에서 진도·닻이 영속(2일 연속 fixture 통과)
assembleLetter 미발동(B는 LLM 작문)
upsert error 가시화(issues)
SQL 신규 없음(기존 테이블 재사용 — 점심 사고 교훈상 DDL 불요 확인)
npm test 그린
의존E-04 · E-06
✅E-08 · 시간예산·데드라인 — 2통이라 비용 2배, A 우선·B 잔여예산 게이트코드코드 · 0.75h
목적compare 자녀는 LLM 호출이 2배(A 작문+재생성, B 작문+검증+재생성)다. 1자녀(아린)이라 총 부담은 작지만, A를 항상 먼저 보장하고 B는 잔여 예산이 있을 때만 돌려 SIGKILL 좀비(6/12 사고)를 막는다.
요구TIME_BUDGET_MS(50s)·maxDuration(60s) 안에서 A를 먼저 완성·발행하고, B는 (Date.now()-runStart < TIME_BUDGET_MS - SAFETY) 일 때만 buildLetterB 호출. B의 deadlineMs = runStart + TIME_BUDGET_MS - 2000(A보다 여유 적게 — A 보호 우선). B가 예산 초과면 altLetter.skipped로 기록.
명세
A 발행(696~731 upsert)은 B 빌드와 무관하게 항상 수행 — B는 A 발행 전/후 어디든 A를 막지 않음
목적어드민 스레드와 보고서가 B의 '무엇을·왜·어떻게'를 검증할 수 있게 design 스냅샷(재료·근거·진단·검증 결과·반복 자가측정)을 altLetter에 싣는다. '게이트 그린≠좋은 편지'(인계서 근본원인①)를 어드민이 눈으로 잡을 수 있게.
요구altLetter.design에 { decision, materials(추천 식재료+근거문구), guideSummary, verify(품질축 위반), simToPrevB(B끼리 유사도), repeatAlertB, model, llmCalls, paths(A~I 중 탄 개선) } 를 기록. A의 기존 verifyCtx/simToPrev/repeatAlert(704~712행) 패턴을 B에도 평행 적용.
진도 2일 연속 승계 — 1일차 step=2 advance → 2일차 progress.step≥2
E-10-5
적대
B 괴식 조합 0건 — fixture에 미역국 좋아함+당근 결핍 → B materials에 '미역국+당근' 조합 미출현
E-10-6
적대
예산 부족 시 A만+altLetter.skipped — runStart 조작으로 예산 0 → row.letter=A 존재, altLetter.skipped=true
E-10-7
회귀
compareEnabled=false fixture는 기존 v2/v3 경로 — env 비우고 구동 → altLetter 미생성, 기존 경로 산출
E-10-8
적대
B 빌드 throw → A 발행·altLetter.failed — buildLetterB 스텁 throw → row.letter=A, altLetter.failed=true
E-10-9
통합
B 품질검증 위반 시 issues 기록(품질축 가시화) — B에 클리셰 은유 주입 → cron issues에 'B검증위반' 포함
E-10-10
회귀
스키마 변경 0 — upsert 키 집합 고정 — 캡처된 upsert 객체 키가 기존 7키만(컬럼 추가 없음)
DoD
fixture 기반 2일 연속 리플레이 그린
불변식 6종 전부 박제(A보존·B상이·재료회전·진도영속·괴식0·예산폴백)
리플레이가 적발한 통합버그는 red→green으로 수정(복리)
prebuild 게이트(npm test)에 포함돼 자동 회귀 차단
의존E-04 · E-05 · E-06 · E-07 · E-08 · E-09
✅E-11 · compare 운영 안전·롤백·라이브 무영향 확인운영운영 · 0.75h
목적additive 원칙 준수를 배포 전 실증한다. env 미설정 시 기존 경로 byte 동일, env 토글로 즉시 롤백, 라이브 타 자녀 무영향을 점검 절차로 박는다.
요구compare env 미설정에서 크론이 기존과 동일 동작(타 자녀·아린의 기존 v3/v2)함을 확인하고, COACH_COMPARE_CHILDREN=아린id 설정 후 ?child=아린id&date=과거 시뮬로 A·B 2통이 한 row에 생기는지 라이브-세이프 검증. 롤백은 env 삭제 한 번.
명세
배포 전: env 미설정 상태로 npm run build·npm test 그린(prebuild 게이트)
라이브-세이프 시뮬: GET /api/cron/coach?child=43942d34-...&date=2026-06-10 (CRON_SECRET 인증, force 미사용=캐시 존중) — A·B row 확인
타 자녀 무영향: compare off 자녀 1명을 ?child로 시뮬해 산출이 배포 전과 동일(diff 0)임을 확인
롤백 절차 문서화: Vercel env COACH_COMPARE_CHILDREN 삭제 → 다음 크론부터 아린도 기존 v3 카나리아(COACH_V3_CHILDREN) 또는 v2로 복귀
비용 모니터: cron_runs.meta.compareLlmCalls·B failed/skipped 비율을 3일 관찰 후 승격 판단(어드민 피드백 👍/👎/🔁)
SQL 없음 확인 — 본 EPIC 전 원자가 jsonb context만 사용(점심 사고 교훈: DDL 동반 없음을 명시)
유즈케이스
배포 직후 아린만 2통, 타 자녀는 평소대로
B가 비싸거나 불안정하면 env 삭제로 즉시 끔
테스트6개
ID
종류
케이스 · 검증(입력→기대)
E-11-1
수동
env 미설정 빌드·테스트 그린 — env 없이 npm run build && npm test → 0 실패
E-11-2
E2E
compare 시뮬 — A·B 2통 한 row — ?child=아린&date=과거 → coach_letters row.letter(A) + context.altLetter.letter(B) 둘 다 존재
E-11-3
회귀
타 자녀 무영향(diff 0) — compare off 자녀 ?child 시뮬 산출이 배포 전과 동일
E-11-4
수동
env 삭제 롤백 — COACH_COMPARE_CHILDREN 삭제 후 시뮬 → altLetter 미생성, 기존 경로 복귀
E-11-5
수동
B 비용·실패율 모니터 노출 — cron_runs.meta에 compareLlmCalls·B skipped/failed 카운트 확인 가능
E-11-6
E2E
force 미사용 시 재사용 분기 정상(A 재사용) — 같은 date 2회 시뮬(force 없음) → 2회차는 reused, B도 캐시 정책 준수
DoD
env 미설정=기존과 byte 동일(additive 입증)
라이브 시뮬에서 A·B 2통 생성 확인
타 자녀 diff 0
env 토글 롤백 절차 확립·SQL 없음 확인
3일 모니터 후 어드민 피드백으로 승격 판단(전 자녀 전개는 별도 결정)
의존E-10
EPIC F — 앱 2카드 노출 (아린 코칭 화면 A/B 비교 UI) D3
아린 코칭 화면에 기존 v2(라벨 '기존')와 새 설계 Letter B(라벨 '새 설계') 두 카드를 나란히 렌더하고, 변형별(A/B) 1탭 피드백을 받는다. context.altLetter가 있는 자녀만 두 번째 카드를 그리며, 다른 자녀는 단일 카드로 무영향이어야 한다(스키마 변경 0 + 피드백 variant 컬럼 1개).
✅F-01 · altLetter 추출·검증 순수 함수 lib/altLetter.ts코드코드 · 1h
목적coach_letters.context jsonb에서 Letter B(altLetter)를 안전하게 파싱·검증하는 순수 함수를 만들어, 렌더 레이어가 raw jsonb를 직접 만지지 않게 한다(다른 자녀 = altLetter 없음 → null 반환으로 단일 카드 보장).
요구context가 임의 shape인 jsonb이므로(타입 미보장), letter 문자열이 실제로 있을 때만 비교 카드 후보를 반환한다. 빈 문자열·누락·타입 불일치는 전부 null(=무영향).
중첩 깊은 unexpected 키 무시 — pickAltLetter({altLetter:{letter:'B',extra:{x:1}}}) → {letter:'B',...}로 정상, extra 무시
DoD
lib/altLetter.ts가 순수 함수(no I/O)로 단독 테스트 가능
npm test 그린(prebuild 게이트)
Letter A(v2 메인 letter/oneliner)는 이 함수와 무관 — A 무변경 확인
17 케이스 전부 통과
의존없음
✅F-02 · 홈 coach_letters select에 context 추가 + altLetter 상태코드코드 · 1h
목적현 홈 쿼리(select('letter_date,letter,oneliner')·select('letter,oneliner,source_hash'))는 context를 안 가져와 altLetter가 클라에 도달 못 한다. context를 select에 추가하고 altB 상태를 세팅한다(Letter A 표시 경로는 무변경).
요구오늘 편지·재생성 폴백·과거 폴백 3경로(app/page.tsx 313~344) 모두에서 context를 함께 읽되, 메인 aiLetter/aiOneliner는 기존대로 A(v2)를 가리킨다. altB는 별도 상태.
신규 상태: `const [altB, setAltB] = useState<AltLetter | null>(null)`(F-01 타입 import)
cached/gen/prevL 분기에서 `setAltB(pickAltLetter(row.context))` 호출 — 표시 중인 편지의 context 기준
자녀 전환 race 가드(기존 `if (cancelled) return` 패턴) 안에서 setAltB도 가드 — 교차오염 방지(615행 주석 패턴 준수)
자녀 전환 시 altB 리셋: selectedId effect 진입부에서 `setAltB(null)`(기존 상태 리셋 컨벤션 따름)
유즈케이스
아린 오늘 편지 cached → context.altLetter 파싱 → altB 세팅 → F-03이 두 번째 카드 렌더
다른 자녀 → context.altLetter 없음 → altB=null → 단일 카드
크론 폴백(api/cron/coach 재호출) 후 gen.context에서 altB 세팅
테스트6개
ID
종류
케이스 · 검증(입력→기대)
F-02-1
회귀
select에 context 포함(정적 검사) — grep로 app/page.tsx의 coach_letters select 3곳이 모두 'context' 문자열 포함 → 일치
F-02-2
통합
altB 세팅 — altLetter 있는 row — mock cached={letter:'A',oneliner:'oa',context:{altLetter:{letter:'B'}}} → 렌더 후 두 번째 카드(라벨 '새 설계') DOM 존재
F-02-3
통합
altB null — altLetter 없는 row — mock cached={letter:'A',context:{reds:[]}} → 두 번째 카드 미존재, 첫 카드만
F-02-4
적대
자녀 전환 시 altB 리셋 — 아린(altLetter 있음)→다른 자녀(altLetter 없음) 전환 → 두 번째 카드 사라짐(이전 altB 잔류 없음)
F-02-5
적대
cancelled 가드 — 전환 중 setAltB 미반영 — 로드 도중 selectedId 변경(cancelled=true) → setAltB 호출 안 됨(교차오염 0)
F-02-6
회귀
Letter A 경로 무변경 — context 추가 후에도 aiLetter=cached.letter(=A)·aiOneliner=cached.oneliner 그대로 — A 표시 동일
DoD
coach_letters select 3경로에 context 추가
altB 상태가 표시 편지 context 기준으로 세팅
자녀 전환 시 altB null 리셋 + cancelled 가드
npm test 그린
Letter A 표시 로직 무변경(대조군 보존)
의존F-01
✅F-03 · CompareLetterCard 두 카드 나란히 렌더 컴포넌트UI코드 · 2h
목적기존 v2 카드와 Letter B 카드를 라벨('기존'/'새 설계')과 함께 나란히(세로 스택) 렌더하는 클라이언트 컴포넌트. altB가 있을 때만 두 번째 카드를 그린다.
요구이 Next는 브레이킹 체인지 버전 — node_modules/next/dist/docs 확인 후 기존 app/page.tsx 코치 카드 마크업(582~637행: rounded-2xl·linear-gradient·인라인 style)을 그대로 패턴화. altB=null이면 컴포넌트는 두 번째 카드 자리에 null 반환(단일 카드 유지).
명세
신규 `web/components/CompareLetterCard.tsx`, `'use client'`(app/page.tsx와 동일 디렉티브)
node_modules/next/dist/docs/01-app 읽고 클라 컴포넌트/props 규약 확인 후 작성(AGENTS.md 경고 준수)
두 카드 = 기존 코치 카드 스타일(background linear-gradient(135deg,#FFF8E1,#FFECB3), border 1.5px solid #F9A825) 재사용, 각 카드 상단에 라벨 배지: A='기존'(중립색)·B='새 설계'(강조색 #16A085)
altB가 null이면 B 카드 영역 미렌더(`{altB && (...)}`) — 다른 자녀 단일 카드 무영향
B 카드 본문 = altB.letter, oneliner = altB.oneliner, mirror 있으면 작은 회색 줄로 표기(어드민 검증 보조)
두 카드 모두 fmtLetterDate(letterDate) 날짜 라벨 — A/B 같은 날짜
피드백 버튼(F-04)은 각 카드 하단에 variant 구분해 배치(props로 onFeedback(variant, rating) 받음)
유즈케이스
아린: A 카드(기존 v2) + B 카드(새 설계) 세로로 나란히, 라벨로 구분
다른 자녀: altB=null → A 카드만(기존 화면과 픽셀 동일)
mockup(비로그인/3일 미만): altB=null → 기존 예시 카드 그대로
테스트8개
ID
종류
케이스 · 검증(입력→기대)
F-03-1
단위
altB 있음 → 두 카드 렌더 — render(CompareLetterCard, {letterA:'A',altB:{letter:'B'}}) → 텍스트 'A'·'B' 둘 다 DOM 존재
F-03-2
단위
라벨 '기존'/'새 설계' 표기 — altB 있을 때 '기존' 배지와 '새 설계' 배지 모두 렌더
B oneliner 렌더 — altB={letter:'B',oneliner:'B한줄'} → 'B한줄' DOM 존재
F-03-5
단위
B mirror 있으면 표기, 없으면 생략 — altB={letter:'B',mirror:'거울'} → '거울' 노출 / altB={letter:'B'} → mirror 영역 미존재
F-03-6
적대
isMockup일 때 B 미렌더 — isMockup=true, altB=null → '새 설계' 배지 미존재
F-03-7
단위
날짜 라벨 A/B 동일 — letterDate='2026-06-13' → 두 카드 모두 fmtLetterDate('2026-06-13') 결과 노출
F-03-8
단위
onFeedback 콜백 variant 전달 — B 카드 '👍' 클릭 → onFeedback('B','up') 호출(variant='B')
DoD
node_modules/next/dist/docs/01-app 읽고 클라 컴포넌트 규약 준수
CompareLetterCard가 altB 유무로 1카드/2카드 분기
기존 코치 카드 스타일 재사용(시각 일관)
렌더 테스트 8 통과(@testing-library/react 또는 기존 tests 패턴)
npm test 그린
의존F-01
✅F-04 · 피드백 variant 컬럼 SQL + 유니크 재정의SQLSQL · 0.5h
목적현 letter_feedback unique(child_id,letter_date)는 하루 1표라 A/B 두 카드에 따로 피드백을 못 받는다. variant 컬럼(A/B)을 추가하고 유니크를 (child_id,letter_date,variant)로 재정의해 변형별 표를 받는다.
요구기존 단일 카드 피드백(variant 없음)을 깨지 않게 variant 기본값 'A'. SQL 실행 확인 없이 다음 단계(F-05) 금지(점심 사고 교훈).
명세
신규 `web/sql/2026-06-13_letter_feedback_variant.sql`
`alter table public.letter_feedback add column if not exists variant text not null default 'A' check (variant in ('A','B'))`
기존 unique(child_id,letter_date) 드롭 후 `unique (child_id, letter_date, variant)` 재생성 — 기존 제약명 확인 후 drop constraint(없으면 if exists 가드)
RLS 정책 lf_owner는 그대로(parent_id=auth.uid()) — variant 추가가 정책에 영향 없음 확인
인덱스 lf_child_date(child_id, letter_date desc) 유지
기존 행 백필 불필요(default 'A'가 자동 적용)
유즈케이스
아린 B 카드 👍 → variant='B'로 별도 행 insert(A 행과 공존)
다른 자녀 단일 카드 👍 → variant='A'(기본값) → 기존과 동일 동작
같은 날 B 카드 재투표 → onConflict(child_id,letter_date,variant)로 덮어쓰기
테스트5개
ID
종류
케이스 · 검증(입력→기대)
F-04-1
통합
variant 컬럼 존재 — SQL 실행 후 information_schema.columns에 letter_feedback.variant 존재(default 'A')
F-04-2
통합
유니크 (child_id,letter_date,variant) — 같은 child_id·letter_date로 variant='A','B' 두 행 insert 성공(중복 에러 없음)
F-04-3
적대
같은 variant 중복 거부 — child_id·letter_date·variant='A' 동일 행 2번 insert → 두 번째는 unique 위반(upsert로만 갱신)
F-04-4
회귀
기존 행 default 'A' — variant 미지정 insert → variant='A'로 저장
F-04-5
적대
variant 제약 위반 — variant='C' insert → check 제약 위반 에러
목적피드백을 변형(A/B)별로 구분 저장하고, 이사님의 일일 어드민 선호 판정(A vs B)을 별도 테이블에 기록할 스키마를 추가한다.
요구기존 letter_feedback(child_id,letter_date uniq·rating up/down/repeat)에 variant를 더하고, 어드민 비교투표용 compare_votes를 신설한다. 기존 부모 피드백 행은 무영향(default 'A').
명세
web/sql/2026-06-14_letter_feedback_variant.sql 신규. 멱등(if not exists / add column if not exists)
letter_feedback에 variant text not null default 'A' check (variant in ('A','B')) 추가 — 기존 부모 피드백(메인 letter=A)은 default로 'A' 자동 매핑
기존 unique(child_id,letter_date) 제약을 unique(child_id,letter_date,variant)로 교체(부모가 A·B 둘 다 평가 가능). 제약명 drop/add 또는 새 인덱스 — 실행 전 기존 제약명 확인(점심 사고 교훈: SQL 실행확인 없이 다음 단계 금지)
compare_votes 신설: id uuid pk · admin_id uuid not null · child_id uuid not null references children(id) on delete cascade · vote_date date not null · winner text not null check (winner in ('A','B','tie')) · note text · created_at timestamptz default now() · unique(admin_id,child_id,vote_date)(이사님 하루 1표 덮어쓰기)
compare 코호트 판정 = compareEnabled(process.env, cid) — 기존 v3Enabled 패턴 복제(COACH_COMPARE_CHILDREN 또는 기존 COACH_V3_CHILDREN 재활용). 인계서: compare 모드는 v3 순수 조립을 대체(아린은 더이상 v3 블록 조립 안 함)
compare ON이면 v3 블록 조립 경로(v3Ctx) 분기 대신 Letter B 생성 경로 사용 — 단 본 원자는 '저장 자리'만, B 생성기 자체는 EPIC B~F 산출물(altLetterOut 주입 인터페이스만 정의)
카나리아 ID 매칭 — {COACH_COMPARE_CHILDREN:'a,b'} → 'a' true·'c' false
G-05-3
단위
공백 트림 — {COACH_COMPARE_CHILDREN:' a , b '} → 'a' true
G-05-4
단위
미설정 전원 false — {} → 임의 id false
G-05-5
적대
빈 문자열 방어 — {COACH_COMPARE_CHILDREN:''} → false(빈 토큰 filter)
G-05-6
단위
v3Enabled와 독립 — COACH_V3=1만 설정 → compareEnabled는 false(키 분리)
DoD
순수함수 단독 테스트 6케이스 그린
v3Enabled와 키·동작 독립 확인
prebuild 게이트 통과
의존없음
✅G-06 · 어드민 쓰레드 — A/B 나란히 렌더 + 변형 라벨UIUI · 1.5h
목적app/admin/[childId] 편지 버블에서 메인 letter(A)와 context.altLetter(B)를 나란히/대비해 보여줘, 이사님이 같은 날 두 편지를 직접 비교한다.
요구context.altLetter가 있는 편지(아린)는 A 버블 아래에 B 버블을 'A(v2)'·'B(새설계)' 라벨로 추가. 없으면 기존 단일 렌더. 회귀 없음.
명세
app/admin/[childId]/page.tsx 편지 렌더(L312~340): ev.data.context.altLetter 존재 시 두 버블 — A 버블 헤더에 칩 'A · v2 대조군', B 버블 헤더에 'B · 새설계' + altLetter.design.unit·mode·materials 요약
B 버블에 기존 chip() 스타일 재사용(L318). B 검증결과(altLetter.verify.ok) 칩·model(sonnet) 칩
altLetter 없으면 기존 단일 letter 버블 그대로(타 자녀 무영향)
B 버블 아래 VoteButtons(G-08) 마운트 자리 — child_id·letter_date 전달
Ctx(우리 판단)는 메인 context 그대로(altLetter.design은 B 버블 인라인)
유즈케이스
아린 쓰레드: 6/14 칸에 A·B 두 버블 나란히 → 톤·재료·은유 직접 비교
타 자녀: 단일 편지(회귀 없음)
altLetter.letter만 있고 oneliner 없는 경우도 안전 렌더
테스트6개
ID
종류
케이스 · 검증(입력→기대)
G-06-1
수동
altLetter 있을 때 2버블 — context.altLetter 있는 편지 → 'A·v2'·'B·새설계' 두 버블 노출
G-06-2
수동
altLetter 없을 때 단일 — altLetter 키 없는 편지(타 자녀) → 버블 1개(회귀)
fixture: '아린 6통 v3 수렴' 시나리오(B repeat 0·A repeat 3·이사님 votes B 우세) → winner='B' 고정 회귀
엣지 발견 시 fixture+테스트(red→green)+수정(불변 원칙③ 복리)
유즈케이스
머지 전 npm test → compare 로직 그린
엣지(미세차·표본0·음수) 회귀 영구 보호
테스트3개
ID
종류
케이스 · 검증(입력→기대)
G-12-1
단위
전체 스위트 그린 — npm test → compare-vote.test.ts 전 케이스 pass
G-12-2
회귀
아린 시나리오 회귀 — B 다양·A 수렴 fixture → decideWinner.winner='B'
G-12-3
회귀
prebuild 게이트 — 테스트 실패 시 build 중단(prebuild 게이트)
DoD
tests/ 총 케이스 +24 이상, 전체 그린
prebuild 게이트에서 실행됨
엣지 fixture 회귀 영구화(불변③)
의존G-02 · G-05
EPIC H — 검증 하네스·회귀·적대 테스트 (Letter B 품질 게이트) D4
Letter B 개선 A~I의 결정론층(재료 회전·4기준 랭킹·조합 정합·품질 스캔)을 다일 리플레이·적대·회귀 테스트로 박제해 'prebuild 게이트 그린=좋은 편지'가 성립하게 만든다. 아린 실데이터 6통 회귀·괴식 조합 차단·14일 수렴 0·merged vs v2 골든을 모두 게이트화한다.
대칭성: 입력 순서 무관(dish×ingredient 고정 방향) — comboFit는 첫 인자=dish·둘째=ingredient로만 평가, ingredient를 dish 위치에 넣어도 throw 없이 ok=false
DoD
block 5+·ok 5+ 케이스 전부 기대대로
임계(score 2)·pair 폴백·오탐방지 모두 핀됨
npm test 그린
의존H-01 · A-EPIC(comboFit 검증기 export)
✅H-03 · 재료 결정론 회전·3일 무재사용 불변식 테스트테스트테스트 · 1h
목적개선 B(재료=코드가 매일 회전·3일내 재사용 금지, 문장만 LLM)의 회전 함수가 결정론이고 3일 창에서 같은 추천 식재료를 반복하지 않음을 박제. 'LLM이 매일 독립 최적화→당근→미역국 수렴'을 코드가 막는 증거.
요구B-EPIC 재료 회전 함수(같은 결핍군에서 날짜 시드로 식재료를 회전)에 14일 연속 입력을 주면 (1)같은 (날짜,자녀) 입력은 byte-동일 출력(결정론) (2)연속 3일 창에 같은 식재료 0건 (3)풀 소진 시에도 라운드로빈으로 다양성 유지.
명세
coach-materials.test.ts에 추가. B-EPIC 회전 함수(예 `rotateMaterial({target, seed, recent3})`→식재료명, 또는 기존 pickFoodReco의 seed 회전 + recent 제외 확장 — 실제 export명 TBD(B-EPIC 확인 필요))를 import.
기존 lib/coachRecos.ts pickFoodReco의 회전 토대(off=seed%reps0.length·daySeed)와 buildMealMirror의 recent 쿨다운(pickFresh) 패턴을 참고해 '3일 무재사용'이 코드 보장인지 검증.
결정론: 같은 {target,seed} 두 번 호출 → 동일 식재료(coach-guards.test.ts pickQuestionTopic 결정론 패턴 답습).
3일 창: 14일 시뮬(daySeed = floor(Date.parse/86400000) 연속) 돌려 results[i]가 results[i-1..i-2]에 없음.
비타민A채소 GROUP_INGREDIENTS(단호박·당근·시금치·근대 4종) 풀에서 회전 시 4종을 고르게 순회(편중 없음) 분포 검증.
엣지: 풀 길이 1(단일 식재료군)이면 3일 무재사용 불가 → 함수가 '문장 변주로 위임'하거나 명시적 허용을 반환(throw 금지).
유즈케이스
14일 동안 비타민A채소 추천이 당근→시금치→근대→단호박 식으로 회전
같은 날짜·자녀는 재실행해도 같은 추천(reuse·백필 byte-동일)
풀 1종 군은 회전 불가를 graceful 처리
테스트8개
ID
종류
케이스 · 검증(입력→기대)
H-03-1
단위
결정론: 동일 입력 두 번 = 동일 식재료 — rotateMaterial({target:'비타민A채소',seed:100,recent3:[]}) 두 호출 결과 === 동일
H-03-2
속성
3일 창 무재사용: 14일 연속에서 results[i]∉results[i-1..i-2] — daySeed 0..13 회전 결과 배열에서 모든 i에 대해 window(i-2,i-1) 미포함
목적브리프 핵심 ①: 아린 실데이터(5/16~6/13 capture)로 Letter B 6통을 재현해 (1)괴식 0 (2)품질 위반 0 (3)수렴(블록/조합/도입/추천 식재료) 0임을 박제. 하이브리드가 v3 조립본을 6:0으로 이긴 실증의 회귀 잠금.
요구tests/fixtures/real-arin.json rows로 Letter B 파이프(decide-재료회전-comboFit-품질스캔-합본검증)를 LLM 0콜 결정론층으로 6~14일 리플레이해, 괴식 조합 0·품질 위반 0·comboRepeat7d 0·추천 식재료 인접 동일 0·도입 수렴 0을 단언.
명세
compare-replay.test.ts에 추가. 기존 real-arin.json(브리프대로 5/16~6/13, ingredients 포함)을 import. 필요 시 H-12에서 6통 골든 기대치 보강.
Letter B는 LLM 작문이 들어가므로 회귀는 '결정론층'만(재료·조합·품질 스캔 입력) — replayRunner.runV3Family는 조립식 v3용이라, Letter B 결정론층 전용 리플레이 헬퍼(H-09)를 사용하거나 재료 회전+comboFit+scanQuality(고정 본문 템플릿)로 근사.
괴식 0: 6통 각 추천 dish×ingredient가 comboFit.ok===true(미역국+당근류 0건).
수렴 0: 6통 추천 식재료에 인접 동일 0·3일 창 재사용 0(replayMetrics 창 패턴).
품질 입력 위반 0: 코드가 만든 거울·추천 문장(coachFacts.buildMealMirror)에 scanQuality 위반 0(은유·나열·모호기간·재료밖).
✅H-09 · Letter B 결정론층 리플레이 헬퍼(replayRunner 확장 runBFamily)코드코드 · 1.5h
목적H-07·H-08·H-10이 공유할 Letter B 전용 다일 리플레이 헬퍼를 lib/replayRunner.ts에 추가. 기존 runV3FamilyFull(v3 조립식)과 별개로, Letter B의 결정론층(재료 회전→comboFit→코드 거울/추천 문장→품질 스캔 입력)만 LLM 0콜로 통주한다.
요구ReplayFamily(기존 타입 재사용) + Letter B 결정론 산출(추천 식재료·조합·거울·품질 위반)을 일자별로 반환하는 순수 헬퍼를 추가해, 테스트가 14일 시계열 불변식을 측정할 수 있게 한다.
명세
lib/replayRunner.ts에 `runBFamily(fam: ReplayFamily, opt?)`→{days: BReplayDay[]} 추가. 기존 addD·isoWeekKey·rows28 창 로직 재사용.
BReplayDay 형태 검증 — 각 day에 date·target·material·combo·mirror·quality 필드 존재
H-09-4
회귀
기존 runV3FamilyFull 무영향(회귀) — runV3FamilyFull(synthetic fam) 결과가 기존 replay.test.tsI-05 게이트 그대로 통과
H-09-5
적대
ingredients 없는 rows도 수용(synthetic CRow) — ingredients 미포함 rows 입력 → throw 0(빈 material 가능)
H-09-6
회귀
결정론: 동일 fam 두 번 = 동일 days — runBFamily 두 호출 결과 JSON 동일
DoD
additive(기존 runner·v3 게이트 무영향)
순수·결정론·LLM 0콜
H-07/H-08/H-10이 import 가능
npm test 그린
의존H-02 · H-03 · H-05
✅H-10 · 30가정 통주 게이트 — Letter B 괴식 0·수렴 0·품질 0(bCutoverGate)테스트테스트 · 1h
목적기존 I-05(v3 조립 30가정 게이트)의 Letter B 버전. synthetic-families.json 30가정×14일을 runBFamily로 통주해 가정별 괴식 0·인접 수렴 0·3일 재사용 0·품질 위반 0을 전수 게이트화(prebuild 편입).
요구30가정 각각 bReplayMetrics가 괴식 0·인접동일 0·3일재사용 0·품질위반 0을 만족(미달 가정 목록이 빈 배열)해야 통과. 기존 I-05 v3 게이트와 병존.
명세
compare-replay.test.ts에 describe('H-10 Letter B 30가정 통주'). 기존 tests/fixtures/synthetic-families.json(30가정·28일) import — replay.test.tsI-05와 동일 fixture 재사용.
H-09 runBFamily + lib/replayMetrics.ts bCutoverGate(BReplayReport→미달 문자열[]) 사용.
규모 sanity: 30가정·발행 300통+(저기록 생략 허용 — I-05 패턴 답습).
가정별 전수: perFam.flatMap(cutover) === [].
분포 sanity: 결핍군 다양(비타민A채소·기타채소·콩류 등 여러 군 추천 등장) — 한 군 고착 아님.
modeDist류는 v3 전용이라 미적용 — B는 재료·조합·품질 축만.
유즈케이스
배포 게이트로 30가정 Letter B 괴식/수렴/품질 0 보장
합성 가정으로 아린 외 패턴 일반화 검증
테스트7개
ID
종류
케이스 · 검증(입력→기대)
H-10-1
단위
규모: 30가정·발행 300통+ — families.length===30 && 총 발행 days>300
v3Assembled가 비어있지 않음(약점 박제 대상 존재) — 각 v3Assembled[i] 텍스트 길이>0
H-12-4
단위
날짜 정렬·중복 0 — dates 오름차순·고유
DoD
골든 fixture가 H-08·H-11에서 import됨
bExpected가 runBFamily와 일치(드리프트 추적)
v3Assembled 약점 케이스 보존(없으면 생성 경로 명시)
npm test 그린
의존H-09 · real-arin fixture(기존)
✅H-13 · replayMetrics 확장 — bReplayMetrics·bCutoverGate(B축 지표)코드코드 · 1h
목적기존 lib/replayMetrics.ts(v3 조립 지표)에 Letter B의 결정론 품질 지표(괴식건수·인접동일·3일재사용·품질위반)와 게이트를 additive로 추가한다. H-07/H-08/H-10의 측정 토대.
요구BReplayDay[]를 받아 miscombo·adjacentSame·materialRepeat3d·qualityViolations·materialDiversity를 산출하는 순수 bReplayMetrics와, 0 게이트를 적용하는 bCutoverGate(미달 문자열[])를 추가한다.
명세
lib/replayMetrics.ts에 BReplayDay·BReplayReport 타입 + bReplayMetrics()·bCutoverGate() 추가(기존 ReplayDay/replayMetrics/cutoverGate 옆·무수정).
miscombo = combo.ok===false 건수. adjacentSame = material[i]===material[i-1] 건수. materialRepeat3d = 직전 3일 창 material 재등장(기존 blockRepeat3d 창 로직 70행 패턴 차용). qualityViolations = quality.length 합. materialDiversity = new Set(materials).size.
bCutoverGate: miscombo>0·adjacentSame>0·materialRepeat3d>0·qualityViolations>0 중 하나라도면 사유 문자열 push(기존 cutoverGate 130행 패턴).
순수·LLM 0콜. 기존 replayMetrics·cutoverGate 시그니처 불변(회귀).
유즈케이스
H-10 30가정 게이트가 bCutoverGate로 전수 판정
H-07/H-08이 지표로 수렴·괴식 측정
테스트10개
ID
종류
케이스 · 검증(입력→기대)
H-13-1
단위
miscombo 카운트(괴식 1건 입력→1) — combo.ok=false 1건 days → bReplayMetrics.miscombo===1
H-13-2
단위
adjacentSame 카운트 — material ['당근','당근','시금치'] → adjacentSame===1
H-13-3
속성
materialRepeat3d 3일 창 — ['당근','시금치','근대','당근'](4일) → 당근 3일창 재등장 1건
기존 replayMetrics/cutoverGate 무영향(회귀) — 기존 replay.test.tsI-05가 그대로 그린
H-13-9
적대
빈 days 안전 — bReplayMetrics([]) → 전 지표 0·throw 0
H-13-10
속성
순수·LLM 0콜 — bReplayMetrics 동기 반환·외부 호출 0
DoD
B축 지표·게이트 additive로 추가
기존 v3 지표 시그니처 불변
순수·엣지 안전
npm test 그린
의존없음
✅H-14 · A/B 대조군 불변식 — Letter A는 v2 그대로(무변경 회귀)테스트테스트 · 0.75h
목적불변 원칙 ⑤(모든 개선은 Letter B에만·A 무변경)을 박제. compare 모드에서 Letter A 산출(planFor+composeLetter 경로)이 개선 A~I 도입 전후로 동일함을 회귀로 잠가, A/B 비교의 정당성을 보장.
요구동일 입력에서 Letter A 결정론 부분(planFor의 plan/scenario/signature)이 Letter B 코드 추가에 영향받지 않음을 검증하고, compare 저장 형태(context.altLetter에 B·메인 letter/oneliner=A)의 스키마 불변(스키마 변경 0)을 단언.
명세
compare-replay.test.ts에 추가. lib/coach.ts planFor(358)로 Letter A plan 산출 — Letter B 함수 import 유무와 무관하게 동일 plan/signature.
스키마 불변: coach_letters.context 형태 — 메인 letter/oneliner=A, context.altLetter={letter,oneliner,design,mirror,materials}. context는 jsonb라 DDL 변경 0(불변 원칙 ④ SQL 실행확인 — compare는 스키마 변경 없음을 명시).
Letter A는 기존 planFor(signals,...) 시그니처 그대로 — coach-plan.test.ts 패턴 답습.
compare 모드 게이트: COACH_COMPARE_CHILDREN 또는 COACH_V3_CHILDREN(아린)에서만 B 생성·A 항상 생성(v3 순수 조립 대체).
대조군 무변경 회귀: 동일 daySeed·cidHash·signals → planFor 결과가 H-EPIC 코드 추가 전후 동일(plan.signature 핀).
유즈케이스
아린은 A(v2)+B(처치) 2통, 메인 letter=A
B 코드가 A의 plan/signature를 바꾸지 않음
스키마 변경 0(jsonb context에 altLetter)
테스트6개
ID
종류
케이스 · 검증(입력→기대)
H-14-1
회귀
Letter A planFor 결정론 불변 — planFor(고정 signals,seed,cidHash).plan.signature가 알려진 골든값과 동일
H-14-2
단위
compare 저장 형태: 메인=A·altLetter=B — compare ctx에서 letter===A.letter && context.altLetter.letter===B.letter
H-14-3
회귀
스키마 변경 0(context jsonb에만 altLetter) — altLetter는 context 하위 키 — coach_letters 컬럼 추가 0(타입/형태 단언)
H-14-4
단위
compare 모드 아닌 자녀는 altLetter 없음 — 비대상 자녀 ctx.context.altLetter===undefined
H-14-5
적대
A는 개선 A~I 적용 안 됨(comboFit·scanQuality 미경유) — Letter A 경로에 comboFit/scanQuality 호출 없음(B 전용 가드)
H-14-6
단위
아린(43942d34...)이 compare 대상 — v3Enabled/compare 게이트가 아린 child_id를 B 대상으로 인식
DoD
Letter A 무변경 회귀 핀
compare 저장 형태·스키마 불변 박제
B 전용 가드가 A 미경유 확인
npm test 그린
의존compare-EPIC(저장 형태) · H-02 · H-05
✅H-15 · prebuild 게이트 편입 — 신규 테스트 4파일 + 데이터 정합(I) 가드운영운영 · 0.5h
목적불변 원칙 ①(머지 전 npm test 그린=prebuild 게이트). H-EPIC 신규 4파일(coach-materials·coach-quality·coach-merged·compare-replay)이 vitest 수집·prebuild에서 돌고, 개선 I(GROUP_INGREDIENTS 데이터 정합)도 빌드타임 가드로 박제됨을 확인.
요구npm test가 신규 4파일을 수집·실행하고 그린이며, package.json prebuild가 test를 게이트로 거는지 확인. 개선 I의 '대표 식재료=급식빈도 있는 식재료' 데이터 정합이 테스트로 잠긴다.
명세
package.json scripts 확인: prebuild/test가 vitest를 거는지(기존 'prebuild 게이트' 표현). 신규 4파일이 tests/ 글롭에 자동 포함(기존 7파일과 동일 패턴).
개선 I 데이터 정합 가드: coach-materials.test.ts에 GROUP_INGREDIENTS 각 대표 식재료가 freqMap 또는 kit-matrix에서 급식빈도>0(또는 popularDishesFor 비어있지 않음)인지 — 단호박(freq 0·learned 0회)이 빈도 가중 시 탈락하므로 대표 목록 정비 확인.
EPIC I — 데이터 정합성·근거 보강 (급식빈도 상위%·GROUP_INGREDIENTS 정비·괴식 조합 점검·근거 문구) D4
Letter B의 '재료 결정론'이 실측 근거 위에 서도록 데이터층을 정비한다: 식재료별 급식빈도+상위% 테이블을 산출하고, 단호박(0회) 같은 죽은 대표 식재료를 빈도 있는 것으로 교체하며, 괴식 조합(미역국×당근)을 kit-dish-matrix로 차단·검증하고, 식재료→영양역할·상위% 근거 문구를 결정론으로 생성한다.
📁 신규web/scripts/build-ingredient-freq.pyweb/public/ingredient-freq.jsonweb/lib/ingredientFreq.tsweb/lib/comboGuard.tsweb/lib/recoEvidence.tsweb/tests/ingredient-freq.test.tsweb/tests/group-ingredients.test.tsweb/tests/combo-guard.test.tsweb/tests/reco-evidence.test.ts · 수정web/lib/coachRecos.ts · 문서 web/lib/coachRecos-data.audit.md
목적엔진이 쓰는 freqMap(public/ingredient-recipes.json)은 '식재료→레시피명'(dish 단위)이라 '식재료 자체가 급식에 몇 번 나오나'(빈도 가중 랭킹의 ②급식빈도)를 직접 못 준다. 식재료 단위 등장 빈도와 그 상위 백분위(percentile)를 산출하는 별도 데이터를 만든다(C 에픽 4기준 랭킹·F 수치·D5 근거 문구의 공통 데이터 소스).
요구learned_menus(또는 ingredient-recipes 집계)에서 식재료별 등장 빈도를 집계하고, 전체 식재료 분포 대비 상위 백분위(상위 N%)를 계산해 public/ingredient-freq.json으로 출력한다. 산출 식재료명은 도감 표준명(ingredients-light.json)과 100% 일치해야 한다.
명세
신규 web/scripts/build-ingredient-freq.py — 기존 scripts/build-foods-recipes.py(freq=distinct source_months, 출력 public/ingredient-recipes.json) 패턴을 재사용하되 출력 단위를 '레시피'가 아니라 '식재료 1개'로 집계
집계원: ① 1차 = lib 외부 learned_menus(scripts/seed-learned-menus.py가 채운 DB·~9,988행) 또는 그 산출 캐시, ② 폴백 = public/ingredient-recipes.json의 식재료별 freq 합산. 1차 소스가 없으면 ②로 graceful
상위% = 식재료를 freq 내림차순 정렬 후 rank/total → topPct(예: 당근 rank=2/183 → 1.1% → '상위 2%'). 동률은 같은 rank
SEASONING 블로클리스트(build-foods-recipes.py L17 그대로 재사용)로 양념 제외. 도감 표준명 norm(L21-28 재사용)으로 통일
출력 스키마: { [표준명]: { freq:number, rank:number, topPct:number } }. separators=(',',':') 압축 출력(기존 스크립트와 동일)
실측 검증 기준값을 스크립트 말미 print: 당근(상위 2%대)·근대(상위 39%대)·단호박(0회)·치즈(상위 27%대)·요거트(0회) — 브리프 [실측 급식빈도 learned_menus 1000개]표와 동일 방향인지 콘솔 확인
산출 JSON은 도감에 없는 식재료(SEASONING 잔류·오타) 0건 — 도감set 교집합만 출력
유즈케이스
scripts/build-ingredient-freq.py 실행 → public/ingredient-freq.json 생성, 당근 topPct가 단호박보다 작다(더 흔하다)
ingredient-recipes.json만 있고 learned_menus 캐시가 없는 환경 → 폴백 경로로도 JSON 생성
도감에 없는 양념(마늘·소금)은 출력에서 제외
테스트11개
ID
종류
케이스 · 검증(입력→기대)
I-01-1
단위
산출 JSON 키 전부 도감 표준명 — public/ingredient-freq.json의 모든 키 ∈ ingredients-light.json nm 집합 → 차집합 길이 0
I-01-2
단위
당근 상위% < 근대 상위% — freq[당근].topPct < freq[근대].topPct (당근이 더 흔함). 실측 당근~2%·근대~39% 방향 일치
목적I-01이 만든 ingredient-freq.json을 엔진이 안전하게 읽도록 순수 로더를 둔다. C 에픽 랭킹과 D5 근거 문구가 식재료의 급식빈도·상위%를 동일 API로 쓰게 한다(죽은 코드 방지).
요구식재료명을 받아 { freq, rank, topPct } 또는 미수록 시 null을 반환하는 순수 함수와, 상위% 임계(예: 상위20% 이내) 판정 헬퍼를 제공한다. fs/HTTP 불사용(빌드타임 import).
명세
신규 web/lib/ingredientFreq.ts — lib/ingredientFreq.json 또는 import 경로는 빌드 산출 위치에 맞춤(public 파일을 빌드타임 import 불가 시 lib로 복제하거나 SSG fetch). coachRecos.ts 주석 'fs/HTTP 불사용·순수 함수' 규약 준수
export type IngredientFreq = { freq:number; rank:number; topPct:number }
export function freqOf(nm:string): IngredientFreq | null — 미수록은 null(단호박·요거트)
export function topPctOf(nm:string): number | null — 미수록 null(0회를 '상위 100%'로 위장 금지)
export function isCommon(nm:string, pctMax=20): boolean — topPct<=pctMax 이고 freq>0
주식 곡물(밀/쌀)은 freq 조회 시 stapleDisplay 형태가 아닌 원재료 키로 조회(데이터 키 일관)
✅I-03 · GROUP_INGREDIENTS 정비 — 빈도 있는 대표 식재료만 (단호박 강등·계란 중복 제거)코드코드 · 1h
목적인계서 I[데이터 정합성]: GROUP_INGREDIENTS 대표가 급식빈도 없는 식재료(단호박 0회)를 선두에 두면, C 에픽 빈도 가중 랭킹에서 그 식재료가 탈락하거나 0근거 추천이 된다. 대표 목록을 빈도 있는 식재료 중심으로 재정비한다.
요구lib/coachRecos.ts의 GROUP_INGREDIENTS 각 그룹 대표 목록을, ingredient-freq 기준 빈도가 있는 식재료를 우선 배치하도록 정비한다(0회 식재료는 후순위 또는 제외). 단 도감/영양 커버리지를 깨지 않도록 보수적으로(영양상 중요하나 급식빈도 0인 식재료는 제거 대신 후순위).
명세
lib/coachRecos.ts L20-28 GROUP_INGREDIENTS 재정렬. 현 비타민A채소=['단호박','당근','시금치','근대'] → 단호박(0회) 선두에서 강등, 당근(상위2%)·시금치(상위33%)·근대(상위39%) 우선
'고기·계란'=['달걀','계란',...]에서 '계란'은 '달걀' 중복(freq 0·도감표준=달걀)이므로 제거 또는 별칭 정리
목적인계서 A[최우선·괴식]: '잘먹는음식+결핍식재료' 조합을 점수화해 낮으면 금지. 실증 괴식='미역국에 당근'(kit-dish-matrix 미역국×당근 score=1·cells=2). LLM이 조합 지어내게 두지 않도록, 검증 통과 조합만 LLM 후보로 넘기는 순수 게이트를 만든다.
요구(음식, 식재료) 또는 (식재료, 식재료) 조합의 정합 점수를 kit-dish-matrix scores(0~3)와 food-graph pair로 산출하고, 임계 미만이면 금지(false) 반환하는 순수 함수를 제공한다. 미역국×당근은 금지, 볶음밥·카레×당근은 허용이 되어야 한다.
명세
신규 web/lib/comboGuard.ts. kit-dish-matrix는 lib/kitGuide.ts dishesForIngredient로 접근(scores[dish][ing], cells[dish][ing])
dish를 ingredientPairFit에 넣으면 금지 — ingredientPairFit('볶음밥','당근').ok===false (볶음밥은 food-graph 노드 아님 — 경계 강제)
I-04-11
적대
빈/공백 입력 — dishIngredientFit('','당근').ok===false, dishIngredientFit('미역국','').ok===false
I-04-12
단위
validCombos 빈 입력 — validCombos([],[]) === [], validCombos(['미역국'],['당근'])===[]
I-04-13
회귀
전 dishes×당근 괴식 스냅샷 회귀 — score<2인 dish(미역국1·빵토스트1·쌈1·요거트간식1·김치찌개1)는 모두 ok===false로 일관
DoD
lib/comboGuard.ts 순수 함수·단독 테스트 그린
미역국×당근 차단·볶음밥/카레×당근 허용(I-04-1·2·3 그린)
미수록=금지(I-04-5)·dish↔ingredient 경계 강제(I-04-10)
머지 전 npm test 그린·LLM 후보는 validCombos 화이트리스트만(Letter B)
의존없음
✅I-05 · food-graph pair 정합 점검 — dish+당근은 pair 아닌 kit-matrix 사실 문서화·보강 검토코드코드 · 0.75h
목적인계서 A: '짜파게티/볶음밥/카레+당근 pair 존재 확인·없으면 보강'. 실측 점검 결과 짜파게티·볶음밥·카레는 food-graph 노드가 아니라(식재료 그래프) dish+ingredient 적합도는 kit-dish-matrix에만 있다. 이 정합을 코드·문서로 못 박아 '엉뚱한 보강'(dish를 graph에 억지 삽입)을 막는다.
요구food-graph는 ingredient×ingredient(pair/bridge)만 표현함을 회귀 테스트로 고정하고, dish+ingredient 정합은 kit-dish-matrix 경로(I-04)로 처리됨을 검증한다. 당근의 핵심 pair(두부·달걀·감자 등)가 실제로 존재하는지 확인하고, 결핍 시 보강 후보를 audit.md에 기록한다.
명세
lib/foodGraph.ts neighborsOf로 당근 이웃 검증: 실측 pair=가다랑어·감자·게맛살·고구마·국수·꼬막·녹두·느타리버섯·달걀·닭고기·당면·돼지고기·두부… (충분) — 핵심 pair 존재 확인 테스트
'짜파게티'·'볶음밥'·'카레'는 food-graph 노드 아님(NOT A NODE) → graph.nodes에 없음을 회귀로 고정. 이들의 당근 조합은 kit-dish-matrix(볶음밥3·카레3)로 처리됨을 교차 검증
보강 정책: dish를 food-graph에 삽입하지 않는다(노드 단위가 식재료라 오염). 대신 kit-dish-matrix에 누락 dish/score가 있으면 gen-kit-matrix.py로 보강(별 작업·여기선 점검만)
lib/coachRecos-data.audit.md에 ① 당근 pair 목록 ② 'dish+당근은 kit-matrix 소관' 결정 ③ 보강 필요 셀(있으면) 기록
목적인계서 C[이사님 핵심]: 근거문구를 LLM 재료로('당근=급식 상위2%·베타카로틴이 눈·면역'). 식재료를 받아 ①영양 역할(NUTRIENT_FOODS/NUTRI_MAP 기반) ②급식 상위%(I-02) 두 사실을 결합한 결정론 근거 문구를 생성한다. LLM은 이 문구를 재료로 받아 작문만 한다(사실 코드 못박기).
요구식재료명을 받아 '급식 상위 N%·{영양역할}' 형태의 검증가능한 근거 문구를 결정론으로 생성한다. 급식빈도 0(단호박)이면 상위% 문구를 생략하고, 영양 역할만 또는 제철 근거로 대체한다(0회를 '상위 100%'로 위장 금지). 영양 역할은 NUTRIENT_FOODS 역인덱스(식재료가 어떤 영양소의 대표인가)로 산출.
✅I-07 · freqMap·ingredient-freq 실배선(coachRecos가 죽은 코드 안 되게) — 데이터 정합 통합 테스트테스트테스트 · 0.75h
목적인계서 C: freqMap이 route.ts에서 로드만 되고 본경로(pickFoodReco) 미전달=죽은 코드. I 에픽의 데이터(ingredient-freq·정비된 GROUP_INGREDIENTS·comboGuard·recoEvidence)가 실제 함수 시그니처로 흘러가는지 통합 검증으로 못 박는다(C 에픽이 배선을 구현, 여기선 데이터층 계약 테스트).
요구I-01~I-06 산출물이 서로 정합함을 통합 테스트로 검증한다: GROUP_INGREDIENTS 대표가 ingredient-freq/recoEvidence/comboGuard와 모순 없이 동작하고, freqMap(public/ingredient-recipes.json)과 ingredient-freq.json이 동일 식재료에 대해 일관된 방향을 가진다.
명세
신규 통합 테스트 tests/ingredient-freq.test.ts(또는 reco-evidence와 분리). I-01~06 산출물 전체를 import해 교차 검증
계약1: GROUP_INGREDIENTS 모든 대표 식재료가 ingredients-light.json·food-graph.json·nutrition NUTRI_MAP 중 최소 하나에 등장(고아 식재료 0)
계약2: GROUP_INGREDIENTS 비타민A채소 대표 중 freqOf 있는 것에 대해 evidenceFor.text에 상위% 절 존재
계약3: weeklyExposureTarget(정비된 GROUP_INGREDIENTS)가 반환한 challenge 식재료는 popularDishesFor로 dish가 1개 이상 나오거나 STAPLE 형태(빈손 추천 0)
freqMap 소스 경로 고정 — app/api/cron/coach/route.ts가 '/ingredient-recipes.json' 문자열 포함(데이터 소스 경로 의도 고정)
I-07-6
통합
ingredient-freq vs ingredient-recipes 방향 일관 — 두 소스 모두 있는 식재료(당근·치즈)에서 ingredient-freq topPct 작은 식재료가 ingredient-recipes freq도 큰 경향(역전 0건은 아니어도 당근>근대 일관)
I-07-7
적대
단호박 회전 시 환각 방지 — target=비타민A채소·seed가 단호박을 가리킬 때라도 evidenceFor(단호박).text에 '상위' 절 0(허위 빈도 미생성)
I-07-8
회귀
정비 전후 A 대조군 불변 — Letter A 경로(기존 pickFoodReco)는 정비된 GROUP_INGREDIENTS로도 동일 시드·동일 입력에서 결정론 출력 유지(A 무변경 원칙 — 단 상수 변경이 A 출력을 바꾸면 별도 검토 플래그)
DoD
I-01~06 산출물 통합 계약 테스트 그린
고아 식재료 0·추천 빈손 0·추천 파이프 괴식 0(I-07-1·3·4 그린)
freqMap 소스 경로 회귀 고정(I-07-5)
A 대조군 영향 검토(I-07-8) — GROUP_INGREDIENTS 공유 상수가 A 출력을 바꾸면 명시 기록
머지 전 npm test 그린(prebuild 게이트)
의존I-01 · I-02 · I-03 · I-04 · I-06
EPIC J — 컷오버·운영·롤백·모니터·문서 (A/B 2통 하이브리드) D5
아린 A/B 2통(A=v2 대조군·B=하이브리드 처치군)의 승자 판정→전 자녀 승격 절차와 즉시 롤백(env 제거)을 정의하고, 자가진단 크론·cron_runs.issues에 품질 위반·괴식 차단·2통 LLM 비용 지표를 추가해 모니터링하며, 문서(plan-engine·scenarios·WBS 상태칩)를 현행화하고 엣지 복리 규칙을 운영 규약으로 못 박는다.
📁 신규web/lib/coachCompare.tsweb/tests/coach-compare.test.ts · 수정web/app/api/cron/coach/route.tsweb/app/api/cron/coach-selfcheck/route.tsweb/app/admin/cron/page.tsxweb/app/admin/[childId]/page.tsxweb/lib/coachDaily.ts · 문서 coaching-plan-engine.htmlcoaching-scenarios.htmlcoaching-v3-build-plan.htmlweb/docs/coach-compare-runbook.md
✅J-01 · compare 모드 env 게이트 — compareEnabled + 즉시 롤백 계약코드코드 · 0.5h
목적아린에게만 A/B 2통을 켜고, env 제거 한 번으로 A만 남게(즉시 롤백) 하는 단일 진입 게이트를 결정론 순수 함수로 둔다. v3Enabled가 'v3 순수 조립'을 켜는 것과 분리해, compare 모드는 v3 조립을 대체(아린은 더이상 v3 블록 편지를 발행하지 않고 A=v2·B=하이브리드).
요구COACH_COMPARE_CHILDREN(또는 기존 COACH_V3_CHILDREN 재활용)에 자녀 id가 있으면 compare=true. compare=true면 그 자녀는 v3 순수 조립 경로를 타지 않는다. env 미설정/빈값=false(=A만, 기존 v2 단통 발행).
명세
lib/coachDaily.ts의 v3Enabled(env,childId) 패턴을 그대로 따라 lib/coachCompare.ts에 compareEnabled(env:{COACH_COMPARE?:string;COACH_COMPARE_CHILDREN?:string},childId:string):boolean 신규.
COACH_COMPARE==='1'→전체 true, 아니면 (COACH_COMPARE_CHILDREN||'').split(',').map(trim).filter(Boolean).includes(childId).
app/api/cron/coach/route.ts 자녀 루프 진입부에서 const compare=compareEnabled(process.env,cid) 계산. compare===true이면 line 502의 v3Enabled(...) 분기를 건너뛰어 v3 순수 조립을 막는다(compare가 v3 조립을 대체 — 사상서 '아린은 더이상 v3 블록 조립 안 함').
즉시 롤백 계약: env에서 COACH_COMPARE_CHILDREN 제거→compareEnabled=false→코드 변경 없이 A(v2)만 발행.
lib/coachCompare.ts 신규·순수 함수(외부 IO 0)·단독 테스트 9케이스 그린.
npm test 전체 그린(prebuild 게이트).
compare=true 자녀가 v3 순수 조립 경로를 타지 않음을 라우트 코드에서 확인(분기 우선순위).
B에만 적용 원칙: A(v2) 경로는 무변경.
의존env(COACH_COMPARE_CHILDREN 설정)
✅J-02 · 승자 판정 기준 함수 — judgeWinner(피드백 N일·선호율 임계)코드코드 · 1h
목적아린 A/B에서 어떤 편지가 이겼는지를 letter_feedback(rating up/down/repeat) 집계로 판정하는 결정론 함수. '며칠·선호율 임계'를 코드로 못 박아 '느낌'이 아니라 데이터로 승격 결정. 사상서: B가 피드백에서 이기면 전 자녀 승격.
요구A·B 각각의 up/down/repeat 카운트를 입력받아 verdict('B-win'|'A-win'|'inconclusive'|'insufficient')와 근거 수치를 반환. 표본(피드백 총수)이 임계 미만이면 'insufficient'. 선호율(up-down-repeat의 순점수 또는 up비율) 차이가 임계 미만이면 'inconclusive'.
명세
lib/coachCompare.ts에 type FbCount={up:number;down:number;repeat:number}; export function judgeWinner(a:FbCount,b:FbCount,opts?:{minDays?:number;minMargin?:number}):{verdict:string;scoreA:number;scoreB:number;reason:string}.
목적B/하이브리드가 사고를 내면 한 동작(env 제거)으로 즉시 A(v2)로 되돌아가게 보장. v3Enabled가 이미 '미설정=기존 경로'인 패턴(coachDaily.ts line 20 주석 '즉시 롤백=env 끄기')을 compare/hybrid에도 동일 적용. 어떤 실패도 throw 없이 v2 폴백.
요구COACH_COMPARE_CHILDREN/COACH_HYBRID* env 제거→다음 크론부터 100% A(v2)만. 또한 B 생성 도중 예외 발생 시 A는 정상 발행돼야 함(B 실패가 A를 망치지 않음). route.ts의 v3 경로처럼 B 생성은 try/catch로 감싸 throw 없이 폴백.
명세
B 편지 생성 블록 전체를 try/catch로 감싸 실패 시 issues.push('compare B 생성 실패→A만 발행 '+name)·altLetter=null로 진행(A는 정상 upsert).
롤백 후 검증: env 제거→compareEnabled=false→2통 분기 미진입→기존 v2 upsert만(coach_letters.letter=A·context.altLetter 없음).
coachDaily.ts line 20 주석과 동일 톤으로 coachCompare.ts에 '즉시 롤백=env 제거' 주석 명문화.
runbook에 롤백 명령(Vercel env 키 삭제 후 재배포 불필요 — 다음 크론 자동 반영) 기재.
유즈케이스
B가 괴식 편지 발행→이사가 즉시 COACH_COMPARE_CHILDREN 삭제→당일 새벽 크론부터 A만.
B 생성 중 LLM 타임아웃→A는 정상 발행되고 issues에 'B 실패' 1줄·부모는 A 편지 정상 수신.
테스트5개
ID
종류
케이스 · 검증(입력→기대)
J-04-1
회귀
env 제거=2통 미진입 — compareEnabled false면 altLetter 생성 코드 미실행·coach_letters.context.altLetter 부재
J-04-2
적대
B 실패 시 A 보존 — B 생성 함수가 throw→catch로 잡혀 A letter는 정상 upsert·errors 미증가(B만 issues 기록)
J-04-3
단위
B 실패 issues 기록 — B 예외 시 issues 배열에 'compare B 생성 실패' 포함 문자열 1건 push
J-04-4
회귀
롤백 후 v2 byte 동일 — compare off 상태 발행 = compare 도입 전 v2 발행과 동일 경로(planFor+composeLetter, 메인 letter 무변경)
J-04-5
통합
폴백 시 알림톡 A 기준 — B 실패해도 sendCoachLetterPreview는 A oneliner로 발송(부모 알림 정상)
DoD
B 생성 try/catch 폴백 배선·errors 비증가 확인.
롤백 회귀 테스트로 v2 경로 무변경 보증(B에만 적용 불변식).
runbook 롤백 절차 1동작(env 키 삭제) 명시.
npm test 전체 그린.
의존J-01 · J-03
✅J-05 · 2통 A/B 발행·altLetter 저장 + 자가 비교 지표 cron_runs.meta 적재코드코드 · 1h
목적compare 자녀에 대해 A(v2 무변경)·B(하이브리드) 두 편지를 같은 트랜잭션에서 만들고, 메인 letter=A·context.altLetter={letter,oneliner,design,mirror,materials,verify...}로 저장. 발행 시점에 A↔B 자가 유사도·B 검증결과를 cron_runs.meta에 적재해 비교 모니터 기반을 만든다.
✅J-06 · 자가진단 크론에 품질 지표 추가 — coach-selfcheck B 품질·괴식·폴백 감시운영운영 · 1h
목적coach-selfcheck route.ts(현재 반복점수·oneliner중복·거울누락·피드백만 봄)에 B 전용 품질축을 추가해, 'B가 v2처럼 좋은가'를 매일 결정론으로 자가 점검. 인계서 근본원인 ①(검증이 품질축 0줄)을 운영 모니터 레벨에서도 닫는다.
요구coach-selfcheck가 compare 자녀의 context.altLetter를 읽어 (1) A↔B 유사도(설계 수렴 감지) (2) B의 검증위반/괴식차단/폴백 누적(cron_runs.meta.compare 또는 context에서) (3) A/B 피드백 비교(judgeWinner)를 cron_runs.meta.alerts에 한국어로 기록. 결정론·LLM 0콜 유지(현 라우트 계약).
명세
coach-selfcheck route.ts: 기존 coach_letters select에 context는 이미 포함 — context.altLetter.letter가 있으면 abSim=letterSimilarity(l.letter, altLetter.letter) 계산, abSim>=0.45면 flags.push('A/B 수렴 '+pct).
letter_feedback 집계(이미 fbByChild 있음)를 A/B 구분 저장 시 judgeWinner 호출→verdict를 alerts에 'A/B: B-win(+4)' 형태로 기록(피드백이 A/B 구분 가능할 때만).
compare 자녀에 한해 context.altLetter.verify.ok===false 누적·design 부재 등 결손을 flags로.
현 라우트의 결정론·LLM 0콜·graceful(테이블 없으면 skip) 계약 유지 — try/catch로 altLetter 부재 안전.
admin/cron/page.tsx Meta 타입에 compare?:{abSimAvg?:number;bVerifyFail?:number;bGhostBlocked?:number;bRegen?:number;llmCalls?:number} 추가, Stat 컴포넌트로 'A/B유사도'·'B검증실패'·'B괴식차단'·'LLM콜' 렌더(있을 때만 — 기존처럼 ?? '—').
admin/[childId]/page.tsx: 편지 context.altLetter 존재 시 메인 편지(A) 옆/아래에 'B(하이브리드)' 박스로 altLetter.letter 렌더 + design 라벨 배지.
letter_feedback을 A/B 구분 집계(rating에 변형 또는 별도 키)할 수 있으면 judgeWinner(a,b) 호출해 verdict 배지(B-win/A-win/insufficient) 표시.
기존 어드민 화면(period_summaries·dailyJudg·자가진단 alerts) 무변경 — 추가만(append-only).
컬럼/테이블 부재 시 빈 패널 생략(기존 안전 degrade 패턴 준수).
유즈케이스
이사가 /admin/cron 열면 'A/B유사도 0.19·B검증실패 0·LLM콜 14'.
이사가 /admin/아린 열면 A·B 편지 나란히 읽고 'B가 낫다' 직관 확인 + verdict 배지로 정량 교차검증.
B가 괴식 1건→cron 보고서 'B괴식차단 1' 빨강.
테스트6개
ID
종류
케이스 · 검증(입력→기대)
J-07-1
단위
cron 패널 조건부 렌더 — meta.compare 부재→A/B Stat 미표시(기존 화면 회귀 없음)
J-07-2
수동
cron 패널 값 표시 — meta.compare.abSimAvg=0.19→'A/B유사도 0.19' Stat 노출
J-07-3
수동
childId A/B 나란히 — compare 자녀 상세에 A·B 편지 두 박스·design 배지 표시
J-07-4
수동
verdict 배지 — 피드백 충분 시 'B-win' 배지·부족 시 'insufficient' 배지 표시
관리자 게이트 유지 — isAdmin 아니면 두 페이지 모두 기존대로 차단(권한 회귀 없음)
DoD
admin/cron·admin/[childId]에 A/B 패널 append-only 추가·기존 화면 회귀 없음.
컬럼/필드 부재 시 graceful(빈 패널 생략).
judgeWinner verdict 배지로 정량 판정 노출.
빌드 그린·npm test 그린.
의존J-02 · J-05
✅J-08 · 2통 LLM 비용 모니터 — costEstimate + cron_runs.meta 비용 적재운영코드 · 0.5h
목적compare 자녀는 A(v2)+B(하이브리드)로 LLM 콜이 ~2배. 비용 폭주를 조기 탐지하려 모델별 콜 수를 집계해 cron_runs.meta.cost에 적재하고, 비용 추정 순수 함수로 일일 ₩ 환산. callClaude가 Haiku 기본·intro/recap만 Sonnet(coach.ts line 644·COACH_INTRO_MODEL) 패턴 기반.
요구크론 1회 실행의 (haikuCalls, sonnetCalls)를 누적하고 costEstimate(calls)로 추정 비용을 산출. compare 자녀는 A·B 둘 다 카운트(2배 가시화). meta.cost={haikuCalls,sonnetCalls,estKRW,compareExtraCalls}.
명세
lib/coachCompare.ts에 export function costEstimate(c:{haikuCalls:number;sonnetCalls:number},rate?:{haikuKRW:number;sonnetKRW:number}):number — 콜당 평균 토큰 가정 상수로 ₩ 환산(rate 기본값은 문서화된 추정치, claude-api 스킬 참조 TBD: 정확 단가 확인 필요).
route.ts: callClaude 호출부마다 modelUsed로 haikuCalls/sonnetCalls 증분(composeLetter out.modelUsed·B 생성 콜·recap 'sonnet-recap'·intro 'sonnet'). compare 자녀의 B 콜은 compareExtraCalls로 별도 합산.
meta.cost 적재(기존 meta 키 무변경). 임계 초과(예: estKRW가 평소 2.5배+) 시 issues.push('비용 경보').
목적A/B 운영 중 새 엣지(괴식 조합·B 수렴·검증 우회 등)를 발견하면 즉시 fixture+회귀 테스트로 박제하고 수정하는 '복리' 규약을 코드·문서·테스트 인프라에 못 박는다. 불변 원칙 ③(엣지 발견 시 fixture+테스트 red→green+수정). 한 번 잡은 사고는 두 번 안 나게.
요구신규 엣지 발견 시 절차: ①재현 fixture를 tests/fixtures에 추가 ②red 테스트 작성(현재 코드로 실패) ③최소 수정으로 green ④커밋에 사고 ID. 이 절차를 runbook과 WBS에 규약으로 기재하고, 첫 적용 사례(괴식 'A: 미역국에 당근'·B 수렴 6통 당근→미역국)를 회귀 fixture로 선등록.
명세
docs/coach-compare-runbook.md '엣지 복리 규칙' 절: tests/fixtures에 사고 케이스 추가→tests/coach-compare.test.ts에 red 케이스→수정→green. 점심 사고·byte-동일 조합 사고처럼 리플레이(replay.test.ts) 패턴 재사용.
tests/fixtures에 ghost-mirim-carrot.json(미역국+당근 괴식)·converge-carrot-mirim.json(B 6통 수렴) 선등록 — 향후 J/A/G 원자 수정의 회귀 가드.
buildTeachingGuide가 decideDailyV3 결정→stepBehavior·unit_ko·lever·arcStage·why·doNotRestate·weeklyImpressionSoft를 결정론 합성(고정 블록 조립 아님). composeLetter groundingMode='merged'가 재료(결정론)·거울(byte 고정 금지)·두뇌가이드·온보딩 분기를 LLM 재료로 주입하고 letterQualityBad(은유·나열·모호기간·재료밖) + verifyFacts 합본(거울·재료 1패스)이 발행 전 가드로 작동, 위반 시 재생성 루프. Letter A 경로 byte-무변경 보장 단위 테스트 통과. coach-guide·coach-grounding·coach-quality 스위트 그린.
D3
크론 2통 발행 + 앱 2카드 + 어드민 A/B
E, F, G
아린(43942d34) compare 모드에서 매일 Letter A(v2 무변경)+Letter B(하이브리드) 2통 동시 발행, context.altLetter 저장(스키마 변경 0·메인 letter/oneliner=A). 앱 홈 CompareLetterCard 두 카드 나란히 + variant별 피드백 upsert. /admin/compare 승자 위젯(선호율·반복신고율)·VoteButtons. 다른 자녀=단일 카드 무영향 E2E 통과. 2일 연속 리플레이에서 A≠B·진도 영속·재료 회전 확인. 라이브 무영향·롤백 경로 확인.
D4
검증 하네스 · 회귀 · 적대 테스트 (Letter B 품질 게이트)
H
신규 4파일(coach-materials·coach-quality·coach-merged·compare) prebuild 게이트 편입. 괴식 골든셋(comboGolden) 0 통과·재료 3일 무재사용 불변식·14일 다양성(byte 동일 0)·아린 6통 수렴 0 재현·30가정 통주 게이트(괴식 0·수렴 0·품질 0)·merged vs v2 골든(은유·환각·기간·구체성 우열)·Letter A 대조군 무변경 불변식 전부 그린. 프로젝트 테스트 합계 500+ 충족.
D5
컷오버 · 운영 · 롤백 · 모니터 · 문서 (A/B 하이브리드)
J
compareEnabled env 게이트 + 즉시 롤백 계약·폴백 안전망 회귀. judgeWinner(피드백 N일·선호율 임계)로 B가 A를 이기면 promoteToProduction 런북으로 전 자녀 승격. coach-selfcheck가 B 품질·괴식·폴백·2통 비용을 cron_runs.meta에 적재·감시. plan-engine·scenarios 문서 현행 절 + WBS 상태칩 갱신. 엣지 복리 규칙(발견→fixture+테스트 red→green→수정) 명문화.
② 의존성 그래프 (핵심 16개)
A 에픽 (재료 엔진) → I 에픽 (급식빈도·GROUP_INGREDIENTS 정비) · 4기준 가중 랭킹(A-04)·근거문구(A-08)·온보딩 분기가 실측 급식빈도와 상위%(I-01·I-02·I-03)를 입력으로 받아야 단호박(0회) 같은 죽은 식재료가 추천·근거에 새지 않는다. 빈도 가중의 분모가 I.
A-04 (4기준 가중 랭킹) → A-01·A-02·A-03 · 랭킹은 빈도 정비(A-01)·liked 판정(A-02)·괴식 조합 점수(A-03)를 합성한 결과다. 세 입력이 정의되기 전엔 가중치를 계산할 수 없다.
A-11 (freqMap 실배선) → E-03 (route freqMap 본경로) · 브리프 핵심 죽은 코드: freqMap이 route.ts:100-101에서 로드만 되고 본경로에 미전달. A-11 헬퍼가 E 에픽 크론에서 실제 selectDailyMaterials로 흘러야 C(4기준 랭킹)가 빈도를 반영. 라이브 배선은 E-03.
C 에픽 (merged 작문) → A 에픽 (재료 엔진) · composeLetter merged 분기(C-02·C-07 serializeMaterials)는 selectDailyMaterials 산출(검증 조합·근거문구·결핍 수치)을 LLM 텍스트로 직렬화해 주입한다. 재료가 결정론으로 못 박힌 뒤에야 '문장만 LLM'이 성립.
C 에픽 (merged 작문) → B 에픽 (두뇌 가이드) · buildLetterUser merged(C-04)가 buildTeachingGuide 산출(unit_ko·arcStage·why·doNotRestate)을 LLM 재료로 주입. 두뇌가 무엇을 가르칠지 정해야 작문이 그 가이드로 쓴다.
C 에픽 (merged 작문) → D 에픽 (품질·무검증 채널 검증) · composeLetter merged 재생성 루프(C-13)가 letterQualityBad(D-05)·verifyFacts 합본(C-11↔D-06)을 호출. 품질 스캐너가 정의돼야 '테스트 그린≠좋은 편지'를 깨는 발행 전 게이트가 작동.
E 에픽 (크론 2통) → C 에픽 (merged 작문) · Letter B 빌더(E-02 buildLetterB)는 composeLetter groundingMode='merged'를 호출하는 오케스트레이터다. merged 작문 경로가 완성돼야 크론이 B를 생성.
E 에픽 (크론 2통) → B·A 에픽 (두뇌·재료) · E-05가 selectDailyMaterials(재료 회전·daySeed/cidHash 입력)와 buildTeachingGuide(진도 영속)를 호출해 Letter B 입력을 조립. A·B 산출 없이는 크론 B 경로가 빈다.
F 에픽 (앱 2카드) · G 에픽 (어드민 A/B) → E 에픽 (크론 2통) · F의 CompareLetterCard와 G의 /admin/compare는 E가 저장한 context.altLetter를 읽어 렌더·집계한다. 저장 형태(스키마 변경 0)가 확정돼야 추출 함수(F-01 altLetter.ts)·승자 판정(G-02)이 의미 있다.
G 에픽 (어드민 승자 판정) → F-04 / G-01 (variant 컬럼 SQL) · compareVote.ts 승자 판정·variant별 집계는 letter_feedback.variant 컬럼·compare_votes 테이블에 의존. 불변원칙④(SQL 실행 확인 없이 다음 단계 금지)에 따라 SQL 실행이 선행 게이트.
H 에픽 (검증 하네스) → A·B·C·D 에픽 (검증기·회전·랭킹·품질 export) · 적대 테스트(괴식 H-02·회전 H-03·랭킹 H-04·품질 H-05)는 각 에픽이 export한 순수 함수(comboFit·rotateRecommendation·rankIngredients·letterQualityBad)를 단독 호출. export 시그니처가 고정돼야 골든 케이스를 박제.
H-10 (30가정 통주 게이트) → H-09 (runBFamily 리플레이 헬퍼)·H-12 (arin-golden-b fixture) · Letter B 결정론층을 30가정×통주 돌려 괴식 0·수렴 0·품질 0을 게이트하려면 replayRunner 확장(runBFamily)과 B 기대치 골든 fixture가 먼저 있어야 한다.
H-15 (prebuild 게이트 편입) → I 에픽 (GROUP_INGREDIENTS 정비)·H-02·H-05·H-08·H-11 · 신규 테스트 4파일을 prebuild에 묶으면서 데이터 정합(I) 가드도 게이트에 포함. 정비된 식재료 목록이 빈도 가중 테스트의 전제라 I가 선행.
E·F·G·J 에픽 (compare 발행·승격) → 불변원칙⑤ (모든 개선은 Letter B에만·A 무변경) · A/B 비교가 의미를 가지려면 대조군 Letter A가 v2 그대로여야 한다. compare 분기(E-04)·저장(E-06)·승격 게이트(J-02·J-03)가 전부 A 격리 불변식(H-14)에 묶인다.
미수록 조합은 통과가 아니라 '금지(보수적 기본값)'로 처리. food-graph pair(레시피 동시출현 증거)가 0이면 후보 탈락. H-01/H-02 골든셋에 알려진 괴식(미역국+당근)과 OK(짜파게티+당근·볶음밥+당근·카레+당근)을 박제하고 prebuild 게이트로 회귀 차단. LLM에 조합 생성권을 주지 않고 검증 통과 조합만 후보로 직렬화(C-09 verifyComboSafety).
2
품질 스캔(C-12/D-05) 오탐(false positive)으로 정상 편지가 무한 재생성→데드라인 초과→Letter B 폴백. 은유 클리셰 사전·나열 패턴이 자연스러운 표현까지 위반 처리하면 2통 중 B가 매일 빈약한 폴백으로 떨어져 A/B 비교가 무의미.
상
품질축은 '과용' 임계(은유 N개 이상·동일 클리셰 반복)로만 위반 판정, 1회 등장은 허용. 재생성은 최대 2회 캡(기존 verifyLetter 패턴 재사용) 후 마지막 후보 채택. H-05 테스트에 정상/위반 양쪽 fixture를 넣어 오탐 0을 회귀로 박제. E-08 시간예산에서 A 우선·B 잔여예산 게이트로 데드라인 보호.
3
2통 발행으로 LLM 비용·시간 2배. 아린뿐이라 절대량은 작지만 compare 코호트를 넓히면 비용 폭증, B가 품질 재생성 루프로 호출 수를 더 늘림(생성+검증+재생성+퇴고).
중
compare 코호트를 아린 단일(COACH_COMPARE_CHILDREN 기본=COACH_V3_CHILDREN 재활용)로 고정해 컷오버 전까지 비용 상한 확정. E-08 데드라인 게이트로 B는 잔여예산 내에서만, 초과 시 폴백. J-08 costEstimate를 cron_runs.meta에 적재해 호출 수·토큰 모니터. 컷오버(J-03) 시 A는 폐기되므로 전 자녀 승격 후엔 1통으로 복귀(비용 정상화).
회전은 4기준 가중 랭킹(A-04) 상위 후보 풀 '안에서'만 돌린다(블라인드 회전이 아님). 3일 무재사용은 풀이 충분할 때만 강제, 풀이 작으면 완화. 문장은 LLM 자유라 같은 재료라도 표현이 갈림. H-07 14일 리플레이로 다양성 지표(고유 재료 수)와 맥락 적합(타깃 그룹 일치)을 동시 측정.
5
Letter A 대조군 오염: merged 작문 개선이 공유 함수(composeLetter·buildLetterUser·verifyLetter)를 건드려 A 경로까지 byte가 바뀜. 그러면 A/B 비교가 '구 v2 vs 신 v2'가 되어 인계서 개선 효과를 측정 불가.
상
groundingMode 분기를 명시적 인자로 받아 기본값='v2'(무변경)·B만 'merged'. A 경로는 기존 planFor+composeLetter를 그대로 호출(E-04 격리). H-14 불변식 테스트: 동일 입력에서 Letter A 출력이 v2와 byte-동일(또는 동일 srcHash)인지 회귀. 신규 코드는 별도 모듈(coachGuide.ts·coachGrounding.ts·coachCompare.ts)로 분리해 A 경로 import 0.
6
무검증 채널 누락 재발: 거울·추천이 본문과 별도로 직렬화되면서 검증 1패스(H개선)를 우회. 슬롯값·거울이 발행 직전 가드를 빠져나가 환각·괴식이 사용자에게 노출(근본원인 ②).
상
C-11 verifyFacts merged 확장·D-06 합본 입력 빌더가 거울·추천·슬롯값을 본문과 합쳐 단일 검증 패스로 처리. H-06 테스트가 '거울에만 있는 환각이 검증에 걸리는지'를 명시 회귀. 직렬화 함수(serializeMaterials)와 검증 합본 함수가 같은 allowed 목록을 공유하도록 단일 소스화.
7
freqMap 실배선(A-11/E-03) 실패 또는 데이터 부재(/ingredient-recipes.json fetch 실패)로 빈도 가중이 0이 되어 C 4기준 랭킹이 다시 죽은 코드로 회귀. graceful 폴백이 너무 관대하면 정비된 빈도가 무시됨.
온보딩/무기록 분기(A-09·B-07·C-05·C-08) 누락으로 기록<3일 자녀에 환각 분석('요즘 채소 비어요')이 나간다. 아린은 이력이 있어 테스트에서 안 잡히고, 신규 자녀 승격 후 터지는 잠복 버그.
중
buildOnboardingDecision(C-08)을 순수 함수로 분리해 기록<3일이면 분석 경로 진입 자체를 차단, '빠뜨린 입력 안내 + 배경 없는 즉효 팁'으로 분기. B-07이 decision=null/lowData=true를 두뇌 가이드 계약에 명시. H-08 아린 회귀 외에 별도 lowData fixture를 추가해 신규 자녀 시나리오를 박제.
9
SQL 단계(F-04·G-01 variant 컬럼·compare_votes 테이블)를 실행 확인 없이 코드가 앞서나가 점심 사고(크론 사망) 재발. variant 컬럼 부재 시 피드백 upsert가 전량 실패해 A/B 신호 수집 불가.
중
불변원칙④ 준수: SQL 실행 확인을 게이트로 박고 다음 원자 차단. 컬럼 부재 시 graceful degrade(variant 없으면 기존 단일 피드백 경로 유지)로 라이브 무영향. G-03 client upsert는 variant 컬럼 존재를 런타임 방어. 어드민 집계(G-07·G-11)는 테이블 없으면 안전 0행 표시.
10
Next/빌드 브레이킹: 신규 모듈(coachGuide·coachGrounding·coachCompare·altLetter·comboGuard·ingredientFreq·recoEvidence)과 컴포넌트(CompareLetterCard·VoteButtons·/admin/compare) 다수 추가로 import 순환·타입 불일치·prebuild 게이트 깨짐. 220→500+ 테스트 추가가 vitest 실행시간·CI를 압박.
중
결정론 로직은 전부 순수 함수 모듈로 분리(불변원칙②)해 단독 테스트·import 단방향 유지. tsc·build·npm test를 각 D 마일스톤 exit 게이트로 강제(불변원칙①). 테스트는 순수함수 원자에 집중(10~25 케이스)해 빠르게 유지, 통합/UI는 3~10으로 절제. 신규 4파일을 H-15에서 prebuild에 점진 편입.
④ 테스트 인덱스 (총 893개)
EPIC별 / 종류별 분포. 각 테스트 ID는 원자 카드 안에 검증식과 함께 명시(예 A-03-1).
EPIC
A
B
C
D
E
F
G
H
I
J
합계
테스트 수
136
84
94
101
84
60
78
114
71
71
893
종류
단위
통합
회귀
적대
E2E
수동
속성
합계
개수
409
89
158
139
8
48
42
893
⑤ 용어
용어
정의
Letter A (대조군)
아린에게 매일 발행하는 2통 중 첫 통. 기존 v2 그대로(planFor+composeLetter, 무변경). 메인 coach_letters.letter/oneliner에 저장되며 어떤 개선도 적용하지 않는 비교 기준점.
Letter B (처치군)
2통 중 둘째 통. 인계서 개선 A~I를 전부 적용한 하이브리드 설계. coach_letters.context.altLetter에 저장. 어드민 피드백(👍/👎/🔁)에서 A를 이기면 전 자녀로 승격.
merged 모드 (groundingMode)
composeLetter의 새 작문 분기. 재료=결정론 코드, 사실=카드, 거울=데이터 주입(byte 고정 금지), 문장·도입·톤만 LLM 자유. groundingMode='v2'(기본·A)와 'merged'(B)를 명시 인자로 분기해 A 경로 무변경 보장.
재료 결정론 (B 개선)
'무엇을 추천할지'(식재료·조합)는 코드가 매일 회전(3일 내 재사용 금지), '어떻게 쓸지'(문장)만 LLM에 맡기는 분담. 데이터+재료를 다 LLM에 주면 6통이 '당근→미역국'으로 수렴한 실증의 보완책.
두뇌 가이드 (TeachingGuide)
buildTeachingGuide 산출. decideDailyV3 결정→unit_ko·lever·stepBehavior·arcStage·why(근거)·doNotRestate·weeklyImpressionSoft를 결정론 합성한 '가이드'. 고정 블록 조립이 아니라 LLM이 직접 쓸 재료로 전달.
품질 스캔 (qualityScan / letterQualityBad)
발행 전 LLM 출력에 적용하는 품질축 결정론 스캐너. (1)은유 클리셰 과용(통장·계단·사슬 등) (2)'어제 X 먹었어요' 나열 (3)모호 기간어 (4)재료 밖 음식명을 스캔, 위반 시 재생성. '게이트 그린≠좋은 편지'를 깨는 핵심.
무검증 채널 (H 개선)
거울·추천·슬롯값이 본문과 별도라 발행 직전 가드를 우회하던 경로. verifyFacts merged 확장·합본 입력 빌더로 본문과 1패스 검증해 닫는다. 근본원인 ②의 수술 대상.
괴식 조합 검증 (comboFit / verifyComboSafety)
'잘먹는음식+결핍식재료' 조합을 kit-dish-matrix(음식×식재료 0~3 정성채점)+food-graph pair로 점수화해 낮으면 금지. 검증 통과 조합만 LLM 후보로 직렬화. 미수록 조합은 보수적으로 금지(미역국+당근 차단).
식단 거울 (mirror)
buildMealMirror 산출. 실제 식단을 부모에게 비추는 사실 문장. merged 모드에서 데이터로 주입하되 byte 고정은 금지(템플릿 느낌 원인). compileFactCards가 cards·forbidParts와 함께 반환.
사실 카드 (FactCard)
compileFactCards가 반환하는 검증된 사실 단위(거부·환경·빈도 등). LLM이 사실을 인용할 때는 raw timeseries가 아니라 카드만 참조(C-06 강등)해 환각·날조를 차단.