AI 파이프라인 · 폴백 정책
너홀로프로의 AI 기능 설계 기준. 새 AI 화면을 만들기 전에 본 문서를 먼저 읽고, 본인 기능이 (A) 자동 갱신형 인지 (B) 사용자 트리거형 인지를 먼저 결정한다.
어시스트 통화 (assist weight)
모든 과금 AI 기능은 월간 카운터 tenants/{tid}/counters/ai-assists 에서 weight 만큼 차감된다. 플랜별 한도는 PlanLimits.assistLimit (free 3 · starter 35 · pro 140).
| weight | 기능 | 비고 |
|---|---|---|
| 0 | 대시보드 브리핑 | 무료, 공유 Firestore 캐시 |
| 1 | 법률 문서 생성 · 사무실 기억 분석 · 증거 요약 | 단일 Gemini 호출 |
| 3 | 전략 보고서 | 4096 tokens + RAG + 구조화 JSON, 실질 비용 약 3배 |
호출 래퍼 — executeAiAction
신규 기능은 반드시 apps/web/lib/ai/execute-ai-action.ts 의 executeAiAction 을 사용한다. reserve → run → (retryable 실패 시) refund 주기를 자동화해 reserveAssistAction 호출 누락·환불 분류 드리프트를 구조적으로 차단.
const result = await executeAiAction({
weight: 3,
feature: "strategyReport",
signal: controller.signal,
run: async ({ markRetryablePhase }) => {
const ctx = await getContextAction(); // 실패해도 환불 X (AI 비용 0)
markRetryablePhase(); // ← 이 줄 이후 실패만 환불 대상
const report = await generateStrategyReport(ctx, signal);
await saveReportAction(report);
return report;
},
});
if (result.kind === "reserve_failed") { /* 한도/readOnly/플래그 off */ }
else if (result.kind === "run_failed") { /* aborted/refunded 플래그 체크 */ }
else { /* result.data 사용 */ }
저수준 API (래퍼로 커버 안 되는 특수 케이스만)
reserveAssistAction(weight, feature?)— Firestore 트랜잭션 한도 검증 + 원자적 차감.feature값:docGeneration·briefing·ragSearch·strategyReport— 개별 킬 스위치 검증 포함.apps/web/lib/ai/client.ts의generate*— 클라이언트 전용.refundAssistAction(weight, reason)—parse_fail/timeout/upstream_5xx3종만 환불. 성공 후 불만족·사전 검증 실패는 환불 금지.
폴백 정책 — 2분화
(A) 자동 갱신형 (passive AI)
화면 진입·탭 재활성화만으로 트리거되는 기능. 대시보드 브리핑, 증거 자동 요약, 사건 요약 카드 등.
- 실패·빈값·비정상 응답·한도 초과·readOnly 전부 "보유 데이터 가공 폴백" 으로 수렴. 사용자는 AI 호출이 있었는지조차 몰라야 한다.
- weight = 0 또는 공유 캐시 패턴 (하루 첫 진입자만 호출).
- 구현 체크:
buildFallbackText()순수 함수 존재reserveAssistAction(0)또는 공유 캐시- 에러는
console.error만 — 배지·토스트·"AI 실패" 문구 전면 금지
(B) 사용자 트리거형 (active AI)
명시적 버튼·마법사로 트리거. 전략 보고서 (weight 3), 법률 문서 생성 (weight 1), 사무실 기억 분석 (weight 1) 등.
- 실패 시 재시도 UI + 어시스트 환불 투명 공개 허용. 단, 환불은 retryable 3종만.
- weight ≥ 1.
executeAiAction래퍼 필수. - 구현 체크:
markRetryablePhase()호출 위치 결정- 재시도 버튼
- "어시스트 환불됨" 토스트 (환불된 경우만)
금지 사항 (공통)
- A 범주에서 "AI 호출 실패"를 사용자에게 노출 — 폴백이 본 경로여야 한다.
- B 범주에서 환불 가능 실패를 환불 없이 묵살 — retryable 3종은 반드시
refundAssistAction호출. - 두 범주 혼용 — 동일 화면에 passive + active 를 섞으면 사용자가 실패 책임을 혼동한다. 별도 컴포넌트로 분리.
재사용 추상 — 새 AI 화면 복제 템플릿
_lib/{name}-types.ts — *Context 타입 정의
_lib/{name}-context.ts — buildAiPromptInput / buildFallbackText / isValid* 순수 함수
_lib/__tests__/{name}.test.ts — 상태 × 엣지 케이스 전수 테스트
_actions/*.ts — 구조화 객체 반환 (집계 문자열 금지)
_components/*.tsx — 5단계 흐름:
1) getContextAction
2) state 분기 (AI 호출 없이 바로 폴백)
3) 캐시 히트 체크
4) reserveAssistAction (실패 시 폴백)
5) generate* + isValid* (실패·빈값 시 폴백)
+ useRef requestIdRef 동시성 가드
+ visibilitychange 리스너
+ 헤더 "N분 전" 상대 시각
자동 갱신 + 작성 시각
apps/web/lib/ai/briefing-timestamp.ts—formatRelativeTime,isStale,BRIEFING_STALE_THRESHOLD_MS=30min.- 수동 새로고침 버튼 금지 — "사용자가 AI 상태를 신경 써야 한다"는 신호를 주기 때문.
- 탭 재활성화(
visibilitychange) 시 서버 캐시 재조회 → stale 감지 시 재생성. - "N분 전" 상대 시각은 AI 성공 캐시에만 표시. 폴백은 숨김.
대시보드 브리핑 — 3-계층 한 장 카드 (참조 구현)
(#83, #87, #88)
- [A] 내러티브 본문 — tenant 공유 Firestore 캐시
tenants/{tid}/dailyBriefings/{YYYY-MM-DD}에서 읽음. 하루 첫 방문자만 Gemini 호출(무료), 이후 방문자는 0ms. - [B]
InlineStatusStrip—tenantDoc.stats기반 실시간 상태 한 줄. 전체 사건 fetch 금지,aggregateCaseStatsCloud Function 이 갱신. - [C] 변경사항 델타 —
briefing.createdAt이후activityLog집계. 0건 전체면 섹션 숨김.
브리핑 자동 stale 무효화
writeActivityLog 내부 훅이 case_created · hearing_added · hearing_result · evidence_added · client_message_received 5종 이벤트 기록 시 해당 tenant 의 오늘자 브리핑을 자동 stale 플립. mutation Server Action 코드 변경 불필요.
참고: 사건 브리핑 ("이 사건의 오늘") 은 제거되었음 (#88 취소).
CaseSummaryCards(다음 기일 D-day · 결과 미입력 · 회수율) 가 결정적 배지로 동일 역할을 수행.
전략 보고서 (weight 3 특수 요건)
- 타입:
apps/web/types/strategy-report.ts(Zod-first) - 저장:
cases/{caseId}/strategyReports/ - 플래그:
features.ai.strategyReport - PII 마스킹 필수:
apps/web/app/(workspace)/cases/_lib/ai-helpers.ts:maskPII(하이픈/공백/구분자 없음 모두 커버) - AbortController 30초 타임아웃 + retryable 실패 시
refundAssistAction(3)
서류 생성 (docgen, weight 1)
플로우 (ADR 0012 S1)
- 액션:
generateDocxAction({ caseId, kind, data })— owner/staff + kill switch + weight 1 - 렌더러:
renderDocxTemplate(docx-templates) · 5MB 상한 - 결과:
tenants/{tid}/generatedDocs/{caseId}/{kind}-{uuid}.docx· Signed URL 15분 - 엔진 버전:
DOCGEN_ENGINE_VERSION = "docgen-1.0"— 스냅샷 마이그레이션 키
텔레메트리 (docgenEvents 컬렉션)
- 성공·실패·killswitch 3 경로 모두
writeDocgenEvent호출 - 핵심 필드:
outcome·failureReason·elapsedMs·byteLength·templateSource(tenant/public) ·engineVersion - 실패 원인 매핑:
mapErrorToFailureReason(순수 함수, 12 테스트) - 집계:
scripts/aggregate-docgen-stats.ts→tenantDoc.stats.docgen - 대상 윈도우: 30일 롤링
템플릿 커스터마이징 (owner 전용)
- 액션:
uploadDocxTemplateAction(formData)— 5MB · .docx MIME · 5 DocKind 화이트리스트 - 저장:
tenants/{tid}/docxTemplates/{kind}.docx(덮어쓰기) loadTemplateBuffer우선순위: tenant 커스텀 →public/docxTemplates/공용
사무실 기억 참조 (weight 0, ADR 0012 S1 B)
RelatedMemoriesPanel 플로우
- 서류 생성 Step 2 진입 시 우측 skeleton → 카드 렌더 → 실패/빈 결과 silent hide
- 액션:
findRelatedMemoriesForDocGenAction({ caseId, docType })— ragSearch gate, weight 0 (Vertex Search 단독) - 쿼리 빌더:
buildRelatedMemoryQuery— PII 무전파 (유형·서면·금액 bucket·상사만) - 최대 5 카드 · snippet 150자 ·
caseNumber/fileName/closedAtIso배치 조인 (N+1 회피)
텔레메트리 (relatedMemoriesEvents 컬렉션)
related_memories_shown— cardCount, queryUsed, fetchElapsedMsrelated_memory_clicked— memoryDocId, rankIndex (0-4)step2_cancel_before_load— waitedMs- Writer:
writeRelatedMemoriesEventAction(서버 측 tenantId·uid 덮어쓰기 — spoofing 방어) - 집계:
scripts/aggregate-related-memories-stats.ts→tenantDoc.stats.relatedMemories
S3 말 C 캐시 재평가 조건 (3 모두 충족)
- clickThroughRate ≥ 40%
- 월 weight 소비 ≥ 20/tenant
- cancelRate ≥ 25%
두 RAG 결합 — Platform + Tenant (ADR 0041)
generateDocxAction 호출 시 getRagContextForDocGenAction 가 Platform RAG (publicDocuments — 공공 판례·법령) + Tenant RAG (legacyDocuments — 사무소 기억) 두 컬렉션 자동 결합.
- balanced
sourceTypefindNearest + composite vector index (sourceType + embedding.vector) - 한 줄 본질의 핵심 구현체 — "자기 데이터로 자기 AI 학습·활용"
- 텔레메트리
tenants/{tid}/ragMergeQuality/{dateKey}(avgRecall · avgPrecision · casesCount) → ops dashboard학습 회귀axis 자동 회귀 가드
Citation 모듈 chain — 4 pure functions
자율 세션 #9 cycle 7 (2026-05-12) — 인용 lifecycle pure functions SSoT.
AI 출력의 RAG 인용 lifecycle 을 pure functions 로 분리. 4 단계 모두 mock 없이 단위 테스트 가능.
1. extractCitations(text) (apps/web/lib/ai/citation-extractor.ts)
법령·판례 인용 span 추출. 사건번호 정규식 (2023다12345 등) + 조문 정규식 (민법 §477 등) 두 패턴. AI 출력이 아닌 임의 텍스트에서 인용 발굴 — Platform RAG 의 raw 법령 텍스트에도 활용 가능.
2. buildUnifiedCitations({ tenant, platform }) (apps/web/lib/ai/citation-builder.ts)
Tenant RAG (내 사무실) + Platform RAG (공공) 카드를 하나의 [1][2][3] 번호 체계 로 합쳐 AI 프롬프트에 주입. 출처는 라벨에 "내 사무실" · "공공" prefix 로 구분. AI 가 답변에 [N] 으로 인용 → UI 가 원 카드로 역매핑.
3. verifyCitationFormat({ text, citationCount }) (apps/web/lib/ai/citation-format-verifier.ts)
AI 출력의 [N] 인용 유효성 + hallucination 분류. citationCount 초과 [N] 자동 탐지. 4 보조 함수:
extractCitationIndices(text)— 중복 제거 + 정렬stripCitations(text)— 본문만 (telemetry)citationCoverage({ text, citationCount })— Platform/Tenant 활용도 0~1
4. computeDraftCitationDiff(initialDraft, finalText) (apps/web/app/(workspace)/cases/[caseId]/_lib/draft-diff-metrics.ts)
AI 초안 vs 변호사 finalize 본의 인용 diff — 한 줄 본질 5번째 axis 측정. preservationRatio 가 높을수록 AI RAG 인용을 변호사가 그대로 채택 (좋은 신호). 낮을수록 hallucination 또는 부적절 인용 (변호사가 모두 교체). finalize-document-action 이 자동 캡처 → draftDiffs.citation 필드.
chain 정합: extract → buildUnified (프롬프트 주입) → verify (AI 출력 검증) → diff (변호사 finalize 후 보존율). 각 단계 pure → mock 없이 단위 테스트 + ops dashboard 5 axis (AI 인용 보존율) telemetry 직접 연결.
판단 가이드 — 원칙 적용 매트릭스
| 기능 성격 | 폴백 전면 적용 | 비고 |
|---|---|---|
| 조용히 정보를 제공하는 카드 (브리핑형) | ✅ | (A) 자동 갱신형 |
| 사용자가 적극적으로 입력하는 위저드 (문서 생성형) | ⛔ | (B), 다른 패턴 |
| 스키마 강제 JSON 출력 (전략 보고서형) | 🟡 | 작성 시각 + stale 힌트만 |
| 사용자 명시적 트리거 + 검색 결과 (사무실 기억형) | ✅ | (B), 결과 폴백 |
| 백그라운드 저장형 (증거 요약형) | ✅ | (A), 파일 메타 폴백 |
안티패턴
- ❌ "AI 분석 중..." 스피너만 크게 표시하고 placeholder 없음
- ❌ 실패 시 "AI 서비스 점검 중" / "분석에 실패했습니다" 에러 카드
- ❌ "요약 모드" "폴백 모드" 같은 구분 배지 노출
- ❌ AI 출력을 통계/메타 나열로 평가 ("총 12건의 사건이 있습니다")
- ❌ 수동 새로고침 버튼을 UX 의 기본 경로로 사용
- ❌ 한 화면에서 AI 출력과 동일 정보를 중복 표시 (KPI 카드 + 브리핑 같이)