Files
hyungi_document_server/frontend/src/lib/utils/resolveAnchorMap.ts
T
hyungi aeb9290cbd 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>
2026-06-09 10:12:26 +09:00

83 lines
3.9 KiB
TypeScript

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