diff --git a/app/services/hier_decomp/builder.py b/app/services/hier_decomp/builder.py index 3718e54..416ef4d 100644 --- a/app/services/hier_decomp/builder.py +++ b/app/services/hier_decomp/builder.py @@ -26,7 +26,16 @@ _ATX = re.compile(r'^(#{1,6})\s+(?P\S.*?)\s*#*\s*$') _KO_JANG = re.compile(r'^\s*(?P<title>제\s*\d+\s*장\b.*)$') _KO_JEOL = re.compile(r'^\s*(?P<title>제\s*\d+\s*절\b.*)$') _KO_JO = re.compile(r'^\s*(?P<title>제\s*\d+\s*조\b.*)$') -_ENG = re.compile(r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+[\dIVXLA-Z]+\b.*)$') +# _ENG: 영문 구조 헤딩(ATX 미사용 문서용). ASME 파트는 보통 ATX(`# PART PG`)로 잡혀 _ENG 의존 낮음. +# D1: 식별자 뒤가 소문자 문장연속이면("Part III to demonstrate to the satisfaction…") 본문이므로 +# 미탐지 — 가짜 절 차단. 선택 제목은 대문자/괄호/숫자로 시작해야 헤딩 인정(소문자 시작=문장으로 봄). +# 식별자는 번호/PG/3.31/UHX/A-1 등 (.·- 소수·하이픈 확장 허용). +_ENG = re.compile( + r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+' + r'[\dIVXLA-Z]+(?:[.\-][\dA-Za-z]+)*' + r'(?:\s+[A-Z(\d][^\n]*)?' + r')\s*$' +) # 코드펜스 경계 (FE outlineAnchors.ts:60 `/^\s{0,3}(```|~~~)/` 와 동일). 펜스 내부 라인은 # heading 미탐지 — 코드블록 안 '# foo' 가 가짜 절을 만들지 않게(O3). diff --git a/frontend/src/lib/components/SectionOutline.svelte b/frontend/src/lib/components/SectionOutline.svelte index ab213aa..ea7f61f 100644 --- a/frontend/src/lib/components/SectionOutline.svelte +++ b/frontend/src/lib/components/SectionOutline.svelte @@ -1,13 +1,18 @@ <script lang="ts"> // 문서 상세 좌측 절(section) 목차 (PR-DocSrv-Hier-Section-UI-1). - // - groupOrFlat 로 per-doc 동적 (top-segment 1단 그룹 vs flat). + // - ASME 등 구조화 코드(buildPartOutline.hasParts): front-matter 단일 접이그룹 + PART 접이 + // (기본 접힘, 1030 flat → ~14 top-level). scroll-spy/딥링크 진입 시 조상 PART auto-expand. (D8) + // - 그 외(per-doc): groupOrFlat 폴백 — top-segment 1단 그룹 vs flat(5140/5186/비-ASME 무회귀). // - 항목 클릭 → 인라인 아코디언으로 요약/section_type/heading_path breadcrumb 표시. - // - 본문 스크롤 점프 없음(§Q2, deep-link 는 follow-up). summary=NULL 은 "요약 없음" 문구. + import { untrack } from 'svelte'; import Badge from '$lib/components/ui/Badge.svelte'; import { cleanHeading, pathSegments, groupOrFlat, + buildPartOutline, + partGroupViews, + groupKeyByChunkId, sectionTypeLabel, type DocumentSection, type OutlineItem, @@ -17,14 +22,38 @@ sections: DocumentSection[]; /** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */ onJump?: (chunkId: number) => void; - /** scroll-spy 현재 절(chunk_id) — 강조용. */ + /** scroll-spy 현재 절(chunk_id) — 강조 + Part auto-expand. */ activeKey?: number | null; } let { sections, onJump, activeKey = null }: Props = $props(); - let layout = $derived(groupOrFlat(sections)); + let partOutline = $derived(buildPartOutline(sections)); + // hasParts(ASME 등): Part 접이 모드. 아니면 partViews=null → groupOrFlat 폴백. + let partViews = $derived(partOutline.hasParts ? partGroupViews(partOutline) : null); + let layout = $derived.by(() => (partOutline.hasParts ? null : groupOrFlat(sections))); + let groupIndex = $derived(partViews ? groupKeyByChunkId(partViews) : null); let total = $derived(sections.length); + let selectedId = $state<number | null>(null); + // Part 그룹 접이 상태: key 없으면 접힘(기본 전부 접힘). $state Record = Svelte5 deep-proxy 반응형. + let expanded = $state<Record<string, boolean>>({}); + function toggleGroup(key: string) { + expanded[key] = !expanded[key]; + } + // 문서 전환(DocumentViewer 가 sections prop 교체) 시 접이/선택 리셋 — 문서 간 PART 라벨/chunk_id 가 + // 우연히 겹쳐 이전 펼침/선택이 이월되는 것 차단(기본 전부 접힘 불변식 보존). untrack=쓰기 자기재발화 차단. + $effect(() => { + void sections; + untrack(() => { expanded = {}; selectedId = null; }); + }); + // scroll-spy/딥링크 활성 절의 조상 Part 를 펼침(다른 그룹은 건드리지 않음). untrack=쓰기 자기재발화 차단. + $effect(() => { + const ak = activeKey; + const idx = groupIndex; + if (ak == null || !idx) return; + const gk = idx.get(ak); + if (gk) untrack(() => { expanded[gk] = true; }); + }); function toggle(item: OutlineItem) { const id = item.section.chunk_id; @@ -95,7 +124,37 @@ <span class="text-faint font-normal">{total}</span> </h3> - {#if layout.mode === 'group'} + {#if partViews} + <!-- Part 접이 모드 (ASME 등): front-matter 단일 그룹 + PART 접이, 기본 접힘 --> + <div class="space-y-1"> + {#each partViews as g (g.key)} + {@const isOpen = !!expanded[g.key]} + <div> + <button + type="button" + onclick={() => toggleGroup(g.key)} + aria-expanded={isOpen} + class={[ + 'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-semibold uppercase tracking-wide transition-colors', + g.isFrontMatter ? 'text-faint' : 'text-dim', + 'hover:bg-surface hover:text-text', + ].join(' ')} + > + <span class="shrink-0 transition-transform duration-150 {isOpen ? 'rotate-90' : ''}">›</span> + <span class="flex-1 min-w-0 text-left truncate normal-case">{g.label}</span> + <span class="font-normal text-faint">{g.items.length}</span> + </button> + {#if isOpen} + <ul class="space-y-0.5 mt-0.5"> + {#each g.items as item (item.section.chunk_id)} + {@render itemRow(item)} + {/each} + </ul> + {/if} + </div> + {/each} + </div> + {:else if layout?.mode === 'group'} <div class="space-y-3"> {#each layout.groups as g (g.key)} <div> @@ -118,7 +177,7 @@ </div> {:else} <ul class="space-y-0.5"> - {#each layout.items as item (item.section.chunk_id)} + {#each layout?.items ?? [] as item (item.section.chunk_id)} {@render itemRow(item)} {/each} </ul> diff --git a/frontend/src/lib/utils/headingPath.test.ts b/frontend/src/lib/utils/headingPath.test.ts index adb5613..68278c2 100644 --- a/frontend/src/lib/utils/headingPath.test.ts +++ b/frontend/src/lib/utils/headingPath.test.ts @@ -7,6 +7,12 @@ import { pathSegments, collapseWindows, groupOrFlat, + buildPartOutline, + partitionOutlineItems, + partGroupViews, + groupKeyByChunkId, + FRONT_MATTER_KEY, + FRONT_MATTER_LABEL, sectionTypeLabel, type DocumentSection, } from './headingPath.ts'; @@ -190,3 +196,211 @@ 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: PART/SUBSECTION 마커 없으면(항목코드만) hasParts=false → 폴백', () => { + // 실 ASME 코드(5180/5210)는 PART/SUBSECTION 마커를 갖는다. PART 가 0 인 문서(항목코드만)는 + // 접을 PART 가 없으므로 hasParts=false → 호출자가 groupOrFlat/flat 으로 폴백. + const o = buildPartOutline([ + sec({ heading_path: 'FOREWORD', section_title: 'FOREWORD' }), + sec({ heading_path: null, section_title: 'U-1 적용범위' }), + ]); + assert.equal(o.hasParts, false); + assert.equal(o.groups.length, 0); +}); + +test('buildPartOutline: (NON)MANDATORY APPENDIX 도 최상위 섹션 경계 — 마지막 PART 흡수 방지', () => { + // 5180 실측: 부록을 마커로 안 잡으면 마지막 PART(PHRSG)가 부록 289항목을 carry-forward 흡수(=300). + const o = buildPartOutline([ + sec({ heading_path: 'PART PHRSG REQUIREMENTS > PHRSG-1', section_title: 'PHRSG-1' }), + sec({ heading_path: 'PHRSG-2 SCOPE', section_title: 'PHRSG-2' }), // PHRSG 로 carry + sec({ heading_path: 'MANDATORY APPENDIX IV LOCAL THIN AREAS', section_title: '...' }), + sec({ heading_path: 'IV-1 GENERAL', section_title: 'IV-1' }), // APPENDIX IV 로 carry + sec({ heading_path: 'NONMANDATORY APPENDIX A EXPLANATION', section_title: '...' }), + ]); + assert.deepEqual(o.groups.map((g) => [g.key.slice(0, 24), g.items.length]), [ + ['PART PHRSG REQUIREMENTS', 2], // PHRSG-1 + PHRSG-2(carry), 부록 안 섞임 + ['MANDATORY APPENDIX IV LO', 2], // 부록 헤딩 + IV-1(carry) + ['NONMANDATORY APPENDIX A ', 1], + ]); +}); + +test('buildPartOutline: 본문 cross-ref/문장 false PART 차단 (5210 stale 패턴)', () => { + // 혼합대소문자 'Part D…' · 코드 뒤 비대문자(한글) 문장 'PART UW 규정은…' · 비대문자 코드 'PART 층이…' + // = 전부 본문이라 PART 아님. 깨끗한 PART 0 → hasParts=false → flat 폴백(가짜 그룹 0). + const o = buildPartOutline([ + sec({ heading_path: 'Part D, Subpart 3의 해당 재료', section_title: 'Part D…' }), + sec({ heading_path: 'PART UW 규정은 용접에 의해 제작되는', section_title: 'PART UW 규정은…' }), + sec({ heading_path: 'PART 층이 진 구조로 조립되는', section_title: 'PART 층이…' }), + ]); + assert.equal(o.hasParts, false); +}); + +test('buildPartOutline: SUBSECTION 마커도 PART 경계로 인식(Sec VIII)', () => { + const o = buildPartOutline([ + sec({ heading_path: 'TOC', section_title: 'TOC' }), + sec({ heading_path: 'SUBSECTION A GENERAL > UG-1', section_title: 'UG-1' }), + sec({ heading_path: 'SUBSECTION B > UW-1', section_title: 'UW-1' }), + ]); + assert.equal(o.hasParts, true); + assert.equal(o.frontMatter.length, 1); + assert.deepEqual(o.groups.map((g) => g.key), ['SUBSECTION A GENERAL', 'SUBSECTION B']); +}); + +// ── D8: partitionOutlineItems — 이미 collapse 된 OutlineItem 재배치(인스턴스 보존) ── +test('partitionOutlineItems: flat outline 의 인스턴스를 그대로 재배치(재-collapse 없음)', () => { + const sections = [ + sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }), + 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 flat = collapseWindows(sections); // 컴포넌트의 outline 과 동일 경로 + const o = partitionOutlineItems(flat); + assert.equal(o.hasParts, true); + assert.equal(o.frontMatter.length, 1); + assert.equal(o.groups.length, 2); + // ★ 인스턴스 동일성: 재배치된 item 이 flat outline 의 바로 그 객체여야 selectedSectionId 정합. + assert.ok(o.frontMatter[0] === flat[0], 'front-matter item = flat[0] 인스턴스'); + assert.ok(o.groups[0].items[0] === flat[1], 'PART PG 첫 item = flat[1] 인스턴스'); + assert.ok(o.groups[1].items[0] === flat[3], 'PART PW item = flat[3] 인스턴스'); + // chunk_id 집합이 flat 과 정확히 일치(클릭→selectedSectionId 조회 실패 없음). + const flatIds = flat.map((it) => it.section.chunk_id).sort(); + const partIds = [...o.frontMatter, ...o.groups.flatMap((g) => g.items)] + .map((it) => it.section.chunk_id).sort(); + assert.deepEqual(partIds, flatIds); +}); + +test('partitionOutlineItems: 비-PART top-segment 항목은 직전 PART 로 carry-forward (marker 트리 불규칙 흡수)', () => { + // ★ 5180 실측 패턴: PART 아래 직접 중첩 안 된 항목('PG-28'·'GENERAL')의 top-segment 가 PART 가 + // 아니다 → 단순 segs[0] 그룹핑이면 가짜 그룹 폭발. carry-forward 가 직전 PART 로 흡수해야 한다. + const items = collapseWindows([ + sec({ heading_path: 'TOC', section_title: 'TOC' }), + sec({ heading_path: 'PART PG GENERAL > PG-1', section_title: 'PG-1' }), + sec({ heading_path: 'PG-28 EXTERNAL PRESSURE', section_title: 'PG-28' }), // top-seg ≠ PART → carry + sec({ heading_path: 'OPENINGS AND COMPENSATION', section_title: 'OPENINGS' }), // carry + sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), + sec({ heading_path: 'GENERAL', section_title: 'GENERAL' }), // PART PW 로 carry + ]); + const o = partitionOutlineItems(items); + assert.equal(o.hasParts, true); + assert.equal(o.frontMatter.length, 1); + assert.equal(o.groups.length, 2, 'PART PG / PART PW 단 2그룹(가짜 그룹 0)'); + assert.equal(o.groups[0].key, 'PART PG GENERAL'); + assert.equal(o.groups[0].items.length, 3, 'PG-1 + PG-28 + OPENINGS carry'); + assert.equal(o.groups[1].key, 'PART PW'); + assert.equal(o.groups[1].items.length, 2, 'PW-1 + GENERAL carry'); + // carry 된 항목도 인스턴스 보존(클릭 정합) + assert.ok(o.groups[0].items[1].section.section_title === 'PG-28'); +}); + +test('partitionOutlineItems: buildPartOutline 과 그룹 구조 동치(collapse→partition == partition∘collapse)', () => { + const sections = [ + sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }), + sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b1' }), + sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b2' }), + sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), + ]; + const viaBuild = buildPartOutline(sections); + const viaPartition = partitionOutlineItems(collapseWindows(sections)); + assert.equal(viaBuild.hasParts, viaPartition.hasParts); + assert.deepEqual(viaBuild.groups.map((g) => [g.key, g.items.length]), viaPartition.groups.map((g) => [g.key, g.items.length])); + // window 흡수 후 PART PG 는 1 항목(fragmentCount 2). + assert.equal(viaPartition.groups[0].items.length, 1); + assert.equal(viaPartition.groups[0].items[0].fragmentCount, 2); +}); + +// ── D8: partGroupViews / groupKeyByChunkId — 렌더 그룹 평탄화 + auto-expand 역인덱스 ── +test('partGroupViews: front-matter 를 첫 그룹(sentinel key)으로, 이어 PART 그룹', () => { + const sections = [ + sec({ heading_path: 'TOC', section_title: 'TOC' }), + sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }), + sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), + ]; + const views = partGroupViews(buildPartOutline(sections)); + assert.equal(views.length, 3); + assert.equal(views[0].key, FRONT_MATTER_KEY); + assert.equal(views[0].label, FRONT_MATTER_LABEL); + assert.equal(views[0].isFrontMatter, true); + assert.equal(views[1].key, 'PART PG'); + assert.equal(views[1].label, 'PART PG'); + assert.equal(views[1].isFrontMatter, false); + assert.equal(views[2].key, 'PART PW'); + // 모든 key 유일(Svelte each key 안전) + const keys = views.map((v) => v.key); + assert.equal(new Set(keys).size, keys.length); +}); + +test('partGroupViews: front-matter 없으면 PART 그룹만(첫 그룹 sentinel 없음)', () => { + const sections = [ + sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }), + sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), + ]; + const views = partGroupViews(buildPartOutline(sections)); + assert.equal(views.length, 2); + assert.ok(views.every((v) => !v.isFrontMatter)); + assert.equal(views[0].key, 'PART PG'); +}); + +test('groupKeyByChunkId: 대표 chunk_id → 소속 group key (auto-expand 역인덱스)', () => { + const sections = [ + sec({ chunk_id: 1, heading_path: 'TOC', section_title: 'TOC' }), + sec({ chunk_id: 2, heading_path: 'PART PG > PG-1', section_title: 'PG-1' }), + sec({ chunk_id: 3, heading_path: 'PART PG > PG-2', section_title: 'PG-2' }), + sec({ chunk_id: 4, heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), + ]; + const views = partGroupViews(buildPartOutline(sections)); + const idx = groupKeyByChunkId(views); + assert.equal(idx.get(1), FRONT_MATTER_KEY); + assert.equal(idx.get(2), 'PART PG'); + assert.equal(idx.get(3), 'PART PG'); + assert.equal(idx.get(4), 'PART PW'); + assert.equal(idx.get(999), undefined); +}); diff --git a/frontend/src/lib/utils/headingPath.ts b/frontend/src/lib/utils/headingPath.ts index 8d22215..2259233 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Þ(`<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,129 @@ 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 만으로 산출. + +/** + * 최상위 섹션 경계 top-segment 패턴: 대문자 'PART'/'SUBSECTION'/'(MANDATORY|NONMANDATORY) APPENDIX' + * + 대문자 코드(PG/UW/IV/A) + 선택 제목(대문자/숫자/괄호 시작). + * 예: 'PART PG GENERAL REQUIREMENTS…', 'SUBSECTION A GENERAL', 'NONMANDATORY APPENDIX A EXPLANATION…'. + * 부록(APPENDIX)도 ASME 최상위 섹션(파트와 동격)이라 별 그룹으로 — 안 그러면 마지막 PART 가 부록 전체를 + * carry-forward 로 흡수(5180 실측: PART PHRSG 11항목 → 부록 289 흡수 = 300). + * + * ★ case-sensitive + 제목-대문자 가드 = 본문 cross-ref/문장 false match 차단(5210 실측): + * 'Part D, Subpart 3의 …'(혼합대소문자) · 'PART UW 규정은 용접에 …'(코드 뒤 한글 문장) · 'PART 층이 진 …' + * (코드 비대문자) 전부 거부. D1 빌더 _ENG 가드의 read-time 대응([[feedback_docstring_invariant_swap_audit]]). + * ⚠ 알려진 트레이드오프(D3 재검토): 제목-대문자 가드는 비영문(한글) 제목으로 시작하는 PART 도 거부한다 + * (예: 'PART PG 일반 요건'). false-negative(→flat 폴백)는 false-positive(→가짜 그룹)보다 안전한 방향이라 + * 파일럿(5180 영문)엔 옳고 5210(D3 재분해 전 한글 stale)은 flat 폴백된다. **5210 D3 재분해 후 실 PART + * 제목 형태(영문/한글/코드만)를 보고 가드를 정련** — read-time 라 마이그 0. [[project_hierarchical_decomposition]] D3. + */ +const PART_MARKER_RE = /^((MANDATORY |NONMANDATORY )?APPENDIX|PART|SUBSECTION)\s+[A-Z][A-Z0-9.\-]*(\s+[A-Z0-9(].*)?$/; + +/** top-segment 문자열이 PART/SUBSECTION/APPENDIX 헤딩인가 (마커 판정 단일 소스 — 경계·carry 공용). */ +function isPartMarkerSeg(seg0: string): boolean { + return PART_MARKER_RE.test(seg0); +} + +/** 절의 heading_path 첫 세그먼트가 PART/SUBSECTION/APPENDIX 헤딩 = 새 최상위 섹션 경계. */ +function isPartMarker(s: DocumentSection): boolean { + const segs = pathSegments(s.heading_path); + return segs.length > 0 && isPartMarkerSeg(segs[0]); +} + +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; +} + +/** + * 이미 collapseWindows 된 OutlineItem[] 를 front-matter(첫 PART 마커 전) 분리 + 본문을 PART 로 + * **순서 기반 carry-forward** 그룹. 정렬(chunk_index) 유지. + * + * ★ carry-forward 가 핵심: 실 ASME md 는 marker 추출 트리가 불규칙해 'PG-28'·'GENERAL' 등 다수 + * 항목의 heading_path 첫 세그먼트가 PART 가 아니다(자기 자신/중간 헤딩). 단순 segs[0] 그룹핑은 + * 250+ 가짜 그룹을 낳는다(5180 실측). → PART/SUBSECTION 마커를 만나면 새 그룹을 열고, 비-마커 + * 항목은 직전 PART 로 흡수 = 실제 ~13 PART 로 수렴. + * ★ 같은 OutlineItem 인스턴스를 재배치만 한다(재-collapse 없음) → 호출자의 flat outline 과 + * chunk_id·인스턴스가 1:1 일치(상세페이지 treeNav 가 selectedSectionId/focusView 와 정합). + * PART 마커가 0 이면 hasParts=false → 호출자가 groupOrFlat/flat 으로 폴백. + */ +export function partitionOutlineItems(items: OutlineItem[]): PartOutline { + let boundary = -1; + for (let i = 0; i < items.length; i++) { + if (isPartMarker(items[i].section)) { boundary = i; break; } + } + if (boundary < 0) { + return { frontMatter: [], groups: [], hasParts: false }; + } + const frontMatter = items.slice(0, boundary); + + const order: string[] = []; + const map = new Map<string, OutlineItem[]>(); + let current = ''; // 현재 PART 키 — boundary 가 PART 마커라 첫 본문 항목에서 즉시 설정됨. + for (let i = boundary; i < items.length; i++) { + const it = items[i]; + const segs = pathSegments(it.section.heading_path); + if (segs.length && isPartMarkerSeg(segs[0])) current = segs[0]; // 새 PART 경계(경계 루프와 동일 판정 = '' 누출 불가) + if (!map.has(current)) { map.set(current, []); order.push(current); } + map.get(current)!.push(it); + } + const groups: OutlineGroup[] = order.map((key) => ({ key, isOther: false, items: map.get(key)! })); + return { frontMatter, groups, hasParts: true }; +} + +/** + * front-matter 경계(첫 content part) 분리 + 본문을 PART(heading_path 첫 세그먼트)로 그룹. + * = collapseWindows 후 partitionOutlineItems (절뷰 rail/treeNav 공용 진입점, sections 기반). + */ +export function buildPartOutline(sections: DocumentSection[]): PartOutline { + return partitionOutlineItems(collapseWindows(sections)); +} + +// ── D8: Part 접이 렌더용 — front-matter 를 첫 그룹으로 평탄화 + auto-expand 역인덱스 ── + +/** front-matter 접이 그룹의 안정 key/라벨(실 PART 키와 충돌 불가능한 sentinel). */ +export const FRONT_MATTER_KEY = '__front_matter__'; +export const FRONT_MATTER_LABEL = '문서 정보·서문'; + +/** 접이 그룹 1개(front-matter 또는 PART) 의 렌더 뷰. */ +export interface PartGroupView { + /** Svelte each key + 접이 상태 key. front-matter = FRONT_MATTER_KEY. */ + key: string; + /** 헤더 표시 라벨. */ + label: string; + isFrontMatter: boolean; + items: OutlineItem[]; +} + +/** + * PartOutline → 렌더 그룹 배열. front-matter(있으면)를 항상 첫 그룹으로, + * 이어서 PART 그룹들. 기본 접힘/auto-expand 는 컴포넌트가 key 로 관리. + */ +export function partGroupViews(outline: PartOutline): PartGroupView[] { + const views: PartGroupView[] = []; + if (outline.frontMatter.length) { + views.push({ key: FRONT_MATTER_KEY, label: FRONT_MATTER_LABEL, isFrontMatter: true, items: outline.frontMatter }); + } + for (const g of outline.groups) { + views.push({ key: g.key, label: g.key, isFrontMatter: false, items: g.items }); + } + return views; +} + +/** + * 대표 OutlineItem 의 chunk_id → 소속 group key 역인덱스(딥링크/스크롤스파이 진입 시 + * 조상 그룹 auto-expand 용). activeKey/selectedSectionId 는 대표 chunk_id 라 대표만 매핑. + */ +export function groupKeyByChunkId(views: PartGroupView[]): Map<number, string> { + const m = new Map<number, string>(); + for (const v of views) for (const it of v.items) m.set(it.section.chunk_id, v.key); + return m; +} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 3583370..941f31a 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -24,7 +24,8 @@ import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte'; import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte'; import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte'; - import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows } from '$lib/utils/headingPath'; + import { untrack } from 'svelte'; + import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows, partitionOutlineItems, partGroupViews, groupKeyByChunkId } from '$lib/utils/headingPath'; import { domainLabel } from '$lib/utils/domainSlug'; marked.use({ mangle: false, headerIds: false }); @@ -69,6 +70,13 @@ // 강등한다(예: 5180 = 27개 논리 절 → 562 window). raw sections 를 그대로 그리면 동일 제목 수백 행으로 // 파편화되므로, collapseWindows 로 논리 절 1개(대표=split-parent, bodyText=window 본문 합본)로 합친다. let outline = $derived(collapseWindows(sections)); + // Part 접이 트리(ASME 등 hasParts): 같은 outline 인스턴스를 front-matter/PART 로 재배치(재-collapse 없음 + // → selectedSectionId/focusView 정합). flat 1030 → front-matter 단일그룹 + ~14 PART 접이. (D8) + let treePart = $derived(partitionOutlineItems(outline)); + let treeGroups = $derived(treePart.hasParts ? partGroupViews(treePart) : null); + let treeGroupIndex = $derived(treeGroups ? groupKeyByChunkId(treeGroups) : null); + let treeExpanded = $state({}); // key 없으면 접힘(기본 전부 접힘). Svelte5 deep-proxy 반응형. + function toggleTreeGroup(key) { treeExpanded[key] = !treeExpanded[key]; } async function loadSections() { const reqId = docId; try { const r = await api(`/documents/${reqId}/sections`); if (reqId === docId) sections = r?.sections ?? []; } @@ -116,7 +124,31 @@ let mTree = $state(false); let mIns = $state(false); let manageOpen = $state(false); - $effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = outline[0].section.chunk_id; }); + // 기본 선택 = 첫 본문 Part 의 첫 절(front-matter TOC 가 아니라 실제 내용으로 진입, front-matter 접힘 유지). + let defaultSelId = $derived.by(() => { + if (treeGroups) { + const body = treeGroups.find((g) => !g.isFrontMatter); + if (body && body.items.length) return body.items[0].section.chunk_id; + } + return outline[0]?.section.chunk_id ?? null; + }); + $effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = defaultSelId; }); + // 문서가 바뀌면(sections 교체) Part 접이·모바일 본문 펼침 상태 리셋 — 문서 간 PART 라벨/chunk_id 가 + // 겹쳐 이전 상태가 이월되는 것 차단(기본 전부 접힘 보존). ※ 같은 컴포넌트 인스턴스로 client 네비 시 + // sections 가 재로딩될 때만 발화 — 현재 [id] 페이지는 onMount 1회 로딩이라 SPA prev/next 미reload 는 + // 선존 별도 이슈(D8 범위 밖, 사용자 보고 대상). + $effect(() => { + void sections; + untrack(() => { treeExpanded = {}; mBodyOpen = {}; }); + }); + // 선택 절의 조상 Part 를 펼침(prev/next·딥링크 진입 시 트리에서 자동 노출). untrack=쓰기 자기재발화 차단. + $effect(() => { + const sel = selectedSectionId; + const idx = treeGroupIndex; + if (sel == null || !idx) return; + const gk = idx.get(sel); + if (gk) untrack(() => { treeExpanded[gk] = true; }); + }); let selectedItem = $derived(outline.find((it) => it.section.chunk_id === selectedSectionId) ?? outline[0] ?? null); let selectedSection = $derived(selectedItem?.section ?? null); let selIdx = $derived(outline.findIndex((it) => it.section.chunk_id === selectedItem?.section?.chunk_id)); @@ -168,13 +200,14 @@ <span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#7a8b3f;"></span>절차</span> <span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#b5840a;"></span>요건</span> </div> - {#each outline as it (it.section.chunk_id)} + {#snippet treeNode(it)} {@const s = it.section} {@const tm = typeMeta(it.sectionType)} {@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id} {@const child = secDepth(s) > 0} {@const low = isMidLow(it.confidence)} - <svelte:element this={jumpMode ? 'a' : 'div'} href={jumpMode ? `#m-sec-${s.chunk_id}` : undefined} role="button" tabindex="0" + <svelte:element this={jumpMode ? 'a' : 'div'} href={jumpMode ? `#m-sec-${s.chunk_id}` : undefined} + role={jumpMode ? undefined : 'button'} tabindex={jumpMode ? undefined : 0} onclick={() => !jumpMode && (selectedSectionId = s.chunk_id)} onkeydown={(e) => { if (!jumpMode && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectedSectionId = s.chunk_id; } }} class="d3node {child ? 'd3child' : ''} {active ? 'd3active' : ''}" @@ -189,7 +222,25 @@ {/if} </div> </svelte:element> - {/each} + {/snippet} + + {#if treeGroups} + <!-- Part 접이(ASME 등): front-matter 단일그룹 + PART 접이, 기본 접힘. 선택/딥링크 시 조상 Part auto-expand. --> + {#each treeGroups as g (g.key)} + {@const isOpen = !!treeExpanded[g.key]} + <button type="button" class="d3grp" aria-expanded={isOpen} onclick={() => toggleTreeGroup(g.key)} + style="display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:none;border:none;cursor:pointer;border-radius:8px;padding:6px 8px;margin:4px 0 1px;"> + <span style="transition:transform .16s;transform:rotate({isOpen ? 90 : 0}deg);color:#9aa090;font-weight:700;font-size:12px;flex-shrink:0;">›</span> + <span style="flex:1;min-width:0;font-size:11px;font-weight:700;color:{g.isFrontMatter ? '#9aa090' : '#697061'};letter-spacing:.3px;text-transform:uppercase;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{g.label}</span> + <span style="font-size:10px;color:#9aa090;font-variant-numeric:tabular-nums;flex-shrink:0;">{g.items.length}</span> + </button> + {#if isOpen} + {#each g.items as it (it.section.chunk_id)}{@render treeNode(it)}{/each} + {/if} + {/each} + {:else} + {#each outline as it (it.section.chunk_id)}{@render treeNode(it)}{/each} + {/if} {#if quality} <div style="margin-top:12px;padding-top:10px;border-top:1px solid #dde3d6;"> <div style="font-size:10.5px;font-weight:700;color:#697061;margin-bottom:7px;letter-spacing:.3px;">추출 품질</div> @@ -387,7 +438,7 @@ {#if useSectionView} <!-- 데스크탑(xl+): 3영역 --> <div class="hidden xl:grid" style="grid-template-columns:252px minmax(0,1fr) 336px;gap:13px;align-items:start;"> - <div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh-2rem);overflow-y:auto;">{@render treeNav(false)}</div> + <div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh - 2rem);overflow-y:auto;">{@render treeNav(false)}</div> <div style="min-width:0;"><div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:20px 22px;">{@render focusView()}</div></div> <div style="position:sticky;top:14px;">{@render rail()}</div> </div> @@ -400,6 +451,9 @@ </div> {#if mTree}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:6px;margin-bottom:10px;">{@render treeNav(true)}</div>{/if} {#if mIns}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:13px 14px;margin-bottom:10px;">{@render rail()}</div>{/if} + <!-- D8 스코프 한계(의도적): 모바일 본문은 전체 outline(~1030)을 연속 카드로 eager 마운트한다. + Part 접이는 위 treeNav(앵커 점프 네비)에만 적용 — 본문 롱스크롤은 줄이지 않는다. 데스크탑은 + focusView 가 단일 절만 렌더하므로 무관. 모바일 본문 분할/가상화는 별 follow-up. --> <div style="display:flex;flex-direction:column;gap:10px;">{#each outline as it (it.section.chunk_id)}{@render sectionCard(it)}{/each}</div> </div> {:else} @@ -474,6 +528,7 @@ <style> .d3node:hover { background: #ecf0e8; } .d3active:hover { background: #e3ebdf; } + .d3grp:hover { background: #ecf0e8; } .d3child { position: relative; } .d3child::before { content: ""; position: absolute; left: 2px; top: -3px; bottom: 50%; width: 1px; background: #cdd6c4; } .d3child::after { content: ""; position: absolute; left: 2px; top: 50%; width: 7px; height: 1px; background: #cdd6c4; } diff --git a/tests/hier_decomp/test_eng_matcher.py b/tests/hier_decomp/test_eng_matcher.py new file mode 100644 index 0000000..7705bb0 --- /dev/null +++ b/tests/hier_decomp/test_eng_matcher.py @@ -0,0 +1,106 @@ +"""_ENG 매처 노이즈 차단 단위테스트 (asme-item-decomp-1 D1). + +핵심 불변식: 영문 구조 헤딩 매처(_ENG)가 + - (음성) 본문 중간 'Part III to demonstrate…' 같은 소문자 문장연속을 가짜 절로 잡지 않고, + - (양성) 진짜 영문 구조 헤딩(PART PG / Part 1 / Section 3.31 / Part UHX …)은 탐지하며, + - (ATX 보존) _ENG 축소가 ATX 파트(`# PART PG`)·항목(`#### PG-1`)을 떨구지 않는다(ATX 우선). + +pytest + 단독 실행 양쪽 지원: + PYTHONPATH=. python3 tests/hier_decomp/test_eng_matcher.py +""" +from __future__ import annotations + +try: # pytest 경로 (앱 패키지) + from app.services.hier_decomp.builder import _detect_heading, build_hier_tree +except Exception: # 단독 실행 (앱 deps 없이 builder.py 직접 로드 — stdlib only) + import importlib.util + import pathlib + import sys + + _bp = pathlib.Path(__file__).resolve().parents[2] / "app/services/hier_decomp/builder.py" + _spec = importlib.util.spec_from_file_location("_hier_builder_t", _bp) + _m = importlib.util.module_from_spec(_spec) + sys.modules[_spec.name] = _m # dataclass __module__ 해소 + _spec.loader.exec_module(_m) + _detect_heading, build_hier_tree = _m._detect_heading, _m.build_hier_tree + + +# ── 음성: 본문 문장은 헤딩 아님 (가짜 절 차단 — D1 회귀의 핵심) ── +NEG = [ + "Part III to demonstrate to the satisfaction of the represen-", + "Section V of the agreement applies to all parties", + "Part IV is hereby amended as follows", + "Article II shall be interpreted broadly", + "Chapter 3 describes the general method used here", +] + +# ── 양성: 진짜 영문 구조 헤딩 ── +POS = [ + "PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION", + "Part 1", + "Part PFH", + "Part UHX (TUBESHEET CALCULATION)", + "Section 3.31", + "Chapter 1 Introduction", + "Article 5 Definitions", +] + + +def test_eng_negatives_not_detected(): + for line in NEG: + assert _detect_heading(line) is None, f"가짜 절로 잡힘: {line!r}" + + +def test_eng_positives_detected_as_chapter(): + for line in POS: + r = _detect_heading(line) + assert r is not None, f"진짜 헤딩 미탐지: {line!r}" + _lvl, _title, nt = r + assert nt == "chapter", f"{line!r} node_type={nt}" + + +def test_atx_part_and_item_still_detected(): + # _ENG 축소가 진짜 ATX 파트/항목을 떨구지 않음 (ATX 우선 탐지) + r = _detect_heading("# PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION") + assert r is not None + lvl, title, nt = r + assert lvl == 1 and nt is None, r # ATX = level(# 수), node_type None + assert title.startswith("PART PG") + r2 = _detect_heading("#### PG-1 SCOPE") + assert r2 is not None and r2[0] == 4 and r2[2] is None, r2 + + +def test_build_hier_tree_drops_false_part_section(): + # 본문에 'Part III to demonstrate…' 가 섞여도 가짜 절이 생기지 않음 + md = ( + "# PART PG GENERAL REQUIREMENTS\n" + "#### PG-1 SCOPE\n" + "The rules cover power boilers.\n" + "Part III to demonstrate to the satisfaction of the representative\n" + "that the requirements are met, the manufacturer shall proceed...\n" + "#### PG-2 SERVICE LIMITATIONS\n" + "body of pg-2 here.\n" + ) + titles = [n.section_title for n in build_hier_tree(md) if n.section_title] + assert any(t.startswith("PART PG") for t in titles), titles + assert any(t.startswith("PG-1") for t in titles), titles + assert any(t.startswith("PG-2") for t in titles), titles + assert not any("demonstrate" in (t or "") for t in titles), f"가짜 절 누출: {titles}" + + +if __name__ == "__main__": + import sys + import traceback + + fns = [(k, v) for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failed = 0 + for name, fn in fns: + try: + fn() + print(f"PASS {name}") + except Exception as e: + failed += 1 + print(f"FAIL {name}: {e}") + traceback.print_exc() + print(f"\n{len(fns) - failed}/{len(fns)} passed") + sys.exit(1 if failed else 0)