// hier 절(section) 목차 표시용 순수 유틸 (PR-DocSrv-Hier-Section-UI-1). // SvelteKit/Svelte 의존 0 → Node 내장 test runner(`node --test`)로 검증 가능. // // 책임: // - cleanHeading: section_title/heading_path 의 raw 마크다운/HTML 잔재 strip. // - pathSegments: heading_path("A > B > C")를 정제 세그먼트 배열로. // - collapseWindows: 연속 동일 heading 의 node_type='window'(과대 본문 인공 분할) dedupe. // - groupOrFlat: per-doc 동적 판정 — top-segment 1단 그룹 vs flat (실측 임계 기반). export interface DocumentSection { chunk_id: number; section_title: string | null; heading_path: string | null; level: number | null; node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null is_leaf: boolean; /** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */ char_start?: number | null; section_type: string | null; summary: string | null; confidence: number | null; } /** window dedupe 후 목차 한 항목 (대표 절 + 합쳐진 조각 수). */ export interface OutlineItem { section: DocumentSection; fragmentCount: number; // >1 이면 "(n조각)" 배지 } export interface OutlineGroup { key: string; // top segment (OTHER → '기타') isOther: boolean; items: OutlineItem[]; } export interface OutlineLayout { mode: 'group' | 'flat'; items: OutlineItem[]; // flat 모드에서 채워짐 groups: OutlineGroup[]; // group 모드에서 채워짐 } const OTHER = '__OTHER__'; // 동적 그룹 판정 임계 (실측 pilot 3 검증: 5140 group→그룹 / 5186·5225→flat). const GROUP_MIN = 2; const GROUP_MAX = 30; const OTHER_PCT_MAX = 50; /** section_type → 한글 라벨 (느슨한 enum, 미지정/미상은 그대로 표시). */ export const SECTION_TYPE_LABEL: Record = { definition: '정의', requirement: '요건', procedure: '절차', formula: '수식', data_table: '표·데이터', example: '예시', case_study: '사례', question: '문제', reference: '참조', overview: '개요', other: '기타', }; export function sectionTypeLabel(t: string | null | undefined): string | null { if (!t) return null; return SECTION_TYPE_LABEL[t] ?? t; } export function cleanHeading(raw: string | null | undefined): string { if (!raw) return ''; return raw .replace(/.*?<\/sup>/gi, '') // 각주 위첨자 .replace(/.*?<\/sub>/gi, '') .replace(/<[^>]+>/g, '') // 잔여 HTML 태그 .replace(/\*\*/g, '') // **bold** .replace(/[*_`]/g, '') // 잔여 마크다운 마커 .replace(/\s+/g, ' ') .trim(); } export function pathSegments(hp: string | null | undefined): string[] { if (!hp) return []; // ⚠ 먼저 strip 후 split: heading_path 에 2 등 raw HTML 의 '>' 가 섞여 있어 // bare '>' 로 먼저 split 하면 태그가 잘림(단위테스트로 발견). cleanHeading 이 HTML 태그를 // 제거하므로 separator ' > '(bare '>')만 남은 뒤 split 한다. return cleanHeading(hp) .split('>') .map((s) => s.trim()) .filter(Boolean); } /** 그룹 키: window/%_split(인공 조각·windowed split-parent) 또는 path 없음/깨짐 → OTHER. */ function topSegment(s: DocumentSection): string { if (s.node_type === 'window' || !!s.node_type?.endsWith('_split')) return OTHER; const segs = pathSegments(s.heading_path); return segs.length === 0 ? OTHER : segs[0]; } /** * 서버 chunk_index 순서를 유지한 채(정렬 변경 금지), 연속된 동일 cleaned heading_path 의 * node_type='window' 절을 1 항목으로 dedupe. fragmentCount = window 조각 수. * * [C2] g4-t2 가 split-parent(%_split, char_start 보유)를 그 window child 들보다 먼저(낮은 chunk_index) * 노출하므로, 후속 window child 를 직전 split-parent(또는 legacy window 대표)에 흡수해 rail 1행으로 만든다. * merged row 의 대표 section = split-parent 여야 jump(anchorMap[split-parent char_start])가 성립한다 — * window-child(char_start NULL, anchorMap 부재)가 대표면 windowed section 이 점프 안 됨. * fragmentCount: split-parent 대표는 0 에서 시작(자신은 조각 아님) + 흡수 child 수 = 실제 조각 수; * legacy window 대표는 1 에서 시작(자신이 첫 조각). */ export function collapseWindows(sections: DocumentSection[]): OutlineItem[] { const out: OutlineItem[] = []; for (const s of sections) { const prev = out[out.length - 1]; const h = cleanHeading(s.heading_path); const prevAbsorbs = prev && (prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) && h !== '' && cleanHeading(prev.section.heading_path) === h; if (s.node_type === 'window' && prevAbsorbs) { prev!.fragmentCount += 1; // window child 흡수 — 대표(split-parent 우선)는 그대로 유지 } else { out.push({ section: s, fragmentCount: s.node_type?.endsWith('_split') ? 0 : 1 }); } } return out; } /** * per-doc 동적 판정: top-segment 1단 그룹 vs flat. * 판정은 raw 절 기준(실측 임계와 동일 차원), 표시는 collapseWindows 적용. * - 그룹 채택: GROUP_MIN ≤ distinct top-segment ≤ GROUP_MAX AND 기타% < OTHER_PCT_MAX. * - 아니면 flat 강등. */ export function groupOrFlat(sections: DocumentSection[]): OutlineLayout { const total = sections.length; const order: string[] = []; const map = new Map(); let otherCount = 0; for (const s of sections) { const key = topSegment(s); if (key === OTHER) otherCount += 1; if (!map.has(key)) { map.set(key, []); order.push(key); } map.get(key)!.push(s); } const groupCount = map.size; const otherPct = total === 0 ? 0 : (otherCount / total) * 100; const useGroup = groupCount >= GROUP_MIN && groupCount <= GROUP_MAX && otherPct < OTHER_PCT_MAX; if (!useGroup) { return { mode: 'flat', items: collapseWindows(sections), groups: [] }; } const groups: OutlineGroup[] = order.map((key) => ({ key: key === OTHER ? '기타' : key, isOther: key === OTHER, items: collapseWindows(map.get(key)!), })); return { mode: 'group', items: [], groups }; }