diff --git a/app/api/documents.py b/app/api/documents.py index f65d894..31b4722 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -680,6 +680,8 @@ class SectionItem(BaseModel): level: int | None = None node_type: str | None = None # window | chapter_split | clause_split | section_split | null is_leaf: bool + parent_id: int | None = None # 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent. + # 프런트 collapseWindows 가 비인접 window 를 split-parent 에 흡수할 때 사용. char_start: int | None = None # md_content 내 heading offset(UTF-16). jump-target 만 값, 그 외 None (Path B) text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존 # (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라 @@ -722,12 +724,12 @@ async def get_document_sections( await session.execute( sql_text( """ - SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start, + SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, parent_id, char_start, text, section_type, summary, confidence FROM ( SELECT DISTINCT ON (c.id) c.id AS chunk_id, c.chunk_index, c.section_title, c.heading_path, - c.level, c.node_type, c.is_leaf, c.char_start, c.text, + c.level, c.node_type, c.is_leaf, c.parent_id, c.char_start, c.text, a.section_type, CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary, a.confidence diff --git a/frontend/src/lib/utils/headingPath.test.ts b/frontend/src/lib/utils/headingPath.test.ts index 086217a..adb5613 100644 --- a/frontend/src/lib/utils/headingPath.test.ts +++ b/frontend/src/lib/utils/headingPath.test.ts @@ -132,6 +132,25 @@ test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤 assert.deepEqual(none[0].summaries, []); }); +test('collapseWindows: 비인접 window 도 parent_id 로 split-parent 에 흡수 (빈 split 행 방지)', () => { + // 실데이터 버그: split-parent(chunk_index 1143)와 그 window(1233~)가 비인접 → 인접 흡수 실패로 + // 빈 split 행 + 별도 window-그룹 행 2개로 쪼개짐. parent_id 링크로 정확히 합친다. + const out = collapseWindows([ + sec({ chunk_id: 10, heading_path: 'FOREWORD', node_type: 'section_split', is_leaf: false, char_start: 5, text: '# FOREWORD' }), + sec({ chunk_id: 11, heading_path: 'POLICY', node_type: null, text: '정책 본문' }), // 사이에 낀 다른 절 + sec({ chunk_id: 12, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각1', section_type: 'overview', summary: '요약A', confidence: 0.9 }), + sec({ chunk_id: 13, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각2', section_type: 'overview', summary: '요약B', confidence: 0.8 }), + ]); + assert.equal(out.length, 2, 'FOREWORD(split, window 흡수) + POLICY = 2행 (빈 split 행 없음)'); + assert.equal(out[0].section.chunk_id, 10, '대표 = split-parent(char_start 보유)'); + assert.equal(out[0].bodyText, '서문 조각1\n\n서문 조각2', '비인접 window 본문을 split-parent 에 흡수'); + assert.equal(out[0].fragmentCount, 2); + assert.equal(out[0].sectionType, 'overview'); + assert.deepEqual(out[0].summaries, ['요약A', '요약B']); + assert.equal(out[1].section.chunk_id, 11, '사이 낀 절은 별도 행 유지'); + assert.equal(out[1].bodyText, '정책 본문'); +}); + test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => { // 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0% const sections: DocumentSection[] = []; diff --git a/frontend/src/lib/utils/headingPath.ts b/frontend/src/lib/utils/headingPath.ts index 00a0236..8d22215 100644 --- a/frontend/src/lib/utils/headingPath.ts +++ b/frontend/src/lib/utils/headingPath.ts @@ -14,6 +14,8 @@ export interface DocumentSection { 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 가 실 본문 보유. */ @@ -138,19 +140,41 @@ function majorityType(types: (string | null)[]): string | null { export function collapseWindows(sections: DocumentSection[]): OutlineItem[] { const out: OutlineItem[] = []; const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child) + const repByChunkId = new Map(); // 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) { - 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 우선)는 그대로 유지 - const t = (s.text ?? '').trim(); // 흡수한 조각 본문을 대표 bodyText 에 이어붙임 - if (t) prev!.bodyText = prev!.bodyText ? `${prev!.bodyText}\n\n${t}` : t; - members[members.length - 1].push(s); + 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 가 본문 보유). 그 외엔 자기 본문으로 시작. @@ -159,6 +183,7 @@ export function collapseWindows(sections: DocumentSection[]): OutlineItem[] { 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 들이 보유).