Files
hyungi_document_server/frontend/src/lib/utils/headingPath.ts
T
hyungi aeb9290cbd feat(documents): hier 절 char_start offset (Path B) — md_content 점프 builder offset
플랜 ds-outline-anchor-b5 (g1~g6 코드). 핵심 ASME/법령 windowed 절의 0% 점프를
서버계산 char_start(builder offset)로 100% deterministic 점프로 전환.

- g1 migration 318: document_chunks.char_start INTEGER NULL (단일 statement, 멱등)
- g2 builder: char_start emit = FE 라인/offset 모델 미러(split('\n')+UTF-16 code unit+코드펜스 skip).
  window-child=NULL, split-parent=heading offset, preamble=NULL, CR 미strip, NFC=telemetry.
  node.text 보존(라인모델 hash-neutral) → hash_stable doc 보존. 단위테스트 7건.
- g3 persist+backfill 하이브리드:
  * persist INSERT char_start
  * update-char-start (g3-tU): hash_stable doc 비파괴 — 100% jump-target VERIFY(NEW-1) +
    position-aligned PK UPDATE(NEW-2), 미달 doc DEMOTE → re-decompose 합류(NEW-4)
  * --reprocess (g3-t2): md_content 출처(g0-t1) + jump-target-set 완료마커(B1) + B_jumptarget>=1(B3),
    --doc 필수 else REFUSE. self-heal sweep(g3-t3).
- g4 /sections: char_start inner+outer SELECT + split-parent 노출(is_leaf OR %_split)
- g5 FE: resolveAnchorMap(BE-first, NEW-5 jump-target-candidate-scoped 폴백, C1 OR-exclude),
  per-render-site basis guard(C3), endsWith('_split') 정정 + collapseWindows split-parent 흡수(C2).
  단위테스트 25건(NEW-5/B4/C1/C2 포함).
- g6 hier_outline_quality_gate.py: read-only g-measure(verdict/B_jumptarget/hash_stable/dup/fence)

배포(g7: --no-deps, 스냅샷, UPDATE-only 32 + re-decompose 230∪demote, 정확도 게이트)는 별 ops 단계.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:12:26 +09:00

163 lines
6.2 KiB
TypeScript

// 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<string, string> = {
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>.*?<\/sup>/gi, '') // 각주 위첨자
.replace(/<sub>.*?<\/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 에 <sup>2</sup> 등 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<string, DocumentSection[]>();
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 };
}