diff --git a/app/api/documents.py b/app/api/documents.py index d2b847b..31b4722 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -680,7 +680,12 @@ class SectionItem(BaseModel): level: int | None = None node_type: str | None = None # window | chapter_split | clause_split | section_split | null 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) + 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 @@ -719,12 +724,12 @@ async def get_document_sections( await session.execute( sql_text( """ - SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start, - section_type, summary, confidence + SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, parent_id, char_start, + 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.parent_id, 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..adb5613 100644 --- a/frontend/src/lib/utils/headingPath.test.ts +++ b/frontend/src/lib/utils/headingPath.test.ts @@ -83,6 +83,74 @@ 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('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('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[] = []; diff --git a/frontend/src/lib/utils/headingPath.ts b/frontend/src/lib/utils/headingPath.ts index b8132f0..8d22215 100644 --- a/frontend/src/lib/utils/headingPath.ts +++ b/frontend/src/lib/utils/headingPath.ts @@ -14,8 +14,12 @@ 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 가 실 본문 보유. */ + text?: string | null; section_type: string | null; summary: string | null; confidence: number | null; @@ -25,6 +29,17 @@ export interface DocumentSection { export interface OutlineItem { section: DocumentSection; fragmentCount: number; // >1 이면 "(n조각)" 배지 + /** 대표 + 흡수된 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 { @@ -107,22 +122,78 @@ 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) + const repByChunkId = new Map(); // 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 우선)는 그대로 유지 + 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 { - 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 ?? ''), + 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 들이 보유). + 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/+page.svelte b/frontend/src/routes/documents/+page.svelte index 1f2d96d..606df39 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -8,8 +8,7 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { Info, X, Plus, Trash2, Tag, FolderTree, Sparkles, ChevronLeft, ArrowUpDown } from 'lucide-svelte'; - import DocumentViewer from '$lib/components/DocumentViewer.svelte'; + import { X, Plus, Trash2, Tag, FolderTree, Sparkles, ArrowUpDown } from 'lucide-svelte'; import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte'; import { isMdStatusVisible } from '$lib/utils/mdStatus'; import UploadDropzone from '$lib/components/UploadDropzone.svelte'; @@ -233,15 +232,12 @@ goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true }); } - async function selectDoc(doc) { - if (selectedDoc?.id === doc.id) { selectedDoc = null; return; } - selectedDoc = doc; // 즉시 표시(리더 + 기본 인스펙터) - // 인스펙터 풀 메타 하이드레이션 — 검색 결과(SearchResult)는 메타가 빈약(태그/크기/하위/md상태/읽음 없음). - // 풀 문서를 조회해 채운다(기존 GET /documents/{id}, 백엔드 무변). 리스트 모드도 md상태 등 보강. - try { - const full = await api(`/documents/${doc.id}`); - if (selectedDoc?.id === doc.id) selectedDoc = { ...doc, ...full }; - } catch { /* 실패 시 기본 정보 유지 */ } + // 문서 열기 = 개선된 상세 페이지(D3 절 구조 탐색기)로 이동. + // 사용자 결정: "개선된 페이지가 앞으로 표시되야지" — 인라인 미리보기 폐기. + // /documents = 브라우즈/검색/필터/일괄 목록, 문서 열기 = /documents/[id] D3 리더. + function selectDoc(doc) { + if (!doc) return; + goto(`/documents/${doc.id}`); } // bulk 선택 @@ -386,8 +382,8 @@
- -
+ +
@@ -487,6 +483,19 @@ {/if}
+ + {#if showAskCard} +
+ goto(`/documents/${docId}`)} + onDismiss={() => { askDismissed = true; }} + /> +
+ {/if} + {#if selectionCount > 0}
@@ -587,47 +596,6 @@
- -
- {#if selectedDoc} - -
- -
- -
-
- -
- {:else if showAskCard} -
- goto(`/documents/${docId}`)} - onDismiss={() => { askDismissed = true; }} - /> -
- {:else} - - {/if} -
- - - {#if selectedDoc && inspectorOpen} - - {/if}
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 1016377..6ba0bd2 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,19 +1,17 @@ -
+ +{#snippet treeNav(jumpMode)} +
+
+
절 구조
+ {outline.length}절 +
+
+ 정의 + 절차 + 요건 +
+ {#each outline as it (it.section.chunk_id)} + {@const s = it.section} + {@const tm = typeMeta(it.sectionType)} + {@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id} + {@const child = secDepth(s) > 0} + {@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; } }} + class="d3node {child ? 'd3child' : ''} {active ? 'd3active' : ''}" + style="display:block;border:1px solid {active ? '#4f8a6b' : low ? '#e7d49a' : 'transparent'};border-radius:9px;padding:{child ? '6px 8px' : '7px 8px'};margin-bottom:2px;{low ? 'background:#fbf6e6;' : ''}text-decoration:none;cursor:pointer;"> +
+ + {secTitle(s)} + {#if low} + ! + {:else if !child} + + {/if} +
+
+ {/each} + {#if quality} +
+
추출 품질
+
+ {#if quality.headings != null}headings {quality.headings}{/if} + {#if quality.tables != null}tables {quality.tables}{/if} + {#if quality.images != null}images {quality.images}{/if} +
+
+ {/if} +
+{/snippet} + + +{#snippet focusView()} + {#if selectedSection} + {@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} +
+
+

{secTitle(selectedSection)}

+ {#if tm.label}{tm.label} {tm.en}{/if} +
+ {#if conf != null} +
+ 신뢰도 +
+ {conf.toFixed(2)} +
+ {/if} + {#if isLowConf(conf)} +
!저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.
+ {/if} + {#if summaries.length} +
+
절 요약{#if summaries.length > 1} · {summaries.length}개 부분{/if}
+ {#if summaries.length === 1} +
{summaries[0]}
+ {:else} +
    + {#each summaries as sm, i}
  • {i + 1}{sm}
  • {/each} +
+ {/if} +
+ {/if} + {#if selectedBodyHtml} +
{@html selectedBodyHtml}
+ {:else} +

이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.

+ {/if} +
+ {#if selIdx > 0} + {@const pv = outline[selIdx - 1].section} + + {:else}{/if} + {#if selIdx >= 0 && selIdx < outline.length - 1} + {@const nxIt = outline[selIdx + 1]} + {@const nx = nxIt.section} + + {:else}{/if} +
+ {/if} +{/snippet} + + +{#snippet rail()} +
+ {#if doc.ai_tldr || doc.ai_summary} +
+
TL;DR
+
{doc.ai_tldr || doc.ai_summary}
+
+ {/if} + {#if doc.ai_bullets && doc.ai_bullets.length} +
+
핵심점
+
    + {#each doc.ai_bullets as b}
  • ·{b}
  • {/each} +
+
+ {/if} + {#if doc.ai_detail_summary} +
+
+ 심층 + {#if doc.ai_analysis_tier === 'deep'}DEEP{/if} +
+
{doc.ai_detail_summary}
+
+ {/if} + {#if doc.ai_inconsistencies && doc.ai_inconsistencies.length} +
+
불일치 {doc.ai_inconsistencies.length}
+
    {#each doc.ai_inconsistencies as inc}
  • · {typeof inc === 'string' ? inc : inc.desc || inc.kind}
  • {/each}
+
+ {/if} + {#if doc.ai_domain} +
+
분류
+
+
도메인{domainLabel(doc.ai_domain)}
+ {#if doc.ai_sub_group}
하위{doc.ai_sub_group}
{/if} + {#if doc.ai_analysis_tier}
tier{doc.ai_analysis_tier}
{/if} + {#if doc.ai_confidence != null}
신뢰도{doc.ai_confidence.toFixed(2)}
{/if} +
+
+ {/if} + {#if doc.ai_tags && doc.ai_tags.length} +
+
태그
+
{#each doc.ai_tags as t}{t}{/each}
+
+ {/if} +
+
관련 문서
+
벡터 유사도 기반 — 준비 중
+
+
+{/snippet} + + +{#snippet sectionCard(it)} + {@const s = it.section} + {@const tm = typeMeta(it.sectionType)} +
+
+

{secTitle(s)}

+ {#if tm.label}{tm.label}{/if} +
+ {#if isLowConf(it.confidence)} +
!저신뢰 — 표·수식 추출 불완전, 원본 확인 권장
+ {/if} + {#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; }}> + 본문 보기 + {#if mBodyOpen[s.chunk_id]}
{@html bodyHtml(it)}
{/if} +
+ {/if} +
+{/snippet} + +
+
-
- 문서 - / +
+ 문서/ {doc?.title || '로딩...'}
{#if loading} -
- -
+ {:else if error === 'not_found'} - - - + {:else if error === 'network'} - - - + {:else if doc} -
- {#if hasSections} - - - {/if} - - -
- {#if hasSections} - -
- 절 목차 ({sections.length}) - -
- {/if} - -
- {#if doc.edit_url} - - {/if} - - {#if doc.preview_status === 'ready'} - - {/if} - - {#if doc.category === 'library'} - - {/if} + +
+
+
{doc.file_format}
+
+
{doc.title}
+
+ {#if doc.ai_domain}{domainLabel(doc.ai_domain)}{/if} + {#if doc.ai_sub_group}{doc.ai_sub_group}{/if} + {#if doc.ai_analysis_tier === 'deep'}tier DEEP{/if} + {#if doc.ai_confidence != null}신뢰도 {doc.ai_confidence.toFixed(2)}{/if} + {#if canShowMarkdown}PDF→MD success{/if} +
+
+ {#if doc.edit_url}{/if} + + + {#if doc.category === 'library'}{/if} + +
+
+
- - + {#if useSectionView} + + + + +
+
+ + +
+ {#if mTree}
{@render treeNav(true)}
{/if} + {#if mIns}
{@render rail()}
{/if} +
{#each outline as it (it.section.chunk_id)}{@render sectionCard(it)}{/each}
+
+ {:else} + + {#snippet fbViewer()} +
+ {#if !hasSections && canShowMarkdown}

이 문서는 절 분석이 없어 전체 본문으로 표시합니다. 위/옆 인사이트는 그대로 제공됩니다.

{/if} {#if viewerType === 'markdown' || viewerType === 'hwp-markdown'} - + {:else if viewerType === 'pdf'}
- - {#if canShowMarkdown} - - - {/if} + + {#if canShowMarkdown}{/if}
{#if pdfViewMode === 'markdown' && canShowMarkdown} - - {:else} - - {/if} + + {:else}{/if} {:else if viewerType === 'image'} - {doc.title} + {doc.title} {:else if viewerType === 'synology'} - - - + {:else if viewerType === 'article'} -
-

{doc.title}

-
- 출처: {doc.source_channel} - · - - {new Date(doc.created_at).toLocaleDateString('ko-KR', { - year: 'numeric', - month: 'short', - day: 'numeric', - })} - -
- {#if doc.md_content || doc.extracted_text} - - - {/if} - {#if doc.edit_url} - - {/if} -
- {:else} - - {/if} - + {#if doc.md_content || doc.extracted_text}{/if} + {#if doc.edit_url}
{/if} + {:else}{/if} +
+ {/snippet} - - {#if noteOpen && doc.category === 'library' && noteLoaded} - -
- saveNote(strokes)} - /> -
-
- {/if} + + + +
+
{@render rail()}
+ {@render fbViewer()} +
+ {/if} - - -
- - {#if doc.category === 'library'}
- - - - - + + +
-
{/if} {/if} +
+ +