AI 응답이 마크다운 자체를 \`\`\` 으로 감싸서 오는 패턴 (시작만 있고 닫음 누락 포함)
때문에 explanation/AI 해설 영역이 raw 코드블록으로 보이는 회귀.
- frontend/lib/utils/mathMarkdown.ts: stripOuterFence helper.
- terminated wrap 처리 (inner 에 \`\`\` 추가 있으면 보존)
- unterminated 처리 (백틱 그룹 == 1 인 경우만 안전하게 unwrap)
- 본문 중간 정상 코드블록은 보존
- scripts/strip_outer_fences.py: dry-run + --apply 양 모드.
- 5개 필드 (question_text, choice_1~4, explanation, ai_explanation, content) 검사.
- 운영 결과 explanation 34건 unwrap 적용 완료, recount 0 검증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
이전 fix(4c26b91)는 query 회차명이 examRounds 에 없을 때 mode='new' 자동 전환했지만,
사용자 화면은 여전히 select 모드 노출 (캐시 또는 동선 이슈). 더 직관적인 방식으로 수정:
- onMount 의 mode='new' 자동 전환 제거.
- select dropdown markup 에 query 회차가 examRounds 에 없으면 "(신규)" 라벨 옵션 추가.
- 사용자는 select 모드 그대로 유지하면서 신규 회차도 보임. 폼 제출 시 그 값 그대로 박힘.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 카드 페이지의 [새 회차 시작] → /questions/new?exam_round=...&start_qnum=1 진입 시
query 의 회차명이 기존 examRounds 에 없으면 (신규 회차라 등록된 문제 0개) select dropdown
옵션에 매칭이 없어서 회차 정보가 표시 안 되는 회귀.
onMount 에서 query 회차명이 examRounds 에 없으면 mode='new' + f_exam_round_new prefill.
사용자가 신규 회차로 입력한 이름이 그대로 폼에 박힘.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
학습 의미가 회차 간 반복성 — 차단/제거가 아니라 패턴 표시 frame.
- 신규 service `related_types.py` — threshold/회차 필터/round_count 계산 공유
- REPEAT >= 0.95 / SIMILAR 0.88~0.95
- 회차 조건 백엔드 강제 (자기 자신/같은 회차/null exam_round candidate 제외)
- round_count: related_count == 0 → 0 (현재 회차만 1로 채우지 않음)
- GET /study-questions/{qid}/related-types — 단건 분류 (repeat_questions / similar_questions)
- POST /study-topics/{tid}/related-types-bulk — 카드 배지용 카운트 batch
- 비교 대상 = 토픽 전체 ready pool (입력 qid 끼리 비교 X)
- 응답 키 보존 — 권한 없음/임베딩 미준비 등도 (0,0,0,0)
- 보기 페이지: PR-11 비슷한 문제 토글 제거 + 🔥 반복 출제 / 🧩 유사 유형 두 섹션 자동 노출
- 헤더 = round_count "N개 회차", 본문 위 = related_count "관련 N문제"
- source_status / source_exam_round 안내 분기
- 결과 페이지 (틀린/모르겠음 카드): bulk 호출 후 round_count >= 2 일 때만 배지
- 통합뷰 회차 expand 시 lazy bulk 호출 — 같은 회차 캐시
- 기존 /similar 엔드포인트 유지 (raw 디버깅용)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tailwindcss/typography 플러그인 미설치 — prose prose-sm prose-invert
max-w-none 클래스가 무효라 결과 페이지(특히 모르겠음·틀림 카드)와 풀이 페이지의
질문 본문/사용자 해설/AI 해설/분야 설명에서 마크다운 스타일링이 안 먹었음.
이 codebase 의 정식 마크다운 클래스는 src/app.css 에 정의된 .markdown-body
(h1~h4, ul/ol, blockquote, code, pre, table, hr 등 완비). 모든 renderMathMarkdown
컨테이너에 markdown-body + math-area 두 클래스 적용.
영향 파일:
- review/+page.svelte (풀이 중 본문)
- quiz-sessions/[sid]/+page.svelte (결과 카드 expand 시 본문/해설/AI/분야설명)
- questions/[qid]/+page.svelte (보기 페이지)
- questions/[qid]/edit/+page.svelte (편집 페이지의 AI 풀이 미리보기)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 통합뷰 문제 섹션: 평면 리스트 → 회차별 아코디언 (디폴트 모두 접힘)
- 회차 정렬: "YYYY년 N회" 파싱 → year desc / round desc (localeCompare 단독 회귀 차단)
- 회차 행 라벨: "총 시도 N건 · 마지막 결과: 정답 K / 오답 M" (누적/마지막 혼동 회피)
- 회차 미지정 그룹은 노란 톤 + 안내, 표시 문자열은 UI 전용 (원본 NULL 분리)
- 본문 / [편집] 링크 구조 분리로 이벤트 버블링 충돌 차단
- /study/topics/{tid}/questions/{qid} 신규 — KaTeX 마크다운 렌더 + 정답 표시 +
AI 해설 5상태 (idle/loading/success/stale/error) + 비슷한 문제 + prev/next
- prev/next URL 직접 접근 — 단건 fetch + 같은 회차 목록 fetch 자체 처리
- page_size=200 만땅 + total>200 시 토스트 안내 (조용히 자르지 않음)
- 사용자 입력 해설/이미지 없으면 섹션 숨김, exam_round NULL 이면 prev/next 비활성
- StudyTopicQuestionSummary 에 exam_question_number 추가 (회차 안 정렬 키)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-10 결과 페이지에서 GET /quiz-sessions/{sid} 가 500. 이미지 batch 호출에서
존재하지 않는 컬럼 position 사용 → AttributeError. 기존
_images_for_questions_batch 헬퍼 (sort_order 기준 + served_url 포함) 재사용.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- study_quiz_sessions 테이블 (한 토픽 in_progress 1개 partial unique)
- study_question_attempts 에 quiz_session_id + reviewed_at 컬럼
- 풀이 진행률 서버 단일 진실 (cursor) — 나갔다 와도 이어풀기 가능
- 통합뷰: 진행 중 카드(이어풀기) + 최근 완료 결과 카드(미확인 N건 배지)
- 신규 /quiz-sessions/[sid] 결과 페이지 (3 카테고리 + AI 해설 + 분야 설명 + 학습완료 토글)
- /review 페이지는 풀이만, 마지막 문제 풀이 후 결과 페이지로 redirect
- 마이그레이션 206~209 (single-statement, asyncpg 호환)
- API: POST/GET/PATCH /study-topics/{tid}/quiz-sessions(/{sid}),
PATCH /study-question-attempts/{aid}/review-mark
- AttemptCreate.quiz_session_id 추가 — submit_attempt 가 같은 트랜잭션에서
세션 cursor + count 증가, 마지막이면 status='done' + finished_at
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 라벨 "복습 시작" → "문제풀이"
- attempts.outcome 컬럼 + selected_choice nullable (correct/wrong/unsure)
- 풀이 중 정답·해설·AI·비슷한 문제 모두 비노출, 답 클릭 시 자동 진행
- "모르겠음" 5번째 옵션 추가
- 결과 화면 = 정답/틀린/모르겠음 3 카테고리 탭, 카드 클릭 expand
- 틀린 → PR-3 AI 해설 (RAG)
- 모르겠음 → 분야(subject+scope) 설명 AI 즉석 생성 + 캐시 (PR-9 신규)
- 분야 설명 RAG: 매핑 documents 청크 + 같은 분야 다른 문제·해설 → bge-reranker
- 마이그레이션 200~205 (single-statement, asyncpg 호환)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CREATE TABLE + CREATE INDEX 한 파일에 들어가 asyncpg prepared statement
원칙 위반 (cannot insert multiple commands). 198 = TABLE 만, 199 = idx 분리.
첫 시작에서 198 적용 fail 로 init_db 트랜잭션 전체 롤백 → 컨테이너 시작 후
schema_migrations 미반영 + study_question_images 테이블 미생성. 본 fix 후
다음 시작 시 198+199 순차 적용.
문제별 N개 이미지 첨부. 회로도/그래프 등이 필요한 시험 문제 지원.
입력·편집·복습 모두에서 표시.
데이터 모델 (migration 198):
- study_question_images: id, user_id FK CASCADE, study_question_id FK CASCADE,
file_path, file_size, mime_type, sort_order, created_at
- partial idx (study_question_id, sort_order, id)
저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext}
file_watcher 가 보는 PKM 경로와 분리 — 자동 인덱싱 안 됨.
API:
- POST /api/study-questions/{qid}/images (multipart, MIME PNG/JPEG/WEBP/GIF,
10MB/파일 제한, sort_order 자동 max+1)
- GET /api/study-questions/{qid}/images/{img_id}/raw (FileResponse, Bearer 인증)
- DELETE /api/study-questions/{qid}/images/{img_id} (DB row + 파일 시스템 정리)
- StudyQuestionResponse / ReviewQuestionItem 응답에 images 배열 포함
- StudyQuestionSummary 응답에 has_images bool 추가
프론트:
- 신규 lib/components/ImgAuth.svelte — Bearer 인증 endpoint 의 이미지를 fetch +
blob URL 로 변환해 <img> 표시. unmount 시 URL.revokeObjectURL.
- /questions/new: 입력 폼에 이미지 dropzone (client-side 보유) → POST
/questions 받은 qid 로 자동 multipart 업로드. "저장 후 계속 입력" 시 reset.
- /questions/[qid]/edit: 별도 카드 — 기존 이미지 grid + 추가/삭제. 즉시 업로드.
- /review: 문제 본문 아래 이미지 grid (max-h-72 object-contain).
- 모든 표시는 ImgAuth 컴포넌트 — accessToken 만료 케이스 대비.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
이전 fix(effect→onchange)에도 race 재발 (id 306,307 qnum=1,2 로 또 들어감).
근본 해결 — 클라이언트의 f_qnum 표시값과 실제 저장값을 분리.
변경:
- f_qnum_user_edited dirty flag 추가
- input 에 oninput → user_edited=true (사용자가 직접 박스 수정한 경우)
- onMount fallback / onRoundChange / applyNewRound / 저장 후 → user_edited=false
- POST body 의 exam_question_number: user_edited=true 면 명시 전송, false 면
null → 서버가 같은 회차 max+1 자동 채움 (PR-6 의 기존 서버 로직)
- POST 응답의 실제 저장 qnum 으로 화면 동기화 (saved.exam_question_number)
→ 표시값이 어긋났어도 저장 후 정확하게 갱신
- applyNewRound 에서 이미 존재하는 회차명 입력 시 next_question_number 적용
(사용자가 dropdown 대신 새 회차 모드로 같은 이름 다시 입력해도 1번부터 다시 시작 X)
이제 클라이언트가 어떤 표시값을 보여주든 실제 저장은 항상 정확. 사용자가
직접 박스를 수정한 경우만 명시 전송.
$effect 가 examRounds fetch 전 첫 실행되며 f_qnum=1 로 reset 하는 race 가
이전 fix(lastExamRound sync) 만으로 완전히 막히지 않음. effect 자체를
제거하고 select onchange + applyNewRound 에서 명시 호출하는 onRoundChange()
로 변경. examRounds 미적재 시 (length=0) 는 skip — onMount fallback 이 처리.
이제 흐름:
- 진입 (sessionStorage prefill 만 있음) → onMount await fetch 후 fallback
으로 next_question_number 적용
- dropdown 으로 회차 변경 → onchange={onRoundChange}
- 새 회차 입력 → applyNewRound() 안에서 직접 f_qnum=1
- examRounds 변경 (저장 후 refreshExamRounds) → 어떤 자동 reset 도 발생 안 함
$effect 가 examRounds fetch 전에 첫 실행되면 found=undefined → f_qnum=1
로 강제 reset. 그 후 examRounds fetch 완료해도 effect 재실행 안 돼서
사용자가 그대로 입력 시작 → 회차 안 문항 번호 중복 (1,2,3,1,1,...) 발생.
수정:
- applyQueryParams() 가 start_qnum 명시 여부 boolean 반환
- onMount 에서 await loadTopicAndRounds() 후 explicit start_qnum 없고
f_exam_round 가 있으면 examRounds.find().next_question_number 명시 적용
- lastExamRound 를 현재 값으로 sync — $effect 첫 실행이 또 reset 안 함
이미 발생한 데이터(중복 qnum) 는 사용자 직접 정정 또는 별도 SQL 보정 필요.
이 fix 후 새로고침/재진입 시에는 정상 next 적용.
기사시험 문제·해설에 √, ρ, R̄, α/β/γ, ㎥, $\\sqrt{...}$ 같은 수식이 자주
들어가는데 기존 plain text 표시는 LaTeX 문법이 그대로 노출되거나 깨짐.
표시·미리보기 영역에서만 KaTeX 렌더링 (입력 textarea 는 plain text 유지).
의존성: marked-katex-extension + katex (frontend/).
공통 유틸 frontend/src/lib/utils/mathMarkdown.ts:
- renderMathMarkdown(text): block 렌더 (문제 본문·해설·AI 해설용)
- renderMathMarkdownInline(text): inline parseInline (보기 1~4 button 안)
- 별도 marked 인스턴스 사용 → 글로벌 marked 영향 없음
- $...$ inline / $$...$$ block 모두 지원
- KaTeX throwOnError=false → 잘못된 수식은 빨간색 fallback (페이지 안 깨짐)
- DOMPurify USE_PROFILES.html + ADD_ATTR style/aria-hidden + FORBID
script/iframe/onclick 등 — XSS 차단 유지
- 실패 시 text-only fallback (HTML escape)
CSS (app.css):
- .math-area .katex-display { overflow-x: auto } — 모바일 가로 overflow
생기면 수식만 가로 스크롤, 페이지 레이아웃 보존
- .katex { white-space: nowrap } — KaTeX 자체 줄바꿈 방지
적용 위치 (표시·미리보기만, textarea 무변경):
- review: 문제 본문, 보기 1~4(inline), 답 제출 후 explanation, AI 해설
- edit: AI 해설 본문 (기존 marked → 통일)
- new 화면 preview / 통합뷰 카드 snippet: 무변경 (1차 보류, 사용자 요청 시 추가)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AI 응답에서 dataOrigin='knowledge' 같은 doc_purpose enum 값이 data_origin
컬럼에 잘못 매핑되면 asyncpg InvalidTextRepresentationError 발생. 같은
classify_worker session 의 후속 autoflush 호출이 PendingRollbackError
로 cascade 되어 batch 안 다른 문서까지 모두 실패.
doc_purpose 처럼 enum 허용값(work/external) 검증 후 박도록 수정. 외 값은
skip (data_origin NULL 유지). 가스기사 토픽 결손 15건의 RAG 결손 root cause.
기존엔 whitespace-pre-line 으로 plain 표시 → '**굵게**' 같은 markdown 문법이
그대로 노출. DocumentViewer 와 동일한 marked + DOMPurify 패턴 적용. prose
타이포그래피 클래스로 list/heading/inline 코드 스타일 자동.
기사시험 회차별 100문제 채워가기 시나리오. 문제 입력 페이지를 단순 폼에서
"회차 진행률 추적·재개" 도구로 보강.
데이터 모델 (migrations 195~197):
- study_topics: exam_round_size INT CHECK 1~300 (회차당 문항 수, NULL=미설정)
+ exam_subjects JSONB DEFAULT '[]' (과목 리스트, 입력 페이지 드롭다운 옵션)
- study_questions: exam_question_number SMALLINT CHECK >0 (회차 안 문항 번호)
- partial idx (study_topic_id, exam_round, exam_question_number) WHERE
deleted_at IS NULL AND exam_round IS NOT NULL — 회차별 max+count 고속화
백엔드:
- POST /questions: exam_round 명시 + exam_question_number 미명시 시 서버가
같은 토픽·회차의 max+1 자동 채움
- 신규 GET /api/study-topics/{id}/exam-rounds: 회차별 진행률 집계
{exam_round_size, items: [{exam_round, question_count, max_question_number,
next_question_number, is_complete}]}
- StudyTopic Create/Update/Response/Meta 에 exam_round_size·exam_subjects
- StudyQuestion Create/Update/Response 에 exam_question_number
- exam_question_number 변경은 embedding stale 트리거에서 제외 (의미 영향 없음)
프론트:
- 토픽 생성/편집 모달: "시험 정보" 섹션 (회차당 문항 수 + 과목 리스트
+추가/제거 칩)
- /study/topics/[id]/exam-rounds 신규 페이지: 회차 카드 + 진행 바 +
[N번부터 이어서] 버튼 + [새 회차 시작] 모달
- 통합뷰 문제 섹션 헤더에 [회차 보기] 진입점
- /questions/new 페이지 전면 개편:
- 시험명 = topic.name 자동 prefill
- 과목 드롭다운 (topic.exam_subjects + 기존 distinct, "직접 입력" 토글)
- 회차 드롭다운 (기존 distinct + "새 회차")
- 문항 번호 자동 (회차 선택 시 next_question_number, 새 회차 = 1)
- 진행률 바 (현재/exam_round_size)
- 출처/메모 자동 합성 "회차 N번" (수정 가능)
- "저장 후 계속 입력" → 본문/보기/정답 reset, 회차 유지, 문항 +1
- 회차 변경 감지 시 문항 번호 1로 reset
- exam_round_size 도달 시 회차 강조 + "저장 후 계속 입력" 비활성
- query string ?exam_round=&start_qnum= 지원 (회차 목록에서 재개 진입)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
study_questions 자동 임베딩(PR-4 bge-m3 1024차원) 기반 cosine 유사도
top-K. 26B 호출 없음, vector search 만. additive UI — 기존 입력·복습
흐름 영향 없음.
백엔드: GET /api/study-questions/{id}/similar?limit=5&topic_only=true
- 자기 자신/soft-deleted/embedding_status!=ready 제외
- topic_only=true (default) 면 같은 study_topic 안에서만
- 응답: items[{id, question_text(80자 truncate), subject, scope, exam_round,
similarity(1-cosine), attempt_count, last_correct}], source_status, source_id
- 현재 문제 embedding 미생성/실패/stale 시 빈 결과 + source_status 안내
- attempt_count + last_correct batch 조회 (N+1 회피)
프론트:
- 편집 화면(/questions/[qid]/edit): 페이지 로드 시 자동 GET /similar →
카드 5개. 본문 truncate + subject/scope/exam_round + 유사도 % + attempt
배지 (정/오답 아이콘). 카드 클릭 시 해당 문제 편집 페이지로 이동.
- 복습 화면(/review): 답 제출 후 "비슷한 문제 보기" 토글 → expand 5개 카드.
같은 형태. 다음 문제로 cursor 이동 시 자동 닫힘.
- 통합뷰: 변경 없음 (이미 편집 진입점이 시각적 cue 역할).
source_status별 안내 (pending/failed/stale/none): 임베딩이 아직 준비 안 됐을
때 "약 1분 안에 cron 자동 처리" 메시지 노출.
후속 PR 예정: subject/scope 자동 추천(PR-6), 오답노트/통계(PR-7),
AI 풀이 idle batch(PR-8). 현재 PR-5 는 vector search 결과 노출까지만.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
문제 본문 + 보기 1~4 → bge-m3 1024차원. status 자체가 큐 역할 (별도 큐
테이블 없음 — ProcessingQueue 인프라 영향 0). APScheduler 1분 cron 이
status in {none, failed, stale} 행을 batch=10 처리. 새 문제는 default
'none' 으로 자동 backfill.
데이터 모델 (migrations 193~194):
- study_questions: embedding vector(1024), embedding_status VARCHAR(20)
DEFAULT 'none' (none/pending/ready/failed/stale), embedding_updated_at,
embedding_model
- HNSW partial index (vector_cosine_ops) WHERE deleted_at IS NULL AND
embedding IS NOT NULL — bge-m3 cosine 기준, documents.embedding (ivfflat)
과 ops 일관
재계산 트리거: question_text / choice_1~4 변경 시 ready→stale 자동.
correct_choice / explanation / subject / scope 변경은 재계산 안 함
(의미 검색에 영향 없음).
워커 (workers/study_question_embed_worker.py):
- race-safe pending 마킹 (조건부 UPDATE WHERE status IN none/failed/stale)
- AIClient.embed(text) bge-m3 호출, 15s timeout
- 실패 시 status='failed', 직전 embedding 보존, 다음 cron 틱에 재시도
- 본문 = "문제: ...\n보기:\n1. ...\n2. ...\n3. ...\n4. ..." (subject/scope
의도 제외 — 분류명이 의미 검색 노이즈)
후속 PR 예정: 비슷한 문제 검색 UI / 중복 입력 감지 / RAG 정확도 향상 /
오답 클러스터링. 본 PR 은 임베딩 저장·재계산·backfill 까지만.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
복습 답 제출 후 또는 편집 화면에서 사용자가 명시적으로 누를 때만 AI 가
4지선다 풀이 생성. 자동 일괄 생성 금지 (하루 100문제 입력 시 MLX 부하·
잘못 입력 문제 해설 위험).
데이터 모델 (migrations 191~192):
- study_questions 4 컬럼 추가: ai_explanation TEXT, ai_explanation_status
VARCHAR(20) DEFAULT 'none' (none/pending/ready/failed/stale),
ai_explanation_generated_at, ai_explanation_model
- partial idx (study_topic_id, ai_explanation_status) WHERE status != 'none'
PATCH stale 자동 전이: question_text/choice_*/correct_choice 변경 시
status='ready' 만 'stale' 로. 본문은 보존, UI 배지 + "다시 생성" 동선.
신규 엔드포인트: POST /api/study-questions/{id}/ai-explanation
- regenerate=false + ready/stale → 캐시 즉시 (MLX 호출 없음, is_stale 플래그)
- pending → 409 (race-safe 조건부 UPDATE 로 동시 호출 차단)
- 그 외 → 새 생성
RAG 입력 풀:
- 1순위: study_topic 매핑 documents 청크 + ai_summary, bge-reranker top-5
- 2순위: 같은 토픽 다른 questions (자기 자신 제외, ai_explanation 은 ready
상태만 포함 — 재귀적 hallucination 방지), reranker top-3
- 제외: 필기 OCR / 외부 웹 / Premium 모델
모델: Mac mini MLX gemma-4-26b primary 단독. get_mlx_gate() Semaphore(1) 경유,
30s timeout. 실패 시 status='failed' + 직전 본문 보존.
프롬프트 (app/prompts/study_question_explanation.txt): 자료 우선순위·인용
형식·할루시네이션 방지 절대 규칙 (법령명·조항·수치·표준 번호 단정 금지,
"자료에서 확인되지 않음" 명시).
프론트:
- 복습 화면 답 제출 후 인라인 expand. status별 버튼 분기 (ready 캐시 /
stale "이전 풀이"+"다시 생성" / failed "다시 시도")
- 편집 화면 별도 카드. 상태 배지 + "이전 풀이 보기" / "다시 생성" 분리
- 참고 근거 토글 (source_type 별 아이콘 📄/❓ + 제목 + snippet)
후속 PR 보류: 오답노트/통계, AI 일괄 백그라운드 생성, 필기 OCR RAG,
Premium/Claude 재생성, /api/search/ask retrieval scope 통합.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
가스기사처럼 한 워크스페이스에 273건 자료가 묶이면 평면 리스트로 쭉 나열
되어 통합뷰가 무너졌음. /study/topics/[id] 자료 섹션을 자료실 카테고리
경로 기반 트리로 그룹핑하고 노드별 접기/펼치기 도입. 기본값 모두 접힘.
백엔드: StudyTopicDocumentSummary 에 library_paths(`@library/<path>` 태그
에서 prefix 제거) 필드 추가. 그룹핑은 첫 path 만 사용 (단순화).
프론트: documents 를 path segment 별로 트리 빌드 → snippet 재귀 렌더링.
헤더에 "자료 N개 · 카테고리 K개 · [모두 펼치기/접기]" 컨트롤. 분류 없는
자료는 "분류 없음" 그룹으로 별도. 자료 0건 path 는 자동 누락.
필기/문제 섹션은 분류축이 달라(certification/subject vs subject) 동일
트리 못 쓰므로 본 PR 범위 밖. 후속에서 패턴 일관성 검토.
study_topic 워크스페이스에 4지선다 문제은행 자산 트랙 추가. 기사시험 필기
대비 시나리오 — 빠른 반복 입력 + 과목별 균등 추출 복습 + 정오답 누적.
데이터 모델 (migrations 186~190):
- study_questions: study_topic 1:N, soft delete, is_active 토글, correct_choice
SMALLINT CHECK 1~4
- study_question_attempts: 답 제출 1행 누적. study_question_id FK는 ON DELETE
RESTRICT (이력 보존 원칙 — hard delete 실수로 풀이 기록 소실 차단)
설계 원칙:
- 문제 삭제는 API 에서 soft delete only. attempts FK RESTRICT 로 DB 레벨도 보호
- correct_choice 변경 시 기존 attempts.is_correct 재계산 안 함 (시점 사실 보존)
- 복습 default = 과목별 target_per_subject(20) 무작위 균등 추출. 한 과목이
부족하면 가용한 만큼만
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제 (latest-wrong, ever-wrong 아님)
- 출제 응답에서 정답·해설 비공개. 답 제출 시점에만 노출
- subject/scope 강한 enum 미사용 (자유 텍스트, 자동완성은 후속)
API: /api/study-topics/{id}/questions, /review/questions, /api/study-questions/{id},
/attempt. 통합뷰(/study-topics/{id}) 응답에 sections.questions / stats.question_count
추가. 기존 question_set_count 는 후속 PR(회차/모의고사 묶음)용으로 보존.
프론트: /study/topics/[id]에 문제 섹션 + "새 문제"/"복습 시작" 진입.
/questions/new (저장 후 계속 입력 + sessionStorage persistent),
/questions/[qid]/edit (정답 변경 시 attempts 재계산 안 됨 안내 배너),
/review (시작 옵션 → 풀이 → 마지막 요약).
후속 PR 예정: 오답노트/취약 과목 리포트, AI 해설/클러스터링, spaced
repetition, 이미지 OCR 입력, CSV import, study_question_sets 묶음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
페이지네이션으로 여러 페이지에서 전체선택을 누적하면 100건 초과로 백엔드
StudyTopicDocumentLinkRequest 의 max_length=100 위반 → 422. 백엔드 제약은
abuse 방어용으로 유지하고, 프론트에서 100개씩 chunk 로 분할 POST + 결과
카운트 누적해 단일 토스트로 보고.
기존 page_size=50 으로 박혀 있어서 한 카테고리에 50건 초과 자료가 있을
때 51번째부터 안 보였음. page_size 를 백엔드 max(100)로 올리고 이전/다음
페이지 컨트롤 + 총 건수/페이지 표시 추가. 100건 초과 시 모달 상단에
"좌측 트리 폴더+ 아이콘으로 한 번에 추가" 안내 배너.
자료 추가 모달이 1건씩 검색·체크박스만 지원해서 같은 카테고리에 자료가
많을 때 비효율적. /api/library/tree 의 카테고리 구조를 모달 좌측에 띄우고,
노드 옆 아이콘 한 번으로 그 path 하위 자료 전체를 한 번에 매핑.
백엔드: POST /api/study-topics/{id}/documents/by-path 추가. user_tags
@library/<path> prefix 매칭(documents.py 의 list_library_documents 와
동일한 EXISTS 쿼리)으로 100건 limit 우회. 응답은 linked_count /
skipped_existing_count / total_in_path 카운트만 노출.
프론트: 모달을 max-w-4xl + grid(트리/자료) 레이아웃으로 개편. 트리 노드
클릭 = 우측 자료 목록 path 필터링, 노드 옆 FolderPlus 버튼 = 즉시 일괄
추가. 검색·체크박스·전체선택은 그대로. 모바일은 트리가 상단 max-h-40vh
영역으로 stack.
필기 세션과 자료(library document)를 한 학습 주제(예: 가스기사) 아래로 묶는
1차 컨테이너. 향후 단어장/오디오/문제세트 등 학습 자산이 같은 묶음으로 들어올 수
있도록 응답 구조(sections + stats)를 dict 기반으로 설계.
데이터 모델 (migrations 179~185):
- study_topics: user_id × name partial unique (active 행만), soft delete
- study_sessions.study_topic_id: 1:N nullable FK (ON DELETE SET NULL)
- study_topic_documents: 자료 N:M 매핑 (user_id 반정규화로 권한 격리)
설계 원칙:
- documents.category(자료실 UI 축)와 직교 → 자료실 facet/카테고리 미터치
- StudySession.certification/subject/topic 보존 (세부 메타로 계속 사용)
- study_type은 느슨한 분류 (강한 enum 미사용, jlpt_n3 등 확장 여지)
- polymorphic study_topic_items 영구 금지 → 자산 타입별 조인 테이블 추가 방식
API: /api/study-topics CRUD + /by-document/{id} + 자료/세션 매핑 엔드포인트.
프론트: /study/topics 목록 + /study/topics/[id] 통합 뷰(필기·자료 두 트랙) +
write 폼에 워크스페이스 드롭다운 + study hub 진입 카드.
후속 PR-2 어학 UX, PR-3 오디오 자산, PR-4 AI retrieval scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 분석: 수치 튜닝 무관해 보이면 pressure 입력 자체가 안 들어오는 케이스. perfect-
freehand 옵션 변경 의미 없음. 먼저 PointerEvent.pressure 가 실제로 변동하는지 확인 필요.
진단 패널 (?debug=1) 에 추가:
- PRESSURE PIPELINE 섹션:
· raw = PointerEvent.pressure 원본
· mapped = getStrokePressure 의 inputP (raw 매핑 또는 속도 fallback)
· final = fixedPressure update 후 perfect-freehand 에 전달되는 값
· raw min/max — 세션 내 raw pressure 범위 (사용자가 펜 강약 시도 후 확인)
- tiltX, tiltY, ptr width/height, buttons — Pencil 추가 입력 필드.
판별:
- raw 가 항상 0.5 또는 1.0 → 디바이스/브라우저에서 pressure 미전달.
현재 환경에서는 속도 기반 fallback 이 유일.
- raw 가 변동 (0.1~1.0) 인데 mapped/final 이 일정 → 우리 코드가 무시 중.
- raw + mapped + final 모두 변동 → perfect-freehand 가 무시 (thinning, simulatePressure).
사용자 보고: 빠른 stroke 가 점선처럼 끊겨 보임 ("선이 이어지지 않음").
원인: 속도/raw pressure 기반 inputP floor 가 0.25 ~ 0.3 → thinning 0.5 적용 시
outline 폭이 size × 0.5 미만 → 픽셀 단위 정렬 안 되면 dot 패턴.
Fix:
- 속도 기반 inputP floor 0.25 → 0.5. 가장 빠른 stroke 도 size × 0.825 폭 보장.
- raw pressure 매핑 0.3~1.0 → 0.5~1.0. min 폭 보장.
- thinning 0.5 → 0.35. 변동 폭 줄임 (min 폭 더 보장).
Trade-off: 굵기 변동 폭 줄어듦. 하지만 사용자 우선순위 = visual continuity.
inputP 0.5~1.0 + thinning 0.35 → 폭 변동 ±17.5% (충분히 보임).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 빡세게 눌러도 굵기 차이 거의 안 남.
원인 분석:
1. raw pressure 0.1~0.99 만 활용했는데 dynamic range 그대로 → 변동 작음.
2. 속도 기반 변동 폭 0.3~1.0 작음 + dist/25 비율 작음.
3. INTENT alpha 0.25 너무 느림 → 강한 변화도 stroke 내내 못 따라감.
4. thinning 0.4 변동 폭 부족.
Fix:
- raw pressure 0.1~0.99 → 0.3~1.0 으로 매핑. dynamic range 확장.
- 속도 기반 0.25~1.0 + 비율 dist/18. 변동 폭 키움.
- 3단계 threshold:
· dev < 0.15 (잡음) → alpha 0.03 (fixed 유지)
· 0.15 ≤ dev < 0.3 (의도적) → alpha 0.5 (이전 0.25 → 빠르게 따라감)
· dev ≥ 0.3 (매우 큼, 빡세게 누름) → 즉시 update (alpha 1.0)
- thinning 0.4 → 0.5. 폭 변동 더 명확.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고 통합:
1. "기본이 두꺼움" — 평소 stroke 가 두껍게 느껴짐
2. "힘 줘도 일정 이상 안 두꺼워짐" — max 굵기 부족
3. "약하게 그리면 점선" — min 폭 너무 작음
4. "압력 정해지면 stroke 그 굵기 유지" — Notability felt = stroke 내부 일정
5. "의도적 압력 변화 시 굵기 변동" — 단 명확한 변화는 따라옴
Fix:
- baseSize 6 → 7. max 두꺼움 보장.
- WIDTH_FACTOR { 0.4, 0.6, 1.0 } → { 0.35, 0.5, 0.85 }. 기본 살짝 가늘게.
결과 normal = 7×0.5 = 3.5 (이전 3.6 비슷), thick = 5.95 (충분히 두꺼움).
- thinning 0.55 → 0.4. fixedPressure 가 잡음 흡수하니 폭 변동 더 키워도 안정.
Smart pressure (getStrokePressure):
- raw pressure 정상 시 → 그것 사용 (Pencil pressure 활용).
- 비정상 시 → 점 간 거리 기반 속도 추정 (mouse / Pencil 미지원 빌드).
- fixedPressure: stroke 시작 시 inputP 로 초기화. 그 후 hybrid update:
· 변동 < 15% (잡음/평소) → alpha 0.03 (거의 무시) → 균일 굵기
· 변동 ≥ 15% (의도적 변화) → alpha 0.25 (빠르게 따라감) → 굵기 변화
- simulatePressure: true → false. getStrokePressure 가 자체 처리.
기존 smoothPressureWindow 제거. fixedPressure 가 동일 역할 + Notability felt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 마우스도 Pencil 도 굵기 변화 없음. iPadOS Safari 의 일부 빌드에서
Apple Pencil PointerEvent.pressure 가 정상 도달 안 하거나 일정 → 우리 thinning 0.55
적용해도 input pressure 가 일정이라 효과 0.
Fix: perfect-freehand 의 simulatePressure: true 항상.
- 점 간 속도 (거리) 기반 자동 pressure 추정.
- 빠른 stroke = 가늘게, 천천히 = 굵게.
- Notability 도 동일 felt (속도 기반 ink flow).
- pen 의 실제 pressure 는 무시되지만, 들어오지 않는 빌드에서는 어차피 무관.
stroke 별 simPressure 필드 / serializableStrokes 로직은 유지 (향후 분기 옵션 위해).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
해석 오류 정정: 사용자 "필압 너무 차이나" = "차이가 너무 *안* 난다" 의미였음. 종이
만년필 reference (4 stroke 굵기 차이 5:1) 가 *원하는* 수준이었던 걸 반대로 해석해서
thinning 줄였던 회귀.
Fix:
- thinning 0.18 → 0.55. 폭 변동 ±55%.
- MIN_PRESSURE 0.4 → 0.25. dynamic range 넓게 (0.25~1.0).
- PRESSURE_WINDOW 12 → 8. 압력 변화 빠르게 따라옴.
조합 시 실제 굵기 비율: 약한 stroke ≈ size×0.42, 강한 stroke ≈ size×1.0 → 약 2.4:1.
종이 reference (5:1) 보다는 약하지만 만년필 felt 명확.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 필압이 너무 차이남 (stroke 마다 굵기 들쭉날쭉) + stroke 끝에 dot 점.
종이 만년필 reference 와 비교 시 우리 앱이 작은 압력 변동에 너무 민감.
Fix:
- thinning 0.28 → 0.18. 폭 변동 ±18%. 작은 압력 차이가 큰 굵기로 변환되지 않음.
- PRESSURE_WINDOW 8 → 12. 평균 더 안정 → stroke 간 일관성.
- cap: true → false. round cap 이 짧은 stroke 에서 dot 처럼 보이던 회귀 제거.
taper 가 끝을 자연스럽게 마무리하므로 cap 불필요.
- start.taper size*0.15 → 0.2. end.taper size*0.3 → 0.4. cap 없으니 taper 가 직접
마무리 — 살짝 더 길게 두어 만년필 nib felt 유지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 글자가 작아지면 제대로 인식 못 함 (스크린샷의 작은 "유" 가 부서져 보임).
원인:
1. streamline 0.86 = 입력 점이 펜 위치보다 lazy 하게 따라옴. 긴 stroke 에선 부드러움
이지만 짧은 stroke (작은 글자) 에선 lag 누적 > stroke 길이 → 펜이 떨어져도
stroke 가 못 따라감 → 부서진 dot 처럼 보임.
2. start.taper size*0.3 + end.taper size*0.5 = 짧은 stroke (length ≈ size × 1~2) 의
거의 전체가 taper 영역 → stroke 가 모두 가늘게 그려짐.
Fix:
- streamline 0.86 → 0.75. 부드러움 + 짧은 stroke 정확성 균형.
- start.taper size*0.3 → 0.15.
- end.taper size*0.5 → 0.3.
만년필 nib felt 는 유지 (taper 비율 그대로) 하되 영향 길이 줄임.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: Notability 의 그 맛이 안 남. 만년필 nib 의 핵심 felt 누락.
Notability 의 만년필 stroke 특징:
- 시작 = nib 이 종이에 닿는 순간. 짧게 가늘게 시작.
- 끝 = nib 이 종이에서 떨어짐. 좀 더 길게 가늘어짐.
- ease-out 곡선: 빠르게 굵어졌다 천천히 안정.
Fix:
- start.taper: size * 0.3, easing: t * (2-t) (ease-out)
- end.taper: size * 0.5, easing: t * (2-t)
- cap: true 유지 (round 끝점)
이전에 taper 가 흔들림 원인이라 뺐었지만, 그건 thinning 0.18 + 보간 점 micro 변동 +
EMA 와 겹친 회귀였음. 지금은 마디/흔들림 모두 차단됐으니 taper 안전하게 도입 가능.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 쓰다보면 필압이 줄어드는데 그러면 "학" 의 ㅡ 같은 부분이 거의 안
보이고 점선처럼 됨. 사용감 별로.
원인: thinning 0.4 + Pencil pressure 0.2~0.3 (약한 누름) → stroke 폭이 너무 줄어듦.
Fix:
- normalizePressure 에 MIN_PRESSURE 0.4 floor. pressure 0.05~0.4 도 0.4 로 고정.
dynamic range 0.4~1.0. 약한 pressure 에서도 stroke 가 충분히 보임.
- thinning 0.4 → 0.28. 폭 변동 줄임. floor 와 조합 시 ±17% 정도 변동.
기존 폭 시작점은 유지 (만년필 nib 변화 명확).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
마디 해결 후 사용자 피드백: 굵기 변동이 거의 없음. 만년필 느낌 (pressure 따른 명확한
굵기 차이) 원함.
원인: thinning 0.22 + window 16 = 변동 흡수 너무 강함. Pencil pressure 0.3~0.8
변동 → window 평균 거의 일정 + 22% 폭 반응 → 시각적으로 미세.
Fix:
- PRESSURE_WINDOW 16 → 8. pressure 변화 빠르게 따라옴 (마디는 보간 점 16px 으로
이미 차단됨).
- thinning 0.22 → 0.4. stroke 폭 ±40% 반응. 만년필 nib 처럼 약한 압력 = 가는,
강한 압력 = 굵은. 명확한 차이.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
스크린샷 진단: 빠른 곡선 stroke 에서 일정 간격 마디 (점선 효과) 명확. 윗쪽 천천히
쓴 글씨는 마디 거의 없음. 차이 = stroke 속도. 빠른 stroke = 보간 점 많이 추가됨.
가설: 8px gap 보간이 *일정 간격 dense vertex* 만들고, perfect-freehand outline
polygon 의 vertex 위치가 anti-aliasing 효과로 약간 dim 하게 표현 → 시각적 점선.
Fix:
- MAX_GAP_PX 8 → 16. 보간 점 절반.
- perfect-freehand smoothing 0.99 + streamline 0.86 이 sparse 점에서도 부드러운 곡선
생성 → 16px 간격 충분. 점선 방지는 30px+ gap 만 보간.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 굵기 변동 없고 선 사이사이 마디 (점선 같은 끊어짐) 보임.
원인: EMA(α=0.15) 가 매 점마다 pressure 살짝 변동 + thinning=0.15 → outline polygon
에 점 간 micro 폭 변동 = 마디. 큰 흐름 변동은 약함.
Fix:
- smoothPressure (EMA) → smoothPressureWindow (마지막 16점 평균).
매 점 변동은 1/16 수준 → micro 변동 평균화 (마디 차단). 큰 흐름은 따라옴.
- 보간된 점 (8px gap interpolation) 의 pressure 도 모두 sp 동일.
점진 보간 (lp → sp) 이 outline 에 micro 변동 일으키던 부수 원인 제거.
- thinning 0.15 → 0.22. window 평균이 micro 변동 흡수하니 폭 반응 더 크게 두어도
마디 안 발생. 큰 흐름의 굵기 변화 명확.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 요청: stroke 굵기가 너무 일정해서 단조로움. Notability 처럼 살짝 압력에 따라
변화 있으면 좋겠다.
이전 thinning 0.18 + PRESSURE_SMOOTH_RATE 5% 조합은 점 간 5% 즉시 변동 가능 →
누적 시 들쭉날쭉. thinning 0 으로 회귀했었음.
Fix:
- Pressure smoothing 알고리즘 변경: rate-limit (±5%) → EMA (alpha 0.15).
새 값 15% + 이전 값 85% 가중. 잡음/덜컥 변동 제거하면서도 자연스러운 흐름.
- thinning 0 → 0.15. pressure 변화에 stroke 폭 ±15% 반응.
- EMA + thinning 조합 → "부드러운 흐름에 따른 자연스러운 굵기 변화". 흔들림 없음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>