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>
This commit is contained in:
hyungi
2026-06-17 10:21:14 +09:00
parent 677a59b422
commit 513c6507bc
2 changed files with 122 additions and 0 deletions
@@ -7,6 +7,7 @@ import {
pathSegments,
collapseWindows,
groupOrFlat,
buildPartOutline,
sectionTypeLabel,
type DocumentSection,
} from './headingPath.ts';
@@ -190,3 +191,61 @@ test('groupOrFlat: 빈 입력 → flat, 항목 0', () => {
assert.equal(layout.mode, 'flat');
assert.equal(layout.items.length, 0);
});
// ── D9: cleanHeading ASME 개정바 ðNÞ strip ──
test('cleanHeading: ASME 개정바 ðNÞ 통째 제거 (가운데 25 안 남김)', () => {
assert.equal(
cleanHeading('<sup>ð</sup>**25**<sup>Þ</sup> **PG-5.4 Size Limits**'),
'PG-5.4 Size Limits',
);
// 개정바 없는 일반 제목은 그대로 (회귀)
assert.equal(cleanHeading('#### **PG-2 SERVICE LIMITATIONS**'.replace(/^#+\s*/, '')), 'PG-2 SERVICE LIMITATIONS');
});
// ── D7: buildPartOutline — front-matter 분리 + PART 그룹 ──
test('buildPartOutline: front-matter 분리 + PART 그룹', () => {
const sections = [
sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }),
sec({ heading_path: 'Honors and Awards Committee', section_title: 'Honors and Awards Committee' }),
sec({ heading_path: 'PART PG GENERAL > PG-1 SCOPE', section_title: 'PG-1 SCOPE' }),
sec({ heading_path: 'PART PG GENERAL > PG-2 SERVICE', section_title: 'PG-2 SERVICE' }),
sec({ heading_path: 'PART PW > PW-1 SCOPE', section_title: 'PW-1 SCOPE' }),
];
const o = buildPartOutline(sections);
assert.equal(o.hasParts, true);
assert.equal(o.frontMatter.length, 2); // TOC + Committee
assert.equal(o.groups.length, 2); // PART PG, PART PW
assert.equal(o.groups[0].key, 'PART PG GENERAL');
assert.equal(o.groups[0].items.length, 2); // PG-1, PG-2
assert.equal(o.groups[1].key, 'PART PW');
assert.equal(o.groups[1].items.length, 1);
});
test('buildPartOutline: split-parent + window 가 같은 PART 그룹에서 1항목으로 흡수', () => {
const sections = [
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }),
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 1' }),
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 2' }),
];
const o = buildPartOutline(sections);
assert.equal(o.hasParts, true);
assert.equal(o.groups.length, 1);
assert.equal(o.groups[0].items.length, 1); // split-parent + 2 window → 1 항목
assert.equal(o.groups[0].items[0].fragmentCount, 2);
});
test('buildPartOutline: content part 없으면 hasParts=false (폴백 신호)', () => {
const o = buildPartOutline([sec({ heading_path: 'Intro', section_title: 'Intro' })]);
assert.equal(o.hasParts, false);
assert.equal(o.groups.length, 0);
});
test('buildPartOutline: 항목코드 제목(path 없음)도 본문 시작 인식', () => {
const sections = [
sec({ heading_path: 'FOREWORD', section_title: 'FOREWORD' }),
sec({ heading_path: null, section_title: 'U-1 적용범위' }),
];
const o = buildPartOutline(sections);
assert.equal(o.hasParts, true);
assert.equal(o.frontMatter.length, 1); // FOREWORD
});
+63
View File
@@ -84,6 +84,9 @@ export function sectionTypeLabel(t: string | null | undefined): string | null {
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 태그
@@ -231,3 +234,63 @@ export function groupOrFlat(sections: DocumentSection[]): OutlineLayout {
}));
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 };
}