diff --git a/app/api/documents.py b/app/api/documents.py index d2b847b..f65d894 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -681,6 +681,9 @@ class SectionItem(BaseModel): node_type: str | None = None # window | chapter_split | clause_split | section_split | null is_leaf: bool 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 이라 + # md_content 슬라이스로는 본문이 비므로, 청크 text 를 직접 렌더한다. section_type: str | None = None summary: str | None = None # status='summarized' 인 분석행에만, 그 외 None confidence: float | None = None @@ -720,11 +723,11 @@ async def get_document_sections( sql_text( """ SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start, - section_type, summary, confidence + 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.level, c.node_type, c.is_leaf, 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 72334e6..97cf468 100644 --- a/frontend/src/lib/utils/headingPath.test.ts +++ b/frontend/src/lib/utils/headingPath.test.ts @@ -83,6 +83,29 @@ test('[C2] collapseWindows: split-parent + window 들 → rail 1행, 대표=spli assert.equal(out[0].fragmentCount, 2, 'window 조각 수 = 2 (split-parent 자신 제외)'); }); +test('collapseWindows: bodyText — 정상 leaf 는 자기 본문, split-parent 는 window 본문만 이어붙임', () => { + // 정상 leaf → 자기 text 가 본문 + const leaf = collapseWindows([sec({ heading_path: 'Intro', node_type: null, text: '서론 본문' })]); + assert.equal(leaf[0].bodyText, '서론 본문'); + + // split-parent(heading 줄뿐) + window 2개 → window 본문만 순서대로 합침(헤딩 제외) + const split = collapseWindows([ + sec({ heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120, text: '# Article 5' }), + sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각1' }), + sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각2' }), + ]); + assert.equal(split.length, 1); + assert.equal(split[0].bodyText, '본문 조각1\n\n본문 조각2', 'split-parent heading 제외, window 본문만 합침'); + + // legacy window 런(선행 split-parent 없음) → 첫 window 자기 본문 + 흡수 조각 + const legacy = collapseWindows([ + sec({ heading_path: 'Pearson', node_type: 'window', text: 'p1' }), + sec({ heading_path: 'Pearson', node_type: 'window', text: 'p2' }), + ]); + assert.equal(legacy.length, 1); + assert.equal(legacy[0].bodyText, 'p1\n\np2'); +}); + 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 b8132f0..0e4734a 100644 --- a/frontend/src/lib/utils/headingPath.ts +++ b/frontend/src/lib/utils/headingPath.ts @@ -16,6 +16,8 @@ export interface DocumentSection { is_leaf: boolean; /** 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 가 실 본문 보유. */ + text?: string | null; section_type: string | null; summary: string | null; confidence: number | null; @@ -25,6 +27,9 @@ export interface DocumentSection { export interface OutlineItem { section: DocumentSection; fragmentCount: number; // >1 이면 "(n조각)" 배지 + /** 대표 + 흡수된 window child 들의 본문을 순서대로 이어붙인 논리 절 전체 본문. + * split-parent 는 heading 줄(text)을 본문에서 제외(제목과 중복) — window 본문만 합친다. */ + bodyText: string; } export interface OutlineGroup { @@ -119,8 +124,12 @@ export function collapseWindows(sections: DocumentSection[]): OutlineItem[] { 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; } else { - out.push({ section: s, fragmentCount: s.node_type?.endsWith('_split') ? 0 : 1 }); + const isSplit = !!s.node_type?.endsWith('_split'); + // split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작. + out.push({ section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? '') }); } } return out; diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index fde5cfe..7efa415 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -23,7 +23,7 @@ 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 } from '$lib/utils/headingPath'; + import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows } from '$lib/utils/headingPath'; import { domainLabel } from '$lib/utils/domainSlug'; marked.use({ mangle: false, headerIds: false }); @@ -64,6 +64,10 @@ // 절 목차 let sections = $state([]); let hasSections = $derived(sections.length > 0); + // 과대 절은 builder 가 window 조각(같은 제목·is_leaf)으로 분해하고 부모를 heading 만 남긴 split-parent 로 + // 강등한다(예: 5180 = 27개 논리 절 → 562 window). raw sections 를 그대로 그리면 동일 제목 수백 행으로 + // 파편화되므로, collapseWindows 로 논리 절 1개(대표=split-parent, bodyText=window 본문 합본)로 합친다. + let outline = $derived(collapseWindows(sections)); async function loadSections() { const reqId = docId; try { const r = await api(`/documents/${reqId}/sections`); if (reqId === docId) sections = r?.sections ?? []; } @@ -85,7 +89,9 @@ let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'); let canShowMarkdown = $derived(!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim())); - let useSectionView = $derived(hasSections && canShowMarkdown && !!doc?.md_content); + // 절 본문은 청크 text(절별 원문)에서 오므로 md_content 성공/존재와 무관. + // hasSections 만으로 절뷰 사용 → partial / 대형 split(md_content 5만 자 절단) 문서도 절뷰 표시. + let useSectionView = $derived(hasSections); let pdfViewMode = $state('markdown'); let lastDocId = $state(null); @@ -109,18 +115,17 @@ let mTree = $state(false); let mIns = $state(false); let manageOpen = $state(false); - $effect(() => { if (sections.length && !sections.some((s) => s.chunk_id === selectedSectionId)) selectedSectionId = sections[0].chunk_id; }); - let selectedSection = $derived(sections.find((s) => s.chunk_id === selectedSectionId) ?? sections[0] ?? null); - let selIdx = $derived(sections.findIndex((s) => s.chunk_id === selectedSection?.chunk_id)); - let sortedSecs = $derived([...sections].filter((s) => s.char_start != null).sort((a, b) => a.char_start - b.char_start)); - function sectionBodyHtml(sec) { - if (!doc?.md_content || !sec || sec.char_start == null) return ''; - const idx = sortedSecs.findIndex((s) => s.chunk_id === sec.chunk_id); - const start = sec.char_start; - const end = idx >= 0 && idx + 1 < sortedSecs.length ? sortedSecs[idx + 1].char_start : doc.md_content.length; - return renderMd(doc.md_content.slice(start, end)); - } - let selectedBodyHtml = $derived(sectionBodyHtml(selectedSection)); + $effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = outline[0].section.chunk_id; }); + 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)); + // 절 본문 = 청크 원문(it.bodyText, window 조각 합본) 직접 렌더. 과거 char_start 로 md_content 를 + // 슬라이스했으나, 대형 split 문서는 md_content 가 앞 5만 자만 보존되고 char_start 도 NULL 이라 본문이 + // 비었다. 청크 text 는 절 전체를 담으므로(절 보유 문서 344개, 본문 합 평균 68KB·max 1.6MB) 그대로 렌더. + function bodyHtml(it) { return it?.bodyText ? renderMd(it.bodyText) : ''; } + let selectedBodyHtml = $derived(bodyHtml(selectedItem)); + // 모바일 연속 카드: 본문은 '본문 보기' 펼칠 때만 파싱(논리 절 수백 개 × marked 즉시 파싱 회피). + let mBodyOpen = $state({}); // 절 유형 색 (시안: 정의 청 / 절차 올리브 / 요건 황) const TYPE_META = { @@ -155,14 +160,15 @@