Compare commits

..

12 Commits

Author SHA1 Message Date
hyungi 624b9d523d refactor(search): Phase 2A/2B cand reranker 잔재 teardown
- RERANKER_BACKEND_MAP 에서 cand_gte_ml_base 슬러그 제거 (컨테이너·DB 테이블 마이그360·override 이미 종료)
- docker-compose.override.cand.yml / override.rerank-cand.yml 삭제
- search.py allowlist · run_eval.py help 정합
- dispatcher scaffold(_resolve_reranker)는 보존 (후보 재진입 대비)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:00:42 +00:00
hyungi 5cabf728e6 fix(search): reranker MAX_CLIENT_BATCH_SIZE 64→256
rerank_service.py 가 후보를 MAX_RERANK_INPUT=200 까지 청크 없이 한 번에 TEI 로 POST → TEI 한도 64 초과(85) 시 HTTPError → RRF silent fallback(리랭크 누락=검색 품질 저하, 48h 4회). MAX_BATCH_TOKENS=16384 가 VRAM 상한이라 client batch entries 한도만 256(MAX_RERANK_INPUT 200 커버)으로 상향, reranker 만 재생성. 검증: 85-text rerank HTTP 200, batch 에러 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:35:43 +00:00
hyungi cd694e7386 refactor(ds): vestigial ai-gateway 폐기
예산캡 LLM 게이트웨이(2026-04-03 GPU 이관 최초 커밋부터 존재). config.ai.gateway 파싱만·소비코드 0줄·established 0·요청 이력 0 = vestigial 입증. docker-compose.yml ai-gateway 서비스블록 + config.yaml ai.gateway 블록 제거. 컨테이너+image(256MB) 제거, fastapi 무손상(재생성 안 함). dangling CLAUDE_API_KEY env 노출 동반 제거(credentials.env=gitignore 별도).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:29:19 +00:00
hyungi 7247d242a2 Merge pull request 'fix(docpage): 절뷰 로딩 시 이미지 '나왔다 사라짐' 플래시 제거' (#46) from fix/section-view-image-flash into main
Reviewed-on: #46
2026-06-17 15:51:15 +09:00
hyungi 5efe19b5a3 fix(docpage): 절뷰 로딩 시 이미지 '나왔다 사라짐' 플래시 제거
절 보유 문서(예 5180)에서 이미지가 살짝 보였다 빈 절로 바뀌는 2단 플래시 수정:
① sections 로딩 전 useSectionView=false → fallback 풀-문서 뷰어(전체 md_content=이미지)가
   잠깐 뜨고 곧 절뷰로 교체 → sectionsLoaded 플래그로 로딩 중엔 skeleton(풀-문서 미표시).
② 절뷰 진입 시 selectedSectionId=null 이면 selectedItem 이 outline[0](표지/front-matter,
   이미지 가능)로 잠깐 렌더됐다 effect 가 defaultSelId(첫 본문 Part)로 점프 → selectedItem
   조회 키를 (selectedSectionId ?? defaultSelId)로 바꿔 첫 프레임부터 본문 Part 직행.
데이터는 정상(5180 이미지 207개 DB row+파일 실존+key 일치) — 순수 렌더 전환 플래시였음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:19:35 +09:00
hyungi 9434017114 Merge pull request 'fix(docpage): 절뷰 본문 MarkdownDoc 렌더 복원 — 이미지·수식 살림 (D8 배포 회귀 복구)' (#45) from fix/section-view-md-render-d8 into main
Reviewed-on: #45
2026-06-17 14:54:56 +09:00
hyungi 753a432c25 fix(docpage): 절뷰 본문 MarkdownDoc 렌더 복원 — 이미지·수식 살림 (D8 배포 회귀 복구)
96bd849(절뷰 본문 MarkdownDoc 교체, 이미지·수식 fix)는 main 에 머지된 적 없이 라이브
프론트엔드에만 배포돼 있었는데, D8(main 기준 빌드) 배포가 옛 renderMd(plain marked)로
되돌려 docimg 이미지 제거·$$ 수식 raw 회귀. 절 본문 2곳(데스크탑 focusView·모바일 카드)을
다시 <MarkdownDoc mdContent={bodyText}> 로 — pre-render(수식·이미지 placeholder) + swap
(실 이미지). 96bd849 와 동일 변경, D8 의 Part 접이 위에 재적용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:47:06 +09:00
hyungi 66f3287564 Merge pull request 'Feat/asme item decomp d1' (#44) from feat/asme-item-decomp-d1 into main
Reviewed-on: #44
2026-06-17 12:37:19 +09:00
hyungi a850745f85 feat(docpage): asme 절뷰 Part 접이 그룹 렌더 — SectionOutline rail + [id] treeNav (asme D8)
flat 1030 절뷰를 read-time 표현계층에서 front-matter 단일 접이그룹 + PART/APPENDIX 접이그룹
(기본 전부 접힘)으로. 빌더/재분해 무접촉, 검색 무관(in_corpus=false 불변).

- partitionOutlineItems: 순서기반 carry-forward 그룹핑(비-PART top-segment 항목은 직전 PART 흡수).
  buildPartOutline = partitionOutlineItems∘collapseWindows 로 통일. PART_MARKER_RE = case-sensitive
  PART/SUBSECTION/APPENDIX(+대문자제목 가드) — 본문 cross-ref/문장 false match 차단
  (5210 'Part D…'·'PART UW 규정은…' 거부). 한글제목 PART 미인식은 D3 재정련(주석 박제).
- partGroupViews/groupKeyByChunkId: front-matter 첫 그룹 평탄화 + auto-expand 역인덱스.
- SectionOutline.svelte: Part 접이 모드 + groupOrFlat 폴백 + activeKey auto-expand.
- [id]/+page.svelte: treeNav 그룹 접이(treeNode 스니펫·d3 시안 보존) + 기본선택=첫 본문 Part +
  selectedSectionId auto-expand. 데스크탑/모바일 treeNav 공유.
- 리뷰 반영: rail max-height calc() 공백 fix / treeNode a11y role 조건부 / 문서 전환 접이상태 리셋 /
  모바일 본문 스코프 주석.

real-data 검증(prod read-only): 5180 → front-matter231 + 15 PART + 6 APPENDIX = 22 접이그룹·
커버리지 1030/1030·PG-27 정상. 5210(D3 재분해 전 stale) → 깨끗 PART 0 → hasParts=false →
flat 폴백(무회귀). 단위 26/26, vite build PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:32:25 +09:00
hyungi 513c6507bc feat(docpage): 절뷰 read-time front-matter 억제 + Part 그룹 유틸 (asme D7/D9)
긴 ASME 코드 절뷰가 flat 1030 으로 길어지는 문제(front-matter 240 + 다중 PART 가 GROUP_MAX 초과
→ flat 폴백)를 표현 계층에서 해결. 빌더/재분해 무접촉.
- D9 cleanHeading: ASME 개정바 ðNÞ(<sup>ð</sup>**25**<sup>Þ</sup>) 통째 strip (가운데 25 안 남김).
- D7 buildPartOutline: 첫 content part(PART/SUBSECTION/항목코드) 경계로 front-matter 분리 +
  본문을 heading_path 첫 세그먼트(PART)로 그룹. window/_split 도 PART 로 모여 흡수. content part
  없으면 hasParts=false 폴백. SectionOutline(D8) 이 소비.
단위 17/17(신규 6: 개정바 strip·front-matter 분리·window 흡수·폴백·항목코드). 미배포·prod 무접촉.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:21:14 +09:00
hyungi 677a59b422 fix(hier): _ENG 매처 소문자 문장연속 가짜 절 차단 + 단위테스트 (asme-item-decomp D1)
영문 구조 헤딩 매처가 본문 'Part III to demonstrate…'·'Section I or Section VIII…'
같은 소문자 문장연속을 가짜 절로 잡던 것 차단. 식별자 뒤 선택 제목은 대문자/괄호/숫자로
시작해야 헤딩 인정. ATX 파트(# PART PG)·항목(#### PG-1)은 ATX 우선이라 무영향.
단위 11/11(음성·양성·ATX보존·통합 + 기존 7) + held-out 실데이터 회귀(5180 가짜절 1건 제거·
5206/5120/5130 무영향·added 0). CHUNKER_VERSION 유지(hier-rule-v1, D0a 결정).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:28:06 +09:00
hyungi af74312a57 Merge pull request 'Feat/backend audit r3' (#43) from feat/backend-audit-r3 into main
Reviewed-on: #43
2026-06-16 16:12:54 +09:00
13 changed files with 606 additions and 277 deletions
+1 -1
View File
@@ -282,7 +282,7 @@ async def search(
content={
"error_reason": "unknown_reranker_backend",
"backend_requested": reranker_backend,
"allowed": ["baseline", "cand_gte_ml_base"],
"allowed": ["baseline"],
"detail": msg,
},
)
+10 -1
View File
@@ -26,7 +26,16 @@ _ATX = re.compile(r'^(#{1,6})\s+(?P<title>\S.*?)\s*#*\s*$')
_KO_JANG = re.compile(r'^\s*(?P<title>제\s*\d+\s*장\b.*)$')
_KO_JEOL = re.compile(r'^\s*(?P<title>제\s*\d+\s*절\b.*)$')
_KO_JO = re.compile(r'^\s*(?P<title>제\s*\d+\s*조\b.*)$')
_ENG = re.compile(r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+[\dIVXLA-Z]+\b.*)$')
# _ENG: 영문 구조 헤딩(ATX 미사용 문서용). ASME 파트는 보통 ATX(`# PART PG`)로 잡혀 _ENG 의존 낮음.
# D1: 식별자 뒤가 소문자 문장연속이면("Part III to demonstrate to the satisfaction…") 본문이므로
# 미탐지 — 가짜 절 차단. 선택 제목은 대문자/괄호/숫자로 시작해야 헤딩 인정(소문자 시작=문장으로 봄).
# 식별자는 번호/PG/3.31/UHX/A-1 등 (.·- 소수·하이픈 확장 허용).
_ENG = re.compile(
r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+'
r'[\dIVXLA-Z]+(?:[.\-][\dA-Za-z]+)*'
r'(?:\s+[A-Z(\d][^\n]*)?'
r')\s*$'
)
# 코드펜스 경계 (FE outlineAnchors.ts:60 `/^\s{0,3}(```|~~~)/` 와 동일). 펜스 내부 라인은
# heading 미탐지 — 코드블록 안 '# foo' 가 가짜 절을 만들지 않게(O3).
+4 -5
View File
@@ -44,11 +44,10 @@ RERANK_TIMEOUT = 5.0
# server-side allowlist map. query parameter 가 raw endpoint URL 받지 않음.
RERANKER_BACKEND_MAP: dict[str, dict[str, str] | None] = {
"baseline": None, # production reranker (config.yaml endpoint via AIClient.rerank)
"cand_gte_ml_base": {
"endpoint": "http://rerank-cand-gte-ml-base:80/rerank",
},
# mxbai_large 후보 (deberta-v2 → TEI 1.7 미지원) Phase 2B-Extended 이관
# bge_v2_gemma_2b 후보 (LLM-based reranker, 1_Pooling/config.json 부재) Phase 2B-Extended 이관
# Phase 2B 후보 reranker 전부 NO-GO 종결 (2026-06-18 teardown):
# - cand_gte_ml_base : 컨테이너·DB 테이블(마이그 360)·override.rerank-cand.yml 제거됨
# - mxbai_large (deberta-v2 → TEI 1.7 미지원) / bge_v2_gemma_2b (1_Pooling 부재) 미진입
# dispatcher scaffold(_resolve_reranker)는 향후 후보 재진입 위해 보존.
}
-2
View File
@@ -1,8 +1,6 @@
# hyungi_Document_Server 설정
ai:
gateway:
endpoint: "http://ai-gateway:8080"
models:
# ─── 단일 generation 호스트 routing (2026-05-14 GPU LLM 제거) ───
-135
View File
@@ -1,135 +0,0 @@
# Phase 2A — Embedding candidate compose override (Diagnose only)
#
# Profile-isolated: `--profile embed-cand` 명시 opt-in. default up 시 미기동.
# production fastapi/postgres/reranker 에 영향 0.
# 본 PR 종료 시 별 chore (PR-2A-Chunks-Cand-Cleanup-1) 에서 제거.
#
# 후보 상태 (2026-05-23):
# - me5_large_inst : ✅ smoke PASS (dim 1024)
# - bge_mgemma2 : ❌ Phase 2A-Extended 별 PR 이관 (9B FP16 → VRAM OOM risk + 다운로드 cost)
# - me5_ko : ❌ 폐기 (401 Unauthorized, gated/모델명 부정확)
# - snowflake_l_v2 : 신규 추가 (Snowflake/snowflake-arctic-embed-l-v2.0, 2024-12, multilingual 강화)
#
# 사용:
# docker compose -f docker-compose.yml -f docker-compose.override.cand.yml \
# --profile embed-cand up -d embedding-cand-me5-inst
#
# 호출 (DS network 내부):
# http://embedding-cand-me5-inst:80/embed
# http://embedding-cand-snowflake-l-v2:80/embed
services:
embedding-cand-me5-inst:
image: ghcr.io/huggingface/text-embeddings-inference:1.7
restart: unless-stopped
container_name: hyungi_document_server-embedding-cand-me5-inst-1
expose:
- "80"
environment:
- MODEL_ID=intfloat/multilingual-e5-large-instruct
- MAX_BATCH_TOKENS=8192
- MAX_CONCURRENT_REQUESTS=4
volumes:
- embedding_cand_me5_inst_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
profiles: ["embed-cand"]
embedding-cand-snowflake-l-v2:
image: ghcr.io/huggingface/text-embeddings-inference:1.7
restart: unless-stopped
container_name: hyungi_document_server-embedding-cand-snowflake-l-v2-1
expose:
- "80"
environment:
- MODEL_ID=Snowflake/snowflake-arctic-embed-l-v2.0
- MAX_BATCH_TOKENS=8192
- MAX_CONCURRENT_REQUESTS=4
volumes:
- embedding_cand_snowflake_l_v2_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
profiles: ["embed-cand"]
# ===== 비활성 후보 (Phase 2A-Extended 별 PR 이관 또는 폐기) =====
# 진단 박제만 보존. 본 PR scope 외.
embedding-cand-bge-mgemma2:
image: ghcr.io/huggingface/text-embeddings-inference:1.7
container_name: hyungi_document_server-embedding-cand-bge-mgemma2-1
expose:
- "80"
environment:
- MODEL_ID=BAAI/bge-multilingual-gemma2
- MAX_BATCH_TOKENS=8192
- MAX_CONCURRENT_REQUESTS=4
volumes:
- embedding_cand_bge_mgemma2_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 300s
profiles: ["embed-cand-extended"] # 본 PR 미사용. extended 별 profile.
embedding-cand-me5-ko:
image: ghcr.io/huggingface/text-embeddings-inference:1.7
container_name: hyungi_document_server-embedding-cand-me5-ko-1
expose:
- "80"
environment:
- MODEL_ID=dragonkue/multilingual-e5-large-ko
- MAX_BATCH_TOKENS=8192
- MAX_CONCURRENT_REQUESTS=4
volumes:
- embedding_cand_me5_ko_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
profiles: ["embed-cand-disabled"] # 401 fail. 사용 X.
volumes:
embedding_cand_me5_inst_cache:
embedding_cand_snowflake_l_v2_cache:
embedding_cand_bge_mgemma2_cache:
embedding_cand_me5_ko_cache:
-101
View File
@@ -1,101 +0,0 @@
# Phase 2B — Reranker candidate compose override (Diagnose only)
#
# Profile-isolated: `--profile rerank-cand` 명시 opt-in. default up 시 미기동.
# production fastapi/postgres/reranker(bge-reranker-v2-m3) 에 영향 0.
# 본 PR 종료 후 별 chore (PR-2B-Rerank-Cand-Cleanup-1) 에서 제거.
#
# 후보 상태 (2026-05-23):
# - gte_ml_base : Apache 2.0, 305M, smoke 대기
# - mxbai_large : Apache 2.0, ~435M, safetensors 부재 — TEI smoke risk
# - bge_v2_gemma_2b : Gemma 라이센스, 2.5B FP16 ~5GB, smoke 대기
#
# 사용:
# docker compose -f docker-compose.yml -f docker-compose.override.rerank-cand.yml \
# --profile rerank-cand up -d rerank-cand-gte-ml-base
services:
rerank-cand-gte-ml-base:
image: ghcr.io/huggingface/text-embeddings-inference:1.7
restart: unless-stopped
container_name: hyungi_document_server-rerank-cand-gte-ml-base-1
expose:
- "80"
environment:
- MODEL_ID=Alibaba-NLP/gte-multilingual-reranker-base
- MAX_BATCH_TOKENS=8192
- MAX_CONCURRENT_REQUESTS=4
volumes:
- rerank_cand_gte_ml_base_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
profiles: ["rerank-cand"]
rerank-cand-mxbai-large:
image: ghcr.io/huggingface/text-embeddings-inference:1.7
restart: unless-stopped
container_name: hyungi_document_server-rerank-cand-mxbai-large-1
expose:
- "80"
environment:
- MODEL_ID=mixedbread-ai/mxbai-rerank-large-v1
- MAX_BATCH_TOKENS=8192
- MAX_CONCURRENT_REQUESTS=4
volumes:
- rerank_cand_mxbai_large_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
profiles: ["rerank-cand"]
rerank-cand-bge-v2-gemma-2b:
image: ghcr.io/huggingface/text-embeddings-inference:1.7
restart: unless-stopped
container_name: hyungi_document_server-rerank-cand-bge-v2-gemma-2b-1
expose:
- "80"
environment:
- MODEL_ID=BAAI/bge-reranker-v2-gemma
- MAX_BATCH_TOKENS=8192
- MAX_CONCURRENT_REQUESTS=2
volumes:
- rerank_cand_bge_v2_gemma_2b_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
profiles: ["rerank-cand"]
volumes:
rerank_cand_gte_ml_base_cache:
rerank_cand_mxbai_large_cache:
rerank_cand_bge_v2_gemma_2b_cache:
+1 -14
View File
@@ -149,7 +149,7 @@ services:
# → 32 한도 초과 → 413. 64 로 늘림.
# GPU VRAM free 6199MiB 충분. baseline path (MAX_RERANK_INPUT=200) 영향 0.
- MAX_BATCH_TOKENS=16384
- MAX_CLIENT_BATCH_SIZE=64
- MAX_CLIENT_BATCH_SIZE=256 # 2026-06-18 fix: 64→256, MAX_RERANK_INPUT=200 커버 (batch>64 ERROR=RRF silent fallback 해소; MAX_BATCH_TOKENS가 VRAM 상한이라 entries 증가는 VRAM 무관)
- MAX_CONCURRENT_REQUESTS=4
volumes:
- reranker_cache:/data
@@ -168,19 +168,6 @@ services:
start_period: 120s
restart: unless-stopped
ai-gateway:
build: ./gpu-server/services/ai-gateway
ports:
- "127.0.0.1:8081:8080"
environment:
- PRIMARY_ENDPOINT=http://100.76.254.116:8801/v1/chat/completions
- FALLBACK_ENDPOINT=http://ollama:11434/v1/chat/completions
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- DAILY_BUDGET_USD=${DAILY_BUDGET_USD:-5.00}
# depends_on: ollama 제거 (2026-06-08) — ollama 서비스가 standalone 으로 이관됨.
# FALLBACK_ENDPOINT 의 ollama:11434 는 standalone(동일 hostname, DS 망 부착)으로 해소.
restart: unless-stopped
fastapi:
build: ./app
ports:
@@ -1,13 +1,18 @@
<script lang="ts">
// 문서 상세 좌측 절(section) 목차 (PR-DocSrv-Hier-Section-UI-1).
// - groupOrFlat 로 per-doc 동적 (top-segment 1단 그룹 vs flat).
// - ASME 등 구조화 코드(buildPartOutline.hasParts): front-matter 단일 접이그룹 + PART 접이
// (기본 접힘, 1030 flat → ~14 top-level). scroll-spy/딥링크 진입 시 조상 PART auto-expand. (D8)
// - 그 외(per-doc): groupOrFlat 폴백 — top-segment 1단 그룹 vs flat(5140/5186/비-ASME 무회귀).
// - 항목 클릭 → 인라인 아코디언으로 요약/section_type/heading_path breadcrumb 표시.
// - 본문 스크롤 점프 없음(§Q2, deep-link 는 follow-up). summary=NULL 은 "요약 없음" 문구.
import { untrack } from 'svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import {
cleanHeading,
pathSegments,
groupOrFlat,
buildPartOutline,
partGroupViews,
groupKeyByChunkId,
sectionTypeLabel,
type DocumentSection,
type OutlineItem,
@@ -17,14 +22,38 @@
sections: DocumentSection[];
/** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */
onJump?: (chunkId: number) => void;
/** scroll-spy 현재 절(chunk_id) — 강조. */
/** scroll-spy 현재 절(chunk_id) — 강조 + Part auto-expand. */
activeKey?: number | null;
}
let { sections, onJump, activeKey = null }: Props = $props();
let layout = $derived(groupOrFlat(sections));
let partOutline = $derived(buildPartOutline(sections));
// hasParts(ASME 등): Part 접이 모드. 아니면 partViews=null → groupOrFlat 폴백.
let partViews = $derived(partOutline.hasParts ? partGroupViews(partOutline) : null);
let layout = $derived.by(() => (partOutline.hasParts ? null : groupOrFlat(sections)));
let groupIndex = $derived(partViews ? groupKeyByChunkId(partViews) : null);
let total = $derived(sections.length);
let selectedId = $state<number | null>(null);
// Part 그룹 접이 상태: key 없으면 접힘(기본 전부 접힘). $state Record = Svelte5 deep-proxy 반응형.
let expanded = $state<Record<string, boolean>>({});
function toggleGroup(key: string) {
expanded[key] = !expanded[key];
}
// 문서 전환(DocumentViewer 가 sections prop 교체) 시 접이/선택 리셋 — 문서 간 PART 라벨/chunk_id 가
// 우연히 겹쳐 이전 펼침/선택이 이월되는 것 차단(기본 전부 접힘 불변식 보존). untrack=쓰기 자기재발화 차단.
$effect(() => {
void sections;
untrack(() => { expanded = {}; selectedId = null; });
});
// scroll-spy/딥링크 활성 절의 조상 Part 를 펼침(다른 그룹은 건드리지 않음). untrack=쓰기 자기재발화 차단.
$effect(() => {
const ak = activeKey;
const idx = groupIndex;
if (ak == null || !idx) return;
const gk = idx.get(ak);
if (gk) untrack(() => { expanded[gk] = true; });
});
function toggle(item: OutlineItem) {
const id = item.section.chunk_id;
@@ -95,7 +124,37 @@
<span class="text-faint font-normal">{total}</span>
</h3>
{#if layout.mode === 'group'}
{#if partViews}
<!-- Part 접이 모드 (ASME 등): front-matter 단일 그룹 + PART 접이, 기본 접힘 -->
<div class="space-y-1">
{#each partViews as g (g.key)}
{@const isOpen = !!expanded[g.key]}
<div>
<button
type="button"
onclick={() => toggleGroup(g.key)}
aria-expanded={isOpen}
class={[
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-semibold uppercase tracking-wide transition-colors',
g.isFrontMatter ? 'text-faint' : 'text-dim',
'hover:bg-surface hover:text-text',
].join(' ')}
>
<span class="shrink-0 transition-transform duration-150 {isOpen ? 'rotate-90' : ''}"></span>
<span class="flex-1 min-w-0 text-left truncate normal-case">{g.label}</span>
<span class="font-normal text-faint">{g.items.length}</span>
</button>
{#if isOpen}
<ul class="space-y-0.5 mt-0.5">
{#each g.items as item (item.section.chunk_id)}
{@render itemRow(item)}
{/each}
</ul>
{/if}
</div>
{/each}
</div>
{:else if layout?.mode === 'group'}
<div class="space-y-3">
{#each layout.groups as g (g.key)}
<div>
@@ -118,7 +177,7 @@
</div>
{:else}
<ul class="space-y-0.5">
{#each layout.items as item (item.section.chunk_id)}
{#each layout?.items ?? [] as item (item.section.chunk_id)}
{@render itemRow(item)}
{/each}
</ul>
+214
View File
@@ -7,6 +7,12 @@ import {
pathSegments,
collapseWindows,
groupOrFlat,
buildPartOutline,
partitionOutlineItems,
partGroupViews,
groupKeyByChunkId,
FRONT_MATTER_KEY,
FRONT_MATTER_LABEL,
sectionTypeLabel,
type DocumentSection,
} from './headingPath.ts';
@@ -190,3 +196,211 @@ test('groupOrFlat: 빈 입력 → flat, 항목 0', () => {
assert.equal(layout.mode, 'flat');
assert.equal(layout.items.length, 0);
});
// ── D9: cleanHeading ASME 개정바 ðNÞ strip ──
test('cleanHeading: ASME 개정바 ðNÞ 통째 제거 (가운데 25 안 남김)', () => {
assert.equal(
cleanHeading('<sup>ð</sup>**25**<sup>Þ</sup> **PG-5.4 Size Limits**'),
'PG-5.4 Size Limits',
);
// 개정바 없는 일반 제목은 그대로 (회귀)
assert.equal(cleanHeading('#### **PG-2 SERVICE LIMITATIONS**'.replace(/^#+\s*/, '')), 'PG-2 SERVICE LIMITATIONS');
});
// ── D7: buildPartOutline — front-matter 분리 + PART 그룹 ──
test('buildPartOutline: front-matter 분리 + PART 그룹', () => {
const sections = [
sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }),
sec({ heading_path: 'Honors and Awards Committee', section_title: 'Honors and Awards Committee' }),
sec({ heading_path: 'PART PG GENERAL > PG-1 SCOPE', section_title: 'PG-1 SCOPE' }),
sec({ heading_path: 'PART PG GENERAL > PG-2 SERVICE', section_title: 'PG-2 SERVICE' }),
sec({ heading_path: 'PART PW > PW-1 SCOPE', section_title: 'PW-1 SCOPE' }),
];
const o = buildPartOutline(sections);
assert.equal(o.hasParts, true);
assert.equal(o.frontMatter.length, 2); // TOC + Committee
assert.equal(o.groups.length, 2); // PART PG, PART PW
assert.equal(o.groups[0].key, 'PART PG GENERAL');
assert.equal(o.groups[0].items.length, 2); // PG-1, PG-2
assert.equal(o.groups[1].key, 'PART PW');
assert.equal(o.groups[1].items.length, 1);
});
test('buildPartOutline: split-parent + window 가 같은 PART 그룹에서 1항목으로 흡수', () => {
const sections = [
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }),
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 1' }),
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 2' }),
];
const o = buildPartOutline(sections);
assert.equal(o.hasParts, true);
assert.equal(o.groups.length, 1);
assert.equal(o.groups[0].items.length, 1); // split-parent + 2 window → 1 항목
assert.equal(o.groups[0].items[0].fragmentCount, 2);
});
test('buildPartOutline: content part 없으면 hasParts=false (폴백 신호)', () => {
const o = buildPartOutline([sec({ heading_path: 'Intro', section_title: 'Intro' })]);
assert.equal(o.hasParts, false);
assert.equal(o.groups.length, 0);
});
test('buildPartOutline: PART/SUBSECTION 마커 없으면(항목코드만) hasParts=false → 폴백', () => {
// 실 ASME 코드(5180/5210)는 PART/SUBSECTION 마커를 갖는다. PART 가 0 인 문서(항목코드만)는
// 접을 PART 가 없으므로 hasParts=false → 호출자가 groupOrFlat/flat 으로 폴백.
const o = buildPartOutline([
sec({ heading_path: 'FOREWORD', section_title: 'FOREWORD' }),
sec({ heading_path: null, section_title: 'U-1 적용범위' }),
]);
assert.equal(o.hasParts, false);
assert.equal(o.groups.length, 0);
});
test('buildPartOutline: (NON)MANDATORY APPENDIX 도 최상위 섹션 경계 — 마지막 PART 흡수 방지', () => {
// 5180 실측: 부록을 마커로 안 잡으면 마지막 PART(PHRSG)가 부록 289항목을 carry-forward 흡수(=300).
const o = buildPartOutline([
sec({ heading_path: 'PART PHRSG REQUIREMENTS > PHRSG-1', section_title: 'PHRSG-1' }),
sec({ heading_path: 'PHRSG-2 SCOPE', section_title: 'PHRSG-2' }), // PHRSG 로 carry
sec({ heading_path: 'MANDATORY APPENDIX IV LOCAL THIN AREAS', section_title: '...' }),
sec({ heading_path: 'IV-1 GENERAL', section_title: 'IV-1' }), // APPENDIX IV 로 carry
sec({ heading_path: 'NONMANDATORY APPENDIX A EXPLANATION', section_title: '...' }),
]);
assert.deepEqual(o.groups.map((g) => [g.key.slice(0, 24), g.items.length]), [
['PART PHRSG REQUIREMENTS', 2], // PHRSG-1 + PHRSG-2(carry), 부록 안 섞임
['MANDATORY APPENDIX IV LO', 2], // 부록 헤딩 + IV-1(carry)
['NONMANDATORY APPENDIX A ', 1],
]);
});
test('buildPartOutline: 본문 cross-ref/문장 false PART 차단 (5210 stale 패턴)', () => {
// 혼합대소문자 'Part D…' · 코드 뒤 비대문자(한글) 문장 'PART UW 규정은…' · 비대문자 코드 'PART 층이…'
// = 전부 본문이라 PART 아님. 깨끗한 PART 0 → hasParts=false → flat 폴백(가짜 그룹 0).
const o = buildPartOutline([
sec({ heading_path: 'Part D, Subpart 3의 해당 재료', section_title: 'Part D…' }),
sec({ heading_path: 'PART UW 규정은 용접에 의해 제작되는', section_title: 'PART UW 규정은…' }),
sec({ heading_path: 'PART 층이 진 구조로 조립되는', section_title: 'PART 층이…' }),
]);
assert.equal(o.hasParts, false);
});
test('buildPartOutline: SUBSECTION 마커도 PART 경계로 인식(Sec VIII)', () => {
const o = buildPartOutline([
sec({ heading_path: 'TOC', section_title: 'TOC' }),
sec({ heading_path: 'SUBSECTION A GENERAL > UG-1', section_title: 'UG-1' }),
sec({ heading_path: 'SUBSECTION B > UW-1', section_title: 'UW-1' }),
]);
assert.equal(o.hasParts, true);
assert.equal(o.frontMatter.length, 1);
assert.deepEqual(o.groups.map((g) => g.key), ['SUBSECTION A GENERAL', 'SUBSECTION B']);
});
// ── D8: partitionOutlineItems — 이미 collapse 된 OutlineItem 재배치(인스턴스 보존) ──
test('partitionOutlineItems: flat outline 의 인스턴스를 그대로 재배치(재-collapse 없음)', () => {
const sections = [
sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }),
sec({ heading_path: 'PART PG GENERAL > PG-1 SCOPE', section_title: 'PG-1 SCOPE' }),
sec({ heading_path: 'PART PG GENERAL > PG-2 SERVICE', section_title: 'PG-2 SERVICE' }),
sec({ heading_path: 'PART PW > PW-1 SCOPE', section_title: 'PW-1 SCOPE' }),
];
const flat = collapseWindows(sections); // 컴포넌트의 outline 과 동일 경로
const o = partitionOutlineItems(flat);
assert.equal(o.hasParts, true);
assert.equal(o.frontMatter.length, 1);
assert.equal(o.groups.length, 2);
// ★ 인스턴스 동일성: 재배치된 item 이 flat outline 의 바로 그 객체여야 selectedSectionId 정합.
assert.ok(o.frontMatter[0] === flat[0], 'front-matter item = flat[0] 인스턴스');
assert.ok(o.groups[0].items[0] === flat[1], 'PART PG 첫 item = flat[1] 인스턴스');
assert.ok(o.groups[1].items[0] === flat[3], 'PART PW item = flat[3] 인스턴스');
// chunk_id 집합이 flat 과 정확히 일치(클릭→selectedSectionId 조회 실패 없음).
const flatIds = flat.map((it) => it.section.chunk_id).sort();
const partIds = [...o.frontMatter, ...o.groups.flatMap((g) => g.items)]
.map((it) => it.section.chunk_id).sort();
assert.deepEqual(partIds, flatIds);
});
test('partitionOutlineItems: 비-PART top-segment 항목은 직전 PART 로 carry-forward (marker 트리 불규칙 흡수)', () => {
// ★ 5180 실측 패턴: PART 아래 직접 중첩 안 된 항목('PG-28'·'GENERAL')의 top-segment 가 PART 가
// 아니다 → 단순 segs[0] 그룹핑이면 가짜 그룹 폭발. carry-forward 가 직전 PART 로 흡수해야 한다.
const items = collapseWindows([
sec({ heading_path: 'TOC', section_title: 'TOC' }),
sec({ heading_path: 'PART PG GENERAL > PG-1', section_title: 'PG-1' }),
sec({ heading_path: 'PG-28 EXTERNAL PRESSURE', section_title: 'PG-28' }), // top-seg ≠ PART → carry
sec({ heading_path: 'OPENINGS AND COMPENSATION', section_title: 'OPENINGS' }), // carry
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
sec({ heading_path: 'GENERAL', section_title: 'GENERAL' }), // PART PW 로 carry
]);
const o = partitionOutlineItems(items);
assert.equal(o.hasParts, true);
assert.equal(o.frontMatter.length, 1);
assert.equal(o.groups.length, 2, 'PART PG / PART PW 단 2그룹(가짜 그룹 0)');
assert.equal(o.groups[0].key, 'PART PG GENERAL');
assert.equal(o.groups[0].items.length, 3, 'PG-1 + PG-28 + OPENINGS carry');
assert.equal(o.groups[1].key, 'PART PW');
assert.equal(o.groups[1].items.length, 2, 'PW-1 + GENERAL carry');
// carry 된 항목도 인스턴스 보존(클릭 정합)
assert.ok(o.groups[0].items[1].section.section_title === 'PG-28');
});
test('partitionOutlineItems: buildPartOutline 과 그룹 구조 동치(collapse→partition == partition∘collapse)', () => {
const sections = [
sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }),
sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b1' }),
sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b2' }),
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
];
const viaBuild = buildPartOutline(sections);
const viaPartition = partitionOutlineItems(collapseWindows(sections));
assert.equal(viaBuild.hasParts, viaPartition.hasParts);
assert.deepEqual(viaBuild.groups.map((g) => [g.key, g.items.length]), viaPartition.groups.map((g) => [g.key, g.items.length]));
// window 흡수 후 PART PG 는 1 항목(fragmentCount 2).
assert.equal(viaPartition.groups[0].items.length, 1);
assert.equal(viaPartition.groups[0].items[0].fragmentCount, 2);
});
// ── D8: partGroupViews / groupKeyByChunkId — 렌더 그룹 평탄화 + auto-expand 역인덱스 ──
test('partGroupViews: front-matter 를 첫 그룹(sentinel key)으로, 이어 PART 그룹', () => {
const sections = [
sec({ heading_path: 'TOC', section_title: 'TOC' }),
sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }),
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
];
const views = partGroupViews(buildPartOutline(sections));
assert.equal(views.length, 3);
assert.equal(views[0].key, FRONT_MATTER_KEY);
assert.equal(views[0].label, FRONT_MATTER_LABEL);
assert.equal(views[0].isFrontMatter, true);
assert.equal(views[1].key, 'PART PG');
assert.equal(views[1].label, 'PART PG');
assert.equal(views[1].isFrontMatter, false);
assert.equal(views[2].key, 'PART PW');
// 모든 key 유일(Svelte each key 안전)
const keys = views.map((v) => v.key);
assert.equal(new Set(keys).size, keys.length);
});
test('partGroupViews: front-matter 없으면 PART 그룹만(첫 그룹 sentinel 없음)', () => {
const sections = [
sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }),
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
];
const views = partGroupViews(buildPartOutline(sections));
assert.equal(views.length, 2);
assert.ok(views.every((v) => !v.isFrontMatter));
assert.equal(views[0].key, 'PART PG');
});
test('groupKeyByChunkId: 대표 chunk_id → 소속 group key (auto-expand 역인덱스)', () => {
const sections = [
sec({ chunk_id: 1, heading_path: 'TOC', section_title: 'TOC' }),
sec({ chunk_id: 2, heading_path: 'PART PG > PG-1', section_title: 'PG-1' }),
sec({ chunk_id: 3, heading_path: 'PART PG > PG-2', section_title: 'PG-2' }),
sec({ chunk_id: 4, heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
];
const views = partGroupViews(buildPartOutline(sections));
const idx = groupKeyByChunkId(views);
assert.equal(idx.get(1), FRONT_MATTER_KEY);
assert.equal(idx.get(2), 'PART PG');
assert.equal(idx.get(3), 'PART PG');
assert.equal(idx.get(4), 'PART PW');
assert.equal(idx.get(999), undefined);
});
+129
View File
@@ -84,6 +84,9 @@ export function sectionTypeLabel(t: string | null | undefined): string | null {
export function cleanHeading(raw: string | null | undefined): string {
if (!raw) return '';
return raw
// D9(read-time): ASME 개정바 ðNÞ(`<sup>ð</sup>**25**<sup>Þ</sup>`) 통째 제거 — 개별 sup strip 전에.
// (일반 sup strip 이 먼저면 가운데 '25'(개정 연도)만 남아 'ð25Þ PG-5.4' → '25 PG-5.4' 오염)
.replace(/<sup>\s*ð\s*<\/sup>.*?<sup>\s*Þ\s*<\/sup>/gi, '')
.replace(/<sup>.*?<\/sup>/gi, '') // 각주 위첨자
.replace(/<sub>.*?<\/sub>/gi, '')
.replace(/<[^>]+>/g, '') // 잔여 HTML 태그
@@ -231,3 +234,129 @@ export function groupOrFlat(sections: DocumentSection[]): OutlineLayout {
}));
return { mode: 'group', items: [], groups };
}
// ── D7/D8 (asme-item-decomp read-time): front-matter 억제 + Part 계층 그룹 ──
// 긴 구조화 코드(ASME)의 절뷰가 flat 1030 으로 길어지는 문제(front-matter 240 + 다중 PART)를
// 표현 계층에서 해결. 빌더/재분해 무접촉 — sections 엔드포인트가 주는 heading_path 만으로 산출.
/**
* top-segment 패턴: 대문자 'PART'/'SUBSECTION'/'(MANDATORY|NONMANDATORY) APPENDIX'
* + (PG/UW/IV/A) + (// ).
* : 'PART PG GENERAL REQUIREMENTS…', 'SUBSECTION A GENERAL', 'NONMANDATORY APPENDIX A EXPLANATION…'.
* (APPENDIX) ASME ( ) PART
* carry-forward (5180 실측: PART PHRSG 11 289 = 300).
*
* case-sensitive + - = cross-ref/ false match (5210 ):
* 'Part D, Subpart 3의 …'() · 'PART UW 규정은 용접에 …'( ) · 'PART 층이 진 …'
* ( ) . D1 _ENG read-time ([[feedback_docstring_invariant_swap_audit]]).
* (D3 ): - () PART
* (: 'PART PG 일반 요건'). false-negative(flat ) false-positive( )
* (5180 ) 5210(D3 stale) flat . **5210 D3 PART
* (//) ** read-time 0. [[project_hierarchical_decomposition]] D3.
*/
const PART_MARKER_RE = /^((MANDATORY |NONMANDATORY )?APPENDIX|PART|SUBSECTION)\s+[A-Z][A-Z0-9.\-]*(\s+[A-Z0-9(].*)?$/;
/** top-segment 문자열이 PART/SUBSECTION/APPENDIX 헤딩인가 (마커 판정 단일 소스 — 경계·carry 공용). */
function isPartMarkerSeg(seg0: string): boolean {
return PART_MARKER_RE.test(seg0);
}
/** 절의 heading_path 첫 세그먼트가 PART/SUBSECTION/APPENDIX 헤딩 = 새 최상위 섹션 경계. */
function isPartMarker(s: DocumentSection): boolean {
const segs = pathSegments(s.heading_path);
return segs.length > 0 && isPartMarkerSeg(segs[0]);
}
export interface PartOutline {
/** PART PG / PART PW … 전(前) front-matter(TOC·위원회·인명) — 단일 접이 그룹용. */
frontMatter: OutlineItem[];
/** 본문 Part 그룹들(heading_path 첫 세그먼트 = PART 기준). 기본 접힘은 렌더(D8)에서. */
groups: OutlineGroup[];
/** content part 경계를 못 찾으면 false → 기존 groupOrFlat 폴백 권장. */
hasParts: boolean;
}
/**
* collapseWindows OutlineItem[] front-matter( PART ) + PART
* ** carry-forward** . (chunk_index) .
*
* carry-forward 핵심: ASME md marker 'PG-28'·'GENERAL'
* heading_path PART ( / ). segs[0]
* 250+ (5180 ). PART/SUBSECTION , -
* PART = ~13 PART .
* OutlineItem (-collapse ) flat outline
* chunk_id· 1:1 ( treeNav selectedSectionId/focusView ).
* PART 0 hasParts=false groupOrFlat/flat .
*/
export function partitionOutlineItems(items: OutlineItem[]): PartOutline {
let boundary = -1;
for (let i = 0; i < items.length; i++) {
if (isPartMarker(items[i].section)) { boundary = i; break; }
}
if (boundary < 0) {
return { frontMatter: [], groups: [], hasParts: false };
}
const frontMatter = items.slice(0, boundary);
const order: string[] = [];
const map = new Map<string, OutlineItem[]>();
let current = ''; // 현재 PART 키 — boundary 가 PART 마커라 첫 본문 항목에서 즉시 설정됨.
for (let i = boundary; i < items.length; i++) {
const it = items[i];
const segs = pathSegments(it.section.heading_path);
if (segs.length && isPartMarkerSeg(segs[0])) current = segs[0]; // 새 PART 경계(경계 루프와 동일 판정 = '' 누출 불가)
if (!map.has(current)) { map.set(current, []); order.push(current); }
map.get(current)!.push(it);
}
const groups: OutlineGroup[] = order.map((key) => ({ key, isOther: false, items: map.get(key)! }));
return { frontMatter, groups, hasParts: true };
}
/**
* front-matter ( content part) + PART(heading_path ) .
* = collapseWindows partitionOutlineItems ( rail/treeNav , sections ).
*/
export function buildPartOutline(sections: DocumentSection[]): PartOutline {
return partitionOutlineItems(collapseWindows(sections));
}
// ── D8: Part 접이 렌더용 — front-matter 를 첫 그룹으로 평탄화 + auto-expand 역인덱스 ──
/** front-matter 접이 그룹의 안정 key/라벨(실 PART 키와 충돌 불가능한 sentinel). */
export const FRONT_MATTER_KEY = '__front_matter__';
export const FRONT_MATTER_LABEL = '문서 정보·서문';
/** 접이 그룹 1개(front-matter 또는 PART) 의 렌더 뷰. */
export interface PartGroupView {
/** Svelte each key + 접이 상태 key. front-matter = FRONT_MATTER_KEY. */
key: string;
/** 헤더 표시 라벨. */
label: string;
isFrontMatter: boolean;
items: OutlineItem[];
}
/**
* PartOutline . front-matter() ,
* PART . /auto-expand key .
*/
export function partGroupViews(outline: PartOutline): PartGroupView[] {
const views: PartGroupView[] = [];
if (outline.frontMatter.length) {
views.push({ key: FRONT_MATTER_KEY, label: FRONT_MATTER_LABEL, isFrontMatter: true, items: outline.frontMatter });
}
for (const g of outline.groups) {
views.push({ key: g.key, label: g.key, isFrontMatter: false, items: g.items });
}
return views;
}
/**
* OutlineItem chunk_id group key (/
* auto-expand ). activeKey/selectedSectionId chunk_id .
*/
export function groupKeyByChunkId(views: PartGroupView[]): Map<number, string> {
const m = new Map<number, string>();
for (const v of views) for (const it of v.items) m.set(it.section.chunk_id, v.key);
return m;
}
+75 -11
View File
@@ -24,7 +24,8 @@
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte';
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows } from '$lib/utils/headingPath';
import { untrack } from 'svelte';
import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows, partitionOutlineItems, partGroupViews, groupKeyByChunkId } from '$lib/utils/headingPath';
import { domainLabel } from '$lib/utils/domainSlug';
marked.use({ mangle: false, headerIds: false });
@@ -69,10 +70,21 @@
// 강등한다(예: 5180 = 27개 논리 절 → 562 window). raw sections 를 그대로 그리면 동일 제목 수백 행으로
// 파편화되므로, collapseWindows 로 논리 절 1개(대표=split-parent, bodyText=window 본문 합본)로 합친다.
let outline = $derived(collapseWindows(sections));
// Part 접이 트리(ASME 등 hasParts): 같은 outline 인스턴스를 front-matter/PART 로 재배치(재-collapse 없음
// → selectedSectionId/focusView 정합). flat 1030 → front-matter 단일그룹 + ~14 PART 접이. (D8)
let treePart = $derived(partitionOutlineItems(outline));
let treeGroups = $derived(treePart.hasParts ? partGroupViews(treePart) : null);
let treeGroupIndex = $derived(treeGroups ? groupKeyByChunkId(treeGroups) : null);
let treeExpanded = $state({}); // key 없으면 접힘(기본 전부 접힘). Svelte5 deep-proxy 반응형.
function toggleTreeGroup(key) { treeExpanded[key] = !treeExpanded[key]; }
// sections 로딩 완료 플래그 — 미완 동안 fallback 풀-문서 뷰어를 띄우면, 곧 절뷰로 교체되며
// 풀-문서 이미지가 '살짝 보였다 사라지는' 플래시가 난다(절 보유 문서). 로딩 중엔 skeleton.
let sectionsLoaded = $state(false);
async function loadSections() {
const reqId = docId;
try { const r = await api(`/documents/${reqId}/sections`); if (reqId === docId) sections = r?.sections ?? []; }
catch { if (reqId === docId) sections = []; }
finally { if (reqId === docId) sectionsLoaded = true; }
}
onMount(async () => {
@@ -116,8 +128,34 @@
let mTree = $state(false);
let mIns = $state(false);
let manageOpen = $state(false);
$effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = outline[0].section.chunk_id; });
let selectedItem = $derived(outline.find((it) => it.section.chunk_id === selectedSectionId) ?? outline[0] ?? null);
// 기본 선택 = 첫 본문 Part 의 첫 절(front-matter TOC 가 아니라 실제 내용으로 진입, front-matter 접힘 유지).
let defaultSelId = $derived.by(() => {
if (treeGroups) {
const body = treeGroups.find((g) => !g.isFrontMatter);
if (body && body.items.length) return body.items[0].section.chunk_id;
}
return outline[0]?.section.chunk_id ?? null;
});
$effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = defaultSelId; });
// 문서가 바뀌면(sections 교체) Part 접이·모바일 본문 펼침 상태 리셋 — 문서 간 PART 라벨/chunk_id 가
// 겹쳐 이전 상태가 이월되는 것 차단(기본 전부 접힘 보존). ※ 같은 컴포넌트 인스턴스로 client 네비 시
// sections 가 재로딩될 때만 발화 — 현재 [id] 페이지는 onMount 1회 로딩이라 SPA prev/next 미reload 는
// 선존 별도 이슈(D8 범위 밖, 사용자 보고 대상).
$effect(() => {
void sections;
untrack(() => { treeExpanded = {}; mBodyOpen = {}; });
});
// 선택 절의 조상 Part 를 펼침(prev/next·딥링크 진입 시 트리에서 자동 노출). untrack=쓰기 자기재발화 차단.
$effect(() => {
const sel = selectedSectionId;
const idx = treeGroupIndex;
if (sel == null || !idx) return;
const gk = idx.get(sel);
if (gk) untrack(() => { treeExpanded[gk] = true; });
});
// selectedSectionId 미설정(초기) 시 defaultSelId(첫 본문 Part)로 바로 해석 — outline[0](표지/front-matter)
// 를 잠깐 렌더했다 effect 가 defaultSelId 로 바꾸는 절뷰 내부 플래시 차단.
let selectedItem = $derived(outline.find((it) => it.section.chunk_id === (selectedSectionId ?? defaultSelId)) ?? outline[0] ?? null);
let selectedSection = $derived(selectedItem?.section ?? null);
let selIdx = $derived(outline.findIndex((it) => it.section.chunk_id === selectedItem?.section?.chunk_id));
// 절 본문 = 청크 원문(it.bodyText, window 조각 합본) 직접 렌더. 과거 char_start 로 md_content 를
@@ -168,13 +206,14 @@
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#7a8b3f;"></span>절차</span>
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#b5840a;"></span>요건</span>
</div>
{#each outline as it (it.section.chunk_id)}
{#snippet treeNode(it)}
{@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)}
<svelte:element this={jumpMode ? 'a' : 'div'} href={jumpMode ? `#m-sec-${s.chunk_id}` : undefined} role="button" tabindex="0"
<svelte:element this={jumpMode ? 'a' : 'div'} href={jumpMode ? `#m-sec-${s.chunk_id}` : undefined}
role={jumpMode ? undefined : 'button'} tabindex={jumpMode ? undefined : 0}
onclick={() => !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' : ''}"
@@ -189,7 +228,25 @@
{/if}
</div>
</svelte:element>
{/each}
{/snippet}
{#if treeGroups}
<!-- Part 접이(ASME 등): front-matter 단일그룹 + PART 접이, 기본 접힘. 선택/딥링크 시 조상 Part auto-expand. -->
{#each treeGroups as g (g.key)}
{@const isOpen = !!treeExpanded[g.key]}
<button type="button" class="d3grp" aria-expanded={isOpen} onclick={() => toggleTreeGroup(g.key)}
style="display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:none;border:none;cursor:pointer;border-radius:8px;padding:6px 8px;margin:4px 0 1px;">
<span style="transition:transform .16s;transform:rotate({isOpen ? 90 : 0}deg);color:#9aa090;font-weight:700;font-size:12px;flex-shrink:0;"></span>
<span style="flex:1;min-width:0;font-size:11px;font-weight:700;color:{g.isFrontMatter ? '#9aa090' : '#697061'};letter-spacing:.3px;text-transform:uppercase;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{g.label}</span>
<span style="font-size:10px;color:#9aa090;font-variant-numeric:tabular-nums;flex-shrink:0;">{g.items.length}</span>
</button>
{#if isOpen}
{#each g.items as it (it.section.chunk_id)}{@render treeNode(it)}{/each}
{/if}
{/each}
{:else}
{#each outline as it (it.section.chunk_id)}{@render treeNode(it)}{/each}
{/if}
{#if quality}
<div style="margin-top:12px;padding-top:10px;border-top:1px solid #dde3d6;">
<div style="font-size:10.5px;font-weight:700;color:#697061;margin-bottom:7px;letter-spacing:.3px;">추출 품질</div>
@@ -239,8 +296,8 @@
{/if}
</div>
{/if}
{#if selectedBodyHtml}
<div class="prose prose-base max-w-none text-text">{@html selectedBodyHtml}</div>
{#if selectedItem?.bodyText}
<MarkdownDoc documentId={doc.id} mdContent={selectedItem.bodyText} mdStatus={null} class="prose prose-base max-w-none text-text" />
{:else}
<p style="color:#9aa090;font-size:14px;font-style:italic;">이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.</p>
{/if}
@@ -339,7 +396,7 @@
{#if it.bodyText}
<details class="m-secbody" ontoggle={(e) => { if (e.currentTarget.open) mBodyOpen[s.chunk_id] = true; }}>
<summary style="cursor:pointer;list-style:none;font-size:12px;color:#697061;padding:5px 0;user-select:none;display:flex;align-items:center;gap:5px;">본문 보기 <span class="m-chev" style="transition:transform .16s;color:#9aa090;"></span></summary>
{#if mBodyOpen[s.chunk_id]}<div class="prose prose-sm max-w-none text-text" style="margin-top:6px;">{@html bodyHtml(it)}</div>{/if}
{#if mBodyOpen[s.chunk_id]}<div style="margin-top:6px;"><MarkdownDoc documentId={doc.id} mdContent={it.bodyText} mdStatus={null} class="prose prose-sm max-w-none text-text" /></div>{/if}
</details>
{/if}
</div>
@@ -384,10 +441,13 @@
</div>
</div>
{#if useSectionView}
{#if !sectionsLoaded}
<!-- sections 로딩 중: fallback 풀-문서(이미지)→절뷰 교체 플래시 방지용 skeleton -->
<Skeleton h="h-96" rounded="card" />
{:else if useSectionView}
<!-- 데스크탑(xl+): 3영역 -->
<div class="hidden xl:grid" style="grid-template-columns:252px minmax(0,1fr) 336px;gap:13px;align-items:start;">
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh-2rem);overflow-y:auto;">{@render treeNav(false)}</div>
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh - 2rem);overflow-y:auto;">{@render treeNav(false)}</div>
<div style="min-width:0;"><div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:20px 22px;">{@render focusView()}</div></div>
<div style="position:sticky;top:14px;">{@render rail()}</div>
</div>
@@ -400,6 +460,9 @@
</div>
{#if mTree}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:6px;margin-bottom:10px;">{@render treeNav(true)}</div>{/if}
{#if mIns}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:13px 14px;margin-bottom:10px;">{@render rail()}</div>{/if}
<!-- D8 스코프 한계(의도적): 모바일 본문은 전체 outline(~1030)을 연속 카드로 eager 마운트한다.
Part 접이는 위 treeNav(앵커 점프 네비)에만 적용 — 본문 롱스크롤은 줄이지 않는다. 데스크탑은
focusView 가 단일 절만 렌더하므로 무관. 모바일 본문 분할/가상화는 별 follow-up. -->
<div style="display:flex;flex-direction:column;gap:10px;">{#each outline as it (it.section.chunk_id)}{@render sectionCard(it)}{/each}</div>
</div>
{:else}
@@ -474,6 +537,7 @@
<style>
.d3node:hover { background: #ecf0e8; }
.d3active:hover { background: #e3ebdf; }
.d3grp:hover { background: #ecf0e8; }
.d3child { position: relative; }
.d3child::before { content: ""; position: absolute; left: 2px; top: -3px; bottom: 50%; width: 1px; background: #cdd6c4; }
.d3child::after { content: ""; position: absolute; left: 2px; top: 50%; width: 7px; height: 1px; background: #cdd6c4; }
+106
View File
@@ -0,0 +1,106 @@
"""_ENG 매처 노이즈 차단 단위테스트 (asme-item-decomp-1 D1).
핵심 불변식: 영문 구조 헤딩 매처(_ENG)
- (음성) 본문 중간 'Part III to demonstrate…' 같은 소문자 문장연속을 가짜 절로 잡지 않고,
- (양성) 진짜 영문 구조 헤딩(PART PG / Part 1 / Section 3.31 / Part UHX ) 탐지하며,
- (ATX 보존) _ENG 축소가 ATX 파트(`# PART PG`)·항목(`#### PG-1`)을 떨구지 않는다(ATX 우선).
pytest + 단독 실행 양쪽 지원:
PYTHONPATH=. python3 tests/hier_decomp/test_eng_matcher.py
"""
from __future__ import annotations
try: # pytest 경로 (앱 패키지)
from app.services.hier_decomp.builder import _detect_heading, build_hier_tree
except Exception: # 단독 실행 (앱 deps 없이 builder.py 직접 로드 — stdlib only)
import importlib.util
import pathlib
import sys
_bp = pathlib.Path(__file__).resolve().parents[2] / "app/services/hier_decomp/builder.py"
_spec = importlib.util.spec_from_file_location("_hier_builder_t", _bp)
_m = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = _m # dataclass __module__ 해소
_spec.loader.exec_module(_m)
_detect_heading, build_hier_tree = _m._detect_heading, _m.build_hier_tree
# ── 음성: 본문 문장은 헤딩 아님 (가짜 절 차단 — D1 회귀의 핵심) ──
NEG = [
"Part III to demonstrate to the satisfaction of the represen-",
"Section V of the agreement applies to all parties",
"Part IV is hereby amended as follows",
"Article II shall be interpreted broadly",
"Chapter 3 describes the general method used here",
]
# ── 양성: 진짜 영문 구조 헤딩 ──
POS = [
"PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION",
"Part 1",
"Part PFH",
"Part UHX (TUBESHEET CALCULATION)",
"Section 3.31",
"Chapter 1 Introduction",
"Article 5 Definitions",
]
def test_eng_negatives_not_detected():
for line in NEG:
assert _detect_heading(line) is None, f"가짜 절로 잡힘: {line!r}"
def test_eng_positives_detected_as_chapter():
for line in POS:
r = _detect_heading(line)
assert r is not None, f"진짜 헤딩 미탐지: {line!r}"
_lvl, _title, nt = r
assert nt == "chapter", f"{line!r} node_type={nt}"
def test_atx_part_and_item_still_detected():
# _ENG 축소가 진짜 ATX 파트/항목을 떨구지 않음 (ATX 우선 탐지)
r = _detect_heading("# PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION")
assert r is not None
lvl, title, nt = r
assert lvl == 1 and nt is None, r # ATX = level(# 수), node_type None
assert title.startswith("PART PG")
r2 = _detect_heading("#### PG-1 SCOPE")
assert r2 is not None and r2[0] == 4 and r2[2] is None, r2
def test_build_hier_tree_drops_false_part_section():
# 본문에 'Part III to demonstrate…' 가 섞여도 가짜 절이 생기지 않음
md = (
"# PART PG GENERAL REQUIREMENTS\n"
"#### PG-1 SCOPE\n"
"The rules cover power boilers.\n"
"Part III to demonstrate to the satisfaction of the representative\n"
"that the requirements are met, the manufacturer shall proceed...\n"
"#### PG-2 SERVICE LIMITATIONS\n"
"body of pg-2 here.\n"
)
titles = [n.section_title for n in build_hier_tree(md) if n.section_title]
assert any(t.startswith("PART PG") for t in titles), titles
assert any(t.startswith("PG-1") for t in titles), titles
assert any(t.startswith("PG-2") for t in titles), titles
assert not any("demonstrate" in (t or "") for t in titles), f"가짜 절 누출: {titles}"
if __name__ == "__main__":
import sys
import traceback
fns = [(k, v) for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for name, fn in fns:
try:
fn()
print(f"PASS {name}")
except Exception as e:
failed += 1
print(f"FAIL {name}: {e}")
traceback.print_exc()
print(f"\n{len(fns) - failed}/{len(fns)} passed")
sys.exit(1 if failed else 0)
+1 -1
View File
@@ -1394,7 +1394,7 @@ def main() -> int:
"--reranker-backend",
type=str,
default=None,
help="Phase 2B Diagnose reranker dispatcher slug (baseline | cand_gte_ml_base). 미지정 = production.",
help="Phase 2B Diagnose reranker dispatcher slug (baseline). 후보 cand_gte_ml_base = NO-GO 종결·teardown(2026-06-18). 미지정 = production.",
)
parser.add_argument(
"--rewrite-backend",