diff --git a/frontend/src/lib/utils/headingPath.test.ts b/frontend/src/lib/utils/headingPath.test.ts
index adb5613..af0e57b 100644
--- a/frontend/src/lib/utils/headingPath.test.ts
+++ b/frontend/src/lib/utils/headingPath.test.ts
@@ -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('ð**25**Þ **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
+});
diff --git a/frontend/src/lib/utils/headingPath.ts b/frontend/src/lib/utils/headingPath.ts
index 8d22215..f438481 100644
--- a/frontend/src/lib/utils/headingPath.ts
+++ b/frontend/src/lib/utils/headingPath.ts
@@ -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Þ(`ð**25**Þ`) 통째 제거 — 개별 sup strip 전에.
+ // (일반 sup strip 이 먼저면 가운데 '25'(개정 연도)만 남아 'ð25Þ PG-5.4' → '25 PG-5.4' 오염)
+ .replace(/\s*ð\s*<\/sup>.*?\s*Þ\s*<\/sup>/gi, '')
.replace(/.*?<\/sup>/gi, '') // 각주 위첨자
.replace(/.*?<\/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();
+ 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 };
+}