Feat/outline anchor #31

Merged
hyungi merged 2 commits from feat/outline-anchor into main 2026-06-08 21:16:45 +09:00
5 changed files with 303 additions and 7 deletions
+21 -1
View File
@@ -28,6 +28,9 @@
mdStatus?: string | null;
mdExtractionError?: string | null;
mdExtractionQuality?: Record<string, unknown> | null;
/** 개요 점프용 anchor: {chunk_id: md_content 내 char offset}. 렌더 전 해당 위치에
* <span id="sec-{chunk_id}"> 주입(점프 타깃). buildAnchorMap(outlineAnchors) 산출물. */
anchorMap?: Record<number, number> | null;
placeholder?: string;
/** 추가 래퍼 클래스. tailwind prose-* / spacing 등을 호출 측에서 입혀야 할 때. */
class?: string;
@@ -41,10 +44,27 @@
mdStatus = null,
mdExtractionError = null,
mdExtractionQuality = null,
anchorMap = null,
placeholder = '*텍스트 추출 대기 중*',
class: klass = '',
}: Props = $props();
// 개요 anchor 주입: body 의 각 offset(내림차순)에 빈 <span id="sec-N"> 삽입(점프 타깃).
// offset 은 buildAnchorMap 이 body 와 동일 문자열 기준으로 산출했어야 함(호출측 책임).
function spliceAnchors(text: string, map: Record<number, number> | null): string {
if (!map) return text;
const ents = Object.entries(map)
.map(([id, off]) => [id, Number(off)] as [string, number])
.filter(([, o]) => Number.isFinite(o) && o >= 0 && o <= text.length)
.sort((a, b) => b[1] - a[1]);
if (!ents.length) return text;
let out = text;
for (const [id, off] of ents) {
out = out.slice(0, off) + `<span id="sec-${id}" class="md-anchor"></span>\n` + out.slice(off);
}
return out;
}
let usingMarkdown = $derived(!!(mdContent && mdContent.trim()));
let body = $derived(
usingMarkdown
@@ -53,7 +73,7 @@
? extractedText
: placeholder,
);
let renderedHtml = $derived(renderDocMarkdown(body));
let renderedHtml = $derived(renderDocMarkdown(spliceAnchors(body, anchorMap)));
let frontmatterEntries = $derived.by(() => {
if (!usingMarkdown || !mdFrontmatter) return [] as [string, unknown][];
@@ -15,8 +15,12 @@
interface Props {
sections: DocumentSection[];
/** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */
onJump?: (chunkId: number) => void;
/** scroll-spy 현재 절(chunk_id) — 강조용. */
activeKey?: number | null;
}
let { sections }: Props = $props();
let { sections, onJump, activeKey = null }: Props = $props();
let layout = $derived(groupOrFlat(sections));
let total = $derived(sections.length);
@@ -37,15 +41,17 @@
{#snippet itemRow(item: OutlineItem)}
{@const s = item.section}
{@const open = selectedId === s.chunk_id}
{@const active = activeKey != null && activeKey === s.chunk_id}
{@const typeLabel = sectionTypeLabel(s.section_type)}
<li>
<button
type="button"
onclick={() => toggle(item)}
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
aria-expanded={open}
aria-current={active ? 'true' : undefined}
class={[
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors',
open ? 'bg-surface-active text-text' : 'text-dim hover:bg-surface hover:text-text',
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
].join(' ')}
>
<span class="flex-1 min-w-0 leading-snug break-words">{title(s)}</span>
@@ -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>): 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, {});
});
+101
View File
@@ -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)를 직접 찾아 <a id="sec-{chunk_id}"> 를 주입할 좌표를 만든다.
//
// ★ 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<number, number>;
/** 후보(비-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<number, number> = {};
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 };
}
@@ -7,6 +7,7 @@
import { goto } from '$app/navigation';
import { api, getAccessToken } from '$lib/api';
import { isMdSuccess } from '$lib/utils/mdStatus';
import { buildAnchorMap } from '$lib/utils/outlineAnchors';
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -163,6 +164,45 @@
}
});
// ── 개요 점프 (outlineAnchors, 경로 A) ──
// anchorMap = md_content 의 각 절 heading offset. MarkdownDoc 가 <span id="sec-N"> 주입.
let anchorMap = $derived(
hasSections && canShowMarkdown && doc?.md_content
? buildAnchorMap(doc.md_content, sections).anchors
: {}
);
let activeKey = $state(null);
function jumpToSection(chunkId) {
const el = document.getElementById(`sec-${chunkId}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
$effect(() => {
void anchorMap; // 문서/섹션 변화 시 재바인딩
if (typeof window === 'undefined') return;
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
let cur = null;
document.querySelectorAll('.md-anchor').forEach((a) => {
if (a.getBoundingClientRect().top <= 120) cur = a;
});
if (cur) {
const m = cur.id.match(/^sec-(\d+)$/);
if (m) activeKey = Number(m[1]);
}
});
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => {
window.removeEventListener('scroll', onScroll);
if (raf) cancelAnimationFrame(raf);
};
});
function getViewerType(format) {
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
@@ -229,7 +269,7 @@
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
<Card>
<SectionOutline {sections} />
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
</Card>
</aside>
{/if}
@@ -240,7 +280,7 @@
<!-- xl 미만: 절 목차 접이식 -->
<details class="xl:hidden">
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
<Card class="mt-2"><SectionOutline {sections} /></Card>
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
</details>
{/if}
<!-- Affordance row -->
@@ -289,6 +329,7 @@
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
anchorMap={anchorMap}
extractedText={doc.extracted_text || rawMarkdown}
class="prose prose-invert prose-base lg:prose-sm max-w-none"
/>