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:
@@ -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[] = [];
|
||||
|
||||
@@ -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 들이 보유).
|
||||
|
||||
Reference in New Issue
Block a user