// 개요(절 목차) → 본문 점프 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; /** 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 = {}; 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 }; }