From b6a4821cac27c4194c1d11b2fab37ce577689362 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 14 Jun 2026 07:10:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(docpage):=20=EC=A0=88=20=EB=B3=B8=EB=AC=B8?= =?UTF-8?q?=EC=9D=84=20=EC=B2=AD=ED=81=AC=20text=EB=A1=9C=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20+=20window=20=EC=A1=B0=EA=B0=81=20collapse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대형 split 문서는 marker 가 md_content 를 앞 5만 자만 보존하고 char_start 도 NULL 이라 char_start 슬라이스로는 절 본문이 비었다. 전체 본문은 document_chunks.text 에 절별로 보존됨. - /sections API 가 청크 text 반환 (SectionItem.text; 소비자=D3 단독, additive) - collapseWindows 가 window 조각 본문을 대표 절 bodyText 로 합본 (split-parent heading 제외) - D3 페이지가 outline(collapseWindows) 단위로 렌더 → window 파편화 제거 (5180 = 27 논리 절이 562 동일제목 조각으로 쪼개지던 문제) - useSectionView=hasSections 로 단순화(partial/대형 문서도 절뷰), 모바일 본문 lazy 파싱 - headingPath.test.ts: bodyText 누적 회귀 테스트 추가 (10/10 pass) Co-Authored-By: Claude Fable 5 --- app/api/documents.py | 7 ++- frontend/src/lib/utils/headingPath.test.ts | 23 ++++++++ frontend/src/lib/utils/headingPath.ts | 11 +++- .../src/routes/documents/[id]/+page.svelte | 59 +++++++++++-------- 4 files changed, 71 insertions(+), 29 deletions(-) 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 @@
절 구조
- {sections.length}절 + {outline.length}절
정의 절차 요건
- {#each sections as s (s.chunk_id)} + {#each outline as it (it.section.chunk_id)} + {@const s = it.section} {@const tm = typeMeta(s.section_type)} {@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id} {@const child = secDepth(s) > 0} @@ -231,10 +237,11 @@ {/if}
{#if selIdx > 0} - + {@const pv = outline[selIdx - 1].section} + {:else}{/if} - {#if selIdx < sections.length - 1} - {@const nx = sections[selIdx + 1]} + {#if selIdx >= 0 && selIdx < outline.length - 1} + {@const nx = outline[selIdx + 1].section} {:else}{/if}
@@ -298,9 +305,9 @@ {/snippet} -{#snippet sectionCard(s)} +{#snippet sectionCard(it)} + {@const s = it.section} {@const tm = typeMeta(s.section_type)} - {@const body = sectionBodyHtml(s)}

{secTitle(s)}

@@ -312,10 +319,10 @@ {#if s.summary}
절 요약
{s.summary}
{/if} - {#if body} -
+ {#if it.bodyText} +
{ if (e.currentTarget.open) mBodyOpen[s.chunk_id] = true; }}> 본문 보기 -
{@html body}
+ {#if mBodyOpen[s.chunk_id]}
{@html bodyHtml(it)}
{/if}
{/if}
@@ -371,12 +378,12 @@
- +
{#if mTree}
{@render treeNav(true)}
{/if} {#if mIns}
{@render rail()}
{/if} -
{#each sections as s (s.chunk_id)}{@render sectionCard(s)}{/each}
+
{#each outline as it (it.section.chunk_id)}{@render sectionCard(it)}{/each}
{:else}