fix(docpage): 비인접 window 를 parent_id 로 split-parent 에 흡수 (빈 본문 절 수정)

split-parent(절 헤딩)와 그 window 조각이 chunk_index 상 비인접인 경우(예: 5180 FOREWORD
헤딩 idx 1143, window idx 1233~)가 있어, 인접 흡수만 하던 collapseWindows 가 split-parent 를
빈 본문 행으로 남기고 window 들은 따로 대표 행을 만들어 "같은 제목 2행(빈 것 + 본문 있는 것)" 이
됐다. 사용자가 "본문 없는 절" 로 본 것.

- /sections API 에 parent_id 반환 (window.parent_id = 그 split-parent chunk_id, 100% _split 링크)
- collapseWindows 가 window 를 parent_id 로 split-parent 대표에 흡수(비인접 허용), 인접 heading
  fallback 유지(legacy window). 흡수 멤버에서 본문/분석 집계.
- 회귀 테스트: 비인접 parent_id 흡수 (12/12 pass)

실데이터 검증(빈 본문→0): 5180 outline 85→58·5210 318→277·5178 73→49·5151 45→40, 전부 EMPTY_BODY=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-14 07:46:18 +09:00
parent d007ad5492
commit c5bc1f773d
3 changed files with 60 additions and 14 deletions
@@ -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[] = [];
+37 -12
View File
@@ -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<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) {
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 들이 보유).