코드와 스펙 곳곳에 흩어진 도메인 규칙(암묵지)을 "정책 한 장"으로 명문화한 대장입니다. 각 장은 기준일(그 시점 코드 기준) · 업무구조도(핵심 흐름/전체 구조도) · 결정 규칙 · 근거 코드 경로로 구성됩니다.
| NO | 정책 | 담당 | 분류 | 노드 |
|---|
신규 게시글 수집 정책
메가스터디 게시판 목록을 최신순으로 넘기며 새 글을 원본 그대로 적재한다. 한 호출은 목록 1페이지만 처리하고, 멈출 이유를 글 단위로 검사한다.
결정 규칙
| 상황 | 판정 | 비고 |
|---|---|---|
| 글 등록일이 세션 종료 등록일보다 과거 | SUCCESS · END_DATE_REACHED | 고유번호 조건보다 우선 평가 |
| 글 고유번호가 세션 종료 번호 이하 | SUCCESS · END_ID_REACHED | |
| 직전 세션이 수집한 경계 도달 | SUCCESS · ALREADY_COLLECTED_REACHED | 토글 collection.end_on_already_collected, AQ-640 |
| 빈 페이지 4연속 | SUCCESS · BOARD_END_REACHED | 스트릭은 세션에 영속 |
| 킬 스위치 OFF 감지 | FAILED · KILL_SWITCHED | 매 페이지 시작 시 재확인 |
| 인증 만료 + 자동 복구 실패 | FAILED · AUTH_EXPIRED | → P-03 |
| 연속 실패 임계 도달 | 다음 세션 자동 예약 중단 | collection.max_consecutive_failures · 운영자 개입 필요 |
근거
collector/.../application/CollectionEngine.java— 종료 조건 평가 순서collector/.../application/CollectionPostUpserter.java— 멱등 upsertcollector/.../domain/session/TerminationReason.java— 종료 사유 분류- 온보딩: 같은 폴더의
collector-onboarding.md· 순서도(레포):docs/collector-collection-flow.md
답변 갱신 정책
별도 큐 없이 collection_posts 자체를 큐로 쓴다. 살아있고 · 답변 대기고 · 본문이 채워진 글을, 가장 오래 확인 안 한 순서로 재방문한다. 그 위에 사후 갱신(REVISIT) 레인이 얹혀, 완료(COMPLETED)된 글도 하루 1회 저빈도로 재방문해 외부 답변의 재귀속·삭제를 재동기화한다.
결정 규칙
- 갱신 모집단:
deleted_at IS NULL∧post_answer_status ≠ 'COMPLETED'∧ 본문 채워짐. 답변이 달린(COMPLETED) 글은 다시 안 본다. - 동시성: lease(대출 카드)로 워커 간 중복 점유 방지. 죽은 워커의 lease는 TTL 후
PostLeaseSweeper가 회수. - 실패 격리: 글 1건의 재시도 소진·파싱 오류는 그 글만 실패 기록하고 세션은 계속. 인증 만료·킬 스위치는 세션 전체 중단.
- 적응형 주기: 후보 수가 임계 이하로 줄면 갱신 주기를 배수로 늘리고(레벨 1~2), 최저 레벨 3에선 샘플링만 수행.
update.adaptive_* - 변경 분류: 답변 등록 / 답변 수정 / 질문 수정(제목·본문·첨부 세부 분류) / 질문 삭제. 이력은
update_post_change*3개 테이블에 남는다. - 사후 갱신(REVISIT) 레인 (AQ-1671): 완료(COMPLETED) 게시글을 저빈도로 재방문하는 별도 레인이 UpdateEngine 위에 얹힌다. UI 표기는 "사후 갱신"이나 내부 코드값은
lane=REVISIT·revisit.*(D23). 하루 1회revisit.daily_at(LocalTime, KST 정시, 기본 01:00) 스케줄이고, 다음 실행은 순수함수nextDailyRun(현재 ≥ dailyAt이면 +1일 strict)로 산정한다. 즉시실행은?type=UPDATE를 재사용하되 lane 인지 분모(countRevisitCandidates)를 쓰고, 세션관리 7일 스트립은lane='UPDATE'로 스코핑한다. - 종료 사유코드 정비 (AQ-1757): 선점 단건 갱신(ON_DEMAND) 인증 만료 종료 사유가
OTHER → AUTH_EXPIRED로 정정됐다.triggerType(ON_DEMAND)이 additive로 관통하며lane=UPDATE ∧ ON_DEMAND → '선점 갱신'배지로 분기하고, 종료 사유에 한국어 라벨 18종을 붙인다(표시 전용, 코드 무변경). - 발행 시각 = tzone 등록시각 (AQ-1693): 갱신 워커가 발행하는
PostCollectedMessage.occurredAt이clock.instant()(관측시각)에서 실제 답변 등록시각(collection_answers 비삭제 답변의 external_registered_at 최댓값, 없으면 now)으로 교체됐다 — 서버측 완료시각 통일의 collector 발행측(→ P-04).
근거
collector/.../application/update/UpdateEngine.java— 1건 사이클(claim → fetch → apply) · REVISIT/UPDATE 워커collector/.../application/update/UpdatePostApplier.java— 변경 판정·이력 기록collector/.../application/update/UpdateSessionService.java·domain/settings/RevisitSettings.java— REVISIT 레인 · dailyAt 스케줄collector/.../session/StartupSessionSeeder.java·SinglePostRefreshService.java— 시드 · 선점 갱신(ON_DEMAND)collector/.../application/CompletedAnswerTime.java— tzone 등록시각 순수함수collector/.../domain/post/CollectionPost.java— 해시 비교(applyCollected)
인증 자동 복구 정책
세션 쿠키 만료를 응답 본문의 로그인 마커로 감지하고, SMS 2차 인증을 포함한 3단계 로그인을 자동 수행한다. 복구 실패가 반복되면 스스로 멈추고 운영자를 기다린다.
결정 규칙
- 계정 폴백: 자격증명 불일치 → 즉시 차순위 계정. SMS 타임아웃 → 같은 계정 3회 시도 후 차순위. (AQ-1272)
- 단일 실행: 게시판당 락으로 자동 로그인은 동시에 1회만. 동시 만료 감지가 몰려도 로그인 폭주 없음.
- 갱신 엔진도 복구를 킥한다 (AQ-1671): 수집·단건갱신뿐 아니라
UpdateEngine(REVISIT/UPDATE 워커)도AuthExpiredException을 잡으면AsyncAuthRecoveryTrigger.kick()을 호출한다(fire-and-forget·중복 안전 — AutoLoginService 진입 게이트가 skip). REVISIT 세션이AUTH_EXPIRED로 끝나면 다음 daily(내일 01:00)가 아니라revisit.poll_interval_seconds뒤 당일 재시도한다. - 실패 차단: 세션 연속 실패가 임계 도달 시 다음 자동 예약 중단 — 잘못된 비밀번호로 무한 로그인 시도해 계정이 잠기는 사고 방지.
- 수동 경로: 운영자 UI에서 인증번호를 직접 입력하는 2단계 수동 갱신도 병행 제공. (AQ-1314,
manualauth/) - 웹훅 보안: Forwarder 요청은
X-Forwarder-Secret헤더를 timing-safe 비교로 검증.
근거
collector/.../application/autologin/AuthRecoveryHandler.java— 감지→복구→재시도 래퍼collector/.../application/autologin/AutoLoginService.java— 3단계 로그인·계정 폴백collector/.../infrastructure/http/MegastudyLoginClient.java— 사이트별 로그인 HTTP- 순서도:
docs/collector-collection-flow.md
질문 생애주기 정책
질문은 6개 상태를 오가며, 모든 전이는 Question 엔티티의 도메인 메서드가 단일 권위로 강제한다. "닫힌 질문(closed_at ≠ null)은 반드시 DONE"이라는 단방향 불변식이 전체를 지탱한다.
결정 규칙
- 전이의 단일 권위: 상태 변경은 전부
Question엔티티의 mutation 메서드(markAiProcessing,markReviewPending,markDoneByCompletion…)가 강제. 서비스 레이어 가드는 HTTP 409 매핑용 방어적 중복이다. - 닫힘 불변식:
closed_at ≠ null ⇒ DONE(AQ-602). 닫힌 질문은 AI_PROCESSING 진입이 무조건 거부된다. - 편집 vs 완료의 이중 가드: 저장(
AnswerEditService.edit)과 완료(AnswerCompletionService)는 허용 상태 집합을 각자 가진다. 흐름을 바꿀 땐 두 곳을 항상 같이 열어야 한다 — 완료만 열고 편집을 빠뜨려 저장이 막힌 사고(AQ-1399) 전례. - 완료분 편집은 in-place: 완료된 답변 수정은 reopen 없이 상태·closed 메타를 유지한 채 이뤄지며, 타인 완료분 수정 완료는 소유권 이양으로 기록된다 (AQ-1307).
- 외부 삭제 우선: collector가 DELETED를 보고하면 검수 진행 중이어도
DONE(EXTERNALLY_DELETED)로 종결하고 잔존 선점을 해제한다. 이미 종결된 질문은 skip. - 인간 완료 sticky (AQ-1532): 한 번이라도 사람이 완료한 질문은 외부 재수집이 자동 종결하지 못한다. 예외는 딱 하나 — 되돌림 직후 + 외부가 COMPLETED + 답변 해시가 되돌림 시점 스냅샷(
reverted_external_answer_hash)과 달라졌을 때만. 답변과 무관한 본문 편집(해시 동일)은 오종결시키지 않는다. - 완료시각 = tzone 실제 답변 등록시각 (AQ-1668/1693/1730): 외부완료 질문의 완료시각(
closed_at·question_events.occurred_at·answers.completed_at)이 감지/관측시각에서 실제 외부 답변 등록시각으로 통일됐다 — 메시지occurredAt(= collection_answers 비삭제 답변 external_registered_at 최댓값)을 쓴다(going-forward·born-completed·force-reingest 소급 모두 동일 규칙). occurred_at의 seq 단조는 포기하고, 원장 순서·현재상태 판정은seq가 단독 담당한다(AQ-1185 D-012 supersede). - 완료글 재동기화 carve-out (AQ-1700): 완료글 REVISIT 재크롤 시 Guard 1(ALREADY_DONE_OR_CLOSED) 앞에서 완료 상태를 tzone 실태에 맞춘다.
EXTERNAL_ANSWER_EXISTED(자동완료)는 풀 싱크 — 재귀속(최신 COMPLETED actor로 통계 담당자 재판정, 초안 revision 보존) 또는 미완료화(외부 답변 삭제 시 재답변 대기로 되돌림, ANSWER_DELETED) — 즉 DONE이 되돌려질 수 있다. 반면RESOLVED(사람/알파큐 완료)는 통계 담당자만 조정(상태·내용·completed_by·크레딧 불변, 미완료화 없음) — sticky 불변식과 정합. - 처리현황 asOf 분리 (AQ-1729): 조교별 처리현황 판정시점(
asOf)을 완료 귀속(toExclusive)과 분리했다 — 화면은 as-of-now(재귀속·삭제 반영), 정산은 INTERNAL_ADMIN이 asOf=마감일 스냅샷을 쓴다. - 직접질문(direct-ask)은 NEW 유지: 검수자가 직접 답변을 시작해도 상태 전이 없이 NEW에 머물다 완료 시
NEW → DONE직행 (AQ-1399).
전이 표 (전체)
| 전이 | 도메인 메서드 | 일어나는 곳 |
|---|---|---|
| NEW·REVIEW_PENDING·FAILED·DIRECT_ANSWER_PENDING → AI_PROCESSING | markAiProcessing | ingest 펌프 / 재생성 |
| AI_PROCESSING → REVIEW_PENDING | markReviewPending | AnswerGenerationSettler (성공) |
| AI_PROCESSING → FAILED | markFailed | Settler (일시 장애 → 재시도) |
| AI_PROCESSING·FAILED → DIRECT_ANSWER_PENDING | markDirectAnswerPending | Settler (근거 부족) / take-manual |
| REVIEW_PENDING·DIRECT_ANSWER_PENDING·NEW → DONE | markDoneByCompletion | AnswerCompletionService |
| * → DONE (외부 답변 존재 / 외부 삭제) | markExternalAnswerExisted / closeAsExternallyDeleted | QuestionIngestService — closedAt = tzone 답변 등록시각(AQ-1668/1693) |
| DONE(EXTERNAL) → 재귀속 / 미완료화 (재동기화) | recordExternalCompletionReassignment / reopenForExternalAnswerDelete | CollectionPostToQuestionConverter — REVISIT carve-out(AQ-1700) |
| DONE(RESOLVED) → DIRECT_ANSWER_PENDING (되돌림) | reopenForAnswerDelete | AnswerDeleteService — 이때 외부 답변 해시 스냅샷 저장 |
근거
server/.../question/QuestionStatus.java·question/Question.java— 상태와 전이 권위server/.../ingest/QuestionIngestService.java·CollectionPostToQuestionConverter.java— 탄생·외부 변경 판정server/.../answer/generation/AnswerGenerationSettler.java— 생성 정착server/.../answer/complete/AnswerCompletionService.java·answer/edit/AnswerEditService.java— 완료·편집 이중 가드server/.../question/history/RevertedExternalCompletionPolicy.java— sticky 해제 술어 (AQ-1532)
AI 답변 생성·검수 정책
승격된 질문만 비동기 3-Phase 잡이 답변한다. 긴 LLM 호출은 락·트랜잭션 밖에서 이뤄지고(준비·정착만 트랜잭션), 크래딧은 완료가 아니라 초안 성공(REVIEW_PENDING) 순간 차감된다. 품질 모더레이션 게이트는 여전히 없지만, 생성 직후 답변가능성 자기평가 게이트 2종 — 근거충족(MISSING) · 질문부실(S3/S4) — 이 초안을 자동 폐기하고 사람 직접답변으로 넘긴다. 나머지 검수는 사람이 한다.
결정 규칙
- pull 승격 + 유량 조절: 수집 즉시 생성하지 않는다(AQ-1380). 펌프가
capacity = 상한 − (생성중 + 검수대기)만큼만 가장 오래된 NEW를AI_PROCESSING으로 올린다. 누적 신고 3건 이상 학생의 질문은 후보 쿼리에서부터 제외된다(→ P-16). 검수자 즉시 생성(promoteNow)은 상한·락·신고를 모두 무시하되 본인 선점이 전제고, 신고 이력·교재 미보유가 있으면 클라이언트 확인 모달이 먼저 뜬다(→ P-15·P-16). - 3-Phase, 생성은 락 밖: 준비(TX-A)와 정착(TX-B)만 짧은 트랜잭션이고, 수 초~수십 초 걸리는 검색+LLM 호출은 락도 트랜잭션도 없이 수행한다 — 커넥션·행락을 오래 붙들지 않기 위해서다.
- single-flight + CAS: 유효한 생성 클레임(TTL 240초)이 있으면 중복 생성을 스킵한다. 정착 직전 CAS로 상태를 재검증해, 생성 중 질문이
AI_PROCESSING을 벗어났으면 결과를 폐기하고 과금하지 않는다. - 차감 시점 = 초안 성공: 크래딧(AI_QNA)은 완료가 아니라
REVIEW_PENDING승격과 같은 트랜잭션에서 차감된다. 멱등키question:{id}로 재시도해도 이중과금이 없다 (AQ-1251, → P-07). - 품질 모더레이션은 여전히 사람만: 자동 품질 게이트(
QUALITY_BELOW_THRESHOLD)는 구현돼 있지 않다(enum 스텁뿐). 초안은REVIEW_PENDING으로 저장되고 검수자가 완료해DONE이 된다. 단 아래 두 게이트는 품질이 아니라 "답변가능성"을 자기평가해 초안을 자동 폐기한다. (LLM 다단계 자동검증questionreviewv2는 별개 제품 흐름 → P-10) - 게이트 A · 근거충족 자기평가 (AQ-1670): 답변 LLM이 응답 첫 줄 마커
[REFERENT_CHECK] coverage=FULL/PARTIAL/MISSING로 "질문이 가리키는 구체 대상(그래프·강사 발언·풀이 대목)이 검색 근거에 실제 있는가"를 스스로 판정한다.MISSING이면 초안 본문을 폐기하고 hard 차단 → insufficient 전환 →DIRECT_ANSWER_PENDING(실패코드는RAG_NO_EVIDENCE재사용, referentCheck·sources 보존). 적용 범위는 v1 RAG 경로 한정(V2·GENERAL·QRT 무차단). FULL/PARTIAL은 관측 저장만(행동 없음). 마커 부재 시 무동작(fail-open). - 게이트 B · 질문 부실 차단 (AQ-1765): 자료는 찾았으나 질문이 부실(S3·S4)해 답변 시작점이 없으면 초안을 폐기하고 신규 실패코드
QUESTION_TOO_VAGUE(MANUAL_ONLY)로DIRECT_ANSWER_PENDING에 정착, 조교 추천 안내 문구를 실어 보낸다. 원인이 근거부재와 정반대(질문 결함 vs 근거 부재)라RAG_NO_EVIDENCE재사용을 거부한다. 보수 가드 4겹(모두 통과해야 차단): ①카테고리 COURSE/TEXTBOOK ②재질문 아님 ③활성 첨부 0 ④문구 등록됨(fail-open kill switch). S3·S4 동시 감지 시 S3 우선. - 상황분류 채널 S1~S5 (AQ-1764): 두 번째 자기평가 마커
[ADVISORY] cases=…가 같은 응답에 편승해(추가 LLM 호출 없음) 상황을 분류한다. 고정 집합 = {S1 제목불량 · S3 시도없음 · S4 저구체성 · S5 다른방법요구}(S2 없음). S3·S4 → 차단(게이트 B), S1·S5 → 성공 답변 말미에 안내 문구 append(AQ-1766/1764). 문구 저장소advisory_notice는 테넌트 스코프·운영 편집(AQ-1787), 비우면 해당 상황만 독립적으로 kill. append는 우선순위상 등록된 첫 1건만. - 두 차단은 비과금 종결: 근거충족·질문부실 차단은 공통 절차
settleBlocked(초안 폐기·근거 보존·상태 전이·슬롯 보충)를 타며 크래딧을 차감하지 않는다 — 차감은 success/applyAiDraft 경로에만 있어 "REVIEW_PENDING 못 간 종결 비과금" 규칙과 정합. 차감 시점 규칙 자체는 불변, 비과금 종료 사유가 2종 늘었을 뿐이다. - 재시도 vs 직접답변: 인프라 일시 장애는 SQS 재배달로 재시도하고
maxReceive 3회 초과 시 DLQ로 격리된다. 근거 부족·테넌트 AI 미설정은 재시도 대상이 아니라DIRECT_ANSWER_PENDING(사람 직접답변)으로 정착한다. 모델 자체 폴백은 LiteLLM 레벨(429·5xx·콘텐츠 필터). - LLM 채널 = 직결 우선 + 폴백 (AQ-1605): GPT 계열은 OpenAI API 직결 alias 11종이 1차이고, 직결 실패(429·5xx·타임아웃) 시 OpenRouter의 동일 모델로 자동 폴백한다 — 같은 모델·다른 채널이라 품질 비교가 오염되지 않는다. OpenRouter 경유 gpt-5.6은 Azure provider를 배제(pin)해 콘텐츠 필터 오탐을 피한다(AQ-1657). 프롬프트 캐시는 2패턴: gpt-5.6 계열은 시스템 프롬프트 끝에
prompt_cache_breakpoint를, Claude 계열(openrouter/bedrock)은cache_control:{ephemeral}을 system 블록에 주입해야 발동한다(TTL 5분·최소 Opus 4096토큰, AQ-1731). 주입 조건 불충족·파싱 실패는 원문 그대로. - LLM 호출 총시간 천장 (AQ-1749/1746): 호출 1건은 720초에서 전송 계층이 강제 종결한다(
spring.http.clients.read-timeout+ 전송 팩토리jdk고정 — reactor 유휴 타이머는 "찔끔 응답"을 못 끊는다). 데드라인 없는 경로(QnA 답변·QRT eval·검색쿼리 추출)엔@Retry기본 예산 720초(불변식 B ≥ C)를 걸어 재시도 곱연산을 차단한다. - 프롬프트 = 카탈로그(시스템) + 지침(유저): 시스템 프롬프트는 프롬프트 카탈로그가 정하고(→ P-13), 테넌트 답변지침과 재생성 일회성 지침은 유저 메시지에 합류한다(→ P-17) — 시스템 프롬프트의 캐시 프리픽스를 깨지 않기 위해서다.
근거
server/.../answer/generation/AnswerGenerationJob.java— 3-Phase 오케스트레이션.../answer/generation/AnswerGenerationPreparer.java— 진입 가드 · single-flight 클레임(TTL 240s).../answer/generation/AnswerGenerationSettler.java— CAS 재검증 · 정착 · 크래딧 차감.../answer/prewarm/AutoAnswerPumpService.java— 승격 펌프 · capacity · promoteNow.../qna/answer/AnswerGeneratorService.java·SpringAiLlmClient.java— 프롬프트 조립 · 2채널 마커 파싱 · LLM 호출.../qna/answer/ReferentSelfCheck.java·ReferentCoverage.java— 근거충족 자기평가 마커·차단.../qna/answer/AdvisoryCase.java·advisorynotice/AdvisoryScenario.java·AdvisoryNoticeService.java— 상황분류 채널 · 안내 문구.../answer/generation/AnswerGenerationSettler.java·answer/AnswerFailureCode.java— settleBlocked · QUESTION_TOO_VAGUE.../answer/complete/AnswerCompletionService.java— 인간 검수 완료lite-llm/config/litellm-config.yaml— 직결·폴백 체인 · provider pinserver/.../llm/PromptCacheBreakpointInterceptor.java— gpt/Claude 캐시 2패턴 주입 ·LlmAttemptBudget.java— 잔여 예산 가드(720s 천장)
크래딧 차감·정산 정책
후불(postpaid)이다 — 잔액이 부족해도 액션을 막지 않고, 부족분은 초과(overage)로 표시된다. 모든 차감은 단일 지점(CreditService.deduct)을 지나며, 멱등키가 이중과금을 DB 레벨에서 막는다.
과금 항목 — 무엇에, 얼마가, 정확히 언제
| 항목 | 단위 | 시드 단가 | 차감 시점 |
|---|---|---|---|
| AI 질의응답 (AI_QNA) | 질문당 1건 | 10 | 답변 완료가 아니라 초안 성공(REVIEW_PENDING 승격) 순간 — 초안 확정과 같은 트랜잭션 |
| PDF 인덱싱 (PDF_UPLOAD) | 페이지당 | 10/page | 인덱싱 READY 이후 5분 주기 sweep이 사후 정산 (후불) |
| 문제 평가 (QUESTION_REVIEW) | 검수당 1건 | 50 | 검수 COMPLETED 전이 시점 |
| 해설 평가 (QUESTION_REVIEW_EXPLANATION) | 검수당 1건 | 70 | 동일 — 단, 문제 평가를 대체(50+70 누적 아님). 해설 평가는 실패해도 "시도"로 과금 |
결정 규칙
- 잔액 게이트 없음: 후불 정책이라 차감이 비즈니스 룰로 실패하지 않고 잔액 음수를 허용한다. 부족분은
초과 = max(0, −잔액)으로 표시만 된다. (테넌트 정지는 별개의 운영 조치이지 크래딧 자동 게이트가 아님) - 매월 리셋 (use-it-or-lose-it): 매월 1일 0시(KST), 매월 제공량 M>0인 테넌트의 잔액을 정확히 M으로 보정하는 delta를 원자 1쿼리로 삽입. 멱등키
MONTHLY_RESET:{tenant}:{YYYY-MM}로 그 달 1회만. M=0이면 리셋 없음. - 지급 2종: 매월 정액 리셋 + 관리자 수동 지급(GRANT). 수동 지급/조정은 멱등키 없이 매번 신규 기록.
- 게이지 계산: M>0이면 분모=M, 분자=이번 달 소진. M=0이면 전 기간 지급총량/누적 소진으로 폴백. (누적을 M 테넌트에 쓰면 월 정산 후에도 게이지가 안 리셋되는 버그 — 2026-07-01 수정 반영)
- 저잔액 경고: 잔액 ≤ 지급총량의 10% 또는 잔액 ≤ 10,000. 초과(음수)가 저잔액보다 우선 표시.
- 원장 회계: 소진(used)은 소진 4종 유형의 |delta| 합만 집계 — GRANT·MONTHLY_RESET·ADMIN_ADJUST는 소진 통계를 오염시키지 않는다.
근거
server/.../credit/CreditService.java— 단일 차감 지점 · 면제 · 게이지server/.../credit/CreditLedgerRepository.java— ON CONFLICT 멱등 삽입 · 집계 쿼리server/.../credit/MonthlyCreditResetBatch.java— 매월 보정server/.../credit/PdfIndexingCreditReconciler.java— PDF 후불 sweepserver/.../member/InternalAdminCreditExemptionPolicy.java— 관리자 면제 (AQ-1525)
교재 인덱싱 정책
PDF가 검색 가능한 지식(passage)이 되기까지. 트리거는 SQS 메시지 하나뿐이고, 분산 락 대신 상태 가드 UPDATE가 점유를 정한다. source_hash 캐시와 결정성 passage_id upsert 덕에 같은 PDF는 몇 번을 다시 돌려도 안전하다(멱등). 부분 성공은 없다 — 한 페이지라도 실패하면 사이클 전체가 FAILED다.
결정 규칙
- 트리거는 SQS 하나: 스케줄러·HTTP 진입점이 없다. server가 도메인 이벤트를
AFTER_COMMIT에만 발행해 트랜잭션이 구르면 메시지도 안 나간다(DB·큐 정합). 재인덱싱(reindex)은 status를 건드리지 않고 같은 페이로드를 재발행만 한다 — 점유 판정은 rag-worker 가드 몫. - 락 없는 점유: 분산 락도 single-flight도 없다.
UPDATE … WHERE status IN (UPLOADED, FAILED, READY)가드 UPDATE가 점유 — 영향 행이 0이면 남이 잡은 것이니 ack하고 끝(멱등). READY를 허용하는 것이 재인덱싱 경로다. - 3중 멱등: (a) source_hash 기반 VLM S3 캐시 — 같은 PDF 재처리는 외부 호출 0, (b) 결정성 passage_id(
{storageKey}::p{NNNN}::c{idx}) +ON CONFLICT DO UPDATEupsert, (c) orphan cleaner가 이번 사이클에 없는 옛 청크를 삭제. 재시도·재인덱싱을 몇 번 해도 결과는 같다. - 부분 성공 없음: 페이지 fan-out(virtual thread · 세마포어 8) 중 한 페이지라도 실패하면 사이클 전체가 실패한다. VLM 빈 응답(finishReason ≠ STOP — SAFETY 등)은 결정론적이라 재시도에서 제외하고 즉시 실패. 일시 장애(429·5xx)만 재시도한다(VLM 5회 backoff ×2 · 임베딩 5회 최대 60s).
- 테넌트 모델 강제 (AQ-699):
tenant_ai_settings에 VLM·임베딩 모델이 둘 다 있어야 한다. 기본값 폴백 없음 — 미설정 테넌트(글로벌 tenantId null 포함)는 즉시 실패한다. VLM 온도 0.0 · 임베딩 1024차원 고정. - 문서 타입이 파이프라인을 정한다: PDF(교재·해설)는 렌더(200 DPI, 직렬)→VLM→청킹(800/80/1200 + 인접 병합)→임베딩의 8단계. LECTURE_TEXT는 VLM·렌더 없이 텍스트 청킹(2048/256 슬라이딩)만 거친다(ADR-022). LECTURE_TEXT는 page 개념이 없어
pages_countNULL → 페이지당 과금 비대상.
| 상태 전이 | 주체 | 비고 |
|---|---|---|
| PENDING → UPLOADED | server | completeUpload — presigned 업로드 완료 |
| UPLOADED·FAILED·READY → INDEXING | rag-worker | 가드 UPDATE — 0 row면 점유 실패 → ack 스킵 |
| INDEXING → READY | rag-worker | pages_count 원자 기록 — sweep 과금 기준 (→ P-07) |
| INDEXING → FAILED | rag-worker | last_error 1000자 truncate · 예외 재던져 SQS 재배달 |
근거
rag-worker/.../app/listener/KnowledgeUploadedSqsListener.java— 수신 · 점유 · READY/FAILED 마감rag-worker/.../storage/KnowledgeStatusUpdater.java— 가드 UPDATE 상태 전이 (락 없음)rag-worker/.../indexing/PdfIndexingStrategy.java·PlainTextIndexingStrategy.java— PDF 8단계 / 텍스트 4단계 파이프라인rag-worker/.../vlm/VlmClient.java·VlmPageCache.java— VLM 호출 · source_hash S3 캐시rag-worker/.../chunking/HeadingChunker.java·AdjacentPassageMerger.java·EmbedInputSynthesizer.java— 청킹 · 병합 · 합성 규칙rag-worker/.../storage/PassageNativeWriter.java·PassageOrphanCleaner.java— upsert · orphan 정리server/.../knowledge/KnowledgeService.java·KnowledgeIndexingEventPublisher.java— 발행 3지점 · AFTER_COMMITrag-worker/src/main/resources/application.yml·terraform/sqs.tf— 임계값 SSOT · 운영 큐 설정
근거 채택 게이트 (검색 분기) 정책
답변을 만들기 전에 "이 질문에 답할 근거가 있는가"를 먼저 판정한다. 좌표(교재·페이지·문제번호)가 확정된 교재 질문은 결정론적으로, 좌표가 없는 강좌 질문은 LLM 유추 + 유사도 게이트로 근거를 찾는다. 근거를 못 찾으면 LLM을 부르지 않고 사람에게 넘긴다.
강좌 질문 vs 교재 질문 — 무엇이 다른가
| 교재 질문 (좌표 확정) | 강좌 질문 (좌표 없음) | |
|---|---|---|
| 진입 조건 | textbookId + page 있음 (또는 검수자 권위 좌표) | 그 외 전부 |
| 좌표 확보 | 질문에 실려 온 값을 그대로 사용 | LLM이 제목·본문·강의명에서 교재·페이지·문제번호를 유추 |
| 검색 범위 | 지정 좌표로만 한정 — 다른 페이지·교재로 넓히지 않음 | 유추된 신호로 좁히고, 신호가 없으면 강좌 전체 유사도 검색 |
| cosine 게이트 | 미적용 — 유사도가 낮아도 top1 채택(콕 집은 결정론적 의도 존중) | 강좌 전체 검색에서만 절대 0.65 · 마진 0.02 적용 |
| 0건일 때 | 근거없음 → 직접답변 (예외: LLM 교재 재추론 reroute, 0.65 통과 시 구제) | 근거없음 → 직접답변 |
textbookId·page·authoritative)다. 다만 QuestionCategory.resolve가 "교재+페이지 있음 = TEXTBOOK"으로 정의해, 결과적으로 교재 질문/강좌 질문 구분과 일치한다.게이트 임계값
| 게이트 | 기본값 | 적용 지점 | 미달 시 |
|---|---|---|---|
| 절대 cosine | 0.65 | 강좌 전체 dense | LLM 미호출 · 근거없음 |
| 마진 (top1 − top2) | 0.02 | 강좌 dense · 번호 신호 없고 top2 존재 시 | 박빙 오채택 방지 · 근거없음 |
| 교재힌트 override | 0.05 | 페이지힌트 경로 교재 충돌 | 차 미달이면 힌트 교재 존중 |
| 해설 top1 | 0.80 | 채택 후 해설 보강(SolutionAugmenter) | 해설 미채택 · 문제만으로 답 |
| 해설 example 앵커 | 0.85 | 해설 보강 | 앵커 탈락 |
| 교재·페이지 / 권위 좌표 / 페이지힌트 | 게이트 없음 | 결정론적 신호 | top1 무조건 채택 |
top-k = 10건 조회하되 근거로는 top1 1건만 채택한다. 임계값 SSOT는 app.rag.dense.*(RagDenseProperties), 해설 확장 상한 sibling cap 6.결정 규칙
- 교재 경로 = 결정론: 학생이 교재·페이지를 콕 집었으면 그 좌표로만 검색하고, 0건이어도 다른 좌표로 넓히지 않는다. cosine 게이트도 건너뛰어 유사도가 낮아도 top1을 채택한다.
- reroute 구제 (예외): 워크북·해설집 오선택 등으로 지정 교재가 어긋난 경우, LLM이 추론한 다른 교재로 한 번만 재검색한다. 이 재검색은 신뢰도가 낮으므로 절대 cosine 게이트(0.65)를 통과해야만 채택한다 (AQ-996).
- 강좌 경로 = 유추 후 유사도:
denseSearchQueryExtractor가 제목·본문·강의명에서 교재·페이지·문제번호 hint를 뽑는다. 페이지·번호 신호가 잡히면 그 범위로 좁혀 무게이트로 채택하고(결정론적 신호 존중), 아무 신호도 없을 때만 강좌 전체 유사도 검색에 절대 0.65·마진 0.02 게이트를 건다. - 좌표 출처:
page·강의명은 질문 메타데이터,textbookId는 엔티티 필드.problemNo는 엔티티 필드가 없어 검수자 override 또는 텍스트 추론으로만 채운다. 검수자 재생성 좌표(권위 좌표)는 신뢰도가 가장 높아 비운 값을 텍스트로 되살리지 않는다. - 재생성은 다른 강좌 교재도 지정 가능 (AQ-1649): 힌트 보정 재생성에서 검수자가 질문 강좌 밖의 교재를 근거로 고르면, 검색 강좌를 그 교재의 소속 강좌로 역산해 검색한다(교재를 못 찾으면 질문 강좌로 폴백 + 경고 로그). 강좌 AND 필터 자체는 완화하지 않고 "검색 범위 강좌"의 의미만 재해석한 것.
- timeline (강좌 → 교재 변환): 운영 기본은
HINT_ONLY. 강의명·재생 시각으로 강의 1건이 정확히 매칭되면 그 시점 교재·페이지를 역산해 좌표를 만들고 교재 경로를 그대로 재사용한다 — 강좌 신호를 교재 좌표로 승격시키는 장치다. - 결과 매핑: 근거 채택 →
REVIEW_PENDING(초안 + 크래딧 차감, → P-05·P-07). 근거없음(게이트 미달·0건·박빙·좌표 불일치) →DIRECT_ANSWER_PENDING(RAG_NO_EVIDENCE, 사람 직접답변). 주의: 같은RAG_NO_EVIDENCE코드가 이제 출처 둘이다 — 이 게이트의 진짜 0건(sources 빈)과, 생성 이후 근거충족 자기평가 MISSING 차단(sources 보존, → P-05 게이트 A). 이 게이트 자체의 임계값·분기는 불변.
근거
server/.../qna/AnswerService.java—coreRetrieve좌표 분기 · 교재/강좌 경로 · cosine·마진 게이트.../qna/search/RagDenseProperties.java+application.yml(app.rag.dense.*) — 임계값 SSOT.../qna/solution/SolutionAugmenter.java— 해설 보강 게이트(0.80/0.85 · sibling cap 6).../qna/answer/AnswerMode.java·qna/timeline/TimelineCoordinateResolver.java— 답변 모드 · 좌표 합성.../qna/search/DenseRetriever.java·PassageMetaFilter.java— 벡터 검색 · 좌표 필터
문항 검수 정책 (문제연구실)
답변이 아니라 출제된 문항 그 자체의 정합성을 검수한다. AI 검토위원 3슬롯이 blind 풀이로 독립적으로 풀고, 판정은 LLM이 내리는 게 아니라 순수 함수(VerdictCalculator)가 결함→라벨 floor 규칙으로 결정론적으로 파생한다. 결함은 8종이며 과조건(조건 최소성)이 새 축으로 들어왔는데, 오탐이 미탐보다 해로워 전제가 흔들리면 억제 게이트가 침묵시킨다. 판정은 자동 게이트가 아니라 인간 최종 승인 전제의 권고다. v1과 v2가 별도 API로 병존한다.
P-05 답변 검수와 무엇이 다른가 · v1 vs v2
- 검수 대상이 다르다: P-05의 REVIEW_PENDING은 "AI가 만든 답변"의 인간 검수 큐다. 이 정책의 대상은 "출제된 문항(발문·선택지·정답키) + 선택 제출된 해설"이며, 두 도메인은 코드상 전혀 겹치지 않는다(questionreview 패키지에 REVIEW_PENDING 참조 0건).
- v2가 v1을 구조적으로 대체 중: v1(AQ-1016, 2026-06)은 단계 결과를 부모 행의 partial_result JSONB에 누적하고, v2(AQ-1461, 2026-07)는 head 엔티티와 phase×slot 전문 테이블(review_step_run,
UNIQUE(review_id, phase, slot))을 분리했다. 둘 다 deprecated 없이 운영·개선 중이고 API 경로가 다르다(/question-reviewsvs/question-reviews-v2). - v2 신설 3종: 판정 없는 조기 종결
TERMINATED(복수 마킹 미지원 — retry 불가·비과금) · LLM 없는 코드 검증MACHINE_CHECK· 만장일치일 때만 반박을 시도하는 이의신청CHALLENGE. - 크래딧 분리: 멱등키가 v1
review:{id}/ v2reviewV2:{id}로 나뉘어 이중과금이 없다.
결정 규칙
- 배정 큐는 없다 — claim이 배정이다: 사람 배정 개념 없이 SQS 리스너가 compare-and-claim(가드 UPDATE)으로 원자 점유한다.
SUBMITTED신규 또는 lease 만료(480초)된EVALUATING만 잡히고, 0행이면 남이 잡은 것이니 skip. - 1차 풀이는 3슬롯 blind 병렬: v2는 슬롯별 이종(교차 벤더) 모델이 기본이다(D8). 생존 슬롯이 2 미만이면 판정 없이
EVAL_FAILED. 파싱 실패는 1회 재호출 후 격리. 미지정 effort는 요청값 그대로 적용된다 — 서버가 high로 강제하던 config 기본값 백스톱은 제거됐다(AQ-1582). 팬아웃 데드라인은 780초(PT13M)이고(호출 1건 천장 720s보다 커야 shutdownNow 레이스 회피, AQ-1593), 데드라인이 지나면 폴백 모델도 시도하지 않는다. fast-fail 천장 T (AQ-1761): primary-review 360s(실측 p99 358.2s 경계), challenge·final-review·explanation-review 각 300s — T + 폴백 몫 T ≤ 예산(primary는 780s, 나머지는 stage-budget 900s). - 판정은 순수 함수: LLM은 결함을 "제안"만 하고, VerdictCalculator가 결함→라벨 floor(조건모순·조건부족·복수정답·선지에 답 없음 → CRITICAL_ERROR / 정답키불일치·표기오류·과조건 → MINOR_ISSUE)와 확신 밴드(미해소=LOW · 발산=MEDIUM · 그 외 HIGH)를 결정론적으로 파생한다. 정답키 불일치는 MACHINE_CHECK가 MISMATCH일 때만 확증된다. 서술형·단답형 문항에는 '선지 내 정답' 결함을 적용하지 않는다(AQ-1596).
- 과조건(조건 최소성) 축 (AQ-1777/1793): 8번째 결함
CONDITION_REDUNDANT— "빼도 물어본 값이 달라지지 않는 불필요한 조건"(하한 MINOR_ISSUE). 1차 3슬롯이 조건 원자마다 필요성 4값(REQUIRED/REDUNDANT/NON_BINDING/UNDETERMINED)을 항상 판정하고, 종합 검토위원이 의미로 대조해 확증한다. 오탐이 미탐보다 해롭다(잘못 "빼도 된다"면 출제자가 문항을 망가뜨림) — 그래서 억제 게이트(OR, 하나라도 걸리면 침묵): ⓐ조건집합 불확정(조건모순·부족·복수정답 확증) ⓑ도출답 합의 비만장일치 ⓒ판독불일치 확증 ⓓ패널 <3인 ∨ 도출 <2슬롯(AQ-1793이MIN_MINIMALITY_SLOTS(3)을MIN_PANEL_SLOTS(3)+MIN_DERIVING_SLOTS(2)로 분해) ⓔ전 슬롯 필요성 미판정. 표기오류·정답키불일치는 조건집합이 건전하므로 억제하지 않는다. - 이의신청은 만장일치에만 발화 — 기본 활성화: 1차 풀이 만장일치 ∧ 무결함 ∧ 기계통과일 때만 CHALLENGE가 반박을 시도한다. 반박 성립(FIRED_UPHELD)이면 밴드 MEDIUM cap + 인간 확인 강제. 교사 선택 토글이었다가 기본 활성화(관리자만 opt-out)로 전환됐고(AQ-1644), 프롬프트에 가정 감사 절차가 필수로 들어갔다(AQ-1658). 선지 정렬은 이 게이트의 기계통과 집합에서 제외됐다(AQ-1669) — 정식 문항의 내림차순 배열 오탐 하나로 백스톱이 꺼지던 역전을 막고, 정렬 검사도 오름차순 전용 → 양방향 단조(무작위만 위반)로 완화. 선지 중복은 실질 결함 후보라 게이트에 남는다.
- 발화 조건의 "일치"는 완화 중: 선지 교차 대조는 텍스트 완전일치가 아니라 번호 집합 비교다(√3 vs \sqrt{3} 같은 표기 차이 흡수 · 빈 선지 슬롯 제외 · 전원 선지 없음 = 서술형 취급, AQ-1622). 판독 조건 원자(conditionsAgree) 완전일치 축은 거짓 발산이 많아 폐지됐다(AQ-1580).
- 인간 확인(needs_human_review) OR 트리거: 밴드 LOW ∨ CRITICAL_ERROR ∨ 독해 불일치 ∨ 정답키 불일치 ∨ LLM 제안 ∨ 치명 제안 강등(해소 ∧ 결함이 코드 게이트로 전부 억제됨 ∧ LLM 제안=CRITICAL, AQ-1793). 확증분이 있었는데 게이트가 전부 걸러내면 LLM 제안 라벨을 버리고 하한으로 확정해 '경미 배지 + 결함 0건' 모순 화면을 막는다. 표시 권위는 head의 defect_types(서버 확정값).
- 시간 불변식:
LEASE(480s) < stuck(900s) ≤ sweep(1800s). sweep(5분 주기)과 수동 취소는 재발행하지 않는다 — LLM 멱등성이 없어 poison 메시지 무한 재과금을 막기 위해서다. 재구동은 사람의 수동 retry(EVAL_FAILED·stale만 → SUBMITTED)뿐. - crash-resume: 성공(SUCCEEDED)·건너뜀(SKIPPED) 스텝은 재실행 시 LLM을 재호출하지 않는다(재과금 방지). 스텝 upsert는 first-terminal-writer-wins.
- v2 검토 소프트 삭제 (AQ-1738):
deleted_at/deleted_by로 목록·CSV에서 감춘다(deleted_at IS NULL필터). 진행 중(SUBMITTED·EVALUATING·해설 재시도 PENDING/RUNNING)이면 409, 종결 3종+TERMINATED+UNKNOWN만 삭제 가능. 하위 감사데이터(스텝·LLM 원문·해설 리비전·크레딧 원장)는 보존하고, 복구 API는 없다(런북이 두 컬럼을 NULL로). - 과금 (→ P-07): COMPLETED ∧ 해설 시도 → 70(문제 50을 대체) · COMPLETED ∧ 해설 미시도 → 50 · TERMINATED/EVAL_FAILED → 비과금. 해설 평가는 실패해도 "시도"로 과금된다. 차감은 완료 전이와 같은 트랜잭션.
- 고객에겐 확정만, 내부엔 원자료 (AQ-1589 계열): 검증 항목 표는 위원별 신호 OR-집계가 아니라 종합 검토위원의 확정(confirmedFindings) 파생으로 그려 판정과 모순되지 않게 한다. 위원별 각각 매트릭스·발산 감사·모델 원문 응답·실사용 모델 메타는 내부관리자(INTERNAL_ADMIN) 전용이다(AQ-1577·AQ-1631). 결과 화면의 모델·강도는 신청값이 아니라 실사용 관측값 기준(AQ-1621). 해설 판정은 목록 head 컬럼으로 승격됐고(AQ-1550), LLM 호출마다 사용한 프롬프트 리비전 id를 기록해 판정↔프롬프트 혈통을 추적한다(AQ-1641 → P-13).
票×2 > n).근거
server/.../questionreviewv2/QuestionReviewV2.java·ReviewV2Status.java— head 엔티티 · 상태기계(+TERMINATED).../questionreviewv2/orchestration/ReviewV2Orchestrator.java— 6-스테이지 드라이버.../questionreviewv2/orchestration/VerdictCalculator.java— 결정론적 판정 파생 (순수 함수).../questionreviewv2/orchestration/PrimaryReviewStage.java·ChallengeGate.java— 3슬롯 blind · 이의신청 발화 게이트.../questionreviewv2/QuestionReviewV2Repository.java·ReviewStepRunRepository.java— claim·lease SQL · 스텝 upsert CAS.../questionreviewv2/ReviewV2CreditPolicy.java— 50/70 과금 판정 ·orchestration/StuckReviewV2Sweep.java— 자율 sweepserver/.../questionreview/QuestionReview.java·orchestration/FusionReviewOrchestrator.java— v1 대응물
테넌트 생애주기·격리 정책
테넌트 = 워크스페이스 단위의 고객 학원. ONBOARDING 상태 없이 처음부터 ACTIVE로 태어나고(ADR-012), 시드 프로비저닝은 "같은 트랜잭션(필수) + 커밋 후 이벤트(보조)"의 2단이다. 격리는 요청 경계에서만 — JWT의 tenant_id가 경로와 일치하고 테넌트가 ACTIVE일 때만 통과하며, 삭제는 자식 데이터에 전파되지 않는다.
결정 규칙
- 상태 3종, 전이는 엔티티가 강제: ACTIVE / SUSPENDED / DELETED.
suspend는 ACTIVE에서만,reactivate는 SUSPENDED에서만,restore는 DELETED에서만 — 위반은 409. 매 전이는 같은 트랜잭션에서tenant_revisions에 이력이 남는다. - 프로비저닝 2단: 생성 트랜잭션 안에서 기본 ANSWER 프롬프트 시드까지 원자 수행(시드 실패 = 생성 전체 롤백). 질문·수집기 설정 시드는 AFTER_COMMIT 리스너가 하고, 실패해도 테넌트 생성엔 영향 없다(운영자가 화면에서 수동 저장으로 복구).
- 격리는 요청 경계에서만:
@RequiresTenantAccess가 JWT claimtenant_id= 경로 tenantId ∧ 테넌트 ACTIVE를 검사한다. INTERNAL_ADMIN(tenant_id null 계정)은 전체 통과. 실패는 403이 아니라 404로 변환되고, 공개 코드 조회도 비활성·미존재를 NotFound로 통일해 테넌트 존재를 광고하지 않는다(enumeration 방어). - 삭제는 소프트 + 비전파:
deleted_at/deleted_by만 찍고 강좌·질문·회원 등 자식 데이터는 물리적으로 그대로 둔다(CON-2). 접근 차단은 경계 가드가 담당. SUSPENDED·DELETED는 로그인부터 차단된다. - tenant_code는 URL 식별자: 소문자·숫자·하이픈 3~30자, all-digit 금지,
xn--금지, 예약어 목록 금지. 생성 후 불변. - 설정은 sidecar 3종(PK=tenant_id 1:1): AI 설정(모델·프롬프트·강도 — null이면 글로벌/시스템 기본값 폴백, 모델 없이 강도만 설정은 거부(FR-2)) · 질문 설정(선점 TTL 기본 1800초(5분~24시간) · AI 선생성 상한 10 · 동시 선점 1~10 · 폰트 SE2 프리셋만) · 수집기 설정(수집 6h · 갱신 1h · 연속실패 임계 3 — P-01·P-02의 기본값 원천).
- 글로벌(tenantId null)의 의미: 테넌트 행 자체는 항상 실제 ID를 가진다. null은 (a) 프롬프트의 "글로벌 시드" 소유권 (b) INTERNAL_ADMIN 계정 (c) 설정 필드의 "기본값 폴백" 신호로만 등장한다. ※ 글로벌 교재는 인덱싱 불가(P-08 · AQ-699).
근거
server/.../tenant/Tenant.java·TenantStatus.java— 애그리거트 · 상태 전이 가드.../tenant/TenantService.java— 생성 트랜잭션(시드 원자) · 전이 · 이력.../tenant/TenantCode.java— 코드 형식·예약어 검증.../tenant/security/TenantSecurityExpression.java·RequiresTenantAccess.java— 격리 가드.../tenant/aisettings/TenantAiSettingsApplicationService.java— AI 설정 검증·글로벌 폴백.../tenant/questionsettings/DefaultQuestionSettings.java·collector/settings/DefaultCollectorSettings.java— 기본값 SSOT.../auth/workspace/WorkspaceAuthService.java— 로그인 게이트(비ACTIVE 차단)
AI 품질 회귀 테스트 (QRT) 정책
답변·검색 품질을 골든 정답 대비로 회귀 검증하는 내부 도구다. 회귀 런은 운영 답변 파이프라인(P-05·P-09)을 그대로 호출하고, TC별로 SQS fan-out 후 barrier(개수 비교)로 종결한다. 비과금이며(→ P-07), 골든 정답이 시스템 입력으로 새는 "트랙 누출"을 2중으로 방어한다.
런 모드 — 무엇을 재는가
| 모드 | 실행 내용 | 기록되는 평가 |
|---|---|---|
| AI_ANSWER | 답변 생성(LLM) 포함 전체 파이프라인 | 범주① 근거 precision/recall + 범주② 품질 평가 LLM |
| RAG_ONLY | 검색만 수행 — 답변·평가 LLM 미호출 (AQ-783) | 범주①만 |
| HUMAN_ANSWER | 사람 답변을 평가 대상으로 | 범주②만 (범주①·AI 필드 null) |
결정 규칙
- 런 = 설정 스냅샷: 시작 시점의 프롬프트(candidate_id로 해소, → P-13)·모델·강도를 박제해 실행 중 설정 변경에 영향받지 않는다. 강좌 없는 TC는 NO_COURSE로 제외(ADR-024), 제외 후 대상 0건이면 시작 자체를 거부.
- fan-out 멱등: run_started 메시지가 재배달돼도
fanned_out_at가드로 재발행을 skip한다(중복 LLM 비용 차단). TC 실행도 결과 존재 시 dedup — barrier만 self-heal 호출. - barrier 종결: 각 TC 완료 시 "결과 행 수 == 대상 TC 수"를 비교해 마지막 완료자가 DONE 전이를 수행한다.
WHERE status='RUNNING'가드로 동시 진입해도 1회만 성공. - TC 실패 격리 (AQ-705): 트랙 누출·추론 오류·근거 부족은 그 TC만 ANSWER_FAILED로 격리하고 런은 계속된다. 집계 모집단은 "답변 성공 ∧ 평가 성공"만.
- 차단행도 근거 진단은 살린다 (AQ-1676): 근거충족 자기평가 PARTIAL/MISSING으로 차단(ANSWER_FAILED)됐어도 채택 청크가 보존된 행은 범주①(RAG precision/recall)을 계산·저장하고 청크 배지 진단을 노출한다(read-time 재계산이라 기존 차단행도 소급). 단
evalStatus=null은 유지돼 런 집계 평균 모수에선 여전히 자동 배제. 진짜 근거부족(빈 sources)·TRACK_LEAK·INFERENCE_ERROR는 자동 억제한다. - 근거충족 자기평가 노출 (AQ-1698): QRT 결과 상세에 운영 답변 LLM의 근거충족 자기평가(coverage·external·사유)를 노출한다(
test_results에 nullable 3컬럼, 소급 백필 없음). 상태·게이트·과금 무변경. - 트랙 누출 2중 방어: 골든 트랙(정답·근거)과 입력 트랙(질문·첨부)의 교차를 TC 애그리거트(첨부 해시 충돌 거부)와 실행 시(TrackLeakException) 두 곳에서 차단한다 — 정답을 미리 본 답변이 점수를 오염시키는 것을 막는 장치.
- 동시성 8 · 안전망 2시간: qrt_run_tc 큐 동시 소비 8(AQ-703, TC당 LLM 2회라 무거움). 부팅 시 orphan sweep이 2시간(PT2H) 초과 RUNNING 런을 FAILED로 정착.
- 비과금: QRT 경로의 답변 생성·검수는 크래딧을 차감하지 않는다(→ P-07). credit·notification 도메인 참조 0건.
근거
server/.../qrt/testrun/TestRun.java·testcase/TestCase.java— 런·TC 애그리거트 · 트랙 분리 불변식.../qrt/testrun/TestRunStartApplicationService.java— 시작 · 대상 해석·제외 · 스냅샷 합성.../qrt/testrun/exec/RunFanoutConsumer.java·RunTcExecutionConsumer.java— fan-out 멱등 · 동시성 8.../qrt/testrun/exec/RunTcExecutionService.java— TC 실행 7단계 · 모드 분기 · 실패 격리.../qrt/testrun/exec/EvidenceRelevanceEvaluator.java·AnswerQualityEvaluator.java— 평가 2범주.../qrt/testrun/exec/RunAggregationService.java·OrphanRunSweeper.java— barrier 종결 · PT2H 안전망server/src/main/resources/application.yml(app.qrt.*) — 큐·동시성·stale-timeout SSOT
프롬프트 관리·해석 정책
모든 LLM 호출의 프롬프트는 3세대 저장소(prompts → managed_prompts → 버전 관리형 카탈로그)를 거쳐 왔고, 현행은 카탈로그다. 코드는 binding_key로 슬롯을 찾고, 해석은 "테넌트 배정 → 시스템 전역 폴백 → active version"으로 내려간다. 카탈로그가 못 주면(레지스트리 스위치 OFF 포함) 조용히 레거시로 폴백하되(fail-open) 지표 카운터로 감시하고, 레거시가 없는 신규 도메인은 즉시 예외(fail-loud)다.
카탈로그 4계층
| 계층 | 무엇 | 핵심 제약 |
|---|---|---|
| PromptSection | 코드가 읽는 슬롯 | binding_key(영문 slug)로 코드와 1:1 · 전역 유니크 · 불변 |
| PromptCandidate | 섹션 내 이름별 대안(나란한 후보) | ACTIVE/ARCHIVED · active_version_mode = LATEST 또는 PINNED |
| PromptRevision | 후보의 불변 버전 (진실의 원천) | 편집 = 새 행 INSERT, setter 없음 · UNIQUE(candidate, version_no) · 변경 메모 2,000자 (AQ-1651) |
| PromptAssignment | 소비처가 섹션에 선택한 후보 포인터 | 테넌트 스코프 또는 시스템 전역(tenant_id null) · UNIQUE |
결정 규칙
- 해석 체인:
resolve(bindingKey, tenantId)= 섹션 → 배정(테넌트 우선, 없으면 전역 폴백) → active version(PINNED면 고정 번호, 아니면 최신) → 리비전 → ResolvedPrompt(body + output_schema). miss는 예외가 아니라 빈 Optional — 레거시 폴백 신호다. - fail-open 3분기 + 지표: 게이트웨이는 HIT=반환 / MISS·ERROR=빈 Optional로 레거시 폴백. "조용한 폴백"이 사고를 숨기지 않도록 폴백마다 카운터(
aiqna.prompt.resolution.legacy)에 사유를 태깅한다. - 레지스트리 스위치:
app.prompt-registry.enabled=false면 카탈로그 전체를 즉시 miss 처리 — 상관 장애 시 재배포 없이 전 소비처를 레거시 경로로 되돌리는 비상 스위치. 기본값 true(키 누락 흡수). - 신규 도메인은 fail-loud: 문항 변형(P-14)처럼 레거시가 없는 소비처는 miss가 곧 예외다 — null 프롬프트로 LLM을 호출하는 것을 막고, 시드에 전역 배정 포함을 강제한다. 런별 지정 후보 직접 해소(
resolveByCandidate)도 폴백 없는 하드 경로다(QRT·검수 런의 스냅샷용). - 배포는 body 조인 멱등 시더: reconciler(관리자 수동 트리거)가 레거시 선택의 body를 조인 키로 카탈로그 후보를 찾아 배정을 upsert(
ON CONFLICT DO UPDATE)한다 — 컷오버 직후 바이트 동일 보장. - 레거시 2종은 아직 살아있다: prompts(테넌트별 ANSWER, 신규 테넌트 생성 시 시드 1건 — P-11)와 managed_prompts(AQ-1237 통합 중간 세대). 답변 프롬프트는 same-tenant only로 글로벌 미허용 — 검색·문제연구실 프롬프트(글로벌 허용)와 비대칭. 세 번째 레거시 저장소 evaluation_prompts는 코드·테이블·화면 전량 제거됐다(AQ-1708, 카탈로그 컷오버 완료) — 남은 스트랭글러 잔여는 v1 카탈로그
evaluation행(AQ-1785 예정). - v2 프롬프트 배정 (AQ-1788): 서버는 이미
question-review-v2.*·question-variant.v2.*키를 테넌트 스코프로 해소하고 있었고, 배정 관리 화면이 하드코딩 allow-list 8종만 렌더하던 결함을 그룹·접기 UI + 양방향 drift 게이트로 고쳤다(웹 클라이언트 전용 — 서버 binding_key 개수는 이 변경으로 늘지 않음). - 혈통 추적과 조교 지침의 경계: 검수 v2는 LLM 호출 1건마다 사용한 리비전 id를 기록해 "그 판정이 어느 프롬프트에서 나왔나"를 추적한다(AQ-1641). 반면 조교(리드)용 답변지침은 카탈로그가 아니다 — 별도 저장소(answer_guidelines)에 살고 유저 메시지로 주입된다(→ P-17). 카탈로그는 시스템 프롬프트(관리자 도메인)를 담당한다.
근거
server/.../promptcatalog/PromptResolver.java— 해석 체인 · 전역 폴백 · PINNED/LATEST.../promptcatalog/PromptResolutionGateway.java— fail-open 3분기 · 폴백 지표.../promptcatalog/PromptSectionBindingKeys.java— binding_key 단일 출처(22종 · v2 키question-review-v2.*4 ·question-variant.v2.*8 포함).../promptcatalog/PromptAssignmentReconciler.java— body 조인 멱등 시더.../promptregistry/ManagedPrompt.java·prompt/Prompt.java·prompt/PromptSeeder.java— 레거시 2종
문항 변형 생성 정책
원본 문항(또는 개념)이 워크스페이스가 되고, 그 안에서 변형 후보를 만들어 검증한다. 여러 상태 축이 직교한다 — 워크스페이스 분석 · 후보 · 생성 요청 · 검증 런. 후보는 편집으로 되돌릴 수 있어 out-edge 없는 hard terminal은 GENERATION_FAILED 하나뿐이고, "버리기"는 status와 직교인 soft-delete다. 워크스페이스당 활성 생성 요청 1개 게이트와 총 10개 cap · 시간 불변식(HEARTBEAT 120 < LEASE 900 < VISIBILITY 1200 < STALE 1800)이 과금 폭주를 막는다. 생성은 난이도 4축을 목표로 삼고, 통과 후보는 스냅샷으로 채택한다.
결정 규칙
- 입력 3모드 (AQ-1725): 워크스페이스가 무엇을 재료로 받는지가 단일 권위다 —
PROBLEM_ONLY(원본 문항) ·CONCEPT_ONLY(개념만) ·CONCEPT_WITH_PROBLEM. 모드가 등록·교정 검증·분석 합류를 좌우한다(VariantInputMode). - 원본 either-or 불변식: PROBLEM 계열 원본은 텍스트·이미지 중 최소 하나 필수(정답·해설은 선택). 이미지-only 원본은 분석 단계에서 텍스트를 추출한다. 개념만 모드는 전용 레인(CONCEPT_DESIGN_ANALYSIS → EVALUATE_READINESS)을 타며 문항 채널을 요구·수용하지 않는다.
- 상태 축은 여럿이 직교: ①워크스페이스 분석(ANALYZING → ANALYZED / ANALYSIS_FAILED, 실패는 사람 재시도 복귀) ②생성 요청(
VariantGenerationRequestStatus: REQUESTED → PLANNING → GENERATING → COMPLETED / PARTIAL_FAILED / FAILED) ③후보(VariantCandidateStatus: GENERATING → DRAFT → VERIFYING → PASSED / REJECTED / FAILED, 그리고 GENERATION_FAILED) ④검증 런(VariantVerificationRunStatus). MISMATCH는 후보 상태에서 빠지고 검증 사유코드ANSWER_MISMATCH로만 남는다. - hard terminal은 GENERATION_FAILED 하나: 편집 저장(
applyEdit)이 DRAFT · REJECTED · FAILED · PASSED를 새 리비전으로 DRAFT로 되돌리고, FAILED는 재검증(startVerification)으로 복귀한다 — out-edge 없는 진짜 terminal은 GENERATION_FAILED뿐. "버리기(discard)"는 status와 직교인 soft-delete(discarded_at)지만 GENERATING · VERIFYING · GENERATION_FAILED는 discard 불가(FR-14 D8). - 동시성 게이트: 워크스페이스당 활성 생성 요청 1개만 허용(활성 = REQUESTED/PLANNING/GENERATING, 초과 =
GENERATION_IN_PROGRESS409). 총량 cap 10(candidate-cap) 검사 모수 =discarded_at IS NULL ∧ status ≠ GENERATION_FAILED인 후보(버림·생성실패 제외). 내부 draft executor는 pool 4 / queue 20(용량 불변식: 리스너 동시 4 × 요청당 max 5 ≤ 24). - 난이도 4축 기반 생성 (AQ-1723/1724/1741): 분석이 원본을 난이도 프로파일 4축(개념결합수 1~4 · 풀이단계 1~5 · 인지수준 1~5 · 역방향은닉도 1~5)으로 산출한다. 생성 요청은 목표 난이도 4축을 실으며(생략 시 서버가 원본 프로파일 승계 — 허위 델타 방지), 검증 단계의 난이도 부합 판정이 (원본·목표·측정)을 대조해 허용오차 ±1, 조정한 축의 MISSED만 REJECT한다(NOT_TARGETED/INHERITED는 면제).
- 표면 재구성 토글 (AQ-1724): 소재·상황을 갈아 달라는 boolean
repackageSurface가 구BASIC/REPACKAGEDmode를 대체. 개념만 모드엔 부적용(400), 위반코드REPACKAGING_VIOLATION. - 계획-후-병렬 (FR-14): N≥2 후보는 plan LLM 1회로 전략을 배분한 뒤 병렬 생성한다. plan이 통째로 실패하면 claim된 N개 전부 즉시 실패(요청 FAILED), 후보별 부분 실패는 요청
PARTIAL_FAILED로 구분. 퇴화(공백·원본과 동일)는 1회만 resample, 소진 시 GENERATION_FAILED. - row-first + 배치 1건 발행: cap 검사 → 시퀀스 1회 채번 → N행을 GENERATING으로 먼저 INSERT → 배치당 SQS 1건. 검증은 후보당 1건 발행. 큐 URL이 비면 발행 no-op + producer 경계 503 거부.
- sweep은 재발행하지 않는다: 워커 사망으로 고착된 행은 주기 sweep(5분)이 terminal auto-fail만 한다(LLM 재호출·재과금 없음, P-10과 같은 원칙) —
ASYNC_DISPATCH_EXHAUSTED(미claim 나이 초과) ·ASYNC_PROCESSING_EXHAUSTED(진행중 stale). 시간 불변식HEARTBEAT(120s) < LEASE(900s) < VISIBILITY(1200s) < PROCESSING_STALE(1800s), DISPATCH_AGE 900s. LEASE는 잡 하드 데드라인 겸 stale 재claim 컷오프라 호출 1건 천장(720s)보다 크다(AQ-1749로 480→900, visibility 600→1200 연쇄 상향). - 모델 6논리 슬롯 / 9스테이지 키: SOURCE_ANALYSIS · BLUEPRINT_PLAN · DRAFT_GENERATE · VARIANT_BLIND_SOLVE · LEARNING_FIDELITY(충실도 심판) · FALLBACK. 파이프라인은 분석 4스테이지(source-extract · source-blind-solve · source-structure · concept-design) + 생성(blueprint-plan · draft-generate) + 검증(variant-blind-solve · learning-fidelity)로 펼쳐진다. 프롬프트는 카탈로그로 해소하며 레거시가 없어 miss는 즉시 예외다(→ P-13).
- fast-fail 천장 T (AQ-1761/1746): LLM 스테이지 8종별 조기 종결 천장(대부분 120s, blueprint-plan 180s)이 폭주 호출을 끊고, 잔여 예산 가드가 "완주 가능한가"(잔여 ≥ max(60s, 직전 실측))로 새 호출·폴백 시작을 막아 관측 원문 유실을 방지한다. LLM 호출 1건 총시간 천장은 720s(→ P-13).
- 채택본 스냅샷 (AQ-1674): PASSED 후보를 확정 채택하면 문항·정답·해설·graph·repackageSurface·설계도·원본요약을 비정규화 스냅샷(
adopted_question_variant)으로 보존한다. 후보당 1회 UNIQUE, lineage 3-컬럼 복합 FK. 워크스페이스당 여러 후보를 채택할 수 있다.
근거
server/.../questionvariant/QuestionVariantCandidate.java— 후보 상태 축 · 편집 되돌림(applyEdit) · discard 제약.../questionvariant/VariantCandidateStatus.java·VariantGenerationRequestStatus.java·VariantVerificationRunStatus.java— 상태 축 단일 권위.../questionvariant/QuestionVariantGenerationCommandService.java— 단일 활성 요청 게이트 · cap(버림 제외) · 목표 난이도 승계.../questionvariant/VariantInputMode.java·VariantDifficultyProfile.java·VariantDifficultyConformance.java— 입력 3모드 · 난이도 4축 · 부합 판정.../questionvariant/VariantModelSlot.java·VariantPipeline.java— 6논리 슬롯 · 9스테이지.../questionvariant/AdoptedQuestionVariant.java— 채택본 스냅샷 · lineage FK.../questionvariant/QuestionVariantProperties.java·VariantAsyncFailureCode.java— 시간 불변식 · fast-fail · sweep 사유코드
교재 증빙 정책
외부 게시글에서 자동 파싱한 "교재유무" 원본값은 절대 고치지 않는다. 미보유(N) 학생이 실제로 교재를 샀다는 판단은 조교가 (테넌트·학생키·강좌) 단위로 수동 등록하는 별도 증빙 행이고, 화면의 "보유(증빙)"는 조회 시점에 파생된다. 증빙은 표시·필터·경고에만 쓰이며 AI 답변 게이트·크래딧과는 무접점이다.
원본과 증빙 — 두 값은 분리돼 있다
| 원본 교재유무 (textbook_owned) | 증빙 (TextbookPurchaseProof) | |
|---|---|---|
| 원천 | 외부 게시글 상세의 "교재유무 Y/N" 셀을 자동 파싱 (결측·파싱 실패는 null) | 조교(검수 4역할)의 수동 판단·등록 |
| 저장 | questions.textbook_owned | textbook_purchase_proofs — UNIQUE(tenant_id, author_key, course_id) |
| 변경 | 재수집마다 갱신될 수 있음 | 등록(insert) / 해제(hard delete)만 — in-place 수정 없음 |
| 표시 | OWNED=보유 · NOT_OWNED=미보유 · null=− | NOT_OWNED ∧ 증빙 존재 → "보유(증빙)" (조회 시점 EXISTS 파생) |
[비공개] 마커도 저장값을 고치지 않고 읽기 시점에 정화한다(AQ-1590).결정 규칙
- 학생키 없으면 증빙 불가: 증빙의
author_key는 NOT NULL이다 — null을 허용하면 UNIQUE가 무력화된다. 학생키는 질문 최초 변환 시 한 번 확정되면 불변이고(원본 교재유무는 갱신돼도 키는 안 바뀜), 질문·교재 증빙·학생 신고(→ P-16) 세 도메인이 공유한다. - 학생키 = 비밀키 HMAC (AQ-1565): plain SHA-256은 아이디 형식이 예측 가능해 역추적 위험이 있어
HMAC-SHA256(비밀키, 원본 아이디)로 전환했다. 비밀키는 env(APP_MEMBER_KEY_HMAC_SECRET) 주입이며 없으면 서버가 기동을 거부한다(fail-fast — 약한 기본값으로 조용히 도는 것 차단). 전환 시 3테이블 전량 재계산 마이그레이션 수행, 매핑 못 찾은 행은 silent skip 없이 로그 집계. - 필터 의미론: "보유" = 원본 OWNED OR (NOT_OWNED ∧ 증빙 존재). "미보유" = NOT_OWNED AND 증빙 없음(anti-join) — 즉 "아직 처리 안 한 미보유"만 잡힌다. null(미상)은 어느 옵션에도 안 잡힌다. 단일 쿼리 세미/anti-join으로 N+1 없음.
- 유일한 생애주기 부수효과: 강좌 미지정 질문에 증빙을 등록하면 조교가 고른 강좌가 질문의 course_id로도 배정된다 — 이후 자동 재매칭이 이미 배정된 강좌를 덮지 않아 증빙이 고아가 되는 것을 막는다(→ P-04).
- 소비처는 표시·필터·경고뿐: 교재유무·증빙은 AI 자동답변 게이트·크래딧·근거 채택(P-09)과 코드상 무접점이다("미보유면 자동답변 안 함" 게이트는 스펙에서 명시적 out of scope). 유일한 답변 접점은 수동 AI 생성 확인 모달의 경고 사유 하나다(→ P-16).
- 피처플래그는 회수됨: 릴리즈 토글(
textbook-ownership, AQ-1564)로 가렸다가 운영 노출 확정 후 전면 오픈(플래그·분기 제거). 현재 접근 통제는 검수 4역할(TEACHER·LEAD_ASSISTANT·ASSISTANT·INTERNAL_ADMIN) RBAC뿐. 메뉴는 조교 메뉴 그룹 "교재 증빙 관리"(/textbook-proofs). - PII 추가 저장 0: 관리 화면의 학생 표시명은 이미 마스킹된
questions.author를 조인해 얻는다 — 증빙 테이블에 개인정보를 새로 쌓지 않는다. - 증빙 해제는 관리 화면 단일 경로 (AQ-1789): 질문 화면 더보기 메뉴의 '증빙 해제' 경로가 프론트뿐 아니라 서버 엔드포인트
TextbookProofController.revoke(DELETE)까지 삭제돼 405가 된다. 질문 화면은 '구매 증빙' 등록·관리만, 해제(revokeById)는 교재 증빙 관리 화면에서만 한다(스키마 무변경).
근거
server/.../question/proof/TextbookPurchaseProof.java— (테넌트·학생키·강좌) UNIQUE · 등록/해제만.../question/proof/TextbookProofService.java— confirm/revoke · 강좌 배정 부수효과.../question/proof/TextbookProofQueryService.java·TextbookProofsController.java— 관리 화면 · 4역할 GUARD.../question/QuestionSpecifications.java·QuestionQueryService.java— 보유/미보유 필터 · 표시 플래그 파생.../ingest/MemberKeyExtractor.java·db/migration/V20260715010000__rehash_author_key_hmac.java— HMAC 학생키 · 전량 재계산.../question/AuthorDisplayName.java— [비공개] 마커 읽기 시점 정화docs/specs/AQ-1539-textbook-ownership/·AQ-1565-hmac-author-key/— 명세
학생 신고·AI 자동답변 게이트 정책
부적절 이용 신고는 게시물 단위로 쌓이고(게시물당 1회), "차단"은 별도 상태가 아니라 학생 누적 3건에서 파생된다. 자동 승격 파이프라인은 후보 쿼리에서부터 조용히 제외하는 하드 차단, 수동 생성은 확인 모달 경고뿐이다. 학생당 on/off 토글이던 블랙리스트를 전량 제거하고 이 누적 모델로 재설계했다.
경로별 게이트 강도
| 경로 | 게이트 | 강도 |
|---|---|---|
| 자동 승격 (펌프, → P-05) | findPumpCandidates 서브쿼리 — 누적 ≥ 3건 학생 제외 | 하드 차단 — 후보에서 조용히 빠지고 질문은 NEW로 남아 조교 수동 처리 |
| 수동 AI 생성 (promoteNow) | 서버 게이트 없음 · 클라이언트 확인 모달 (신고 ≥ 1건 또는 교재 미보유 → P-15) | 소프트 경고 — [생성]을 누르면 진행 · 경고 조회 실패 시 경고 없이 진행(best-effort) |
| 재생성 | 없음 | — |
결정 규칙
- 신고 단위는 게시물, 차단 단위는 학생: 신고는
abuse_reports에 게시물당 1회(UNIQUE(tenant_id, question_id), 선검사 + UNIQUE 백스톱 멱등)로 쌓인다. 상태 컬럼이 없다 — 차단 여부는 저장된 상태가 아니라 학생 누적 수에서 파생된다. - 임계 3건은 단일 상수:
AbuseReportPolicy.BLOCK_THRESHOLD = 3한 곳에서만 정의되고 펌프 후보 쿼리(JPQL 상관 서브쿼리)에 컴파일타임 결합된다. - 버킷 정규화: 학생 매칭은 학생키(
author_key, HMAC — → P-15)가 있으면 키끼리, 없으면 마스킹 표시명끼리만 합산한다. 키↔이름 교차 매칭 금지 — 표시명 충돌로 무고한 학생이 차단되는 것을 막는다. 둘 다 없으면 신고 자체가 거부(400)된다. - 회수 = hard delete·멱등·자동 해제: 회수로 누적이 3건 미만이 되면 별도 조작 없이 차단이 풀린다(파생값이므로 다음 펌프 주기부터). 회수된 게시물은 재신고 가능.
- 블랙리스트의 후신: 학생당 1건 on/off 토글(
author_blacklist_entries)은 재설계로 전량 제거됐다(테이블 DROP + 피처플래그 삭제). 새 신고 기능은 피처플래그 없이 즉시 활성 — 접근 통제는 RBAC뿐. - 권한은 검수 4역할: 등록·조회·관리 화면·회수 전부 TEACHER·LEAD_ASSISTANT·ASSISTANT·INTERNAL_ADMIN. 원 스펙은 관리·회수를 3역할(조교 제외)로 뒀으나 AQ-1633이 조교에 개방하고 메뉴도 조교 메뉴 그룹 "학생 신고 관리"(
/abuse-reports)로 옮겼다 — 현행 코드가 4역할이 정답(옛 스펙 문서와 불일치 주의). - 스냅샷 보존: 신고 행은 신고 시점의 학생키·표시명을 스냅샷으로 저장한다. 삭제된 질문의 이력은 제목 null로 유지된다(이력이 사라지지 않음).
근거
server/.../question/abusereport/AbuseReport.java— 게시물당 1회 UNIQUE · 스냅샷 · 상태 컬럼 없음.../question/abusereport/AbuseReportPolicy.java— BLOCK_THRESHOLD = 3 (단일 상수).../question/QuestionRepository.java(findPumpCandidates) — 펌프 하드 차단 서브쿼리 · 버킷 매칭.../question/abusereport/AbuseReportService.java·AbuseReportQueryService.java— 등록 멱등 · 집계·이력·회수.../question/abusereport/AbuseReportController.java·AbuseReportsController.java— 4역할 GUARDweb-client/.../qna/utils/aiAnswerWarnings.ts·AiAnswerWarningDialog.tsx— 수동 생성 통합 경고 모달docs/specs/AQ-1540-abuse-report/·AQ-1627-report-history-warning/— 명세
AI 답변지침 정책
개발자 프롬프트 배포 없이 조교(리드 이상)가 답변 품질 루프를 직접 돌린다. 지침은 두 종류 — 테넌트 전역 영구 지침과 재생성 1회용 일회성 지침 — 이고, 둘 다 시스템 프롬프트가 아니라 유저 메시지에 주입된다(캐시 프리픽스 보존). 프롬프트 카탈로그(P-13)와는 완전히 별개 저장소다.
두 종류의 지침
| 영구 지침 | 일회성 지침 (instruction) | |
|---|---|---|
| 저장 | answer_guidelines 테이블 | answers.applied_instruction 컬럼 — 정착 시 스냅샷만 (미영속 입력) |
| 스코프 | 테넌트 전역 — 모든 신규 AI 생성에 자동 반영 | 그 답변의 그 재생성 1회 |
| 주입 헤더 | [테넌트 답변 지침] | [조교 요청 지침] |
| 수명 | hard delete까지 상시 (soft delete·status 없음) | 1회 — 완료 시 영구 지침으로 승격 가능 |
| 권한 | CRUD 리드 이상 (서버 @PreAuthorize) | 진입 UI 리드 이상 · 서버 regenerate는 role 게이트 없음(미영속이라 영구화 게이트가 방어) |
결정 규칙
- 시스템이 아니라 유저 메시지: 지침은 근거 자료 뒤, 유저 메시지 끝에 합류한다(영구 → 일회성 순). 처음엔 시스템 프롬프트 append였다가 옮겼다 — 자주 바뀌는 지침이 시스템 프리픽스를 바꾸면 프롬프트 캐시가 매번 깨지기 때문(→ P-05의 캐시 breakpoint와 같은 동기). v1·v2 두 생성 경로가 같은 resolver를 공유한다.
- 카탈로그와 별개 저장소 (→ P-13): 카탈로그 리비전으로 넣는 안은 명시적으로 탈락 — 개별 등록·삭제·등록자 추적이 필요하고, 카탈로그는 INTERNAL_ADMIN 도메인이라 권한 모델이 안 맞는다. 지침은 base 시스템 프롬프트와 분리된 append 레이어다.
- base를 뒤집을 수 없다: 영구 지침 섹션에는 우선순위 가드 문구가 동봉된다 — 지침이 시스템 프롬프트의 기본 규칙과 충돌하면 기본 규칙이 이긴다(CON-5).
- 0건 = 자연 킬스위치: 지침이 없으면 빈 문자열이 반환돼 유저 메시지가 기존과 바이트 동일하다(회귀 0). 그래서 피처플래그가 없다.
- 상한은 길이만: 1건 500자(도메인 정적 검증 단일 권위 — DB CHECK·DTO 검증 없음). 테넌트당 개수 상한은 20건이었다가 무제한으로 제거됐다.
- 재생성 게이트는 좌표와 독립: 지침-only 재생성은 검토대기(REVIEW_PENDING)에서 통과한다 — 좌표 override의 추가 게이트는 좌표가 있을 때만 발동하고, 지침-only는 권위 좌표 플래그(authoritative)를 켜지 않아 좌표 폴백·문제번호 추론을 훼손하지 않는다. 근거는 그대로, 스타일만 다시 생성한다.
- 승격 루프: 완료 시 그 답변에
applied_instruction이 남아 있고 완료자가 리드 이상이면 "이번 지침을 영구 저장할까요?"를 묻는다. 저장은 best-effort — 실패해도 완료는 유지된다(완료가 지침 저장에 볼모 잡히지 않음).
근거
server/.../answerguideline/AnswerGuideline.java— 500자 도메인 검증 · hard delete.../answerguideline/TenantAnswerGuidelineResolver.java— userSection 조립 · 우선순위 가드 · 0건=빈 문자열.../qna/answer/AnswerGeneratorService.java— 유저 메시지 합류 지점 (composeRagUserMessage).../question/event/RegenerationOverride.java·AnswerRegenerationController.java— 일회성 지침 배선 · 좌표 독립 게이트.../answer/generation/AnswerGenerationSettler.java— applied_instruction 스냅샷.../answerguideline/AnswerGuidelineController.java·answer/complete/AnswerCompletionController.java— 리드 게이트 · 완료 시 승격docs/specs/AQ-1656-answer-guideline/— 명세 (spec.md가 최신 — README는 구버전 주의)
📖 용어사전
문서 곳곳의 용어를 한 곳에 모았습니다. 본문에서 점선 밑줄이 붙은 용어에 마우스를 올리면 같은 설명이 툴팁으로 뜹니다.