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:
@@ -680,6 +680,8 @@ class SectionItem(BaseModel):
|
|||||||
level: int | None = None
|
level: int | None = None
|
||||||
node_type: str | None = None # window | chapter_split | clause_split | section_split | null
|
node_type: str | None = None # window | chapter_split | clause_split | section_split | null
|
||||||
is_leaf: bool
|
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)
|
char_start: int | None = None # md_content 내 heading offset(UTF-16). jump-target 만 값, 그 외 None (Path B)
|
||||||
text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존
|
text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존
|
||||||
# (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라
|
# (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라
|
||||||
@@ -722,12 +724,12 @@ async def get_document_sections(
|
|||||||
await session.execute(
|
await session.execute(
|
||||||
sql_text(
|
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
|
text, section_type, summary, confidence
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT ON (c.id)
|
SELECT DISTINCT ON (c.id)
|
||||||
c.id AS chunk_id, c.chunk_index, c.section_title, c.heading_path,
|
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,
|
a.section_type,
|
||||||
CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary,
|
CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary,
|
||||||
a.confidence
|
a.confidence
|
||||||
|
|||||||
@@ -132,6 +132,25 @@ test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤
|
|||||||
assert.deepEqual(none[0].summaries, []);
|
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-류)', () => {
|
test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => {
|
||||||
// 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0%
|
// 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0%
|
||||||
const sections: DocumentSection[] = [];
|
const sections: DocumentSection[] = [];
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface DocumentSection {
|
|||||||
level: number | null;
|
level: number | null;
|
||||||
node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
|
node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
|
||||||
is_leaf: boolean;
|
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). */
|
/** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
|
||||||
char_start?: number | null;
|
char_start?: number | null;
|
||||||
/** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
|
/** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
|
||||||
@@ -138,19 +140,41 @@ function majorityType(types: (string | null)[]): string | null {
|
|||||||
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
|
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
|
||||||
const out: OutlineItem[] = [];
|
const out: OutlineItem[] = [];
|
||||||
const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child)
|
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) {
|
for (const s of sections) {
|
||||||
const prev = out[out.length - 1];
|
if (s.node_type === 'window') {
|
||||||
const h = cleanHeading(s.heading_path);
|
// 1) parent_id 로 split-parent 대표에 흡수 — split-parent 와 window 가 chunk_index 상 비인접일 수
|
||||||
const prevAbsorbs =
|
// 있으므로(예: 헤딩 1143, window 1233) 인접 가정 대신 트리 부모 링크로 정확히 연결한다.
|
||||||
prev &&
|
let idx = s.parent_id != null ? repByChunkId.get(s.parent_id) ?? -1 : -1;
|
||||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
// 2) fallback: 인접 대표(legacy window run / 같은 heading split)면 흡수
|
||||||
h !== '' &&
|
if (idx < 0) {
|
||||||
cleanHeading(prev.section.heading_path) === h;
|
const prev = out[out.length - 1];
|
||||||
if (s.node_type === 'window' && prevAbsorbs) {
|
const h = cleanHeading(s.heading_path);
|
||||||
prev!.fragmentCount += 1; // window child 흡수 — 대표(split-parent 우선)는 그대로 유지
|
if (
|
||||||
const t = (s.text ?? '').trim(); // 흡수한 조각 본문을 대표 bodyText 에 이어붙임
|
prev &&
|
||||||
if (t) prev!.bodyText = prev!.bodyText ? `${prev!.bodyText}\n\n${t}` : t;
|
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||||
members[members.length - 1].push(s);
|
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 {
|
} else {
|
||||||
const isSplit = !!s.node_type?.endsWith('_split');
|
const isSplit = !!s.node_type?.endsWith('_split');
|
||||||
// split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
|
// split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
|
||||||
@@ -159,6 +183,7 @@ export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
|
|||||||
sectionType: null, confidence: null, summaries: [],
|
sectionType: null, confidence: null, summaries: [],
|
||||||
});
|
});
|
||||||
members.push([s]);
|
members.push([s]);
|
||||||
|
if (isSplit) repByChunkId.set(s.chunk_id, out.length - 1); // window 가 parent_id 로 찾아 흡수
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
|
// 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
|
||||||
|
|||||||
Reference in New Issue
Block a user