Files
hyungi_document_server/frontend/src/lib/utils/headingPath.ts
T
hyungi 513c6507bc feat(docpage): 절뷰 read-time front-matter 억제 + Part 그룹 유틸 (asme D7/D9)
긴 ASME 코드 절뷰가 flat 1030 으로 길어지는 문제(front-matter 240 + 다중 PART 가 GROUP_MAX 초과
→ flat 폴백)를 표현 계층에서 해결. 빌더/재분해 무접촉.
- D9 cleanHeading: ASME 개정바 ðNÞ(<sup>ð</sup>**25**<sup>Þ</sup>) 통째 strip (가운데 25 안 남김).
- D7 buildPartOutline: 첫 content part(PART/SUBSECTION/항목코드) 경계로 front-matter 분리 +
  본문을 heading_path 첫 세그먼트(PART)로 그룹. window/_split 도 PART 로 모여 흡수. content part
  없으면 hasParts=false 폴백. SectionOutline(D8) 이 소비.
단위 17/17(신규 6: 개정바 strip·front-matter 분리·window 흡수·폴백·항목코드). 미배포·prod 무접촉.

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

297 lines
13 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;
/** 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent (비인접 흡수에 사용). */
parent_id?: number | null;
/** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
char_start?: number | null;
/** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
text?: string | null;
section_type: string | null;
summary: string | null;
confidence: number | null;
}
/** window dedupe 후 목차 한 항목 (대표 절 + 합쳐진 조각 수). */
export interface OutlineItem {
section: DocumentSection;
fragmentCount: number; // >1 이면 "(n조각)" 배지
/** 대표 + 흡수된 window child 들의 본문을 순서대로 이어붙인 논리 절 전체 본문.
* split-parent 는 heading 줄(text)을 본문에서 제외(제목과 중복) — window 본문만 합친다. */
bodyText: string;
/** 집계된 절-레벨 분석. windowed 절은 분석이 window child(chunk_section_analysis)에 붙고
* 대표=split-parent 엔 없으므로 멤버에서 집계한다. 단일 절은 대표 자신의 값.
* - sectionType: 멤버 section_type 다수결(동률=첫 등장)
* - confidence: 멤버 confidence 평균
* - summaries: 멤버 요약(빈 것 제외, chunk_index 순) — 단일=1개, windowed=N개(부분별 요약) */
sectionType: string | null;
confidence: number | null;
summaries: string[];
}
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
// D9(read-time): ASME 개정바 ðNÞ(`<sup>ð</sup>**25**<sup>Þ</sup>`) 통째 제거 — 개별 sup strip 전에.
// (일반 sup strip 이 먼저면 가운데 '25'(개정 연도)만 남아 'ð25Þ PG-5.4' → '25 PG-5.4' 오염)
.replace(/<sup>\s*ð\s*<\/sup>.*?<sup>\s*Þ\s*<\/sup>/gi, '')
.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 에서 시작(자신이 첫 조각).
*/
/** 멤버 section_type 다수결(동률은 첫 등장 우선). 비어있으면 null. */
function majorityType(types: (string | null)[]): string | null {
const vals = types.filter((t): t is string => !!t);
if (!vals.length) return null;
const count = new Map<string, number>();
for (const t of vals) count.set(t, (count.get(t) ?? 0) + 1);
let best: string | null = null;
let bestN = -1;
for (const t of vals) {
const n = count.get(t)!;
if (n > bestN) { bestN = n; best = t; } // 첫 등장 우선 tie-break
}
return best;
}
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
const out: OutlineItem[] = [];
const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child)
const repByChunkId = new Map<number, number>(); // split-parent chunk_id → out index (window 가 parent_id 로 흡수)
// window child 본문/멤버를 out[idx] 대표에 흡수.
const absorb = (idx: number, s: DocumentSection) => {
out[idx].fragmentCount += 1;
const t = (s.text ?? '').trim();
if (t) out[idx].bodyText = out[idx].bodyText ? `${out[idx].bodyText}\n\n${t}` : t;
members[idx].push(s);
};
for (const s of sections) {
if (s.node_type === 'window') {
// 1) parent_id 로 split-parent 대표에 흡수 — split-parent 와 window 가 chunk_index 상 비인접일 수
// 있으므로(예: 헤딩 1143, window 1233) 인접 가정 대신 트리 부모 링크로 정확히 연결한다.
let idx = s.parent_id != null ? repByChunkId.get(s.parent_id) ?? -1 : -1;
// 2) fallback: 인접 대표(legacy window run / 같은 heading split)면 흡수
if (idx < 0) {
const prev = out[out.length - 1];
const h = cleanHeading(s.heading_path);
if (
prev &&
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
h !== '' &&
cleanHeading(prev.section.heading_path) === h
) {
idx = out.length - 1;
}
}
if (idx >= 0) {
absorb(idx, s);
continue;
}
// 3) legacy: 부모 없는 window → 자기 대표(자기 본문으로 시작)
out.push({ section: s, fragmentCount: 1, bodyText: s.text ?? '', sectionType: null, confidence: null, summaries: [] });
members.push([s]);
} else {
const isSplit = !!s.node_type?.endsWith('_split');
// split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
out.push({
section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? ''),
sectionType: null, confidence: null, summaries: [],
});
members.push([s]);
if (isSplit) repByChunkId.set(s.chunk_id, out.length - 1); // window 가 parent_id 로 찾아 흡수
}
}
// 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
for (let i = 0; i < out.length; i++) {
const mem = members[i];
out[i].sectionType = majorityType(mem.map((m) => m.section_type));
const confs = mem.map((m) => m.confidence).filter((c): c is number => c != null);
out[i].confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : null;
out[i].summaries = mem.map((m) => (m.summary ?? '').trim()).filter((x) => x !== '');
}
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 };
}
// ── D7/D8 (asme-item-decomp read-time): front-matter 억제 + Part 계층 그룹 ──
// 긴 구조화 코드(ASME)의 절뷰가 flat 1030 으로 길어지는 문제(front-matter 240 + 다중 PART)를
// 표현 계층에서 해결. 빌더/재분해 무접촉 — sections 엔드포인트가 주는 heading_path 만으로 산출.
/** 본문 시작(content part) top-segment 패턴: 'PART PG …' / 'SUBSECTION A …'. */
const CONTENT_PART_RE = /^(PART|SUBSECTION)\s+\S/i;
/** 본문 항목 코드(예: PG-1, UG-27, U-1) — content part 가 없는 문서의 폴백 경계. */
const ITEM_CODE_RE = /^[A-Z]{1,4}-\d/;
function isContentStart(s: DocumentSection): boolean {
const segs = pathSegments(s.heading_path);
if (segs.length && CONTENT_PART_RE.test(segs[0])) return true;
// path 없이 항목 코드가 제목인 경우(드뭄)도 본문 시작으로
const t = cleanHeading(s.section_title);
return ITEM_CODE_RE.test(t);
}
export interface PartOutline {
/** PART PG / PART PW … 전(前) front-matter(TOC·위원회·인명) — 단일 접이 그룹용. */
frontMatter: OutlineItem[];
/** 본문 Part 그룹들(heading_path 첫 세그먼트 = PART 기준). 기본 접힘은 렌더(D8)에서. */
groups: OutlineGroup[];
/** content part 경계를 못 찾으면 false → 기존 groupOrFlat 폴백 권장. */
hasParts: boolean;
}
/**
* front-matter 경계(첫 content part) 분리 + 본문을 PART(heading_path 첫 세그먼트)로 그룹.
* 정렬(chunk_index) 유지. window/_split 도 heading_path 가 PART 를 가리키므로 같은 PART 그룹에
* 들어가 collapseWindows 가 split-parent 에 정상 흡수(topSegment 의 window→OTHER 라우팅 미사용).
*/
export function buildPartOutline(sections: DocumentSection[]): PartOutline {
let boundary = -1;
for (let i = 0; i < sections.length; i++) {
if (isContentStart(sections[i])) { boundary = i; break; }
}
if (boundary < 0) {
return { frontMatter: [], groups: [], hasParts: false };
}
const front = sections.slice(0, boundary);
const body = sections.slice(boundary);
const frontMatter = front.length ? collapseWindows(front) : [];
const order: string[] = [];
const map = new Map<string, DocumentSection[]>();
for (const s of body) {
// PART 그룹 키 = heading_path 첫 세그먼트(있으면). window/_split 포함 — 같은 PART 로 모음.
const segs = pathSegments(s.heading_path);
const key = segs.length ? segs[0] : OTHER;
if (!map.has(key)) { map.set(key, []); order.push(key); }
map.get(key)!.push(s);
}
const groups: OutlineGroup[] = order.map((key) => ({
key: key === OTHER ? '기타' : key,
isOther: key === OTHER,
items: collapseWindows(map.get(key)!),
}));
return { frontMatter, groups, hasParts: true };
}