feat(documents): hier 절 char_start offset (Path B) — md_content 점프 builder offset
플랜 ds-outline-anchor-b5 (g1~g6 코드). 핵심 ASME/법령 windowed 절의 0% 점프를
서버계산 char_start(builder offset)로 100% deterministic 점프로 전환.
- g1 migration 318: document_chunks.char_start INTEGER NULL (단일 statement, 멱등)
- g2 builder: char_start emit = FE 라인/offset 모델 미러(split('\n')+UTF-16 code unit+코드펜스 skip).
window-child=NULL, split-parent=heading offset, preamble=NULL, CR 미strip, NFC=telemetry.
node.text 보존(라인모델 hash-neutral) → hash_stable doc 보존. 단위테스트 7건.
- g3 persist+backfill 하이브리드:
* persist INSERT char_start
* update-char-start (g3-tU): hash_stable doc 비파괴 — 100% jump-target VERIFY(NEW-1) +
position-aligned PK UPDATE(NEW-2), 미달 doc DEMOTE → re-decompose 합류(NEW-4)
* --reprocess (g3-t2): md_content 출처(g0-t1) + jump-target-set 완료마커(B1) + B_jumptarget>=1(B3),
--doc 필수 else REFUSE. self-heal sweep(g3-t3).
- g4 /sections: char_start inner+outer SELECT + split-parent 노출(is_leaf OR %_split)
- g5 FE: resolveAnchorMap(BE-first, NEW-5 jump-target-candidate-scoped 폴백, C1 OR-exclude),
per-render-site basis guard(C3), endsWith('_split') 정정 + collapseWindows split-parent 흡수(C2).
단위테스트 25건(NEW-5/B4/C1/C2 포함).
- g6 hier_outline_quality_gate.py: read-only g-measure(verdict/B_jumptarget/hash_stable/dup/fence)
배포(g7: --no-deps, 스냅샷, UPDATE-only 32 + re-decompose 230∪demote, 정확도 게이트)는 별 ops 단계.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
import SectionOutline from '$lib/components/SectionOutline.svelte';
|
||||
import { getViewerType } from '$lib/utils/viewerType';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { buildAnchorMap } from '$lib/utils/outlineAnchors';
|
||||
import { resolveAnchorMap } from '$lib/utils/resolveAnchorMap';
|
||||
import { cleanHeading } from '$lib/utils/headingPath';
|
||||
|
||||
// 편집 미리보기 전용 plain marked (본문 렌더는 MarkdownDoc 가 담당).
|
||||
@@ -109,7 +109,7 @@
|
||||
(s) => !!(cleanHeading(s.section_title) || cleanHeading((s.heading_path || '').split('>').pop() || '')),
|
||||
),
|
||||
);
|
||||
// MarkdownDoc 가 실제 렌더하는 텍스트(anchor offset 기준과 일치해야 함).
|
||||
// MarkdownDoc 가 실제 렌더하는 텍스트(rail 표시 게이트용).
|
||||
let mdRenderText = $derived.by(() => {
|
||||
if (!fullDoc) return '';
|
||||
if (viewerType === 'pdf') return pdfViewMode === 'markdown' && canShowMarkdown ? (fullDoc.md_content || '') : '';
|
||||
@@ -117,7 +117,26 @@
|
||||
if (viewerType === 'hwp-markdown' || viewerType === 'article') return fullDoc.md_content || fullDoc.extracted_text || '';
|
||||
return '';
|
||||
});
|
||||
let anchorMap = $derived(sections.length && mdRenderText ? buildAnchorMap(mdRenderText, sections).anchors : {});
|
||||
// [g5-t3] basis 는 RENDER SITE 별. anchorMap 을 basis 별로 분리 — 같은 component 가 두 basis 를
|
||||
// 공유하면(md_content vs extracted_text) trustBE 가 어긋난다.
|
||||
// - md_content site(pdf-markdown): trustBE=true (BE char_start 1순위, 비면 내부 string-match 폴백).
|
||||
// - extracted_text site(3-pane markdown): trustBE=false (char_start 는 md_content offset 이라 무효 → 무조건 폴백).
|
||||
let mdBasisText = $derived.by(() => {
|
||||
if (!fullDoc) return '';
|
||||
if (viewerType === 'pdf') return pdfViewMode === 'markdown' && canShowMarkdown ? (fullDoc.md_content || '') : '';
|
||||
return '';
|
||||
});
|
||||
let extractedBasisText = $derived.by(() => {
|
||||
if (!fullDoc) return '';
|
||||
if (viewerType === 'markdown') return fullDoc.extracted_text || rawMarkdown || '';
|
||||
return '';
|
||||
});
|
||||
let anchorMapMd = $derived(
|
||||
sections.length && mdBasisText ? resolveAnchorMap(mdBasisText, sections, { trustBE: true }).anchors : {},
|
||||
);
|
||||
let anchorMapExtracted = $derived(
|
||||
sections.length && extractedBasisText ? resolveAnchorMap(extractedBasisText, sections, { trustBE: false }).anchors : {},
|
||||
);
|
||||
let showRail = $derived(outlineSections.length > 0 && !!mdRenderText);
|
||||
|
||||
let scrollEl = $state();
|
||||
@@ -128,7 +147,8 @@
|
||||
}
|
||||
// scroll-spy: scrollEl 내 .md-anchor 중 컨테이너 상단(+120) 지난 마지막 = 현재 절.
|
||||
$effect(() => {
|
||||
void anchorMap;
|
||||
void anchorMapMd;
|
||||
void anchorMapExtracted;
|
||||
const el = scrollEl;
|
||||
if (!el) return;
|
||||
let raf = 0;
|
||||
@@ -255,7 +275,7 @@
|
||||
mdStatus={fullDoc.md_status}
|
||||
mdExtractionError={fullDoc.md_extraction_error}
|
||||
mdExtractionQuality={fullDoc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
anchorMap={anchorMapExtracted}
|
||||
extractedText={fullDoc.extracted_text || rawMarkdown}
|
||||
class={PROSE}
|
||||
/>
|
||||
@@ -280,7 +300,7 @@
|
||||
mdStatus={fullDoc.md_status}
|
||||
mdExtractionError={fullDoc.md_extraction_error}
|
||||
mdExtractionQuality={fullDoc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
anchorMap={anchorMapMd}
|
||||
extractedText={fullDoc.extracted_text}
|
||||
class={PROSE}
|
||||
/>
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
}: Props = $props();
|
||||
|
||||
// 개요 anchor 주입: body 의 각 offset(내림차순)에 빈 <span id="sec-N"> 삽입(점프 타깃).
|
||||
// offset 은 buildAnchorMap 이 body 와 동일 문자열 기준으로 산출했어야 함(호출측 책임).
|
||||
// [C3 불변식] char_start(BE) 는 호출측이 넘긴 md_content(raw, untransformed)에 대한 UTF-16 offset 이다.
|
||||
// 이 함수는 그 동일 문자열을 'out' 으로 받아 trim/CRLF-normalize/replace 없이 slice 해야 한다 —
|
||||
// prop→out 사이 어떤 변환도 char_start 를 drift 시킨다. (현재 out = text(=body=mdContent prop) 무변환.)
|
||||
function spliceAnchors(text: string, map: Record<number, number> | null): string {
|
||||
if (!map) return text;
|
||||
const ents = Object.entries(map)
|
||||
|
||||
@@ -69,6 +69,20 @@ test('collapseWindows: 연속 동일 heading window 만 dedupe, 순서 유지',
|
||||
);
|
||||
});
|
||||
|
||||
test('[C2] collapseWindows: split-parent + window 들 → rail 1행, 대표=split-parent(char_start 보유)', () => {
|
||||
const input = [
|
||||
sec({ section_title: 'Article 5', heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120 }),
|
||||
sec({ section_title: 'Article 5', heading_path: 'Article 5', node_type: 'window', is_leaf: true, char_start: null }),
|
||||
sec({ section_title: 'Article 5', heading_path: 'Article 5', node_type: 'window', is_leaf: true, char_start: null }),
|
||||
];
|
||||
const out = collapseWindows(input);
|
||||
assert.equal(out.length, 1, 'split-parent + 2 window → rail 1행');
|
||||
// 대표 = split-parent (char_start 보유) → jump 성립
|
||||
assert.equal(out[0].section.node_type, 'chapter_split');
|
||||
assert.equal(out[0].section.char_start, 120);
|
||||
assert.equal(out[0].fragmentCount, 2, 'window 조각 수 = 2 (split-parent 자신 제외)');
|
||||
});
|
||||
|
||||
test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => {
|
||||
// 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0%
|
||||
const sections: DocumentSection[] = [];
|
||||
|
||||
@@ -12,8 +12,10 @@ export interface DocumentSection {
|
||||
section_title: string | null;
|
||||
heading_path: string | null;
|
||||
level: number | null;
|
||||
node_type: string | null; // 'window' | 'section_split' | null
|
||||
node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
|
||||
is_leaf: boolean;
|
||||
/** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
|
||||
char_start?: number | null;
|
||||
section_type: string | null;
|
||||
summary: string | null;
|
||||
confidence: number | null;
|
||||
@@ -87,32 +89,38 @@ export function pathSegments(hp: string | null | undefined): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 그룹 키: window/section_split(인공 조각) 또는 path 없음/깨짐 → OTHER. */
|
||||
/** 그룹 키: window/%_split(인공 조각·windowed split-parent) 또는 path 없음/깨짐 → OTHER. */
|
||||
function topSegment(s: DocumentSection): string {
|
||||
if (s.node_type === 'window' || s.node_type === 'section_split') return OTHER;
|
||||
if (s.node_type === 'window' || !!s.node_type?.endsWith('_split')) return OTHER;
|
||||
const segs = pathSegments(s.heading_path);
|
||||
return segs.length === 0 ? OTHER : segs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 chunk_index 순서를 유지한 채(정렬 변경 금지), 연속된 동일 cleaned heading_path 의
|
||||
* node_type='window' 절을 1 항목으로 dedupe. 대표 = 첫 조각(요약 사용), fragmentCount 누적.
|
||||
* node_type='window' 절을 1 항목으로 dedupe. fragmentCount = window 조각 수.
|
||||
*
|
||||
* [C2] g4-t2 가 split-parent(%_split, char_start 보유)를 그 window child 들보다 먼저(낮은 chunk_index)
|
||||
* 노출하므로, 후속 window child 를 직전 split-parent(또는 legacy window 대표)에 흡수해 rail 1행으로 만든다.
|
||||
* merged row 의 대표 section = split-parent 여야 jump(anchorMap[split-parent char_start])가 성립한다 —
|
||||
* window-child(char_start NULL, anchorMap 부재)가 대표면 windowed section 이 점프 안 됨.
|
||||
* fragmentCount: split-parent 대표는 0 에서 시작(자신은 조각 아님) + 흡수 child 수 = 실제 조각 수;
|
||||
* legacy window 대표는 1 에서 시작(자신이 첫 조각).
|
||||
*/
|
||||
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
|
||||
const out: OutlineItem[] = [];
|
||||
for (const s of sections) {
|
||||
const prev = out[out.length - 1];
|
||||
const h = cleanHeading(s.heading_path);
|
||||
if (
|
||||
s.node_type === 'window' &&
|
||||
const prevAbsorbs =
|
||||
prev &&
|
||||
prev.section.node_type === 'window' &&
|
||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||
h !== '' &&
|
||||
cleanHeading(prev.section.heading_path) === h
|
||||
) {
|
||||
prev.fragmentCount += 1;
|
||||
cleanHeading(prev.section.heading_path) === h;
|
||||
if (s.node_type === 'window' && prevAbsorbs) {
|
||||
prev!.fragmentCount += 1; // window child 흡수 — 대표(split-parent 우선)는 그대로 유지
|
||||
} else {
|
||||
out.push({ section: s, fragmentCount: 1 });
|
||||
out.push({ section: s, fragmentCount: s.node_type?.endsWith('_split') ? 0 : 1 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -69,8 +69,9 @@ export function buildAnchorMap(
|
||||
let matched = 0;
|
||||
|
||||
for (const s of sections) {
|
||||
// window/section_split 조각은 자체 heading 없음(부모 제목 상속) → 건너뜀.
|
||||
if (s.node_type === 'window' || s.node_type === 'section_split') continue;
|
||||
// window 조각 + %_split parent(chapter_split/clause_split/section_split)는 string-match 대상 아님 →
|
||||
// 건너뜀. (split-parent jump 은 Path B 의 BE char_start 로만 성립; Path A 폴백선 windowed 절 무점프=무회귀.)
|
||||
if (s.node_type === 'window' || s.node_type?.endsWith('_split')) continue;
|
||||
let nt = norm(s.section_title);
|
||||
if (!nt && s.heading_path) {
|
||||
const last = s.heading_path.split('>').pop();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// resolveAnchorMap 회귀 테스트 (플랜 ds-outline-anchor-b5 g5-t1 / NEW-5 / B4 / C1).
|
||||
// 실행: node --test src/lib/utils/resolveAnchorMap.test.ts
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveAnchorMap, isJumpTargetCandidate } from './resolveAnchorMap.ts';
|
||||
import { type DocumentSection } from './headingPath.ts';
|
||||
|
||||
let _id = 0;
|
||||
function sec(p: Partial<DocumentSection>): DocumentSection {
|
||||
return {
|
||||
chunk_id: ++_id,
|
||||
section_title: null,
|
||||
heading_path: null,
|
||||
level: null,
|
||||
node_type: null,
|
||||
is_leaf: true,
|
||||
char_start: null,
|
||||
section_type: null,
|
||||
summary: null,
|
||||
confidence: null,
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
const LONG = 'x'.repeat(500);
|
||||
|
||||
test('trustBE=false → 무조건 string-match 폴백(fellBack=true)', () => {
|
||||
const md = '# Alpha\nbody\n# Beta\nx';
|
||||
const secs = [sec({ section_title: 'Alpha', char_start: 999 }), sec({ section_title: 'Beta', char_start: 999 })];
|
||||
const r = resolveAnchorMap(md, secs, { trustBE: false });
|
||||
assert.equal(r.fellBack, true);
|
||||
// char_start(999) 무시하고 string-match offset 사용
|
||||
assert.ok(Object.values(r.anchors).every((o) => o < 50));
|
||||
});
|
||||
|
||||
test('trustBE=true + 모든 jump-target candidate char_start 보유 → BE 채택(fellBack=false)', () => {
|
||||
const secs = [
|
||||
sec({ section_title: 'A', char_start: 5, is_leaf: true }),
|
||||
sec({ section_title: 'B', char_start: 42, is_leaf: true }),
|
||||
];
|
||||
const r = resolveAnchorMap(LONG, secs, { trustBE: true });
|
||||
assert.equal(r.fellBack, false);
|
||||
assert.equal(r.anchors[secs[0].chunk_id], 5);
|
||||
assert.equal(r.anchors[secs[1].chunk_id], 42);
|
||||
assert.equal(r.matched, 2);
|
||||
});
|
||||
|
||||
test('[NEW-5] windowed doc — window-child char_start NULL 이 폴백을 유발하지 않음(split-parent BE 사용)', () => {
|
||||
const secs = [
|
||||
sec({ section_title: 'Big', heading_path: 'Big', node_type: 'chapter_split', is_leaf: false, char_start: 10 }),
|
||||
sec({ section_title: 'Big', heading_path: 'Big', node_type: 'window', is_leaf: true, char_start: null }),
|
||||
sec({ section_title: 'Big', heading_path: 'Big', node_type: 'window', is_leaf: true, char_start: null }),
|
||||
];
|
||||
const r = resolveAnchorMap(LONG, secs, { trustBE: true });
|
||||
// window-child NULL 은 candidate 가 아니므로 트리거 안 됨 → BE 사용, split-parent 점프 보존
|
||||
assert.equal(r.fellBack, false, 'window-child NULL 이 whole-doc 폴백을 유발하면 안 됨(NEW-5)');
|
||||
assert.equal(r.anchors[secs[0].chunk_id], 10, 'split-parent char_start 가 BE 맵에 있어야 함');
|
||||
// window-child 는 anchor 없음
|
||||
assert.equal(r.anchors[secs[1].chunk_id], undefined);
|
||||
});
|
||||
|
||||
test('[B4] non-PASS doc — jump-target candidate char_start NULL → string-match 폴백', () => {
|
||||
const md = '# Gamma\nbody text here\n# Delta\nmore';
|
||||
const secs = [
|
||||
sec({ section_title: 'Gamma', is_leaf: true, char_start: null }),
|
||||
sec({ section_title: 'Delta', is_leaf: true, char_start: null }),
|
||||
];
|
||||
const r = resolveAnchorMap(md, secs, { trustBE: true });
|
||||
assert.equal(r.fellBack, true, 'candidate char_start NULL 이면 폴백해야 함(BE-first not BE-only)');
|
||||
// string-match 로 실제 jump 산출(0 아님)
|
||||
assert.ok(r.matched >= 1, 'md-aligned doc 는 폴백 string-match 로 jump 비-0');
|
||||
});
|
||||
|
||||
test('char_start > splicedText.length → 그 anchor 만 비활성, 폴백 안 함', () => {
|
||||
const secs = [
|
||||
sec({ section_title: 'A', char_start: 3, is_leaf: true }),
|
||||
sec({ section_title: 'B', char_start: 100000, is_leaf: true }), // 범위 초과(truncated tail)
|
||||
];
|
||||
const short = 'hello world';
|
||||
const r = resolveAnchorMap(short, secs, { trustBE: true });
|
||||
assert.equal(r.fellBack, false, '범위 초과는 폴백 트리거 아님(candidate char_start NOT NULL)');
|
||||
assert.equal(r.anchors[secs[0].chunk_id], 3);
|
||||
assert.equal(r.anchors[secs[1].chunk_id], undefined, '초과 anchor 는 비활성');
|
||||
});
|
||||
|
||||
test('preamble(title 없음, is_leaf) char_start NULL 은 candidate 아님 → 폴백 유발 X', () => {
|
||||
const secs = [
|
||||
sec({ section_title: null, heading_path: null, is_leaf: true, char_start: null }), // preamble
|
||||
sec({ section_title: 'Real', is_leaf: true, char_start: 7 }),
|
||||
];
|
||||
const r = resolveAnchorMap(LONG, secs, { trustBE: true });
|
||||
assert.equal(isJumpTargetCandidate(secs[0]), false, 'preamble 은 candidate 아님');
|
||||
assert.equal(r.fellBack, false);
|
||||
assert.equal(r.anchors[secs[1].chunk_id], 7);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// 개요(절 목차) → 본문 점프 anchor 산출 공유 헬퍼 (경로 B: BE char_start primary + string-match 폴백).
|
||||
//
|
||||
// render-site 가 md_content 를 splice 할 때(trustBE=true)는 BE 가 builder 단계에서 박은 char_start 를
|
||||
// 1순위로 쓰고, 비-md basis(3-pane extracted_text 등, trustBE=false)는 무조건 string-match(buildAnchorMap)로
|
||||
// 폴백한다. char_start 가 비어 있으면(non-PASS doc, 또는 multi-night 재처리 중 아직 미백필 PASS doc) BE-only
|
||||
// 가 아니라 string-match 로 graceful degrade 한다(B4: BE-first, NOT BE-only).
|
||||
//
|
||||
// ★ NEW-5 (must-not-miss): 폴백 트리거는 JUMP-TARGET-CANDIDATE 한정이다.
|
||||
// window-child(node_type='window')와 preamble(title 없음)은 char_start=NULL **BY DESIGN**(g2).
|
||||
// 트리거가 'NULL char_start 가 하나라도 있으면 whole-doc 폴백' 이면, window-child 를 항상 보유한 windowed
|
||||
// doc 은 매번 폴백 → split-parent char_start(windowed 절의 단일 jump target)를 영영 안 쓰고 →
|
||||
// buildAnchorMap 은 split-parent 를 skip → windowed 코어 절이 영원히 점프 안 됨 = 이 플랜이 겨냥한
|
||||
// 바로 그 절에서 Path A 0% 회귀. 따라서 트리거 분모 = jump-target-candidate 뿐.
|
||||
|
||||
import { buildAnchorMap } from './outlineAnchors.ts';
|
||||
import { cleanHeading, type DocumentSection } from './headingPath.ts';
|
||||
|
||||
export interface ResolveResult {
|
||||
/** chunk_id → splicedText 내 char offset (UTF-16). */
|
||||
anchors: Record<number, number>;
|
||||
/** jump-target candidate 수(BE 경로) 또는 buildAnchorMap.total(폴백). */
|
||||
total: number;
|
||||
/** 실제 anchor 부여 수. */
|
||||
matched: number;
|
||||
/** string-match(buildAnchorMap) 로 폴백했는지 — V-rail/검증용. */
|
||||
fellBack: boolean;
|
||||
}
|
||||
|
||||
/** 표시 가능한 제목(또는 heading_path 말단)이 있는가. */
|
||||
function hasTitle(s: DocumentSection): boolean {
|
||||
if (cleanHeading(s.section_title)) return true;
|
||||
const last = (s.heading_path || '').split('>').pop() || '';
|
||||
return !!cleanHeading(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* jump-target candidate = char_start 를 받아야 하는 절.
|
||||
* = (비-window leaf) OR (%_split parent), 그리고 제목 보유.
|
||||
* window-child(node_type='window')·preamble(제목 없음)은 설계상 char_start NULL → candidate 아님(NEW-5).
|
||||
*/
|
||||
export function isJumpTargetCandidate(s: DocumentSection): boolean {
|
||||
const structural = (s.is_leaf && s.node_type !== 'window') || !!s.node_type?.endsWith('_split');
|
||||
return structural && hasTitle(s);
|
||||
}
|
||||
|
||||
export function resolveAnchorMap(
|
||||
splicedText: string | null | undefined,
|
||||
sections: DocumentSection[] | null | undefined,
|
||||
opts: { trustBE: boolean },
|
||||
): ResolveResult {
|
||||
const secs = sections ?? [];
|
||||
|
||||
// basis 불일치(extracted_text 3-pane 등) → 무조건 string-match.
|
||||
if (!opts.trustBE) {
|
||||
const r = buildAnchorMap(splicedText, secs);
|
||||
return { ...r, fellBack: true };
|
||||
}
|
||||
|
||||
// [B4 + NEW-5] BE-first: jump-target candidate 가 비었거나, candidate 중 char_start NULL 이 있으면 폴백.
|
||||
// window-child/preamble NULL 은 candidate 가 아니라 트리거에 안 들어간다.
|
||||
const candidates = secs.filter(isJumpTargetCandidate);
|
||||
const beUnusable = candidates.length === 0 || candidates.some((s) => s.char_start == null);
|
||||
if (beUnusable) {
|
||||
const r = buildAnchorMap(splicedText, secs);
|
||||
return { ...r, fellBack: true };
|
||||
}
|
||||
|
||||
// BE char_start 채택 (C1: window/null/no-title 제외 = candidate 집합과 동일).
|
||||
const anchors: Record<number, number> = {};
|
||||
const limit = (splicedText ?? '').length;
|
||||
let matched = 0;
|
||||
for (const s of candidates) {
|
||||
const cs = s.char_start as number;
|
||||
// char_start<=splicedText.length 가드(MarkdownDoc.svelte:58). 초과 = FE serve-truncate tail →
|
||||
// 그 anchor 만 비활성(폴백 안 함 — string-match 도 truncated tail 은 못 찾음).
|
||||
if (Number.isFinite(cs) && cs >= 0 && cs <= limit) {
|
||||
anchors[s.chunk_id] = cs;
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
return { anchors, total: candidates.length, matched, fellBack: false };
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { buildAnchorMap } from '$lib/utils/outlineAnchors';
|
||||
import { resolveAnchorMap } from '$lib/utils/resolveAnchorMap';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -164,11 +164,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── 개요 점프 (outlineAnchors, 경로 A) ──
|
||||
// anchorMap = md_content 의 각 절 heading offset. MarkdownDoc 가 <span id="sec-N"> 주입.
|
||||
// ── 개요 점프 (경로 B: BE char_start primary + string-match 폴백) ──
|
||||
// 이 사이트는 항상 md_content basis(canShowMarkdown && doc.md_content) → trustBE=true.
|
||||
// BE char_start 가 있으면 채택, 비면(non-PASS/미백필) resolveAnchorMap 내부에서 buildAnchorMap 로 폴백.
|
||||
let anchorMap = $derived(
|
||||
hasSections && canShowMarkdown && doc?.md_content
|
||||
? buildAnchorMap(doc.md_content, sections).anchors
|
||||
? resolveAnchorMap(doc.md_content, sections, { trustBE: true }).anchors
|
||||
: {}
|
||||
);
|
||||
let activeKey = $state(null);
|
||||
|
||||
Reference in New Issue
Block a user