diff --git a/frontend/src/lib/utils/headingPath.test.ts b/frontend/src/lib/utils/headingPath.test.ts index 97cf468..086217a 100644 --- a/frontend/src/lib/utils/headingPath.test.ts +++ b/frontend/src/lib/utils/headingPath.test.ts @@ -106,6 +106,32 @@ test('collapseWindows: bodyText — 정상 leaf 는 자기 본문, split-parent assert.equal(legacy[0].bodyText, 'p1\n\np2'); }); +test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤버에서 type 다수결/conf 평균/summaries 합본', () => { + // split-parent(분석 없음) + window 3개(요약·유형·신뢰도 보유) → 대표에 집계 + const out = collapseWindows([ + sec({ heading_path: 'Sec A', node_type: 'section_split', is_leaf: false, char_start: 10, text: '# Sec A', section_type: null, summary: null, confidence: null }), + sec({ heading_path: 'Sec A', node_type: 'window', text: 'b1', section_type: 'requirement', summary: '요약1', confidence: 0.9 }), + sec({ heading_path: 'Sec A', node_type: 'window', text: 'b2', section_type: 'requirement', summary: '요약2', confidence: 0.8 }), + sec({ heading_path: 'Sec A', node_type: 'window', text: 'b3', section_type: 'overview', summary: '', confidence: 1.0 }), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].sectionType, 'requirement', '다수결 = requirement(2) > overview(1)'); + assert.ok(Math.abs(out[0].confidence! - 0.9) < 1e-9, '평균 (0.9+0.8+1.0)/3 = 0.9'); + assert.deepEqual(out[0].summaries, ['요약1', '요약2'], '빈 요약 제외, 순서 유지'); + + // 단일 leaf 는 대표 자신의 분석 + const single = collapseWindows([sec({ heading_path: 'X', node_type: null, text: 'body', section_type: 'definition', summary: '정의 요약', confidence: 0.7 })]); + assert.equal(single[0].sectionType, 'definition'); + assert.equal(single[0].confidence, 0.7); + assert.deepEqual(single[0].summaries, ['정의 요약']); + + // 분석 전혀 없는 절 → null/빈 + const none = collapseWindows([sec({ heading_path: 'Y', node_type: null, text: 'body' })]); + assert.equal(none[0].sectionType, null); + assert.equal(none[0].confidence, null); + assert.deepEqual(none[0].summaries, []); +}); + 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 0e4734a..00a0236 100644 --- a/frontend/src/lib/utils/headingPath.ts +++ b/frontend/src/lib/utils/headingPath.ts @@ -30,6 +30,14 @@ export interface OutlineItem { /** 대표 + 흡수된 window child 들의 본문을 순서대로 이어붙인 논리 절 전체 본문. * split-parent 는 heading 줄(text)을 본문에서 제외(제목과 중복) — window 본문만 합친다. */ bodyText: string; + /** 집계된 절-레벨 분석. windowed 절은 분석이 window child(chunk_section_analysis)에 붙고 + * 대표=split-parent 엔 없으므로 멤버에서 집계한다. 단일 절은 대표 자신의 값. + * - sectionType: 멤버 section_type 다수결(동률=첫 등장) + * - confidence: 멤버 confidence 평균 + * - summaries: 멤버 요약(빈 것 제외, chunk_index 순) — 단일=1개, windowed=N개(부분별 요약) */ + sectionType: string | null; + confidence: number | null; + summaries: string[]; } export interface OutlineGroup { @@ -112,8 +120,24 @@ function topSegment(s: DocumentSection): string { * fragmentCount: split-parent 대표는 0 에서 시작(자신은 조각 아님) + 흡수 child 수 = 실제 조각 수; * legacy window 대표는 1 에서 시작(자신이 첫 조각). */ +/** 멤버 section_type 다수결(동률은 첫 등장 우선). 비어있으면 null. */ +function majorityType(types: (string | null)[]): string | null { + const vals = types.filter((t): t is string => !!t); + if (!vals.length) return null; + const count = new Map(); + for (const t of vals) count.set(t, (count.get(t) ?? 0) + 1); + let best: string | null = null; + let bestN = -1; + for (const t of vals) { + const n = count.get(t)!; + if (n > bestN) { bestN = n; best = t; } // 첫 등장 우선 tie-break + } + return best; +} + export function collapseWindows(sections: DocumentSection[]): OutlineItem[] { const out: OutlineItem[] = []; + const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child) for (const s of sections) { const prev = out[out.length - 1]; const h = cleanHeading(s.heading_path); @@ -126,12 +150,25 @@ export function collapseWindows(sections: DocumentSection[]): OutlineItem[] { 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); } else { const isSplit = !!s.node_type?.endsWith('_split'); // split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작. - out.push({ section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? '') }); + out.push({ + section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? ''), + sectionType: null, confidence: null, summaries: [], + }); + members.push([s]); } } + // 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유). + for (let i = 0; i < out.length; i++) { + const mem = members[i]; + out[i].sectionType = majorityType(mem.map((m) => m.section_type)); + const confs = mem.map((m) => m.confidence).filter((c): c is number => c != null); + out[i].confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : null; + out[i].summaries = mem.map((m) => (m.summary ?? '').trim()).filter((x) => x !== ''); + } return out; } diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 7efa415..6ba0bd2 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -169,10 +169,10 @@ {#each outline as it (it.section.chunk_id)} {@const s = it.section} - {@const tm = typeMeta(s.section_type)} + {@const tm = typeMeta(it.sectionType)} {@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id} {@const child = secDepth(s) > 0} - {@const low = isMidLow(s.confidence)} + {@const low = isMidLow(it.confidence)} !jumpMode && (selectedSectionId = s.chunk_id)} onkeydown={(e) => { if (!jumpMode && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectedSectionId = s.chunk_id; } }} @@ -184,7 +184,7 @@ {#if low} ! {:else if !child} - + {/if} @@ -205,7 +205,9 @@ {#snippet focusView()} {#if selectedSection} - {@const tm = typeMeta(selectedSection.section_type)} + {@const tm = typeMeta(selectedItem?.sectionType)} + {@const conf = selectedItem?.confidence ?? null} + {@const summaries = selectedItem?.summaries ?? []}
{doc.title} {#each pathSegments(selectedSection.heading_path) as seg}/{seg}{/each} @@ -214,20 +216,26 @@

{secTitle(selectedSection)}

{#if tm.label}{tm.label} {tm.en}{/if}
- {#if selectedSection.confidence != null} + {#if conf != null}
신뢰도 -
- {selectedSection.confidence.toFixed(2)} +
+ {conf.toFixed(2)}
{/if} - {#if isLowConf(selectedSection.confidence)} + {#if isLowConf(conf)}
!저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.
{/if} - {#if selectedSection.summary} + {#if summaries.length}
-
절 요약
-
{selectedSection.summary}
+
절 요약{#if summaries.length > 1} · {summaries.length}개 부분{/if}
+ {#if summaries.length === 1} +
{summaries[0]}
+ {:else} + + {/if}
{/if} {#if selectedBodyHtml} @@ -241,8 +249,9 @@ {:else}{/if} {#if selIdx >= 0 && selIdx < outline.length - 1} - {@const nx = outline[selIdx + 1].section} - + {@const nxIt = outline[selIdx + 1]} + {@const nx = nxIt.section} + {:else}{/if} {/if} @@ -307,17 +316,24 @@ {#snippet sectionCard(it)} {@const s = it.section} - {@const tm = typeMeta(s.section_type)} -
+ {@const tm = typeMeta(it.sectionType)} +

{secTitle(s)}

{#if tm.label}{tm.label}{/if}
- {#if isLowConf(s.confidence)} + {#if isLowConf(it.confidence)}
!저신뢰 — 표·수식 추출 불완전, 원본 확인 권장
{/if} - {#if s.summary} -
절 요약
{s.summary}
+ {#if it.summaries.length} +
+
절 요약{#if it.summaries.length > 1} · {it.summaries.length}개 부분{/if}
+ {#if it.summaries.length === 1} +
{it.summaries[0]}
+ {:else} +
    {#each it.summaries as sm, i}
  • {i + 1}{sm}
  • {/each}
+ {/if} +
{/if} {#if it.bodyText}
{ if (e.currentTarget.open) mBodyOpen[s.chunk_id] = true; }}>