From e1a047c2c232fda21bd469485884d15561fc04b4 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 8 Jun 2026 20:11:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(documents):=20=EA=B0=9C=EC=9A=94=20?= =?UTF-8?q?=EC=A0=90=ED=94=84=20anchorMap=20=EC=9C=A0=ED=8B=B8=20(forward-?= =?UTF-8?q?cursor=203=EC=A4=91=20=EB=B0=A9=EC=96=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 불만② 개요→본문 점프의 deterministic anchor 좌표 산출(경로 A, FE-only). 게이트 측정상 textContent 매칭은 중복 63%·비-ATX 로 5% + silent 오점프 → md_content 에서 각 절 heading 라인 offset 을 찾아 주입 좌표를 만든다. ★ false-early-match 방어 3중 (적대 리뷰 반영): - 라인-시작(전체-라인) 매칭 → 본문 중간 상호참조("see Part UW")는 라인 전체가 제목과 같지 않아 제외(forward-cursor 가 못 막던 핵심 구멍). - 전체 매칭 + truncation(builder [:200]) 처리 → '제1조'가 '제1조의2' 오매칭 차단. - 단조 커서 + 코드펜스 회피 → 역행/펜스 매칭 거부 = anchor 없음(점프 비활성, 오점프 금지). window/section_split 조각·빈 제목은 skip. node test 10/10 PASS(상호참조 선행·중복 단조· prefix·평문 제N조·펜스·window·miss·heading_path fallback). 순수 함수, vite build PASS. 다음 commit = MarkdownDoc splice + SectionOutline 점프 + DocumentViewer rail/scroll-spy. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/utils/outlineAnchors.test.ts | 128 ++++++++++++++++++ frontend/src/lib/utils/outlineAnchors.ts | 101 ++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 frontend/src/lib/utils/outlineAnchors.test.ts create mode 100644 frontend/src/lib/utils/outlineAnchors.ts diff --git a/frontend/src/lib/utils/outlineAnchors.test.ts b/frontend/src/lib/utils/outlineAnchors.test.ts new file mode 100644 index 0000000..d76bb8a --- /dev/null +++ b/frontend/src/lib/utils/outlineAnchors.test.ts @@ -0,0 +1,128 @@ +// 순수함수 회귀 테스트. 실행(로컬, 의존성 0): node --test src/lib/utils/outlineAnchors.test.ts +// (Node ≥23 또는 22.6+ --experimental-strip-types — TS 타입 네이티브 strip.) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildAnchorMap } from './outlineAnchors.ts'; +import { type DocumentSection } from './headingPath.ts'; + +let _id = 0; +function sec(p: Partial): DocumentSection { + return { + chunk_id: ++_id, + section_title: null, + heading_path: null, + level: null, + node_type: null, + is_leaf: true, + section_type: null, + summary: null, + confidence: null, + ...p, + }; +} +const md = (lines: string[]) => lines.join('\n'); +const lineOff = (lines: string[], idx: number) => { + let o = 0; + for (let i = 0; i < idx; i++) o += lines[i].length + 1; + return o; +}; + +test('ATX heading 정확 매칭 + offset', () => { + const lines = ['# 개요', '본문 a', '## 설계 기준', '본문 b']; + const s = [ + sec({ chunk_id: 101, section_title: '개요' }), + sec({ chunk_id: 102, section_title: '설계 기준' }), + ]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[101], lineOff(lines, 0)); + assert.equal(r.anchors[102], lineOff(lines, 2)); + assert.equal(r.matched, 2); +}); + +test('★ false early match 방어 — 상호참조가 heading 보다 먼저', () => { + const lines = ['# 개요', '본 절은 Part UW 를 참조한다.', '내용', '# Part UW', '강판']; + const s = [ + sec({ chunk_id: 1, section_title: '개요' }), + sec({ chunk_id: 2, section_title: 'Part UW' }), + ]; + const r = buildAnchorMap(md(lines), s); + // 상호참조(line 1)가 아니라 실제 heading(line 3)으로 + assert.equal(r.anchors[2], lineOff(lines, 3)); + assert.notEqual(r.anchors[2], lineOff(lines, 1)); +}); + +test('중복 제목 — 단조 커서로 N번째 출현 매칭', () => { + const lines = ['## General', 'a', '## Scope', 'b', '## General', 'c']; + const s = [ + sec({ chunk_id: 1, section_title: 'General' }), + sec({ chunk_id: 2, section_title: 'Scope' }), + sec({ chunk_id: 3, section_title: 'General' }), + ]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[1], lineOff(lines, 0)); // 첫 General + assert.equal(r.anchors[2], lineOff(lines, 2)); // Scope + assert.equal(r.anchors[3], lineOff(lines, 4)); // 둘째 General (오점프 아님) +}); + +test('prefix 가드 — 제1조 가 제1조의2 를 오매칭 안 함', () => { + const lines = ['# 제1조의2', 'x', '# 제1조', 'y']; + const s = [sec({ chunk_id: 1, section_title: '제1조' })]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[1], lineOff(lines, 2)); // 제1조의2(line0) 아님 +}); + +test('비-ATX 평문 제N조 (전체-라인 매칭)', () => { + const lines = ['제1조(목적) 이 법은 OO 을 정한다.', '본문', '제2조(정의) 용어는...']; + const s = [ + sec({ chunk_id: 1, section_title: '제1조(목적) 이 법은 OO 을 정한다.', node_type: 'clause' }), + sec({ chunk_id: 2, section_title: '제2조(정의) 용어는...', node_type: 'clause' }), + ]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[1], lineOff(lines, 0)); + assert.equal(r.anchors[2], lineOff(lines, 2)); +}); + +test('window 조각 skip (anchor 없음)', () => { + const lines = ['## 절', 'aaa', 'bbb']; + const s = [ + sec({ chunk_id: 1, section_title: '절' }), + sec({ chunk_id: 2, section_title: '절', node_type: 'window' }), // 부모 제목 상속 조각 + ]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[1], lineOff(lines, 0)); + assert.equal(r.anchors[2], undefined); // window = 점프 비활성 + assert.equal(r.total, 1); +}); + +test('코드펜스 내부 heading 제외', () => { + const lines = ['```', '# General', '```', '# General', 'x']; + const s = [sec({ chunk_id: 1, section_title: 'General' })]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[1], lineOff(lines, 3)); // 펜스 밖 +}); + +test('miss = anchor 없음 (점프 비활성, 오점프 아님)', () => { + const lines = ['# 개요', '본문']; + const s = [ + sec({ chunk_id: 1, section_title: '개요' }), + sec({ chunk_id: 2, section_title: '존재하지 않는 절' }), + ]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[1], lineOff(lines, 0)); + assert.equal(r.anchors[2], undefined); + assert.equal(r.total, 2); + assert.equal(r.matched, 1); +}); + +test('heading_path 마지막 세그먼트 fallback', () => { + const lines = ['# 도입', 'x']; + const s = [sec({ chunk_id: 1, section_title: null, heading_path: 'A > 도입' })]; + const r = buildAnchorMap(md(lines), s); + assert.equal(r.anchors[1], lineOff(lines, 0)); +}); + +test('빈 입력 안전', () => { + assert.deepEqual(buildAnchorMap('', [sec({ section_title: 'x' })]).anchors, {}); + assert.deepEqual(buildAnchorMap('# x', []).anchors, {}); + assert.deepEqual(buildAnchorMap(null, null).anchors, {}); +}); diff --git a/frontend/src/lib/utils/outlineAnchors.ts b/frontend/src/lib/utils/outlineAnchors.ts new file mode 100644 index 0000000..64ecd53 --- /dev/null +++ b/frontend/src/lib/utils/outlineAnchors.ts @@ -0,0 +1,101 @@ +// 개요(절 목차) → 본문 deterministic 점프용 anchor offset 산출 (경로 A: FE-only). +// +// hier 절(section_title)은 md_content 의 heading 라인에서 나왔으나(builder.py build_hier_tree, +// md_content 순수함수), 비-ATX(제N조/Chapter)는 본문에 markdown heading 요소·id 가 안 생기고 +// 중복 제목(표-1·Part UW…)이 흔해 슬러그·textContent 매칭이 깨진다. 그래서 md_content 에서 +// 각 절의 heading 위치(char offset)를 직접 찾아 를 주입할 좌표를 만든다. +// +// ★ false early match 방어 3중 (리뷰 반영): +// 1. 라인-시작(전체-라인) 매칭 — 본문 중간 상호참조("see Part UW for…")는 라인 전체가 제목과 +// 같지 않으므로 제외. heading 라인(선두 #/리스트마커 제거 후 전체)만 매칭. +// 2. 전체 매칭 + truncation 처리 — 'first-N-chars' prefix 금지('제1조'가 '제1조의2' 오매칭 차단). +// builder 가 KO/ENG 제목을 [:200] truncate 하므로 truncated(매우 긴 제목)일 때만 startsWith. +// 3. 단조 커서 + 코드펜스 회피 — 매칭은 직전 매칭 다음 라인부터(역행 불가) + ``` ~~~ 펜스 내부 제외. +// 미스/역행은 anchor 없음 = 점프 비활성(아코디언 폴백). 오점프보다 무점프. +// +// ⚠ 잔여 한계: 본문 앞 '목차(TOC)'가 절 제목을 단독 라인으로 순서대로 나열하면 커서가 TOC 를 +// 먼저 잡을 수 있다(연쇄 시프트). 4-1 의 '정확도' 측정으로 검출 — 빈번하면 경로 B(builder offset). + +import { cleanHeading, type DocumentSection } from './headingPath.ts'; + +const TRUNCATE_HINT = 180; // builder.py 가 KO/ENG 제목을 [:200] 으로 자름 → 거의 그 길이면 truncated 로 간주 + +function norm(s: string | null | undefined): string { + return cleanHeading(s).toLowerCase(); +} + +/** 한 라인을 heading 후보 텍스트로: 선두 ATX #(1~6) / 리스트마커(-*+) / blockquote(>) 제거 후 정규화. */ +function normLine(raw: string): string { + const stripped = raw.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s+)?/, ''); + return cleanHeading(stripped).toLowerCase(); +} + +export interface AnchorMapResult { + /** chunk_id → md_content 내 heading 라인 시작 char offset. (없으면 점프 비활성) */ + anchors: Record; + /** 후보(비-window·제목有) 절 수 — 4-1 커버리지 분모. */ + total: number; + /** 신뢰 anchor 수 — 4-1 커버리지 분자. (정확도는 별도 수작업 검증) */ + matched: number; +} + +/** + * sections 는 서버 chunk_index 순(문서 순서)으로 가정한다(GET /documents/{id}/sections ORDER BY). + */ +export function buildAnchorMap( + mdContent: string | null | undefined, + sections: DocumentSection[] | null | undefined, +): AnchorMapResult { + const anchors: Record = {}; + if (!mdContent || !sections || sections.length === 0) { + return { anchors, total: 0, matched: 0 }; + } + + // 라인별 (offset, 정규화 텍스트, 펜스 여부) 사전계산. + const rawLines = mdContent.split('\n'); + const lines: { off: number; norm: string }[] = []; + let off = 0; + let inFence = false; + for (const raw of rawLines) { + const fenceToggle = /^\s{0,3}(```|~~~)/.test(raw); + const fencedHere = inFence || fenceToggle; // 펜스 경계 라인도 매칭 제외 + lines.push({ off, norm: fencedHere ? '' : normLine(raw) }); + if (fenceToggle) inFence = !inFence; + off += raw.length + 1; // '\n' + } + + let cursor = 0; // 단조 전진 라인 인덱스 + let total = 0; + let matched = 0; + + for (const s of sections) { + // window/section_split 조각은 자체 heading 없음(부모 제목 상속) → 건너뜀. + if (s.node_type === 'window' || s.node_type === 'section_split') continue; + let nt = norm(s.section_title); + if (!nt && s.heading_path) { + const last = s.heading_path.split('>').pop(); + nt = norm(last); + } + if (!nt) continue; + total++; + + const truncated = nt.length >= TRUNCATE_HINT; + let foundIdx = -1; + for (let i = cursor; i < lines.length; i++) { + const ln = lines[i].norm; + if (!ln) continue; // 빈 라인 / 펜스 내부 + if (ln === nt || (truncated && ln.startsWith(nt))) { + foundIdx = i; + break; + } + } + if (foundIdx >= 0) { + anchors[s.chunk_id] = lines[foundIdx].off; + cursor = foundIdx + 1; // 단조: 다음 절은 이 라인 이후만 + matched++; + } + // 미스 → anchor 없음(점프 비활성, 폴백) + } + + return { anchors, total, matched }; +}