Compare commits

..

86 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
hyungi 381fcfc675 ops(ci): 전체 app 부팅 스모크 (boot_smoke.py) — GPU 격리 deploy-blocker 게이트
lifespan 실 경로(init_db + 전 worker import + 전 add_job)를 prod 이미지 컨테이너 +
ephemeral PG 로 실행해 router/worker import 오류·잡 등록 오류를 검출. NAS/scheduler.start/
prewarm 3개 부작용만 중립화(prod/AI 무접촉). GPU 실측 PASS: routes=173·jobs=34·schema 361·health ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:49:28 +09:00
hyungi 3ff1d7c65d fix(migrations): R1 baseline 런타임 버그 3건 — init_db asyncpg 경로 (R1 fix)
★실제 init_db() 런타임 검증(psql migration_smoke 가 못 잡는 asyncpg 경로)에서 발견·수정:
1. baseline 덤프에 CREATE TABLE schema_migrations 포함 → init_db 가 IF NOT EXISTS 로 선-CREATE
   후 baseline 이 재-CREATE 충돌. --exclude-table=schema_migrations 재덤프(init_db 가 소유).
2. baseline 은 multi-statement 인데 exec_driver_sql(asyncpg prepared)은 multi-statement 불허
   ('cannot insert multiple commands into a prepared statement'). raw asyncpg simple 프로토콜
   execute() 로 적재(같은 connection = 트랜잭션 내).
3. 마이그 360(10 DROP)·361(DELETE+CREATE)이 multi-statement → init_db 적용 실패. 360=콤마구분
   단일 DROP, 361=단일 CREATE UNIQUE INDEX(prod 중복0·fresh 빈테이블이라 dedup DELETE 불요).

★검증: scripts/ci/initdb_runtime_test.py 로 실제 init_db 2회 — 1st(fresh: baseline 262 스탬프 +
359/360/361 적용, documents·purge_col·cand_drop·attempt_unique 전부 확인), 2nd(멱등 skip) PASS.
psql migration_smoke 도 PASS 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:59:47 +09:00
hyungi 884ea1e669 docs(news): url normalizer 채널별 의도적 divergence 명시 — 통합 금지 박제 (R11b)
audit 의 dup-url-normalizer-divergent 는 design intent 오탐: news._normalize_url 은 query-식별
사이트(hada.io?id=·HN item?id=) 별개 기사 붕괴 방지 위해 보수적(query 보존·sort/trailing-slash/
소문자화 안 함), file_watcher._canonicalize_url 은 web_clip dedup 위해 공격적 정규화 — 채널별
의도된 차이. 통합하면 news dedup 가 깨진다(docstring 경고). 두 함수 docstring 에 상호 cross-ref +
'통합 금지' 명시해 미래 잘못된 통합 차단. 동작 변경 0(주석만).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:40:56 +09:00
hyungi 523c509954 refactor(news): 3 fetch Document 빌드 _build_news_doc 헬퍼 통합 (R11c)
_fetch_rss/_fetch_api_guardian/_fetch_api_nyt 의 22필드 Document 빌드가 정적 동일
(필드키 22개 동순서 실측) — 채널별 차이는 body(NYT=summary)·extractor_version·ident(category
계산)뿐이라 인자화. _build_news_doc 헬퍼로 통합 = 동작 보존(정적 검증). news_collector
god-file 중복 30줄×3 → 1 헬퍼.

검증: py_compile 통과, doc=Document( 직접빌드 0건. ★채널별 ingest smoke(staging)로 3 경로
동등 확인 권장.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:39:27 +09:00
hyungi 205a7bf3d5 fix(study): attempt (quiz_session_id, study_question_id) partial UNIQUE (R9)
submit_attempt FOR UPDATE(3ba9537) 1차 방어에 더해 DB 레벨 belt-and-suspenders — 모바일
더블탭/재시도가 어떤 경로로든 이중 attempt INSERT 에 도달해도 차단. prod 실측 중복 0
(GROUP BY HAVING count>1 = 0)이라 안전 — dedup DELETE 멱등 precaution + partial UNIQUE
(quiz_session_id IS NOT NULL). 세션 외 직접입력(NULL)은 비대상.

검증: migration_smoke PASS(post-baseline 361 적용). ★FOR UPDATE 가 정상경로선 막으므로
이 제약은 거의 트리거 안 됨 — 트리거 시 IntegrityError→500(should-never-happen 가시화);
graceful 409 변환이 필요하면 submit_attempt 에 try/except 추가 가능(별도).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:36:24 +09:00
hyungi 4d5f35b26e refactor(news): 3 fetch 공통 존재체크 _already_ingested 헬퍼 추출 (R11c)
_fetch_rss/_fetch_api_guardian/_fetch_api_nyt 가 복제하던 동일 존재체크
(file_hash 또는 edit_url.in_([normalized,link]) 매칭) 를 단일 헬퍼로 — byte-identical
블록이라 동작 100% 보존. news_collector god-file 중복 일부 감소.

(채널별 Document 빌드 30줄 3중복 통합은 채널별 필드 차이 검증 필요 → staging/별도.)
검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:30:47 +09:00
hyungi df4b07d29c refactor(library): facet-counts 4블록 헬퍼 추출 — 중복 제거 (R10)
company/topic/year/doctype 4 facet 집계 블록이 거의 동일 복붙(각 base_query 재구성 +
다른 3축 필터 적용). 적용된 facet 필터를 applied dict 로 모으고 '자기 자신 축 제외' 헬퍼
_facet_count(name, col, order_by, value_fn)로 추출 — 쿼리/자기제외/order_by(year=desc·others=count)/
value 매핑(year=str) 모두 동일 보존. 동작 무변경(staging 에서 facet 카운트 동등성 확인 권장).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:28:33 +09:00
hyungi 3729083dc0 perf(search): synthesis 캐시 TTL enforce + chunk news_source DB 필터 (R10)
- synthesis_service: _CACHE 가 ts 없이 result 만 저장해 CACHE_TTL(1h) 미적용 → 원문 수정돼도
  CACHE_MAXSIZE 찰 때까지 stale answer 반환. (ts, result) tuple + get_cached 에서 만료 pop
  (query_rewriter expire_at 정본 복제).
- chunk_worker: 문서마다 news_sources 전량 로드 후 Python prefix 루프 → DB 필터 푸시다운
  ((name==source_name) | startswith(source_name+' ')). split[0]==source_name 과 동치, autoescape.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:24:03 +09:00
hyungi 455a5a66ff fix(classify): ai_suggestion library 블록 is None 가드 — material 제안 clobber 방지 (R9)
거래문서(LIBRARY_SUGGESTION_DOCTYPES) 제안이 doc.ai_suggestion is None 체크 없이 덮어써,
material 제안 블록(material_type 제안)이 이미 점유한 ai_suggestion 을 clobber 하던 비대칭.
material 블록과 동일하게 is None 가드 추가 — 주석의 '기존 제안 우선' 사상 일치.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:19:20 +09:00
hyungi 124b50af53 perf(events): list_events total 을 DB COUNT 푸시다운 (R10)
전체 Event.id 를 메모리 로딩 후 len() 하던 것을 select(func.count(Event.id)) 로 전환 —
행 수에 선형이던 메모리/전송 비용 제거. 결과 동등(단순 카운트라 golden-diff 불요).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:18:12 +09:00
hyungi 0d3c841577 feat(migrations): 스키마 baseline 스냅샷 — fresh-DB/DR 부팅 fix (R1)
R0 가 입증했듯 migrations/ 전체 replay 는 011(view active_documents 가 documents.embedding
의존, DROP COLUMN CASCADE 부재)·326(enum-same-txn) 등 누적 비-replayable 로 깨져 신규/DR
환경 init_db 부팅이 불가능했다. 표준 squash baseline 로 해소:
- migrations/_baseline/0358_schema_baseline.sql: prod 스키마 스냅샷(pg_dump --schema-only
  --no-owner --no-privileges, psql 메타·search_path='' 정리 = asyncpg exec_driver_sql 호환).
- init_db._load_baseline_if_fresh: documents 테이블 부재(fresh) 시 baseline 적재 +
  schema_migrations 1..358 스탬프 → 이후 post-baseline(359/360)만 적용. ★기존 DB(documents
  존재)는 skip = prod 무영향(additive). baseline 부재 시 기존 replay 경로(하위호환).
- migration_smoke: baseline 경로 검증. ★실측 — 이전 FAIL(011 abort) → 이제 FRESH/INCREMENTAL
  모두 PASS (pg16.14). cutoff(_BASELINE_CUTOFF=358) 갱신 시 baseline 재생성.

검증: py_compile + migration_smoke PASS. ★boot-path 변경이라 deploy 전 staging 부팅 검증 필수.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:16:21 +09:00
hyungi 690b22fe58 fix(hardening): collect-lock TOCTOU 제거 (R9) + tier_backfill fstring allowlist (R12)
- news.collect: locked() 체크 후 실제 acquire 가 별도 task 안에서 일어나 그 사이 다른 요청이
  끼어들어 이중 수집 task 가 생기던 TOCTOU. 핸들러에서 동기 acquire + task finally release 로 원자화.
- tier_backfill._enqueue_domain: filter_clause 가 SQL 에 직접 보간되나 allowlist 가드 부재
  (retrieval_service _VALID_DOCS_TABLE 정본 대비 비대칭). DOMAIN_PRIORITY 출처 allowlist final
  gate 추가 — 현재 모듈 상수라 injection 0 이나 외부 입력화 시 즉시 차단.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:07:07 +09:00
hyungi 3565ef9ac4 fix(digest): daily_digest 산출물 이모지 제거 — no-emoji 규칙 (R11a)
실패 강조 라인의 ⚠️ → **[주의]** 텍스트 마커. 산출물(다이제스트 markdown) no-emoji 준수.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:03:31 +09:00
hyungi 719c35afbc refactor(models): ai_tags/user_tags 공유 가변 default 제거 + 주석 정정 (R11a)
- ai_tags: 주석/Mapped 타입이 dict 인데 실제 list 적재 → list 로 정정.
- ai_tags/user_tags: default=[] (정의 시점 1회 평가되는 공유 가변 인스턴스) → default=list
  (callable, 행마다 새 리스트). SQLAlchemy column default 관용 idiom.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:02:56 +09:00
hyungi e664d7b187 perf(setup): setup 미들웨어 user COUNT 캐시 — per-request 쿼리 제거 (R10)
setup 완료 후에도 모든 비-bypass 요청이 select count(User.id) 를 실행하던 per-request
비용. 셋업 완료(user 존재)는 monotonic 이라 1회 확인 후 _setup_complete 플래그로 영구
skip(이후 요청 DB 쿼리 0). global 선언은 함수 첫 줄(read+assign 혼용 UnboundLocalError 방지).

R10 잔여(library-tree jsonb 집계 golden-diff·facet-counts·events-count·synthesis cache TTL)는
결과 동등성 검증 동반이라 후속. 검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:01:25 +09:00
hyungi 3ba9537515 fix(study): submit_attempt FOR UPDATE 행 잠금 — 동시 이중제출 race 차단 (R9)
quiz_session 을 session.get(잠금 없음)으로 읽어 모바일 더블탭/재시도 시 동시 제출 둘 다
cursor=N 을 보고 cursor+1·correct/wrong/unsure count 를 이중 가산하던 race. select +
with_for_update() 로 행 잠금 → 직렬화. 두 번째 제출은 첫 commit 후 cursor=N+1 을 읽고
cursor 위치 불일치 409 로 거부된다.

belt-and-suspenders 인 attempt UNIQUE 제약은 기존 중복 dup-backfill 마이그가 선행조건이라
별도(R9 후속). 검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:59:35 +09:00
hyungi d58565ef38 refactor(search): Phase 2A cand 슬러그·테이블 제거 (R13)
Phase 2A 임베딩 후보(me5_large_inst·snowflake_l_v2·qwen06·qwen4·qwen4m) no-go 종결
(2026-06-12, 후보 전부 -0.03~-0.04) + phase2a_cand_backfill 워커 dormant(미스케줄·미import).
- retrieval_service.CANDIDATE_BACKEND_MAP: 5 cand 엔트리 제거(baseline 만 잔존) — read-path
  슬러그를 먼저 빼야 embedding_backend=cand_X /search 가 dropped 테이블 읽어 500 안 남.
- api.search allowed 하드코딩 리스트 → ["baseline"] (R12 search-error-allowed dangling 동반 제거).
- phase2a_cand_backfill.py 삭제(dead code, 드롭될 테이블 참조 — R12 config-bypass 동반 해소).
- 마이그 360: cand 10테이블 DROP TABLE IF EXISTS(멱등, 환경별 존재차 흡수).

검증: py_compile 통과, 슬러그 잔존 참조 0. migration txn 제어문 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:56:42 +09:00
hyungi 70f90bc914 fix(digest): daily_digest KST↔UTC 경계 정렬 + blocking I/O off-thread (R8)
- func.date(created_at) == today(KST) 비교는 pg TimeZone(UTC) 기준 날짜라 KST 0~9시
  생성 문서(UTC 전날)가 오늘 다이제스트에서 누락되던 경계 버그. KST 하루를 UTC 범위
  (start_utc~end_utc)로 변환해 created_at(UTC저장) 범위 비교로 전환(3곳).
- NAS 저장/glob/stat/rename blocking 파일 I/O 를 _write_and_rotate 헬퍼 + asyncio.to_thread
  오프로드(이벤트 루프 점유 방지, R5 일관).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:53:08 +09:00
hyungi 688532b1fa fix(briefing): held→409 표면화 + study attempt naive datetime→UTC (R8)
- briefing.regenerate: held(정책상 정상 보류)를 digest.py 정본처럼 409 로 표면화. 이전엔
  briefing_worker.run() 이 held/timeout/exception 셋 다 None 반환 → API 가 셋 다 500 으로
  오보(silent-state-conflation). 진입부 'briefing' in pipeline_held_stages 가드.
- study_question.answered_at: naive default datetime.now → lambda datetime.now(timezone.utc).
  컨테이너=UTC 실측이라 값 동일·백필 불요, 컨테이너 TZ 바뀌면 9h 어긋나던 잠복 의존 제거.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:51:42 +09:00
hyungi 3a22d225a0 feat(documents): delete_file=true 큐드-감사삭제 — purge 마커 + retention sweep (R7)
delete_file 파라미터가 광고만 하고 본문에서 0회 참조(soft-delete만, 파일 영구 잔존 +
프론트가 실제 호출)되던 거짓 계약 구현. (c) 큐드삭제:
- 마이그 359: documents.purge_requested_at 컬럼(ADD COLUMN IF NOT EXISTS, replayable).
- delete_document: delete_file=true 시 purge_requested_at 마커 set(deleted_at 과 별도).
- document_purge_sweep cron(03:20 KST): purge_requested_at + grace(30일) 경과 + 파일 존재
  시 NAS 원본 unlink + AUDIT 로그. ★sweep 는 deleted_at 아니라 purge_requested_at 기준 —
  일반 숨김(delete_file=false)은 파일 보존(undelete 가능), 명시 purge 만 물리삭제(데이터 안전).
- DELETE 요청 경로엔 동기 비가역 op 0. 파일 존재 체크로 멱등. unlink 는 to_thread(R5 일관).

검증: py_compile 통과. migration txn 제어문 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:48:25 +09:00
hyungi 8a625bfb27 fix(security): soft-delete 가드 구조화 — get_live_document 헬퍼 + paper-holder (R7)
조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 5엔드포인트
(get_document_file·image_raw·save_content·preview·content)가 'if not doc' 만 검사 →
삭제 문서 원본/preview/전문/마커이미지가 doc_id(+토큰)만으로 노출·삭제 문서 NAS 재기록.
get_live_document(session, doc_id) 헬퍼(없거나 deleted_at 이면 404)로 통일 — '경로마다
deleted_at 기억' 대신 구조 강제(추가될 서빙 경로 자동 보호). save_content 는 삭제 문서
쓰기 차단까지. find_paper_holder 도 deleted_at IS NULL 필터 추가(dedup.find_canonical 대칭).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:45:33 +09:00
hyungi 844a5e0204 fix(security): internal 토큰 상수시간 비교 + memo tag 파라미터 바인딩 (R7)
- internal_study._verify_token: != 비교는 첫 불일치 단락으로 prefix 길이 timing
  side-channel(RAG 정답 endpoint 보호 토큰) → hmac.compare_digest(search.py 정본 일치).
- memos tag 필터: f-string 으로 사용자 tag 를 JSON 배열 리터럴에 직접 삽입 → tag 안
  "/] 가 JSON 깨 500 + 필터 변형. func.jsonb_build_array(tag) 바인드 파라미터로.

검증: py_compile 통과. R7 나머지(get_live_document·paper-holder deleted_at·delete_file
purge 마커+retention sweep·fetch-page·save-content)는 이어서.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:40:35 +09:00
hyungi 456dfaa9f2 fix(ai): _call_chat 무동의 Claude egress 자동폴백 제거 (R6)
primary(맥미니) Timeout/ConnectError 시 동의·과금 통제 없이 ai.fallback(Claude API)으로
자동 전환 → 개인 문서/쿼리/메모가 Anthropic 으로 silent egress 되던 프라이버시 결함 봉쇄.
실패는 전파 — 배치 워커는 재시도/StageDeferred(R3), interactive 는 호출자 5xx 표면화
(documents.analyze 이미 502/504). 클라우드는 premium explicit-trigger / call_fallback
명시 호출로만 (자동 진입 금지).

참고: uncoordinated-mlx-semaphores 는 gitea/main 최신에서 digest/briefing 이 이미
acquire_mlx_gate 사용(감사 20커밋 stale 탓 오탐) — 변경 불요. rerank silent-identity 의
rerank_skipped notes 플래그는 시그니처 변경 동반이라 별도 후속(Low).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:38:46 +09:00
hyungi cb7c0fdc4f fix(workers): blocking I/O off-thread — watch_inbox·getaddrinfo·file stream (R5)
AsyncIOScheduler 가 FastAPI lifespan 과 같은 이벤트 루프를 공유하는데 동기 blocking
I/O 가 루프를 점유 → 같은 루프의 모든 1분 주기 consumer + FastAPI 요청 동시 정지.
- watch_inbox: NFS rglob walk + GB 파일 SHA-256(file_hash)을 asyncio.to_thread 오프로드.
  스캔 루프가 순차라 file_hash 직렬화 유지(병렬 해싱 X = NFS 2.5GbE 대역폭·메모리 blowup 방지).
- news create_source: validate_feed_url 의 getaddrinfo(blocking DNS) off-thread.
- storage/local stream: 청크 f.read off-thread.
marker_worker/mailplus to_thread 컨벤션 재사용. daily_digest blocking 은 R8(TZ)과 한 패스.

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:35:44 +09:00
hyungi 2e19dc3d37 fix(collectors): kosha 부분실패 per-case commit — 전체 rollback 방지 (R4)
kosha run() 이 소스별 단일 세션으로 collector 전체를 돌리고 예외 시 rollback →
페이지 _api_get 실패가 앞서 적재한 케이스/항목을 전부 폐기(부분 적재 손실 + 매번
같은 지점 실패 시 영구 미적재). disaster_cases/fatal_accidents/guide 의 케이스·항목
단위로 session.commit() 경계 추가(csb/api_standards idiom) — 실패 이전 적재분 보존,
dedup 으로 다음 run 이 이어받음. 첨부 실패는 기존대로 격리(변경 없음).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:32:07 +09:00
hyungi 2ad32c5c84 fix(collectors): 워터마크 cap 절단 시 미전진 — silent backlog loss 차단 (R4)
arxiv/openalex 수집기가 run_cap 도달로 카테고리/시드 중도 절단돼도 워터마크를
newest 로 전진시켜, [oldest-ingested, 옛 watermark] 사이 미적재 항목이 다음 run 의
watermark 필터에 영구 배제되던 silent data loss 수정.

capped 플래그: cap 으로 루프 절단 시 set → 워터마크 미전진. 미전진하면 다음 run 이
최신부터 재스캔하며 적재분은 dedup-skip(cap 미소모)하고 gap 까지 내려가 이어 적재
→ 백로그 run 당 cap 소화(livelock 회피). 정상 완주(watermark 도달/cursor 소진) 시에만
전진. bulk(CLI)은 cap 무관. docstring 의 '다음 run 이월' 약속을 실제 동작과 일치.

검증: py_compile 통과. kosha 부분실패 per-case commit 은 R4 후속.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:28:04 +09:00
hyungi c11f113cf1 fix(workers): silent completion 차단 — transient re-raise + enqueue 격리 (R3)
worker_fn 이 transient 실패를 삼켜 정상 반환하면 queue_consumer 가 status=completed
로 확정 → 영구 데이터 손실 + 재시도/추적 0. 정본(extract/marker/fulltext/stt 는
re-raise)과 어긋난 곳을 통일:
- deep_summary: 호출 실패(call_failed)를 삼키지 않고 raise → 재시도→failed dead-letter
  (이전엔 ai_detail_summary 영구 누락 + tier triage 고착).
- thumbnail: _extract_thumbnail 실패를 silent return → raise (썸네일 영구 누락 방지).
- queue_consumer: 완료 커밋 후 enqueue_next_stage(정상·skip-note 2곳)를 자체 try 로
  격리 — enqueue 실패가 outer except 로 전파돼 completed 항목을 재오픈(stage 재실행)
  하던 결함 차단. 실패는 ERROR 로 가시화.
- broad except 에 asyncio.CancelledError 명시 통과(embed worker / ask classifier·verifier).

dead-letter = ProcessingQueue.status='failed'(기존 attempts/max_attempts 머신 재사용,
신규 컬럼 불필요). 검증: py_compile 통과. 큐 재시도 의미 synthetic smoke(staging) 예정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:24:25 +09:00
hyungi 9c22337647 fix(search): 공유 AsyncSession 동시 쿼리 직렬화/세션 분리 + rewrite axis 누락 (R2)
asyncio.gather 가 단일 AsyncSession 에 동시 execute 를 진입시켜 부하 의존적
'another operation in progress' 비결정 크래시 (정상 순차 경로에서만 검증돼 잠복).
사이트별 처방(균일 처방 회피):
- search_with_rewrite._variant_retrieve: variant 마다 독립 async_session() fan-out
  (사용자 대면 — N variant 병렬 유지)
- study explanation_rag / subject_note_rag: 백그라운드 prefetch 라 순차 직렬화
  (rerank 도 순차 — DB 순차+rerank gather 분할은 _gather_* 4곳 침습이라 보류,
   배경 작업의 rerank 병렬 이득 미미)

추가: rewrite(multi-query) 경로가 axis 필터(material_type/jurisdiction/year)를
single-query path 와 달리 조용히 누락 — search_with_rewrite 에 axis 인자 + _variant_retrieve
가 search_text/search_vector 에 전달.

검증: py_compile 통과. 동시 N variant 부하 테스트(staging)로 크래시 소거 확인 예정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:18:17 +09:00
hyungi d8ad097a3a ops(migrations): fresh-DB/DR replay·enum 스모크 게이트 (R0)
init_db 의 단일 트랜잭션 적용 경로(engine.begin)를 미러해 migrations/ 전체가
빈 DB / DR(pre-320 → catch-up) 업그레이드에서 한 트랜잭션으로 적용 가능한지 검증.
pg16(pgvector/pgvector:pg16) 핀, ephemeral 컨테이너 자동 기동/정리.

현재 두 시나리오 모두 011_embedding_1024 에서 FAIL — view active_documents 가
documents.embedding 의존(DROP COLUMN CASCADE 부재). enum(326) 이전 지점.
fresh replay 가 한 번도 검증된 적 없어 누적 비-replayable cruft 다수 확인.
R1(스키마 baseline 스냅샷)으로 fix 후 PASS 가 게이트 기준.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:11:55 +09:00
hyungi 3a780c0d06 Merge pull request 'feat(review): 검토 대기 자동검토 워커 — 고신뢰 자동승인 + 저신뢰 잔류' (#42) from feat/auto-review-pending into main
Reviewed-on: #42
2026-06-15 16:33:25 +09:00
hyungi ac7de71ecd feat(review): 검토 대기 자동검토 워커 — 고신뢰 자동승인 + 저신뢰 잔류
auto_review_worker(interval 3분·배치 300): review_status='pending' + ai_domain
+ ai_confidence>=0.9 인 문서를 review_status='approved' 자동승인 + audit
(source_metadata.auto_reviewed). 저신뢰/미분류는 수동 큐 잔류. 재-LLM 호출 없음
(classify confidence 게이트 = 맥미니 부하 0). review_status 는 검색/RAG/digest 필터
미사용(게이트 실측) → 노출 변동 없이 검토 큐만 드레인. 되돌리기=audit 마커로 식별.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:36:56 +09:00
hyungi 35d7c7eab7 Merge pull request 'Feat/memo to document' (#41) from feat/memo-to-document into main
Reviewed-on: #41
2026-06-15 15:21:57 +09:00
hyungi ffe4c776e9 Revert "feat(viewer): md 본문 외부 링크 새 탭 + rel 보안"
This reverts commit 60f3b25.

병렬 세션이 동일 P0(외부 링크 새 탭+rel)를 feat/memo-to-document 브랜치에
docMarkdown.ts link 렌더러 + ADD_ATTR 방식으로 이미 구현(SSR 적용·memos 번들).
중복 회피 위해 본 $effect 구현(redundant)을 canonical 에서 되돌리고 그쪽에 양보.
분석 산출물/측정 결과는 PKM learning 문서로 기록 보존.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:17:18 +09:00
hyungi 60f3b259df feat(viewer): md 본문 외부 링크 새 탭 + rel 보안
문서 본문 markdown 의 외부 http(s) 링크(코퍼스 실측 521문서/9,496개)가
target/rel 없이 같은 탭으로 열려 SPA 를 이탈하던 문제 수정.

MarkdownDoc 에 heading-anchor 와 동일한 DOM 후처리 $effect 추가 —
sanitize 후 라이브 DOM 의 a[href^=http(s)] 에 target="_blank" +
rel="noopener noreferrer" 부여. marked 렌더러/DOMPurify(전역 hook)·
ADD_ATTR 무수정. 앵커(#)·상대경로·mailto 는 미변경(SPA 내부 항법 보존).

내부 위키링크([[...]])·백링크 그래프는 코퍼스 실측상 실신호 ~8개로
데이터 미지원이라 본 PR 범위에서 제외(보류, 내부 링크 증가가 트리거).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:07:39 +09:00
hyungi fabbca64e9 feat(markdown): 외부 링크 새 탭 + rel=noopener noreferrer (P0)
docMarked link 렌더러: http/https 링크에 target=_blank rel=noopener noreferrer
(탭내빙 차단, 코퍼스 521건). 내부/'#'프래그먼트/상대/mailto 는 무손 — outline
gfmHeadingId 경로 유지(클릭 인터셉터 없음=충돌 0). marked15 토큰객체 시그니처.
SANITIZE_OPTS ADD_ATTR 에 target/rel.

load-bearing 게이트: 상대 .md=코퍼스 0건·doc_key 부재 → path→id prop/document_links
미구현(dead). [[..]]=13건 대부분 인용 노이즈([[3\]]) → resolution/스트립 미구현.
외부 링크 하드닝만 정당화됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:06:58 +09:00
hyungi a6d5734f6c feat(memos): 자료로 보내기 P2 — 메모→문서 26B 문서화 워커
memo_draft_worker(interval 2분): promote 가 찍은 source_metadata.needs_draft=true
문서를 26B(call_primary, acquire_mlx_gate BACKGROUND)로 구조화 마크다운(md_content)
생성. content_origin='ai_drafted'+md_draft_status='draft'(mig212 제약 준수), 원본은
extracted_text 보존. promote 엔드포인트에 needs_draft 마커 + main.py add_job.
큐 enum/컨슈머 무변경(derived-worker 패턴) = 저위험.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:50:44 +09:00
hyungi fe8235d726 feat(memos): 자료로 보내기 — 메모를 문서함 정식 문서로 승격 (P1)
새 POST /memos/{id}/promote-to-document: in-place 승격(별 row X) —
source_channel→manual, file_type note→editable, category=library,
content_origin=manual + classify/embed/chunk 재큐(도메인 재부여·요약·심층분석).
메모 카드에 always-visible '자료로 보내기' 버튼(지식 메모=ai_event_kind note 포함).
P2(거친 메모→구조화 마크다운 draft 워커)는 후속.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:32:04 +09:00
hyungi 4927c585c7 Merge pull request 'Fix/md render katex tldr' (#40) from fix/md-render-katex-tldr into main
Reviewed-on: #40
2026-06-15 14:17:44 +09:00
hyungi b0a73f8506 feat(nav): 상단 nav 질문·이드 제거 + 메모 추가
데스크탑 상단 nav 와 모바일 하단 탭바 모두에서 질문(/ask)·이드(/chat) 링크 제거,
메모(/memos) 추가(모바일은 기존 존재). 라우트 코드는 보존(nav 노출만 제거).
미사용 아이콘 import(HelpCircle·MessageCircle) 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:16:23 +09:00
hyungi 2d6d1b8e8a fix(markdown): 수식 pre-render(katex 직접) + TL;DR 마크다운 렌더
본문 $$수식$$가 raw로 노출되던 문제: marked-katex 토크나이저가 개요 anchor
splice/런타임 환경 영향으로 미발화 → marked 이전에 katex.renderToString 으로
직접 렌더 후 placeholder 복원(위치·인접 무관). TL;DR(ai_tldr)도 plain-text
보간이라 마크다운 미렌더 → renderDocMarkdown 경유로 교체(+summary-md 스타일).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:05:35 +09:00
hyungi 4c111ca7f2 fix(observability): BackgroundJobItem 응답 모델에 machine 필드 추가 (직렬화 누락 수정)
f325bd0 이 서비스 payload·frontend 타입엔 machine 을 넣었으나 API Pydantic
response_model(BackgroundJobItem)에 누락 → FastAPI 가 직렬화 시 탈락. 한 줄 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:43:55 +00:00
hyungi f325bd0509 feat(observability): digest/briefing 을 처리 보드에 맥미니 작업으로 노출 (background_jobs)
큐 밖 cron 생성 작업(global_digest/morning_briefing)이 processing_queue stage 가
아니라 보드에 안 잡혀, 맥미니가 11분짜리 digest 를 돌려도 idle 처럼 보였다.
ebbcaf8 의 background_jobs 메커니즘 재사용:
- digest_worker/briefing_worker = start_job→finish_job (best-effort, 본작업 무해)
- pipeline = cluster 완료마다 heartbeat(processed/total) → 진행바
- queue_overview = kind→machine 맵으로 payload 에 machine 필드 (맥미니 귀속)
- 보드 = 머신 레인에 dot 점등 + "생성 중: <label> N/T" 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:36:57 +00:00
hyungi d4e1f76e81 fix(news)!: mlx_gate_concurrency 4→2 롤백 — gate=4 가 대형 프롬프트(digest/briefing+deep 6764tok) 동시성으로 맥미니 mlx_vlm OOM/연결드롭 유발(08:45 서버 크래시·재시작 실측). digest cap 3000→5400(gate=2 보정). timeout/deep-split 유지
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:55:44 +00:00
hyungi a82b0724df fix(news): digest/briefing 생성 LLM 타임아웃 게이트 단일소스화 + deep_summary 컨슈머 분리
2026-06-11 맥미니 모델 교체(Gemma4 26B→Qwen3.6-27B-6bit, 콜당 ~90~300s)의
타임아웃 상향 sweep 이 config.yaml/synthesis 만 갱신하고 digest/briefing 코드의
하드코딩 LLM_CALL_TIMEOUT=25(빠른 Gemma 기준)를 누락 → digest 600s 하드캡 초과로
06-10 이후 미생성, briefing 4/4 LLM 폴백(status=failed). (적대 리뷰로 블로커 정정:
concurrency=1 사설 세마포로는 digest 44~68 클러스터가 하드캡에 여전히 걸림 + llm_gate
영구 룰 위반.)

- 타임아웃·재시도·하드캡을 config.pipeline 단일소스로 이관(digest_llm_timeout_s=300,
  attempts=2, pipeline_hard_cap_s=3000). 다음 모델 교체 때 재발 차단.
- digest/briefing LLM 호출을 사설 Semaphore 제거하고 전역 MLX gate(BACKGROUND)
  경유로 변경 — llm_gate 영구 룰(같은 endpoint 단일 게이트, 새 Semaphore 금지) 준수 +
  ask/eid(FOREGROUND)와 조율. 동시성 lever = 기존 mlx_gate_concurrency 2→4
  (continuous batching 실측 — 3동시콜 wall 121s ≈ 단일콜, 직렬 대비 ~3배).
- digest/briefing pipeline cluster 루프를 asyncio.gather 동시 실행으로 전환
  (실동시성은 게이트가 제한, rank/순서 보존).
- deep_summary(70~300s)를 메인 consume_queue 에서 분리해 consume_deep_queue 신설
  (markdown/fast split 선례) — 단일 deep 호출이 1분 틱 초과로 메인 큐를 영구 coalesce
  시키던 문제 제거.
- 죽은 PIPELINE_HARD_CAP=600(briefing/pipeline.py) 제거, summarizer docstring 갱신,
  deep 컨슈머 disjoint/hold 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:29:56 +00:00
hyungi b2949d26ff fix(search): documents.embedding HNSW 인덱스(마이그 358) + ef_search=100 — docs vector leg seq scan 제거
documents.embedding 에 벡터 인덱스가 없어 검색마다 40k row Parallel Seq Scan
(콜드 448ms, 코퍼스 성장에 선형 악화)이었음. study_questions 와 동일 패턴의
HNSW 부분 인덱스 추가 → docs vector leg 448ms → 7.9ms (EXPLAIN Index Scan 확인).

docs vector leg LIMIT=limit*4(기본 80)이라 HNSW recall 위해 ef_search>=80 필요 →
ivfflat.probes 와 동일하게 ALTER DATABASE pkm SET hnsw.ef_search=100.

PROD 적용: CREATE INDEX CONCURRENTLY 로 수동 빌드(무중단, /dev/shm 회피 위해
max_parallel_maintenance_workers=0 단일 스레드, 316MB) + schema_migrations(358)
수동 기록. runner 는 단일 트랜잭션이라 CONCURRENTLY 불가 → 본 파일은 fresh-init
재현용 non-concurrent IF NOT EXISTS.

검증(snapshot freeze 43958/195671, eval both, exact vs HNSW):
- graded NDCG 0.575 → 0.575 (±0.000, 전 카테고리·Recall byte-identical)
- ef_search=100 이 top-80 에 사실상 exact recall → 랭킹 손실 0
- prod smoke 'pressure vessel design code ASME' 작업전 5263ms → 650ms

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 23:18:36 +00:00
hyungi 151c1ee518 fix(search): text-leg 본문 스코어링 2000자 절단 + bge-m3 keep_alive 로 검색 latency 개선
코퍼스 ~52배 성장(코드 가정 765 → 실제 40k docs) 후 search_text ORDER BY 가
후보 행마다 extracted_text(평균 3.7KB·최대 1.6MB) 전체에 similarity() +
to_tsvector() 재토큰화를 재연산 → broad/영어 쿼리 text_ms 최대 4960ms.
scoring/match_reason 의 extracted_text 를 left(...,2000) 으로 절단(후보 CTE 의
FTS 매칭은 전체 본문 유지 → recall 불변). embed() 요청에 keep_alive:-1 추가로
ollama bge-m3 GPU 상주 → sparse 검색의 cold reload(~6s) 제거.

검증(snapshot freeze docs 43958/chunks 195671, 51 case, eval-version both):
- graded NDCG 0.575 → 0.575 (±0.000, 전 카테고리 byte-identical)
- Recall g>=2 0.691 / g>=3 0.739 불변, v0.1 NDCG/Recall/Top-3 불변
- latency p50 760→586ms (-23%) / p95 5230→832ms (-84%)
- EXPLAIN 단일쿼리: V0 4917ms → left(2000) 285ms (17x)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 04:34:24 +00:00
hyungi ebbcaf86d8 feat(observability): 큐 밖 백그라운드 작업(backfill)을 처리 머신 보드에 노출
processing_queue 는 파이프라인 stage 전용이라 hier_overnight_backfill 같은 off-queue
관리 스크립트 작업이 대시보드 보드에 안 잡혀, 다른 세션이 모르고 fastapi 를 재생성해
in-flight 재분해를 끊는 사고가 발생(2026-06-14). 사각지대 해소.

- migrations/357_background_jobs.sql: background_jobs 테이블(kind/label/state/processed/
  total/heartbeat). worker_jobs(user_id 필수, worker-pool 전용)와 별개.
- services/background_jobs.py: start/heartbeat/finish 헬퍼 — 자율 트랜잭션(즉시 commit →
  실시간 가시화) + best-effort(관측 실패가 본작업 안 깸).
- hier_overnight_backfill: 작업 시작/절 ~10개마다 heartbeat/종료 계측.
- queue_overview: /api/queue/overview 응답에 background_jobs 추가(running + 최근 6h 완료,
  stale=heartbeat 끊김 추정). SAVEPOINT 로 테이블 부재/오류 시 보드 본체 무영향.
- ProcessingFlowBoard: "백그라운드 작업" 패널(진행/경과/state, stale 끊김 경고).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 12:27:18 +09:00
Claude Code 6d978289b8 feat(papers): B-3 P2-PR1 oa_url 승격 분기 (arXiv 외 doi.org/KISTI/PMC OA)
arxiv_id 없는 OA 논문(oa_status gold/hybrid/green/diamond + oa_url)도 전문 승격 대상에 포함.
url = arxiv.org/pdf 또는 oa_url(friendly OA host). paywall/비-PDF 는 헤더검증서 skip(실패 격리).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:16:47 +00:00
Claude Code 73c6f123b8 feat(papers): B-3 P2-PR1 — arXiv 논문 전문 in-place 승격 + classify paper 요약-스킵
plan safety-library-b3-1 Phase-2. 논문을 초록(signal-only)에서 전문 md/검색으로 승격.
- paper_fulltext_promote.py: 미승격 arXiv 논문(file_format='article') → arxiv.org/pdf/{id} 다운로드
  (kosha 패턴·50MB cap·PDF 헤더검증) → NAS crawl_raw/papers/arxiv/ → in-place 갱신
  (file_format=pdf·file_type=immutable·file_path·md_status=pending, file_hash·extract_meta.paper 보존)
  → 'extract' enqueue. 1-Document(2행 분리 회피, 기존 display 스택 재사용). per-run cap 10(GPU 보호).
  arXiv=공개 프리프린트라 전문 검색/RAG 무난(restricted 불요; 유료 구매분만 Papers_Purchased restricted).
- classify_worker: material_type='paper' 가드 추가 — 요약/분류 LLM 스킵(맥미니 큐 무접촉),
  queue_consumer 가 embed/chunk/markdown 은 chain. law_monitor 스킵 패턴 동형.

CLI 전용(Phase-2 deliberate 승격·GPU 부하 사용자 통제). 파이프라인=extract→classify[skip]→embed/chunk/markdown,
marker 표시 md + hier 절구조 + 전문 검색 청크. 배포 후 라이브 검증.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:04:02 +00:00
hyungi 57c1805a8d Merge pull request 'Feat/safety library b3' (#39) from feat/safety-library-b3 into main
Reviewed-on: #39
2026-06-14 08:05:09 +09:00
hyungi cbdd4a3df7 Merge pull request 'Feat/docpage open as default' (#38) from feat/docpage-open-as-default into main
Reviewed-on: #38
2026-06-14 08:04:59 +09:00
Claude Code bf0348a3e0 feat(papers): B-3 PR5 — 구매 PDF parent_doi 스탬프 (paper_doi_reconcile 통합)
plan safety-library-b3-1 PR5. Papers_Purchased 수동 드롭 PDF(license.restricted=true)를 서지 holder 에
연결: 본문 DOI 파싱 → paper.parent_doi 링크(child, doi 미보유=인덱스 밖, unique 무충돌).
- doi.py: parse_doi_from_text(본문 전체 DOI 정규식 — PDF 구조 무관).
- paper_doi_reconcile: restricted 분기 — restricted 행은 본문 DOI→parent_doi(child),
  그 외(레거시 arXiv)는 holder 스탬프(PR4). 쿼리에 parent_doi IS NULL 추가(링크분 재처리 회피).
- file_watcher merge-only license 주입 clobber-safe 존중. enqueue 0(콘텐츠 무변경).

단위 29 passed(+parse_doi_from_text). ephemeral PASS: 합성 restricted 행 → parent_doi 링크·
paper.doi 부재·restricted 보존·스키마 수용(insert+rollback). reconcile 멱등(재실행 0 변경).
실 구매 PDF 라이브 검증 = 사용자 첫 논문 구매·드롭 시(로직 검증 완료).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:58:19 +00:00
Claude Code 244d526ae2 feat(papers): B-3 PR4 — 레거시 arXiv DOI reconcile + arXiv DataCite DOI 통일 (keyless)
plan safety-library-b3-1 PR4. paper.doi 없는 paper 행을 arXiv DataCite DOI 로 스탬프해
partial-unique 인덱스 편입 → 재유입 차단('동일-DOI 재유입 차단만').
- doi.py: parse_arxiv_id(본문→arXiv id) + arxiv_doi(10.48550/arxiv.{id}, OpenAlex canonical 실측 일치).
- ★arXiv DOI 통일: arxiv_collector 도 프리프린트(저널 DOI 없음)에 arxiv_doi 부여 → PR2/PR3/PR4 가 같은
  함수로 같은 paper.doi → 교차소스 dedup 성립(이전엔 프리프린트 paper.doi 부재로 PR2↔PR3 dup 갭).
- paper_doi_reconcile.py: 전용 worker(dedup_reconcile=file_hash 캐시와 별개 — 적대리뷰 B·C major).
  keyless·결정적(OpenAlex 호출 0)·in-DB·enqueue 0(콘텐츠 무변경). 선재 DOI holder 시 parent_doi
  마킹(unique 위반 회피). add_job daily 03:50 KST. __main__ CLI.

단위 28 passed(+parse_arxiv_id·arxiv_doi). 라이브 PASS (prod, running fastapi 무접촉):
레거시 197행 arXiv DataCite 스탬프·ASME 2행 skip·선재중복 0 / dedup 불변식 206 distinct 206(인덱스 무위반) /
paper summarize active 0(signal-only). 멱등.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:54:24 +00:00
hyungi c5bc1f773d fix(docpage): 비인접 window 를 parent_id 로 split-parent 에 흡수 (빈 본문 절 수정)
split-parent(절 헤딩)와 그 window 조각이 chunk_index 상 비인접인 경우(예: 5180 FOREWORD
헤딩 idx 1143, window idx 1233~)가 있어, 인접 흡수만 하던 collapseWindows 가 split-parent 를
빈 본문 행으로 남기고 window 들은 따로 대표 행을 만들어 "같은 제목 2행(빈 것 + 본문 있는 것)" 이
됐다. 사용자가 "본문 없는 절" 로 본 것.

- /sections API 에 parent_id 반환 (window.parent_id = 그 split-parent chunk_id, 100% _split 링크)
- collapseWindows 가 window 를 parent_id 로 split-parent 대표에 흡수(비인접 허용), 인접 heading
  fallback 유지(legacy window). 흡수 멤버에서 본문/분석 집계.
- 회귀 테스트: 비인접 parent_id 흡수 (12/12 pass)

실데이터 검증(빈 본문→0): 5180 outline 85→58·5210 318→277·5178 73→49·5151 45→40, 전부 EMPTY_BODY=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 07:46:18 +09:00
Claude Code fdabca2a2f feat(papers): B-3 PR6 — OpenAlex ISSN 소스 시드 (KR/JP 안전 저널 직접 커버)
plan safety-library-b3-1 PR6 (revised). 라이브 정찰: KoreaScience=깨끗한 API 없음(OAI 404)·
J-STAGE=ToS bulk 금지, 그리고 Phase-1 메타는 OpenAlex 가 이미 전수 색인(한국안전학회지 1766건 실측)
→ 전용 스크래퍼 대신 검증된 OpenAlex 수집기에 도메인 저널 ISSN 시드 추가(전용 무료 전문 PDF=Phase-2 park).
- _JOURNAL_ISSNS(OpenAlex sources 실측): 한국안전학회지 1738-3803·한국가스학회지 1226-8402·
  KSME A/B 1226-4873·1226-4881·KSME Intl 1226-4865·JP 고압 0917-639X.
- _seeds() = ISSN 시드(cap 우선) + 키워드. build_issn_filter(primary_location.source.issn:).
  run() 루프 통합(종류별 필터, 워터마크 시드별). 적재/parser/cap/signal-only = PR3 재사용.

단위 8 passed(+ISSN 시드). 라이브 PASS: 키주입 run → 한국안전학회지 5건 적재(ISSN 우선 확인),
running fastapi 무접촉. KoreaScience/J-STAGE 전용 fulltext 수집기 = Phase-2 강등(park).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:42:30 +00:00
Claude Code 1fbb341e28 feat(papers): B-3 PR3 — OpenAlex 백본 수집기 (scaffold-first, signal-only, per-run cap)
plan safety-library-b3-1 PR3. 발견+dedup 글로벌 백본(JP/EU/US 색인+정본 DOI, 전문 안 줌).
- scaffold-first: OPENALEX_API_KEY 부재 시 FeedError explicit-skip(silent fallback 0). 키=무료.
- signal-only: inverted-index 초록 복원→색인(embed+chunk), summarize 0. PDF 절대 미fetch(oa_url=신호).
- 관련성 사전필터=title_and_abstract.search 키워드 + per-run cap 60(임베드 firehose 차단, 적대리뷰 A major)
  + cursor 페이징 + from_publication_date 워터마크 증분. 초록 없는 thin 레코드 skip(재료 품질).
- license: 명시 CC→redistribute true / OA·closed→false(restricted 부재=초록 RAG 사용가능, 비-CC 전문은 L-1 Phase-2).
- DOI→paper.doi(holder, 교차소스 dedup) / 없으면 openalex_id. enabled=False 행+add_job(daily 07:45 KST)+CLI.

순수 파서/초록복원/license_meta fixture 단위 7 passed(OpenAlex 실응답: cc-by/cc-by-nc-nd/None·초록 유무).
라이브 검증 PASS (prod, running fastapi 무접촉): 키없음→explicit-skip / 키주입→3건 적재
(paper/NULL/ai_summary NULL/region INT, cc-by→redist true·unspecified→false, green/gold,
큐 embed3+chunk3·summarize 0, distinct openalex_id=total, 교차소스 DOI 4 distinct 4 중복 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:30:36 +00:00
hyungi d007ad5492 fix(docpage): windowed 절에 조각별 분석(유형/신뢰도/요약) 집계 노출
절-레벨 분석(chunk_section_analysis)은 코퍼스 전역에 이미 있으나(절 보유 344문서 중 336)
window 조각의 chunk_id 에 붙어 있고, D3 는 window 를 split-parent 대표로 collapse 하며 버려서
windowed 절은 요약/유형/신뢰도가 안 떴다(분석은 대표가 아닌 조각에 있음).

- collapseWindows 가 멤버(대표+흡수 window)에서 절-레벨 분석 집계:
  sectionType=다수결(동률 첫등장) · confidence=평균 · summaries=조각 요약 배열(빈 것 제외)
- D3 트리/focus/모바일카드/이전다음이 it.sectionType/it.confidence/it.summaries 사용
- 요약은 단일 절=문단, windowed 절="절 요약 · N개 부분" 번호목록
- headingPath.test.ts: 집계 회귀 테스트 추가 (11/11 pass)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 07:27:41 +09:00
Claude Code 6167e03625 fix(papers): B-3 PR2 arxiv _record_success 4-arg 시그니처 (라이브 검증서 발견)
run() 종료 시 _record_success(health, now) → 누락 인자(items·not_modified) 추가
= _record_success(health, inserted, False, now) (news_collector 시그니처 일치).
일회성 compose run 라이브 검증서 TypeError 로 발견 — 배포 전 차단.

라이브 검증 PASS (prod 6건 적재, running fastapi 무접촉): material_type=paper·jurisdiction NULL·
ai_summary NULL·crawl·region=INT·license=arxiv / DOI 보유 1건 paper.doi 인덱스 진입·나머지 arxiv_id /
큐 embed6+chunk6·summarize 0(signal-only) / distinct arxiv_id=총건(dedup 불변식) / health circuit closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:22:51 +00:00
hyungi b6a4821cac fix(docpage): 절 본문을 청크 text로 렌더 + window 조각 collapse
대형 split 문서는 marker 가 md_content 를 앞 5만 자만 보존하고 char_start 도 NULL 이라
char_start 슬라이스로는 절 본문이 비었다. 전체 본문은 document_chunks.text 에 절별로 보존됨.

- /sections API 가 청크 text 반환 (SectionItem.text; 소비자=D3 단독, additive)
- collapseWindows 가 window 조각 본문을 대표 절 bodyText 로 합본 (split-parent heading 제외)
- D3 페이지가 outline(collapseWindows) 단위로 렌더 → window 파편화 제거
  (5180 = 27 논리 절이 562 동일제목 조각으로 쪼개지던 문제)
- useSectionView=hasSections 로 단순화(partial/대형 문서도 절뷰), 모바일 본문 lazy 파싱
- headingPath.test.ts: bodyText 누적 회귀 테스트 추가 (10/10 pass)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 07:10:59 +09:00
Claude Code ba943d703a feat(papers): B-3 PR2 — arXiv 키워드 필터 수집기 (signal-only, per-run cap)
plan safety-library-b3-1 PR2 (keyless). DOI 코어(PR1) 위 첫 실수집기.
- bespoke arXiv API(Atom) 수집기: cat:{category} AND (abs:키워드) — RSS 통째(firehose) 아님.
  신규 7 카테고리(eess.SY·physics.flu-dyn/comp-ph·math.OC/NA·stat.AP·cs.CE) x 압력용기/공정안전 키워드.
- signal-only: 초록만 색인(embed+chunk), summarize 절대 미enqueue(맥미니 큐 무접촉).
- DOI 보유 -> extract_meta.paper.doi(holder, partial-unique 인덱스). 없으면 arXiv id dedup.
  교차소스 dedup = find_paper_holder(PR1) + arxiv id file_hash. paper.source_region=INT(jurisdiction NULL 유지).
- per-run insert cap(_RUN_CAP=80) — 광역 수집이 GPU embed 큐 범람 방지(적대리뷰 A major), 잔여 로깅.
- etiquette: >=3s + 429 백오프 + 카테고리별 submittedDate 워터마크 증분. https 필수(http=301).
- enabled=False news_sources 행 + main.py CronTrigger(daily 07:30 KST). __main__ CLI(--bulk/--limit).

순수 파서·쿼리빌더 fixture 단위 18 passed(arxiv 실응답 박제: DOI/journal_ref/둘다없음 3경로).
적재(run/_ingest_entry)는 news_collector signal-only 패턴 미러 — 배포 후 라이브 검증.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:10:25 +00:00
Claude Code 345e2cedf0 feat(papers): B-3 PR1 — DOI 정규화·dedup 코어 (normalize_doi 단일 함수 + 서지 holder 조회)
plan safety-library-b3-1 PR1 (keyless·마이그 0). 모든 논문 수집기·reconcile·구매 스탬프 공유 토대.
- normalize_doi(): 소문자·URL/doi: prefix 제거·인용 구두점(.,;) 정리. 저장=조회 단일 함수.
  괄호 '()' 보존 — 과삭제는 다른 논문 병합(데이터 손상)이라 near-dup 보다 위험.
- paper_doi_hash(): 서지 holder file_hash 키 = sha256('paper|{doi}')[:32] (statute 다중부 키 선례).
- with_paper_doi/with_parent_doi/read_paper_doi: 2-Document 계약(holder doi / child parent_doi 상호배타) extract_meta 헬퍼 (merge-safe).
- find_paper_holder(): 공유 dedup 조회 — lower(extract_meta #>> '{paper,doi}'), .scalars().first()(BBC 다중행 선례),
  EXPLAIN 으로 uq_documents_paper_doi(마이그 351 라이브) 인덱스 사용 확인.

단위 12 passed. holder DB 조회 = PR2 arXiv 실수집서 라이브 검증. 소비자 없는 순수 코드(배포·런타임 변화 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:50:09 +00:00
hyungi b461559d2f fix(docpage): 절 없는 문서도 인사이트 항상 표시 (fallback 개선)
사용자 "절이 없더라도 인사이트는 보여야지" — fallback(절 데이터 없는 ~92% 문서)이
모바일에서 인사이트 레일을 긴 본문 아래에 묻던 문제 수정. bodyViewer 스니펫 분리 후:
- 모바일: 인사이트 레일을 본문 위에 상시 표시
- 데스크탑: 본문 | 인사이트 레일(sticky)
(별개: 절 트리/집중 뷰는 절 분석 있는 문서에서만 활성 — 현재 4358중 333. 커버리지 확대는 후속.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:39:44 +09:00
hyungi 9b9790f05d fix(docpage): D3 시안 스타일 그대로 포팅 + 모바일 길이/접근성 수정
사용자 "시안대로 안했다" → 앱 토큰 재해석을 폐기하고 d3-deepened 시안의 inline
스타일을 그대로 포팅(데이터만 바인딩): 트리 좌측 색바(3×16)+연결선(ㄴ자)+활성+
저신뢰 맥동배지, 절차색 #7a8b3f, 헤더 PDF아이콘+pill칩+분류/원본/링크/관리, 절 집중
뷰(요건 requirement 배지·신뢰도 바·절요약 인용박스), 슬림 레일 카드(시안 동일).
모바일: 절구조/인사이트 안보임+무한길이("쭉 아래까지") → pill sticky + 절 본문
카드마다 접기('본문 보기', 기본 요약만)로 컴팩트화. svelte-check 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:29:04 +09:00
hyungi b49596135e fix(docpage): 모바일을 확정 시안 그대로 — 나란한 토글 pill + 패널 + 본문 연속
직전 모바일이 세로 details 2개라 시안(나란한 pill 토글)과 불일치
(사용자 "시안에 모바일용도 있잖아 그걸 안 만들었다") → d3-deepened 모바일 프래그먼트
충실 복제: 절 구조|인사이트 나란한 pill(기본 둘 다 접힘) + 절 구조 패널(유형 범례·
점프 링크·저신뢰·들여쓰기) + 인사이트 패널(TL;DR·핵심점·심층DEEP·불일치·분류·태그) +
본문 절 카드 연속(#m-sec 앵커, pill 탭→본문 이동). svelte-check 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:12:59 +09:00
hyungi 0a82a5b1bc feat(docpage): 모바일을 시안대로 — 본문 연속 절 카드 + 접이 + 탭 이동
기존 모바일(데스크탑 focus 단일절)이 시안 모바일과 불일치(사용자 "모바일은 변한게
없잖아") → 시안 모바일 충실 구현:
- 모바일(<xl) = 절 구조/인사이트 접이(기본 절구조 닫힘·인사이트 열림) + 본문이 절
  카드로 연속(각 절 제목·유형배지·절요약·본문) + 절 구조 탭하면 #m-sec 본문 앵커 이동
- 데스크탑(xl+) = 트리 | 절 집중 | 레일 (focusView 스니펫으로 분리)
- treeNav(jumpMode): 데스크탑=절 선택 / 모바일=앵커 점프
svelte-check 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:03:59 +09:00
hyungi 74e29e510e feat(docpage): D3 상세 페이지를 확정 시안 그대로 재구현
기존 컴포넌트 재사용/배치변경(불충실)을 폐기하고 deepened 시안을 충실히 구현:
- 좌 절 트리: 유형 색칩(정의/절차/요건)·신뢰도 dot·저신뢰 경고·레벨 들여쓰기·클릭=절 선택
- 중 절 집중 뷰: breadcrumb + 제목 + 유형 배지 + 신뢰도 막대 + 절 요약 인용 + 절 본문
  (md_content 를 char_start 로 슬라이스) + 이전/다음 절
- 우 슬림 레일: TL;DR · 핵심점 · 심층(DEEP) · 불일치 · 분류 · 태그 (읽기) + 정보/관리 접이(편집 보존)
- 절 없음 fallback: 전체 본문/뷰어 + 레일 (D3 빈 절 graceful)
- 모바일: 본문(절 집중) 메인 + 절구조/인사이트 접이
svelte-check 0. 시안=comparisons/2026-06-13-ds-docpage-d3-deepened.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:53:34 +09:00
hyungi c1555fd6ab feat(docpage): 전체 문서 목록 클릭 시 인라인 미리보기 대신 D3 상세로 이동
사용자 결정 "개선된 페이지가 앞으로 표시되야지" — /documents 브라우저에서
문서를 열면 인라인 DocumentViewer(구) 대신 개선된 /documents/[id](D3 절 구조
탐색기)로 이동. /documents = 브라우즈/검색/필터/일괄 목록(풀폭 중앙) 역할로 정리:
- selectDoc → goto(/documents/[id]) (행 클릭·키보드 enter 공통)
- 인라인 리더(DocumentViewer)·인스펙터 패널 제거, 목록 max-w-5xl 중앙
- AI 답변 카드(질문형 검색)는 목록 상단 고정으로 이동(보존)
- 검색·필터칩·일괄작업·업로드·페이지네이션 전부 유지

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:41:59 +09:00
hyungi 1d5755b279 Merge pull request 'feat(docpage): D3 절 구조 탐색기 — 슬림 인사이트 레일 + 절 트리 (frontend only)' (#37) from feat/ds-docpage-d3 into main
Reviewed-on: #37
2026-06-13 15:23:05 +09:00
hyungi a3e0d30569 Merge pull request 'Feat/ds board merged' (#36) from feat/ds-board-merged into main
Reviewed-on: #36
2026-06-13 15:22:53 +09:00
hyungi 540bc00dba feat(docpage): D3 절 구조 탐색기 — 슬림 인사이트 레일 + 절 트리 (frontend only)
문서 상세 /documents/[id] 재구성 (BE 무변경):
- 우측 탭(정보/AI/관리) → 슬림 전역 인사이트 레일: 요약·심층·불일치를 탭
  게이트 없이 상시 노출(details open, 모바일은 접기 가능), 정보/관리는 접이.
  → 가공 자료가 탭/온디맨드에 묻히던 IA 문제(G1) 해소.
- SectionOutline 절 목차 레벨 기반 들여쓰기(평탄→트리 모양).
- 모바일: 본문 메인 + 절목차/인사이트/정보/관리 접이 + 절 탭 본문 이동(기존 구조 활용).
관련 문서(See Also)는 v1 제외(자리만 유지). 심화 목업 = comparisons/2026-06-13-ds-docpage-d3-deepened.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:18:15 +09:00
hyungi 30c235e4c1 Merge feat/safety-library-a1 (C-1 후속 version_status+facets) into ds-board-merged
검색 결과 wrapper decoration: 법령 version_status + facets 집계(ranking 무관·additive).
2026-06-13 15:08:24 +09:00
hyungi cd439b0ff4 Merge feat/safety-library-a1 (B-4 licensed_restricted + watch 폴더 license) into ds-board-merged
B-4 PR①②: licensed_restricted 단일 술어(retrieval 3-leg/digest/briefing/study 풀이 공유)
+ file_watcher Books/Manuals/Papers_Purchased license 주입. prod 통합 브랜치 배포용.
2026-06-13 14:53:34 +09:00
hyungi 595f4b7d5e feat(board): 통합 보드 v3 — 머신 레인 + 정직 번다운/ETA (B-1·2·3·5)
ProcessingFlowBoard 를 통합안으로 재작성:
- 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드 가시화
  (요약 칩 분담 막대 맥미니 vs 맥북 + 맥북 레인 '요약 합류' 칩, summarize_by_machine 소비)
- 지배 백로그 스트립 + 정직 ETA(summarize_eta, 유입 차감 / null=소진 불가)
- 24h 번다운 SVG(유입 vs 소화) + 맥북 합류 변곡점 + 단계별 정직 ETA 미니리스트
- 신선도 '갱신 N초 전' + stale 경고(queueUpdatedAt, B-4)
- 실패 드로어 + 노드 상세 패널은 v2 자산 그대로 재사용
- 레인 stack + 칩 wrap 으로 모바일 반응형

svelte-check: 변경 파일 에러 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:01:50 +09:00
hyungi b630c31077 feat(board): expose summarize_by_machine for offload visibility (A-1)
요약 풀의 머신별 완료 실적(맥미니 vs 맥북)을 /api/queue/overview 응답에
summarize_by_machine 로 노출. rows_to_summarize_split 이 이미 계산하던 값의
additive 투영 — 신규 수집 SQL/마이그 0. 통합 보드 레인의 오프로드 가시화
(맥북이 요약 86% 처리) 재료. + FE 타입 동기 + store 신선도 timestamp(B-4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:54:39 +09:00
92 changed files with 10212 additions and 1446 deletions
+11 -8
View File
@@ -264,7 +264,7 @@ class AIClient:
"""벡터 임베딩 — GPU 서버 전용"""
response = await self._http.post(
self.ai.embedding.endpoint,
json={"model": self.ai.embedding.model, "prompt": text},
json={"model": self.ai.embedding.model, "prompt": text, "keep_alive": -1}, # bge-m3 GPU 상주(홈랩 sparse 검색 cold reload ~6s 방지)
)
response.raise_for_status()
return response.json()["embedding"]
@@ -289,13 +289,16 @@ class AIClient:
return response.json()
async def _call_chat(self, model_config, prompt: str) -> str:
"""OpenAI 호환 API 호출 + 자동 폴백"""
try:
return await self._request(model_config, prompt)
except (httpx.TimeoutException, httpx.ConnectError):
if model_config == self.ai.primary:
return await self._request(self.ai.fallback, prompt)
raise
"""OpenAI 호환 API 호출 (R6: 무동의 클라우드 폴백 제거).
이전엔 primary(맥미니) TimeoutException/ConnectError 시 동의·과금 통제 없이
self.ai.fallback(Claude API)로 자동 전환 → 개인 문서/쿼리/메모가 Anthropic 으로
silent egress. on-prem 추론 프라이버시 계약 위반이라 봉쇄한다. 실패는 그대로 전파:
배치 워커는 재시도/StageDeferred(R3·queue_consumer), interactive 호출자는 5xx 표면화
(documents.analyze 등 이미 502/504 변환). 클라우드는 premium explicit-trigger
(summarize force_premium) 또는 call_fallback 명시 호출로만 — 자동 진입 금지.
"""
return await self._request(model_config, prompt)
async def _request(self, model_config, prompt: str, system: str | None = None) -> str:
"""단일 모델 API 호출 (OpenAI 호환 + Anthropic Messages API).
+6
View File
@@ -195,8 +195,14 @@ async def regenerate(
date 미지정 시 오늘 KST. 같은 날 row 존재 시 transaction 안에서 삭제 후 신규 생성.
응답 status='success' | 'partial' | 'failed' | 'empty'.
"""
from core.config import settings
from workers.briefing_worker import run
# held(정책상 정상 보류)를 409 로 표면화 (R8) — digest.py 정본 대칭. 이전엔 briefing_worker.run()
# 이 held/timeout/exception 셋 다 None 반환 → API 가 셋 다 500 으로 오보(silent-state-conflation).
if "briefing" in settings.pipeline_held_stages:
raise HTTPException(status_code=409, detail="briefing 단계가 일시 보류(held) 상태입니다")
result = await run(target_date=date)
if result is None:
raise HTTPException(status_code=500, detail="briefing 워커 실행 실패 (로그 확인)")
+42 -27
View File
@@ -69,6 +69,19 @@ def _upload_error(status_code: int, error_code: str, message: str) -> HTTPExcept
)
async def get_live_document(session: AsyncSession, doc_id: int) -> Document:
"""soft-delete(deleted_at) 가드 포함 문서 조회 — 없거나 삭제됐으면 404 (R7).
조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 엔드포인트가 누락 →
삭제 문서의 원본/preview/전문이 doc_id(+유효 토큰)만으로 노출되던 비대칭. '경로마다
deleted_at 기억'에 의존하지 않게 헬퍼로 구조 강제(추가될 서빙 경로도 자동 보호).
"""
doc = await session.get(Document, doc_id)
if not doc or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
return doc
async def _near_dup_scan_bg(doc_id: int) -> None:
"""B-3: post-upload near_duplicate 스캔 (BackgroundTask). 자체 세션, best-effort.
@@ -680,7 +693,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 +737,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
@@ -833,9 +851,7 @@ async def get_document_file(
# 일반 Bearer 헤더 인증 시도
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
doc = await get_live_document(session, doc_id)
# note(메모)는 물리 파일이 없음
if not doc.file_path:
@@ -938,10 +954,8 @@ async def get_document_image_raw(
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단)
doc = await session.get(Document, doc_id)
if doc is None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단 + soft-delete 가드)
doc = await get_live_document(session, doc_id)
img = await session.scalar(
select(DocumentImage).where(
@@ -1352,9 +1366,8 @@ async def save_document_content(
body: dict = None,
):
"""Markdown 원본 파일 저장 + extracted_text 갱신"""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
# soft-delete 문서엔 쓰기 차단 (R7 — 삭제 문서 resurrect / NAS 재기록 방지)
doc = await get_live_document(session, doc_id)
if doc.file_format not in ("md", "txt"):
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
@@ -1394,9 +1407,7 @@ async def get_document_preview(
else:
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
doc = await get_live_document(session, doc_id)
preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
if not preview_path.exists():
@@ -1422,18 +1433,24 @@ async def delete_document(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"),
delete_file: bool = Query(False, description="NAS 원본도 삭제 (grace 후 retention sweep 이 물리삭제)"),
):
"""문서 삭제 (기본: DB만 삭제, 파일 유지)"""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
"""문서 삭제. 기본: soft-delete(숨김, 파일 보존). delete_file=true: purge 예약 (R7)."""
doc = await get_live_document(session, doc_id)
# soft-delete (물리 파일은 cleanup job에서 나중에 정리)
doc.deleted_at = datetime.now(timezone.utc)
# soft-delete(숨김). delete_file=true 면 purge_requested_at 마커를 추가로 set —
# retention sweep cron(document_purge_sweep)이 grace(30일) 경과 후 NAS 원본 물리삭제
# + audit-log. ★일반 숨김(delete_file=false)은 파일 보존 = undelete 가능. sweep 는
# deleted_at 이 아니라 purge_requested_at 기준이라 단순 숨김이 영구삭제되지 않는다.
now = datetime.now(timezone.utc)
doc.deleted_at = now
if delete_file:
doc.purge_requested_at = now
await session.commit()
return {"message": f"문서 {doc_id} soft-delete 완료"}
if delete_file:
return {"message": f"문서 {doc_id} 삭제 — NAS 원본은 30일 후 정리 예약"}
return {"message": f"문서 {doc_id} soft-delete 완료 (파일 보존)"}
@router.get("/{doc_id}/content")
@@ -1443,9 +1460,7 @@ async def get_document_content(
session: Annotated[AsyncSession, Depends(get_session)],
):
"""문서 전문 텍스트 반환 (서비스 호출용)."""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
doc = await get_live_document(session, doc_id)
raw_text = doc.extracted_text or ""
content = raw_text[:15000]
+5 -5
View File
@@ -21,7 +21,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
@@ -388,10 +388,10 @@ async def list_events(
)
base = select(Event).where(and_(*where))
total_q = await session.execute(
select(Event.id).where(and_(*where))
)
total = len(total_q.scalars().all())
# R10: 전체 ID 로딩 후 len() 대신 DB COUNT 푸시다운 (행 수 선형 메모리/전송 비용 제거).
total = (
await session.execute(select(func.count(Event.id)).where(and_(*where)))
).scalar() or 0
rows = await session.execute(
base.order_by(Event.created_at.desc())
+5 -1
View File
@@ -6,6 +6,7 @@ Bearer token 보호 (settings.internal_worker_token).
"""
from __future__ import annotations
import hmac
import logging
from fastapi import APIRouter, Depends, Header, HTTPException, Path, Response, status
@@ -28,7 +29,10 @@ def _verify_token(authorization: str | None = Header(default=None)) -> None:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="missing Bearer token")
token = authorization[7:].strip()
if token != settings.internal_worker_token:
# 상수시간 비교 (R7) — 일반 != 는 첫 불일치에서 단락돼 prefix 길이로 바이트 추정 가능한
# timing side-channel. 이 토큰이 RAG 정답 포함 endpoint 를 보호하므로 compare_digest 로
# 통일(search.py 정본과 일치).
if not hmac.compare_digest(token, settings.internal_worker_token):
raise HTTPException(status_code=403, detail="invalid token")
+27 -64
View File
@@ -473,72 +473,35 @@ async def get_facet_counts(
result = FacetCountsResponse(company=[], topic=[], year=[], doctype=[])
# company counts (다른 facet 필터 적용, 자기 자신 제외)
q_company = base_query()
if facet_topic:
q_company = q_company.where(Document.facet_topic == facet_topic)
if facet_year:
q_company = q_company.where(Document.facet_year == facet_year)
if facet_doctype:
q_company = q_company.where(Document.facet_doctype == facet_doctype)
rows = await session.execute(
select(Document.facet_company, func.count())
.where(Document.facet_company != None) # noqa: E711
.where(Document.id.in_(q_company.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_company)
.order_by(func.count().desc())
)
result.company = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
# topic counts
q_topic = base_query()
# R10: 4 facet 블록 중복 제거 — 적용된 facet 필터(값 있는 것만)를 모아 각 축 집계 시
# '자기 자신 축'만 제외하고 적용하는 헬퍼로. 쿼리/자기제외/order_by/value 매핑 모두 동일.
applied: dict = {}
if facet_company:
q_topic = q_topic.where(Document.facet_company == facet_company)
if facet_year:
q_topic = q_topic.where(Document.facet_year == facet_year)
if facet_doctype:
q_topic = q_topic.where(Document.facet_doctype == facet_doctype)
rows = await session.execute(
select(Document.facet_topic, func.count())
.where(Document.facet_topic != None) # noqa: E711
.where(Document.id.in_(q_topic.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_topic)
.order_by(func.count().desc())
)
result.topic = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
# year counts
q_year = base_query()
if facet_company:
q_year = q_year.where(Document.facet_company == facet_company)
applied["company"] = Document.facet_company == facet_company
if facet_topic:
q_year = q_year.where(Document.facet_topic == facet_topic)
if facet_doctype:
q_year = q_year.where(Document.facet_doctype == facet_doctype)
rows = await session.execute(
select(Document.facet_year, func.count())
.where(Document.facet_year != None) # noqa: E711
.where(Document.id.in_(q_year.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_year)
.order_by(Document.facet_year.desc())
)
result.year = [FacetCountItem(value=str(r[0]), count=r[1]) for r in rows]
# doctype counts
q_doctype = base_query()
if facet_company:
q_doctype = q_doctype.where(Document.facet_company == facet_company)
if facet_topic:
q_doctype = q_doctype.where(Document.facet_topic == facet_topic)
applied["topic"] = Document.facet_topic == facet_topic
if facet_year:
q_doctype = q_doctype.where(Document.facet_year == facet_year)
rows = await session.execute(
select(Document.facet_doctype, func.count())
.where(Document.facet_doctype != None) # noqa: E711
.where(Document.id.in_(q_doctype.with_only_columns(Document.id).subquery().select()))
.group_by(Document.facet_doctype)
.order_by(func.count().desc())
)
result.doctype = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
applied["year"] = Document.facet_year == facet_year
if facet_doctype:
applied["doctype"] = Document.facet_doctype == facet_doctype
async def _facet_count(name, facet_col, order_by, value_fn):
q = base_query()
for k, cond in applied.items():
if k != name: # 자기 자신 facet 필터는 제외 (다른 축만 적용)
q = q.where(cond)
rows = await session.execute(
select(facet_col, func.count())
.where(facet_col != None) # noqa: E711
.where(Document.id.in_(q.with_only_columns(Document.id).subquery().select()))
.group_by(facet_col)
.order_by(order_by)
)
return [FacetCountItem(value=value_fn(r[0]), count=r[1]) for r in rows]
result.company = await _facet_count("company", Document.facet_company, func.count().desc(), lambda v: v)
result.topic = await _facet_count("topic", Document.facet_topic, func.count().desc(), lambda v: v)
result.year = await _facet_count("year", Document.facet_year, Document.facet_year.desc(), lambda v: str(v))
result.doctype = await _facet_count("doctype", Document.facet_doctype, func.count().desc(), lambda v: v)
return result
+57 -2
View File
@@ -300,9 +300,13 @@ async def list_memos(
base = base.where(Document.pinned == pinned)
if tag:
# 파라미터 바인딩 (R7) — f-string 으로 사용자 tag 를 JSON 배열 리터럴에 직접 삽입하면
# tag 안 " 나 ] 가 JSON 을 깨 500 + 필터 의미 변형. jsonb_build_array 로 tag 를
# 바인드 파라미터로 전달(@> JSONB containment).
tag_arr = func.jsonb_build_array(tag)
base = base.where(
Document.user_tags.op("@>")(f'["{tag}"]')
| Document.ai_tags.op("@>")(f'["{tag}"]')
Document.user_tags.op("@>")(tag_arr)
| Document.ai_tags.op("@>")(tag_arr)
)
count_query = select(func.count()).select_from(base.subquery())
@@ -688,6 +692,57 @@ async def dismiss_event_suggestion(
return _to_memo_response(doc)
@router.post("/{memo_id}/promote-to-document", status_code=201)
async def promote_memo_to_document(
memo_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 1건 → 문서함 정식 Document 로 승격 ("자료로 보내기", P1).
동작 (in-place 변환 row 생성 X, extracted_text/태그/이력 보존):
- source_channel memo/voice/hermes 'manual' (메모 목록서 빠지고 문서함 진입)
- file_type 'note' 'editable' (문서함 목록 필터 `file_type != 'note'` 통과)
- category='library' (자료실), content_origin='manual'
- classify/embed/chunk 재큐 도메인 재부여 + 요약/심층분석(26B escalate) + 임베딩/청크 갱신
P2 'draft' 워커(후속) 거친 메모를 구조화 마크다운(md_content)으로 정리 예정.
"""
doc = await session.get(Document, memo_id)
if (
not doc
or doc.deleted_at is not None
or doc.source_channel not in ("memo", "voice", "hermes")
or doc.file_type != "note"
):
raise HTTPException(status_code=404, detail="승격할 메모를 찾을 수 없습니다")
now = datetime.now(timezone.utc)
doc.source_metadata = {
**(doc.source_metadata or {}),
"promoted_from_memo": True,
"promoted_at": now.isoformat(),
"original_source_channel": doc.source_channel,
# P2: memo_draft_worker 가 집어 26B 로 구조화 마크다운(md_content) 생성.
"needs_draft": True,
}
doc.source_channel = "manual"
doc.file_type = "editable"
doc.category = "library"
doc.content_origin = "manual"
doc.updated_at = now
# 문서 컨텍스트로 재처리 — 도메인 재부여 + 요약/심층분석 + 임베딩/청크 갱신.
await _enqueue_ai_stages(session, doc.id)
await session.commit()
await session.refresh(doc)
return {
"document_id": doc.id,
"category": doc.category,
"message": "문서함으로 보냈습니다. AI 분류·요약·심층분석을 진행합니다.",
}
# ─── Memo Intake Upgrade PR-2C: voice upload ───
+10 -2
View File
@@ -65,7 +65,8 @@ async def create_source(
):
from core.url_validator import validate_feed_url
try:
validate_feed_url(body.feed_url)
# getaddrinfo(DNS) 는 blocking — 이벤트 루프 점유 방지 위해 off-thread (R5)
await asyncio.to_thread(validate_feed_url, body.feed_url)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"feed_url 검증 실패: {e}")
source = NewsSource(**body.model_dump())
@@ -194,10 +195,17 @@ async def trigger_collect(
if _collect_lock.locked():
raise HTTPException(status_code=429, detail="수집이 이미 진행 중입니다")
# TOCTOU 제거 (R9) — 기존엔 locked() 체크 후 실제 acquire 가 별도 task 안에서 일어나, 그
# 사이 다른 요청이 끼어들어 이중 수집 task 가 생길 수 있었다. 핸들러에서 동기적으로(uncontended
# Lock.acquire 는 이벤트루프 양보 없이 즉시 완료) acquire 하고 task 의 finally 에서 release.
await _collect_lock.acquire()
async def _run_with_lock():
async with _collect_lock:
try:
from workers.news_collector import run
await run()
finally:
_collect_lock.release()
asyncio.create_task(_run_with_lock())
return {"message": "뉴스 수집 시작됨"}
+31
View File
@@ -59,6 +59,20 @@ class SummarizeEta(BaseModel):
eta_minutes: int | None
class MachineDone(BaseModel):
"""머신 1대의 summarize 완료 실적 (분담 표시용)."""
done_1h: int
done_today: int
class SummarizeByMachine(BaseModel):
"""summarize 풀의 머신별 완료 실적 분담 — 보드 레인의 '맥미니 vs 맥북'
오프로드 가시화용. rows_to_summarize_split 이미 계산하던 값의 노출
(ds-board-merged A-1, 신규 수집 SQL 0)."""
macmini: MachineDone
macbook: MachineDone
class TrendBucket(BaseModel):
"""summarize 24h 추이 버킷 — hour 는 KST "HH:00" 라벨."""
hour: str
@@ -89,12 +103,29 @@ class StageRow(BaseModel):
oldest_pending_age_sec: int | None
class BackgroundJobItem(BaseModel):
"""큐 밖 관리 스크립트(백필 등) 작업 — processing_queue 가 못 보는 사각지대 노출.
stale = running 인데 heartbeat 오래 끊김(프로세스 사망 추정)."""
id: int
kind: str
machine: str
label: str | None
state: Literal["running", "done", "failed"]
processed: int
total: int | None
elapsed_sec: int
stale: bool
error: str | None
class QueueOverviewResponse(BaseModel):
machines: list[MachineCard]
stages: list[StageRow]
summarize_eta: SummarizeEta
summarize_by_machine: SummarizeByMachine
trend_24h: list[TrendBucket]
totals: Totals
background_jobs: list[BackgroundJobItem] = []
class FailedItem(BaseModel):
+8 -4
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,
},
)
@@ -291,7 +291,7 @@ async def search(
content={
"error_reason": "unknown_embedding_backend",
"backend_requested": embedding_backend,
"allowed": ["baseline", "cand_me5_large_inst", "cand_snowflake_l_v2"],
"allowed": ["baseline"],
"detail": msg,
},
)
@@ -710,7 +710,9 @@ async def ask(
# 30s 로 align → classifier 동작 안정. ask 응답 latency 상한 ↑ 의도.
try:
classifier_result = await asyncio.wait_for(classifier_task, timeout=30.0)
except (asyncio.TimeoutError, Exception):
except asyncio.CancelledError:
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
except Exception:
classifier_result = ClassifierResult("timeout", None, [], [], 0.0)
defense_log["classifier"] = {
@@ -872,7 +874,9 @@ async def ask(
# → classifier 와 동일 패턴 (search.py:522 가 6s→15s swap 했던 case). 10s 로 align.
try:
verifier_result = await asyncio.wait_for(verifier_task, timeout=10.0)
except (asyncio.TimeoutError, Exception):
except asyncio.CancelledError:
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
except Exception:
verifier_result = VerifierResult("timeout", [], 0.0)
# Verifier contradictions → grounding flags 머지 (prefix 로 구분, severity 3단계)
+10 -1
View File
@@ -1009,7 +1009,16 @@ async def submit_attempt(
# PR-10: 세션 연동. 기본은 None.
quiz_session: StudyQuizSession | None = None
if body.quiz_session_id is not None:
quiz_session = await session.get(StudyQuizSession, body.quiz_session_id)
# FOR UPDATE 로 행 잠금 (R9) — 모바일 더블탭/재시도로 같은 세션에 동시 제출이 들어오면
# 둘 다 cursor=N 을 읽고 둘 다 cursor+1·count 가산하는 race(이중 가산). 잠금으로 직렬화 →
# 두 번째 제출은 첫 commit 후 cursor=N+1 을 보고 cursor 불일치 409 로 거부된다.
quiz_session = (
await session.execute(
select(StudyQuizSession)
.where(StudyQuizSession.id == body.quiz_session_id)
.with_for_update()
)
).scalar_one_or_none()
if quiz_session is None or quiz_session.user_id != user.id:
raise HTTPException(status_code=404, detail="quiz_session 을 찾을 수 없습니다")
if quiz_session.study_topic_id != q.study_topic_id:
+27
View File
@@ -169,6 +169,14 @@ class Settings(BaseModel):
# 1 = 구 single-inference 동작. 2 = continuous batching 활용 (llm_gate docstring 참조).
mlx_gate_concurrency: int = 1
# digest/briefing 생성 LLM 호출 파라미터 (2026-06-15, 모델 교체 후 타임아웃 단일소스화).
# 구 하드코딩 25s(빠른 Gemma 기준)가 Qwen3.6-27B-6bit(콜당 ~90~300s) 교체 sweep 에서
# 누락돼 digest 600s 하드캡 초과·briefing 4/4 폴백을 유발 → config 단일소스로 이관.
# 동시성은 별 키 아님 — 전역 mlx_gate_concurrency(게이트 단일 budget)가 담당.
digest_llm_timeout_s: int = 200
digest_llm_attempts: int = 2
digest_pipeline_hard_cap_s: int = 1800
# PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini
# GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거.
study_explanation_enabled: bool = True
@@ -257,6 +265,9 @@ def load_settings() -> Settings:
pipeline_held_stages: list[str] = []
mlx_gate_concurrency = 1
digest_llm_timeout_s = 200
digest_llm_attempts = 2
digest_pipeline_hard_cap_s = 1800
if config_path.exists() and raw and "pipeline" in raw:
held_raw = (raw.get("pipeline") or {}).get("held_stages") or []
# 스칼라(문자열) 오기입 시 char-split 방지 — 단일 항목 리스트로 수용.
@@ -269,6 +280,19 @@ def load_settings() -> Settings:
)
except (TypeError, ValueError):
mlx_gate_concurrency = 1
_pl = raw.get("pipeline") or {}
try:
digest_llm_timeout_s = max(1, int(_pl.get("digest_llm_timeout_s", 200)))
except (TypeError, ValueError):
digest_llm_timeout_s = 200
try:
digest_llm_attempts = max(1, int(_pl.get("digest_llm_attempts", 2)))
except (TypeError, ValueError):
digest_llm_attempts = 2
try:
digest_pipeline_hard_cap_s = max(60, int(_pl.get("digest_pipeline_hard_cap_s", 1800)))
except (TypeError, ValueError):
digest_pipeline_hard_cap_s = 1800
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
@@ -300,6 +324,9 @@ def load_settings() -> Settings:
internal_worker_token=internal_worker_token,
pipeline_held_stages=pipeline_held_stages,
mlx_gate_concurrency=mlx_gate_concurrency,
digest_llm_timeout_s=digest_llm_timeout_s,
digest_llm_attempts=digest_llm_attempts,
digest_pipeline_hard_cap_s=digest_pipeline_hard_cap_s,
)
+58 -4
View File
@@ -72,6 +72,55 @@ def _validate_sql_content(name: str, sql: str) -> None:
)
# R1: baseline 스냅샷이 대표하는 마지막 마이그레이션 버전 (이하 버전은 baseline 에 포함).
# 새 baseline 재생성 시 이 값을 갱신한다 (migrations/_baseline/<cutoff>_schema_baseline.sql).
_BASELINE_CUTOFF = 358
async def _load_baseline_if_fresh(conn, migrations_dir: Path) -> None:
"""fresh DB(documents 부재)면 baseline 스키마 스냅샷 적재 + schema_migrations 1..cutoff 스탬프.
기존 DB(documents 존재) 즉시 반환 baseline 미적재, 무영향. baseline 파일 부재 시도
기존 replay 경로 유지(하위호환).
"""
from sqlalchemy import text
baseline_dir = migrations_dir / "_baseline"
baseline_files = (
sorted(baseline_dir.glob("*_schema_baseline.sql")) if baseline_dir.is_dir() else []
)
if not baseline_files:
return
docs_exists = (
await conn.execute(text("SELECT to_regclass('public.documents') IS NOT NULL"))
).scalar()
if docs_exists:
return # 기존 DB — baseline skip
baseline_path = baseline_files[-1]
logger.info(f"[migration] fresh DB 감지 — baseline 적재: {baseline_path.name}")
# baseline 은 multi-statement 덤프 — exec_driver_sql(asyncpg prepared)은 multi-statement
# 불허("cannot insert multiple commands into a prepared statement"). raw asyncpg 의 simple
# 프로토콜 execute() 로 적재한다(같은 connection = 현재 트랜잭션 내). psql 스모크는 이 제약을
# 못 잡으므로 init_db 런타임 검증으로 확인됨.
raw = await conn.get_raw_connection()
await raw.driver_connection.execute(baseline_path.read_text(encoding="utf-8"))
# baseline = cutoff 까지의 스키마 → 실제 파일 버전 기준으로 schema_migrations 스탬프.
versions = [v for v, _, _ in _parse_migration_files(migrations_dir) if v <= _BASELINE_CUTOFF]
for v in versions:
await conn.execute(
text(
"INSERT INTO schema_migrations (version, name) "
"VALUES (:v, :n) ON CONFLICT DO NOTHING"
),
{"v": v, "n": f"baseline:{v}"},
)
logger.info(
f"[migration] baseline 적재 + schema_migrations {len(versions)}건 스탬프 (cutoff {_BASELINE_CUTOFF})"
)
async def _run_migrations(conn) -> None:
"""미적용 migration 실행 (호출자가 트랜잭션 관리)"""
from sqlalchemy import text
@@ -90,10 +139,6 @@ async def _run_migrations(conn) -> None:
f"SELECT pg_advisory_xact_lock({_MIGRATION_LOCK_KEY})"
))
# 적용 이력 조회
result = await conn.execute(text("SELECT version FROM schema_migrations"))
applied = {row[0] for row in result}
# migration 파일 스캔
# /app/core/database.py → parent.parent = /app → /app/migrations (volume mount 위치)
migrations_dir = Path(__file__).resolve().parent.parent / "migrations"
@@ -101,6 +146,15 @@ async def _run_migrations(conn) -> None:
logger.info("[migration] migrations/ 디렉토리 없음, 스킵")
return
# R1: fresh DB(documents 부재)면 baseline 스냅샷 먼저 적재 + schema_migrations 스탬프.
# migrations/ 전체 replay 는 누적 비-replayable(011 view 의존·326 enum-same-txn 등)로
# 깨지므로 신규/DR 환경은 prod 스키마 스냅샷에서 출발한다. 기존 DB 는 skip(무영향).
await _load_baseline_if_fresh(conn, migrations_dir)
# 적용 이력 조회 (baseline 스탬프 반영 — fresh DB 는 1..cutoff 가 이미 applied)
result = await conn.execute(text("SELECT version FROM schema_migrations"))
applied = {row[0] for row in result}
files = _parse_migration_files(migrations_dir)
pending = [(v, name, path) for v, name, path in files if v not in applied]
+34 -4
View File
@@ -51,17 +51,21 @@ async def lifespan(app: FastAPI):
from workers.briefing_worker import run as morning_briefing_run
from workers.daily_digest import run as daily_digest_run
from workers.dedup_reconcile import run as dedup_reconcile_run
from workers.document_purge_sweep import run as purge_sweep_run
from workers.digest_worker import run as global_digest_run
from workers.file_watcher import watch_inbox
from workers.mailplus_archive import run as mailplus_run
from workers.statute_collector import run as statute_run
from workers.news_collector import run as news_collector_run
from workers.arxiv_collector import run as arxiv_collector_run
from workers.openalex_collector import run as openalex_collector_run
from workers.paper_doi_reconcile import run as paper_doi_reconcile_run
from workers.fulltext_worker import reconcile_unresolved as fulltext_reconcile_run
from workers.kosha_collector import run as kosha_collector_run
from workers.csb_collector import run as csb_collector_run
from workers.api_standards_collector import run as api_standards_run
from workers.ccps_collector import run as ccps_collector_run
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_queue
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_queue, consume_deep_queue
from workers.study_queue_consumer import consume_study_queue
from workers.study_session_queue_consumer import consume_study_session_queue
from workers.study_memo_card_jobs_consumer import consume_study_memo_card_queue
@@ -74,6 +78,8 @@ async def lifespan(app: FastAPI):
)
from workers.tier_backfill import run as tier_backfill_run
from workers.upload_cleanup import cleanup_orphan_uploads
from workers.memo_draft_worker import run as memo_draft_run
from workers.auto_review_worker import run as auto_review_run
# 시작: DB 연결 확인
await init_db()
@@ -98,8 +104,14 @@ async def lifespan(app: FastAPI):
# 2026-06-12 fast-consumer split: embed/chunk(건당 <1s)를 LLM 사이클에서 분리 —
# classify(~190s×3)가 사이클을 점유해 벡터 적재가 굶던 구조 캡 해소 (markdown 선례).
scheduler.add_job(consume_fast_queue, "interval", minutes=1, id="fast_queue_consumer")
# 2026-06-15 deep-consumer split: deep_summary(70~300s)를 메인 루프에서 분리 (markdown/fast 선례).
scheduler.add_job(consume_deep_queue, "interval", minutes=1, id="deep_queue_consumer")
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
scheduler.add_job(cleanup_orphan_uploads, "interval", minutes=10, id="upload_cleanup")
# P2: 메모→문서 승격분 26B 문서화 (needs_draft 마커 → md_content). 26B 콜이라 소량·2분 간격.
scheduler.add_job(memo_draft_run, "interval", minutes=2, id="memo_draft", max_instances=1)
# 검토 대기 자동검토: 고신뢰(ai_confidence>=0.9) 자동승인 + 저신뢰 수동 잔류. 순수 DB(LLM 없음).
scheduler.add_job(auto_review_run, "interval", minutes=3, id="auto_review", max_instances=1)
# PR-4: study_questions 자동 임베딩 (status='none/failed/stale' 행을 batch=10 처리).
# 별도 큐 테이블 없이 status 자체가 큐. backfill 도 cron 이 'none' 행을 자연스럽게 처리.
scheduler.add_job(study_q_embed_run, "interval", minutes=1, id="study_q_embed")
@@ -139,6 +151,12 @@ async def lifespan(app: FastAPI):
# plan ds-s1-backend-1 B-4: dedup 컬럼(duplicate_of/duplicate_count) 야간 절대 재계산.
# soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌).
scheduler.add_job(dedup_reconcile_run, CronTrigger(hour=3, minute=30, timezone=KST), id="dedup_reconcile")
# R7: delete_file=true purge 요청 문서의 NAS 원본 grace(30일) 후 물리삭제 + audit.
# purge_requested_at 마커 기준(단순 숨김은 보존). 03:20 = 다른 새벽 잡과 비충돌 슬롯.
scheduler.add_job(purge_sweep_run, CronTrigger(hour=3, minute=20, timezone=KST), id="purge_sweep")
# B-3 PR4: 레거시 paper 행 arXiv DataCite DOI 스탬프(재유입 차단). keyless·in-DB·enqueue 0.
# dedup_reconcile(03:30)·fulltext_reconcile(03:40) 와 별 worker·비충돌 슬롯.
scheduler.add_job(paper_doi_reconcile_run, CronTrigger(hour=3, minute=50, timezone=KST), id="paper_doi_reconcile")
# crawl-24x7 C-2: KOSHA 재해사례 diff + GUIDE 점진 백필 (daily, 새벽 잡들과 비충돌 슬롯).
scheduler.add_job(kosha_collector_run, CronTrigger(hour=6, minute=40, timezone=KST), id="kosha_collector")
# 사이클 3 C-2 잔여: CSB sitemap lastmod diff (weekly 월, cap 40 + 워터마크 점진 백필).
@@ -147,6 +165,12 @@ async def lifespan(app: FastAPI):
scheduler.add_job(api_standards_run, CronTrigger(day=5, hour=7, minute=5, timezone=KST), id="api_standards_collector")
# 사이클 3 C-2 잔여: CCPS Beacon 월간 PDF (playwright 익명 경유 — WAF 차단 시 health 로 가시화).
scheduler.add_job(ccps_collector_run, CronTrigger(day=5, hour=7, minute=20, timezone=KST), id="ccps_collector")
# B-3 PR2: arXiv 키워드 필터 수집기 (daily 07:30 KST — statute 07:00 직후 빈 슬롯).
# signal-only 초록 색인, per-run cap 으로 임베드 큐 보호. keyless.
scheduler.add_job(arxiv_collector_run, CronTrigger(hour=7, minute=30, timezone=KST), id="arxiv_collector")
# B-3 PR3: OpenAlex 백본 수집기 (daily 07:45 KST). scaffold-first(키 부재 explicit-skip),
# signal-only 초록 색인, per-run cap + cursor watermark. 키=OPENALEX_API_KEY(credentials.env).
scheduler.add_job(openalex_collector_run, CronTrigger(hour=7, minute=45, timezone=KST), id="openalex_collector")
scheduler.start()
# Phase 2.1 (async 구조): QueryAnalyzer prewarm.
@@ -216,21 +240,27 @@ SETUP_BYPASS_PREFIXES = (
"/api/setup", "/api/config", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
)
# R10: 셋업 완료(user 존재)는 단조(monotonic) — 한 번 확인되면 영구. 매 요청 COUNT 쿼리
# 대신 캐시 플래그로 전환 (setup 후 모든 요청이 users COUNT 하던 per-request 비용 제거).
_setup_complete = False
@app.middleware("http")
async def setup_redirect_middleware(request: Request, call_next):
global _setup_complete # 함수 내 read+assign 둘 다 모듈 전역 참조 (UnboundLocalError 방지)
path = request.url.path
# 바이패스 경로는 항상 통과
if any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
# 셋업 완료됐거나 바이패스 경로면 즉시 통과 (DB 쿼리 없음)
if _setup_complete or any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
return await call_next(request)
# 유저 존재 여부 확인
# 유저 존재 여부 확인 (셋업 완료 전 1회성 — 완료 확인되면 플래그 set 후 영구 skip)
try:
async with async_session() as session:
result = await session.execute(select(func.count(User.id)))
user_count = result.scalar()
if user_count == 0:
return RedirectResponse(url="/setup")
_setup_complete = True
except Exception:
pass # DB 연결 실패 시 통과 (health에서 확인 가능)
+6 -2
View File
@@ -52,7 +52,8 @@ class Document(Base):
# 2계층: AI 가공
ai_summary: Mapped[str | None] = mapped_column(Text)
ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[])
# R11a: 주석 dict→list 정정(실제 list 적재), 공유 가변 default=[] → callable default=list.
ai_tags: Mapped[list | None] = mapped_column(JSONB, default=list)
ai_domain: Mapped[str | None] = mapped_column(String(100))
ai_sub_group: Mapped[str | None] = mapped_column(String(100))
ai_model_version: Mapped[str | None] = mapped_column(String(50))
@@ -79,7 +80,7 @@ class Document(Base):
user_note: Mapped[str | None] = mapped_column(Text)
# 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력)
user_tags: Mapped[list | None] = mapped_column(JSONB, default=[])
user_tags: Mapped[list | None] = mapped_column(JSONB, default=list) # R11a: 공유 가변 default 제거
# 핀 고정
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -105,6 +106,9 @@ class Document(Base):
# 승인/삭제
review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# delete_file=true 명시 삭제 요청 마커 (R7) — retention sweep(document_purge_sweep)이
# grace 후 NAS 원본 물리삭제. deleted_at(단순 숨김, 파일 보존)과 분리.
purge_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 외부 편집 URL
edit_url: Mapped[str | None] = mapped_column(Text)
+4 -2
View File
@@ -7,7 +7,7 @@ PR-2 가드레일:
- correct_choice 변경 기존 attempt.is_correct 재계산 (기록은 시점의 사실).
"""
from datetime import datetime
from datetime import datetime, timezone
from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, SmallInteger, String, Text
@@ -128,7 +128,9 @@ class StudyQuestionAttempt(Base):
# PR-9: outcome 권장값 (correct/wrong/unsure). 강한 enum 미사용.
outcome: Mapped[str] = mapped_column(String(20), nullable=False)
answered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
# TZ-aware 명시 (R8) — naive datetime.now() 는 컨테이너 TZ 의존. 현 컨테이너=UTC 라
# 값 동일(백필 불요)이나, 컨테이너 TZ 가 바뀌면 9시간 어긋나는 잠복 의존 제거.
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
# PR-10: 어떤 quiz 세션의 attempt 인지 (NULL = 세션 외 직접 입력 또는 세션 삭제됨).
quiz_session_id: Mapped[int | None] = mapped_column(
+93
View File
@@ -0,0 +1,93 @@
"""off-queue 관리 스크립트(백필 등) 진행 가시화 — background_jobs (migration 357).
processing_queue 파이프라인 stage 전용이라 hier_overnight_backfill /
section_summary_pilot 같은 스크립트 작업은 대시보드 보드에 잡힌다. 모듈로
스크립트가 진행상황을 남기면 queue_overview "백그라운드 작업" 패널로 노출한다.
설계 불변식:
- **자율 트랜잭션**: 기록은 engine.begin() 짧은 트랜잭션으로 즉시 commit한다.
스크립트 작업은 별도 세션( 트랜잭션)이라, 같이 묶으면 commit 전까지 보여
실시간 가시화가 깨진다. 그래서 전용 connection 으로 독립 commit.
- **best-effort**: 관측 기록 실패가 작업을 깨면 된다 모든 함수 try/except,
실패 warning 로그만. job_id=None 이면 조용히 no-op (start 실패해도 이어서 동작).
"""
import json
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
async def start_job(
engine: AsyncEngine, kind: str, label: str | None = None, total: int | None = None
) -> int | None:
"""작업 시작 기록 → background_jobs.id (실패 시 None — 호출측은 그대로 진행)."""
try:
async with engine.begin() as conn:
row = (
await conn.execute(
text(
"INSERT INTO background_jobs (kind, label, total) "
"VALUES (:k, :l, :t) RETURNING id"
),
{"k": kind, "l": label, "t": total},
)
).first()
return int(row[0]) if row else None
except Exception as exc: # noqa: BLE001 — 관측은 부가, 본작업 보호
logger.warning(f"[background_jobs] start 실패(무시): {type(exc).__name__}: {exc}")
return None
async def heartbeat(
engine: AsyncEngine,
job_id: int | None,
*,
processed: int | None = None,
total: int | None = None,
detail: dict | None = None,
) -> None:
"""진행 갱신(processed/total/detail). job_id=None 또는 실패 시 no-op."""
if job_id is None:
return
try:
async with engine.begin() as conn:
await conn.execute(
text(
"UPDATE background_jobs SET "
"processed = COALESCE(:p, processed), "
"total = COALESCE(:t, total), "
"detail = COALESCE(CAST(:d AS jsonb), detail), "
"updated_at = now() WHERE id = :id"
),
{
"id": job_id,
"p": processed,
"t": total,
"d": json.dumps(detail, ensure_ascii=False) if detail is not None else None,
},
)
except Exception as exc: # noqa: BLE001
logger.warning(f"[background_jobs] heartbeat 실패(무시): {type(exc).__name__}: {exc}")
async def finish_job(
engine: AsyncEngine, job_id: int | None, *, state: str = "done", error: str | None = None
) -> None:
"""종료 기록(done/failed). job_id=None 또는 실패 시 no-op."""
if job_id is None:
return
try:
async with engine.begin() as conn:
await conn.execute(
text(
"UPDATE background_jobs SET state = :s, error = :e, "
"finished_at = now(), updated_at = now() WHERE id = :id"
),
{"id": job_id, "s": state, "e": (error or None)},
)
except Exception as exc: # noqa: BLE001
logger.warning(f"[background_jobs] finish 실패(무시): {type(exc).__name__}: {exc}")
+6 -4
View File
@@ -18,12 +18,14 @@ from typing import Any
import numpy as np
from ai.client import parse_json_response
from core.config import settings
from core.utils import setup_logger
from services.clustering_common import normalize_vector
from services.search.llm_gate import Priority, acquire_mlx_gate
logger = setup_logger("briefing_comparator")
LLM_CALL_TIMEOUT = 25 # 초. Phase 4 와 동일
LLM_CALL_TIMEOUT = settings.digest_llm_timeout_s # 2026-06-15 config 단일소스 (Phase 4 와 동일 키)
HISTORICAL_TOP_K = 5
HISTORICAL_SIMILARITY_MIN = 0.70
HISTORICAL_WINDOW_DAYS = 30
@@ -39,7 +41,6 @@ MAX_ARTICLE_IDS_PER_COUNTRY = 5 # country_perspectives[].article_ids 후
FALLBACK_HEADLINE = "LLM 분석 실패로 원문 기사 묶음만 표시합니다."
FALLBACK_TOPIC_LABEL = "주요 뉴스 묶음"
_llm_sem = asyncio.Semaphore(1)
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "briefing_comparative.txt"
_PROMPT_TEMPLATE: str | None = None
@@ -112,7 +113,8 @@ def retrieve_historical(
async def _try_call_llm(client: Any, prompt: str) -> str:
async with _llm_sem:
# 전역 MLX gate(BACKGROUND) 경유 — 영구 룰(llm_gate): 새 Semaphore 금지, timeout 은 gate 안쪽.
async with acquire_mlx_gate(Priority.BACKGROUND):
return await asyncio.wait_for(
client.call_primary(prompt),
timeout=LLM_CALL_TIMEOUT,
@@ -282,7 +284,7 @@ async def compare_cluster_with_fallback(
historical_docs = historical_docs or []
prompt = build_prompt(selected, historical_docs)
for attempt in range(2):
for attempt in range(settings.digest_llm_attempts): # 2026-06-15 config 단일소스
try:
raw = await _try_call_llm(client, prompt)
except asyncio.TimeoutError:
+26 -4
View File
@@ -6,6 +6,7 @@
regenerate 정책: briefing_date UNIQUE 충돌 transaction 안에서 DELETE+INSERT.
"""
import asyncio
import time
from datetime import date, datetime, timedelta, timezone
from typing import Any
@@ -15,7 +16,9 @@ from sqlalchemy import delete
from ai.client import AIClient
from core.database import async_session
from core.database import engine as db_engine
from core.utils import setup_logger
from services import background_jobs as bgj
from models.briefing import BriefingTopic, MorningBriefing
from services.briefing.clustering import LAMBDA, cluster_global
from services.briefing.comparator import (
@@ -33,7 +36,6 @@ KST = ZoneInfo("Asia/Seoul")
NIGHT_WINDOW_HOURS = 5 # KST 00:00 ~ 05:00
SELECT_K = 7 # Plan §"Clustering 파라미터" briefing K_PER_CLUSTER=7
SELECT_LAMBDA_MMR = 0.6 # Plan briefing MMR lambda 0.6
PIPELINE_HARD_CAP = 600 # 초. Phase 4 와 동일
def _compute_window(target_date: date | None = None) -> tuple[datetime, datetime, date]:
@@ -143,7 +145,7 @@ async def _save_briefing(
return new.id
async def run_briefing_pipeline(target_date: date | None = None) -> dict[str, Any]:
async def run_briefing_pipeline(target_date: date | None = None, job_id: int | None = None) -> dict[str, Any]:
"""야간 뉴스 브리핑 1회 실행. cron 또는 수동 regenerate API 에서 호출.
Returns:
@@ -206,16 +208,36 @@ async def run_briefing_pipeline(target_date: date | None = None) -> dict[str, An
usable_count = 0
try:
# 2026-06-15: cluster 호출 gather 동시 실행. 실동시성 = 전역 MLX gate
# (config.mlx_gate_concurrency, BACKGROUND 우선순위). rank/순서 보존.
jobs = []
for rank, cluster in enumerate(clusters, start=1):
selected = select_for_llm(cluster, k=SELECT_K, lambda_mmr=SELECT_LAMBDA_MMR)
historical_docs = (
retrieve_historical(cluster, historical_candidates)
if historical_enabled() else []
)
llm_calls += 1
envelope = await compare_cluster_with_fallback(
jobs.append((rank, cluster, selected, historical_docs))
if job_id is not None:
await bgj.heartbeat(db_engine, job_id, total=len(jobs))
_prog = {"n": 0}
async def _run_one(cluster, selected, historical_docs):
r = await compare_cluster_with_fallback(
client, cluster, selected, historical_docs=historical_docs
)
if job_id is not None:
_prog["n"] += 1
await bgj.heartbeat(db_engine, job_id, processed=_prog["n"])
return r
results = await asyncio.gather(
*[_run_one(c, s, h) for (_, c, s, h) in jobs]
)
for (rank, cluster, selected, historical_docs), envelope in zip(jobs, results):
llm_calls += 1
if envelope.get("llm_fallback_used"):
llm_failures += 1
if _is_usable_topic(envelope, envelope["topic_label"]):
+29 -9
View File
@@ -10,6 +10,7 @@ Step:
7. start/end 로그 + generation_ms + fallback 비율 health metric
"""
import asyncio
import hashlib
import time
from datetime import datetime, timedelta, timezone
@@ -19,7 +20,9 @@ from sqlalchemy import delete
from ai.client import AIClient
from core.database import async_session
from core.database import engine as db_engine
from core.utils import setup_logger
from services import background_jobs as bgj
from models.digest import DigestTopic, GlobalDigest
from .clustering import LAMBDA, cluster_country
@@ -73,7 +76,7 @@ def _build_topic_row(
)
async def run_digest_pipeline() -> dict:
async def run_digest_pipeline(job_id: int | None = None) -> dict:
"""전체 파이프라인 실행. worker entry 에서 호출.
Returns:
@@ -107,20 +110,37 @@ async def run_digest_pipeline() -> dict:
stats = {"llm_calls": 0, "fallback_used": 0}
try:
# 2026-06-15: cluster 호출을 gather 로 동시 실행. 실제 동시성은 전역 MLX gate
# (config.mlx_gate_concurrency, BACKGROUND 우선순위) 가 제한한다. rank/순서 보존.
jobs = []
for country, docs in docs_by_country.items():
clusters = cluster_country(country, docs)
if not clusters:
continue # sparse country 자동 제외
for rank, cluster in enumerate(clusters, start=1):
selected = select_for_llm(cluster)
stats["llm_calls"] += 1
llm_result = await summarize_cluster_with_fallback(client, cluster, selected)
if llm_result["llm_fallback_used"]:
stats["fallback_used"] += 1
all_topic_rows.append(
_build_topic_row(country, rank, cluster, selected, llm_result, primary_model)
)
jobs.append((country, rank, cluster, selected))
if job_id is not None:
await bgj.heartbeat(db_engine, job_id, total=len(jobs))
_prog = {"n": 0}
async def _run_one(cluster, selected):
r = await summarize_cluster_with_fallback(client, cluster, selected)
if job_id is not None:
_prog["n"] += 1
await bgj.heartbeat(db_engine, job_id, processed=_prog["n"])
return r
results = await asyncio.gather(*[_run_one(c, s) for (_, _, c, s) in jobs])
for (country, rank, cluster, selected), llm_result in zip(jobs, results):
stats["llm_calls"] += 1
if llm_result["llm_fallback_used"]:
stats["fallback_used"] += 1
all_topic_rows.append(
_build_topic_row(country, rank, cluster, selected, llm_result, primary_model)
)
finally:
await client.close()
+13 -8
View File
@@ -2,8 +2,8 @@
핵심 결정:
- AIClient._call_chat 직접 호출 (client.py 수정 회피, fallback 로직 재사용)
- Semaphore(1) MLX 과부하 회피
- Per-call timeout 25 (asyncio.wait_for) MLX hang / fallback Claude API stall 방어
- 전역 MLX gate(BACKGROUND) 경유 동시성 제어 (services.search.llm_gate 단일 게이트)
- Per-call timeout = config.digest_llm_timeout_s (asyncio.wait_for, gate 안쪽)
- JSON 파싱 실패 1 재시도 그래도 실패 minimal fallback (drop 금지)
- fallback: topic_label="주요 뉴스 묶음", summary = top member ai_summary[:200]
"""
@@ -13,15 +13,16 @@ from pathlib import Path
from typing import Any
from ai.client import parse_json_response
from core.config import settings
from core.utils import setup_logger
from services.search.llm_gate import Priority, acquire_mlx_gate
logger = setup_logger("digest_summarizer")
LLM_CALL_TIMEOUT = 25 # 초. MLX 평균 5초 + tail latency 마진
# 2026-06-15: config 단일소스 (구 하드코딩 25s = 빠른 Gemma 기준, Qwen 27B 교체 후 누락).
LLM_CALL_TIMEOUT = settings.digest_llm_timeout_s
FALLBACK_SUMMARY_LIMIT = 200
_llm_sem = asyncio.Semaphore(1)
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "digest_topic.txt"
_PROMPT_TEMPLATE: str | None = None
@@ -48,8 +49,12 @@ def build_prompt(selected: list[dict]) -> str:
async def _try_call_llm(client: Any, prompt: str) -> str:
"""Semaphore + per-call timeout 으로 감싼 단일 호출."""
async with _llm_sem:
"""전역 MLX gate(BACKGROUND) + per-call timeout 으로 감싼 단일 호출.
영구 (llm_gate): Mac mini endpoint 단일 게이트 공유, Semaphore 금지.
동시성 lever = config.mlx_gate_concurrency. timeout gate 안쪽에서만.
"""
async with acquire_mlx_gate(Priority.BACKGROUND):
return await asyncio.wait_for(
client._call_chat(client.ai.primary, prompt),
timeout=LLM_CALL_TIMEOUT,
@@ -86,7 +91,7 @@ async def summarize_cluster_with_fallback(
"""
prompt = build_prompt(selected)
for attempt in range(2): # 1회 재시도 포함
for attempt in range(settings.digest_llm_attempts): # config 단일소스 (기본 2 = 1회 재시도)
try:
raw = await _try_call_llm(client, prompt)
except asyncio.TimeoutError:
+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).
+5
View File
@@ -0,0 +1,5 @@
"""B-3 논문 수집 트랙 공유 모듈 (plan safety-library-b3-1).
doi DOI 정규화·dedup ·2-Document(holder/parent_doi child) extract_meta 계약 (순수).
holder 서지 holder 공유 dedup 조회 (DB).
"""
+141
View File
@@ -0,0 +1,141 @@
"""B-3 논문 DOI 코어 — 정규화·dedup 키·2-Document(서지 holder / parent_doi child) 계약.
plan safety-library-b3-1 PR1 (keyless·마이그 0).
핵심 계약(모든 논문 수집기·reconcile·구매 PDF 스탬프가 공유):
- DOI 정규화는 단일 함수(normalize_doi) 경유 **저장=조회 동일 함수**
(migration 351 주석 명시, news_collector._normalize_url store=lookup 불변식 선례).
같은 논문이 다른 표기(https://doi.org/ vs doi: vs 대문자) 들어와도 holder 붕괴.
- dedup = lower(extract_meta #>> '{paper,doi}') — 라이브 partial-unique 인덱스
uq_documents_paper_doi(WHERE material_type='paper' AND ... IS NOT NULL) 강제.
- 2-Document(R2-B1): paper.doi **서지 Document 단일 보유**. OA/구매 전문 PDF
doi 없이 paper.parent_doi holder 링크(NULL doi 인덱스 다중행 무충돌).
holder child doi/parent_doi **상호 배타** 가진다.
"""
import hashlib
import re
# 소문자화 후 비교하므로 전부 소문자 prefix. 긴 것부터(dx.doi.org 가 doi.org 보다 먼저).
_DOI_PREFIXES = (
"https://dx.doi.org/",
"http://dx.doi.org/",
"https://doi.org/",
"http://doi.org/",
"dx.doi.org/",
"doi.org/",
"doi:",
)
def normalize_doi(raw: str | None) -> str | None:
"""DOI 정규화 — 소문자 + URL/doi: prefix 제거 + 양끝 공백·잡음 제거. 단일 함수(저장=조회).
유효 DOI(10. 으로 시작) 아니면 None. 저장측·조회측·dedup 생성이 모두 함수를
공유해야 dedup 성립한다(raw 그대로 저장하고 정규화로 조회하면 영구 미스).
"""
if not raw:
return None
s = raw.strip().lower()
for p in _DOI_PREFIXES:
if s.startswith(p):
s = s[len(p):]
break
s = s.strip()
# 인용문 끝 잡음(마침표/쉼표/세미콜론)만 제거. 괄호 '()' 는 DOI 일부일 수 있어 보존한다
# (예: 10.1016/s0010-8650(00)80003-2) — 과삭제는 서로 다른 논문을 한 holder 로 병합하는
# 데이터 손상이라 near-dup(과소삭제)보다 위험. API 소스(OpenAlex/arXiv)의 doi 는 이미 깨끗.
s = s.rstrip(".,;")
if not s.startswith("10."):
return None
return s
# arXiv id: 신형 'YYMM.NNNNN'(+vN) 또는 구형 'archive(.SUBJ)/NNNNNNN'. 'arXiv:' 접두 흡수.
_ARXIV_ID_RE = re.compile(
r"arxiv:\s*([a-z\-]+(?:\.[a-z]{2})?/\d{7}|\d{4}\.\d{4,5})(v\d+)?", re.IGNORECASE
)
def parse_arxiv_id(text: str | None) -> str | None:
"""본문/제목에서 arXiv id(versionless) 추출. 없으면 None. 레거시 reconcile 의 입력."""
if not text:
return None
m = _ARXIV_ID_RE.search(text)
return m.group(1) if m else None
def arxiv_doi(arxiv_id: str | None) -> str | None:
"""arXiv DataCite DOI = 10.48550/arxiv.{id} (정규화). 저널 DOI 없는 프리프린트의 canonical
paper.doi 통일 OpenAlex 프리프린트에 동일 DOI 부여(실측 확인). 모든 수집기·reconcile
같은 함수로 같은 DOI 써야 교차소스 dedup 성립."""
if not arxiv_id:
return None
return normalize_doi(f"10.48550/arXiv.{arxiv_id}")
_DOI_IN_TEXT_RE = re.compile(r"10\.\d{4,9}/[^\s\"'<>]+", re.IGNORECASE)
def parse_doi_from_text(text: str | None) -> str | None:
"""본문에서 첫 DOI 추출(정규화). 구매 PDF 의 paper.parent_doi 링크용(PDF 구조 무관 — 전체 스캔).
DOI 구두점은 normalize_doi 정리. 없으면 None."""
if not text:
return None
m = _DOI_IN_TEXT_RE.search(text)
return normalize_doi(m.group(0)) if m else None
def paper_doi_hash(normalized_doi: str) -> str:
"""서지 holder 의 Document.file_hash — sha256('paper|{doi}')[:32].
statute 'statute|{jur}|{native_id}|{version_key}' 다중부 선례를 따른다.
인자는 normalize_doi() 출력(정규화 완료값)이어야 한다 raw 넣으면 dedup 깨진다.
"""
if not normalized_doi:
raise ValueError("paper_doi_hash 는 정규화된 DOI 필요 (normalize_doi 먼저)")
return hashlib.sha256(f"paper|{normalized_doi}".encode()).hexdigest()[:32]
def read_paper_doi(extract_meta: dict | None) -> str | None:
"""holder 의 정규화 DOI 읽기 — 인덱스 식 lower(extract_meta #>> '{paper,doi}') 의 조회측 거울.
방어적 재정규화(이미 정규화돼 저장되지만 레거시·외부 주입 대비).
"""
if not extract_meta:
return None
paper = extract_meta.get("paper")
if not isinstance(paper, dict):
return None
return normalize_doi(paper.get("doi"))
def with_paper_doi(extract_meta: dict | None, normalized_doi: str) -> dict:
"""서지 holder 의 extract_meta 에 paper.doi 주입 (merge-safe, 타 키 보존).
holder 전용 parent_doi 제거(상호 배타). 반환값은 dict(입력 비변경).
"""
if not normalized_doi:
raise ValueError("with_paper_doi 는 정규화된 DOI 필요")
meta = dict(extract_meta or {})
paper = dict(meta.get("paper") or {})
paper["doi"] = normalized_doi
paper.pop("parent_doi", None)
meta["paper"] = paper
return meta
def with_parent_doi(extract_meta: dict | None, parent_normalized_doi: str) -> dict:
"""child(OA/구매 전문 PDF)의 extract_meta 에 paper.parent_doi 주입 (merge-safe, 타 키 보존).
child paper.doi 갖지 않는다(NULL partial-unique 인덱스 , 2-Document 무충돌).
반환값은 dict(입력 비변경).
"""
if not parent_normalized_doi:
raise ValueError("with_parent_doi 는 정규화된 DOI 필요")
meta = dict(extract_meta or {})
paper = dict(meta.get("paper") or {})
paper["parent_doi"] = parent_normalized_doi
paper.pop("doi", None)
meta["paper"] = paper
return meta
+39
View File
@@ -0,0 +1,39 @@
"""B-3 논문 서지 holder 공유 dedup 조회.
모든 논문 수집기(OpenAlex/arXiv/KoreaScience/J-STAGE)·reconcile·구매 PDF 스탬프가
ingest 함수로 holder 존재를 확인한다(있으면 skip 또는 child 링크).
- 조회 = lower(extract_meta #>> '{paper,doi}') == normalize_doi(...) — 라이브 partial-unique
인덱스 uq_documents_paper_doi 동일 (인덱스 사용).
- .scalars().first() 교차게시·다중 landing-page 2 이상 매칭 MultipleResultsFound
raise 방지(scalar_one_or_none 금지, 2026-06 BBC 수집 중단 선례 / news_collector 동일 규율).
- 서지 holder Document **생성** 수집기/스탬프 경로가 소유한다(초록 signal 문서 vs 구매
최소 holder shape 다름). 모듈은 dedup 조회만 공유한다.
DB 조회라 모듈은 PR2(arXiv 실수집)에서 라이브 검증한다 PR1 단위 테스트 대상은 doi.py(순수).
"""
from sqlalchemy import func, select
from models.document import Document
from services.papers.doi import normalize_doi
# 인덱스 식과 동일: lower(extract_meta #>> '{paper,doi}')
_DOI_EXPR = func.lower(Document.extract_meta[("paper", "doi")].astext)
async def find_paper_holder(session, raw_or_normalized_doi):
"""정규화 DOI 로 서지 holder Document 조회. 없으면 None.
인자는 raw 정규화든 받아 normalize_doi 통일(저장=조회 동일 함수 보장).
"""
doi = normalize_doi(raw_or_normalized_doi)
if not doi:
return None
result = await session.execute(
select(Document)
.where(Document.material_type == "paper", _DOI_EXPR == doi,
Document.deleted_at.is_(None))
.limit(1)
)
return result.scalars().first()
+61 -1
View File
@@ -213,6 +213,16 @@ def build_summarize_eta(stage_stats: dict[str, dict]) -> dict:
}
def build_summarize_by_machine(summarize_split: dict[str, dict]) -> dict:
"""summarize 머신별 완료 실적 분담 (macmini vs macbook) — 보드 레인의
오프로드 가시화용. rows_to_summarize_split 이미 만든 값을 응답 형태로
투영(done_1h/done_today , done_15m 내부 state 판정 전용이라 제외)."""
def m(key: str) -> dict:
s = summarize_split.get(key, {})
return {"done_1h": int(s.get("done_1h", 0)), "done_today": int(s.get("done_today", 0))}
return {"macmini": m("macmini"), "macbook": m("macbook")}
def build_trend(
inflow_buckets: dict[str, int],
done_buckets: dict[str, int],
@@ -292,6 +302,7 @@ def compose_overview(
),
"stages": build_stages(stage_stats),
"summarize_eta": build_summarize_eta(stage_stats),
"summarize_by_machine": build_summarize_by_machine(summarize_split),
"trend_24h": build_trend(inflow_buckets, done_buckets, now_kst),
"totals": build_totals(stage_stats),
}
@@ -401,7 +412,7 @@ async def build_overview(session: AsyncSession) -> dict:
for row in current_result
]
return compose_overview(
result = compose_overview(
rows_to_stage_stats(stage_rows),
rows_to_summarize_split(split_rows),
{row[0]: int(row[1]) for row in inflow_rows},
@@ -410,6 +421,55 @@ async def build_overview(session: AsyncSession) -> dict:
deep_enabled=deep_enabled,
now_kst=now_kst,
)
# 큐 밖 관리 스크립트(백필 등) = background_jobs (migration 357). 테이블 부재 시 graceful([]).
result["background_jobs"] = await _fetch_background_jobs(session)
return result
# kind -> 처리 머신 (보드 머신 카드 귀속용). 미상 kind = gpu(오케스트레이션 호스트).
_BG_JOB_MACHINE = {
"global_digest": "macmini",
"morning_briefing": "macmini",
"section_summary": "macmini",
"hier_backfill": "gpu",
"hier_redecompose": "gpu",
}
_BACKGROUND_JOBS_SQL = """
SELECT id, kind, label, state, processed, total,
EXTRACT(EPOCH FROM (now() - started_at))::int AS elapsed_sec,
(state = 'running' AND updated_at < now() - interval '5 minutes') AS stale,
error
FROM background_jobs
WHERE state = 'running' OR finished_at > now() - interval '6 hours'
ORDER BY (state = 'running') DESC, started_at DESC
LIMIT 20
"""
async def _fetch_background_jobs(session: AsyncSession) -> list[dict]:
"""running + 최근 6h 완료 background_jobs. 테이블 없거나 오류면 [] (보드 무영향).
요청 세션과 **별도 connection**으로 조회한다 테이블 부재(마이그 357 미적용 )
SELECT 실패가 요청 세션의 트랜잭션을 오염시키지 않도록 물리적으로 분리(실패
임시 connection만 폐기). 관측은 부가 기능이라 보드 본체를 절대 깨면 된다.
"""
try:
async with session.bind.connect() as conn: # 풀에서 독립 connection
rows = (await conn.execute(text(_BACKGROUND_JOBS_SQL))).mappings().all()
except Exception: # noqa: BLE001 — 관측 부가, 보드 본체 보호
return []
return [
{
"id": r["id"], "kind": r["kind"], "label": r["label"], "state": r["state"],
"processed": int(r["processed"] or 0), "total": r["total"],
"elapsed_sec": int(r["elapsed_sec"] or 0), "stale": bool(r["stale"]),
"error": r["error"],
"machine": _BG_JOB_MACHINE.get(r["kind"], "gpu"),
}
for r in rows
]
# ─── 실패 처리 (plan ds-board-engines-1) ─────────────────────────────────────
+14 -19
View File
@@ -1,6 +1,6 @@
"""Time-aware retrieval freshness decay (PR-RAG-Time-1).
뉴스(source_channel='news') / 재해사례(material_type='incident', KOSHA) 도메인은
뉴스(source_channel='news') / 법령 알림(source_channel='law_monitor') 도메인은
시간이 중요한 문서. 단순 relevance score 만으로는 오래된 문서가 상위에 머물러
검색 품질이 떨어짐. 모듈은 reranker 이후 final score 합성 단계에서
soft multiplier 시간 가중치 적용. 삭제는 없음 ranking demote.
@@ -9,10 +9,9 @@ soft multiplier 로 시간 가중치 적용. 삭제는 없음 — ranking 만 de
- reranker = 의미 관련도, freshness decay = 운영 정책. 단계 분리 유지.
- floor 0.7 (multiplier 0.7 미만으로 떨어짐) 오래되어도 죽지 않음.
- 일반 업로드 / 학습 자료 / KGS Code 원문 / ai_drafted 비적용 (no-op).
- 법령(law) C-1 후속에서 freshness 제외 현행성은 version_status(B-1 버전체인) 처리.
published_date 컬럼이 documents 없음 created_at(수집 시점) 임시 proxy.
news/KOSHA 워커가 수집 즉시 indexing 하므로 created_at published_date.
news/law_monitor 워커가 수집 즉시 indexing 하므로 created_at published_date.
정확도 향상은 후속 PR (worker published_date 메타 채우기) 분리.
"""
@@ -33,10 +32,10 @@ if TYPE_CHECKING:
# ─── Policy ────────────────────────────────────────────────────────
# half-life (일). 90 일: 한 달 ~0.79 / 6개월 ~0.25.
# C-1 후속(2026-06-13): law_365d 폐기 — 법령 현행성은 version_status(B-1 버전체인)가 처리,
# age-decay 는 current 법령을 부당 강등(의도 변경 기록). 재해사례(incident)는 news_90d 흡수.
# 365 일: 1년 ~0.5 / 3년 ~0.13.
HALF_LIFE_DAYS: dict[str, int] = {
"news_90d": 90,
"law_365d": 365,
}
# soft multiplier — final = base * (FLOOR + (1-FLOOR) * decay).
@@ -53,35 +52,32 @@ class _DocMeta:
source_channel: str | None
content_origin: str | None
created_at: datetime | None
material_type: str | None = None
def freshness_policy(meta: _DocMeta | None) -> str | None:
"""문서 메타 → freshness 정책 이름 또는 None (no-op).
적용:
- material_type='incident' (KOSHA 재해사례/사망사고) news_90d (C-1 후속 흡수, 시간 민감)
- source_channel='news' news_90d
- source_channel='news' news_90d
- source_channel='law_monitor' law_365d
비적용 (None 반환):
- meta 자체가 None
- content_origin='ai_drafted' (생성 시점 = 가치 시점, 시간 demote 부적합)
- 법령(source_channel='law_monitor'/material_type='law'): C-1 후속에서 law_365d 폐기.
법령 현행성은 version_status(B-1 버전체인 current/superseded) 처리 age-decay
current 법령을 부당 강등(의도 변경 기록). law 검색 ranking = version_status decorate.
- 모든 source_channel (manual, drive_sync, inbox_route, memo 자연 비적용)
- 모든 source_channel (manual, drive_sync, inbox_route, memo,
Study/Manual/Reference/Academic/Checklist 자연 비적용)
"""
if meta is None:
return None
# 가드 2: content_origin='ai_drafted' 비적용
if meta.content_origin == "ai_drafted":
return None
# 재해사례/사망사고 = 시간 민감 → news 와 동일 90d (source 무관, 업로드 incident 도 포함)
if meta.material_type == "incident":
sc = meta.source_channel
if sc == "news":
return "news_90d"
if meta.source_channel == "news":
return "news_90d"
# 법령 law_365d 폐기 + unknown source_channel → no decay
if sc == "law_monitor":
return "law_365d"
# 가드 6: unknown source_channel → no decay
return None
@@ -133,7 +129,7 @@ async def _fetch_meta(
text(
"""
SELECT id, source_channel::text AS source_channel,
content_origin, material_type, created_at
content_origin, created_at
FROM documents
WHERE id = ANY(:ids)
"""
@@ -145,7 +141,6 @@ async def _fetch_meta(
source_channel=row.source_channel,
content_origin=row.content_origin,
created_at=row.created_at,
material_type=getattr(row, "material_type", None),
)
for row in rows
}
+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)는 향후 후보 재진입 위해 보존.
}
+7 -39
View File
@@ -54,42 +54,10 @@ QUERY_EMBED_MAXSIZE = 500
# server-side allowlist map. query parameter 가 raw table name 받지 않음.
CANDIDATE_BACKEND_MAP: dict[str, dict[str, str] | None] = {
"baseline": None,
"cand_me5_large_inst": {
"docs_table": "documents_cand_me5_large_inst",
"chunks_table": "document_chunks_cand_me5_large_inst",
"embed_endpoint": "http://embedding-cand-me5-inst:80/embed",
},
"cand_snowflake_l_v2": {
"docs_table": "documents_cand_snowflake_l_v2",
"chunks_table": "document_chunks_cand_snowflake_l_v2",
"embed_endpoint": "http://embedding-cand-snowflake-l-v2:80/embed",
},
# ─── Phase 2A (embedding-phase2a-1, 2026-06-12): Qwen3-Embedding 후보 3종 ───
# embed_kind="ollama" = /api/embed 호출 + 쿼리측 instruct prefix (비대칭 사용,
# G-1 fixture 실측: prefix 가 관련쌍 cos +0.016). 문서측은 backfill 이 plain 으로 적재.
# qwen4m = 4B 의 MRL 1024d (dimensions 옵션 — Ollama 가 truncate+재정규화 수행, G-1 실측).
"cand_qwen06": {
"docs_table": "documents_cand_qwen06",
"chunks_table": "document_chunks_cand_qwen06",
"embed_endpoint": "http://ollama:11434/api/embed",
"embed_kind": "ollama",
"embed_model": "qwen3-embedding:0.6b",
},
"cand_qwen4": {
"docs_table": "documents_cand_qwen4",
"chunks_table": "document_chunks_cand_qwen4",
"embed_endpoint": "http://ollama:11434/api/embed",
"embed_kind": "ollama",
"embed_model": "qwen3-embedding:4b",
},
"cand_qwen4m": {
"docs_table": "documents_cand_qwen4m",
"chunks_table": "document_chunks_cand_qwen4m",
"embed_endpoint": "http://ollama:11434/api/embed",
"embed_kind": "ollama",
"embed_model": "qwen3-embedding:4b",
"embed_dimensions": 1024,
},
# Phase 2A 임베딩 후보(me5_large_inst·snowflake_l_v2·qwen06·qwen4·qwen4m) 전량 no-go
# 종결(2026-06-12, 후보 전부 -0.03~-0.04) → cand 슬러그·테이블 제거 (R13, 마이그 360
# DROP). read-path 슬러그를 먼저 빼야 embedding_backend=cand_X /search 가 dropped 테이블을
# 읽어 500 나지 않는다. baseline(production)만 잔존.
}
# G-1 핀 고정 instruct 문자열 (inventory 2026-06-12-c 기록과 동일해야 함 —
@@ -361,7 +329,7 @@ async def search_text(
+ similarity(coalesce(d.ai_tags::text, ''), :q) * 2.5
+ similarity(coalesce(d.user_note, ''), :q) * 2.0
+ similarity(coalesce(d.ai_summary, ''), :q) * 1.5
+ similarity(coalesce(d.extracted_text, ''), :q) * 1.0
+ similarity(left(coalesce(d.extracted_text, ''), 2000), :q) * 1.0
-- FTS 보너스 (idx_documents_fts_full 활용)
+ coalesce(ts_rank(
to_tsvector('simple',
@@ -369,7 +337,7 @@ async def search_text(
coalesce(d.ai_tags::text, '') || ' ' ||
coalesce(d.ai_summary, '') || ' ' ||
coalesce(d.user_note, '') || ' ' ||
coalesce(d.extracted_text, '')
left(coalesce(d.extracted_text, ''), 2000)
),
plainto_tsquery('simple', :q)
), 0) * 2.0
@@ -380,7 +348,7 @@ async def search_text(
WHEN similarity(coalesce(d.ai_tags::text, ''), :q) >= 0.3 THEN 'tags'
WHEN similarity(coalesce(d.user_note, ''), :q) >= 0.3 THEN 'note'
WHEN similarity(coalesce(d.ai_summary, ''), :q) >= 0.3 THEN 'summary'
WHEN similarity(coalesce(d.extracted_text, ''), :q) >= 0.3 THEN 'content'
WHEN similarity(left(coalesce(d.extracted_text, ''), 2000), :q) >= 0.3 THEN 'content'
ELSE 'fts'
END AS match_reason,
d.material_type, d.jurisdiction, d.published_date
+18 -7
View File
@@ -32,6 +32,8 @@ from typing import TYPE_CHECKING, Literal
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import async_session
from . import query_analyzer, query_rewriter
from .fusion_service import (
DEFAULT_FUSION,
@@ -188,6 +190,7 @@ async def run_search(
snapshot_chunk_id_max=snapshot_chunk_id_max,
reranker_backend=reranker_backend,
rewrite_backend=rewrite_backend,
axis=axis,
)
timing: dict[str, float] = {}
@@ -536,6 +539,7 @@ async def search_with_rewrite(
snapshot_chunk_id_max: int | None,
reranker_backend: str | None,
rewrite_backend: str,
axis: "AxisFilter | None" = None,
) -> PipelineResult:
"""Phase 2Q multi-query retrieval 합성 path (plan v6 §5.5).
@@ -579,13 +583,20 @@ async def search_with_rewrite(
async def _variant_retrieve(
v: str,
) -> "tuple[list[SearchResult], list[SearchResult], dict[int, list[SearchResult]]]":
text = await search_text(session, v, per_variant_k)
raw_chunks = await search_vector(
session, v, per_variant_k,
embedding_backend=embedding_backend,
snapshot_doc_id_max=snapshot_doc_id_max,
snapshot_chunk_id_max=snapshot_chunk_id_max,
)
# 변형별 독립 AsyncSession (fan-out). 공유 session 을 asyncio.gather 로 동시
# execute 에 넘기면 SQLAlchemy async 가 'another operation in progress' 로
# 부하 의존적 비결정 크래시 — variant 마다 독립 연결로 분리한다.
# axis(material_type/jurisdiction/year) 도 single-query path 와 동일하게 전달
# (rewrite 경로가 axis 필터를 조용히 누락하던 결함 수정).
async with async_session() as vsession:
text = await search_text(vsession, v, per_variant_k, axis=axis)
raw_chunks = await search_vector(
vsession, v, per_variant_k,
embedding_backend=embedding_backend,
snapshot_doc_id_max=snapshot_doc_id_max,
snapshot_chunk_id_max=snapshot_chunk_id_max,
axis=axis,
)
vector, chunks_by_doc = compress_chunks_to_docs(raw_chunks, per_variant_k)
return text, vector, chunks_by_doc
+12 -8
View File
@@ -95,8 +95,10 @@ except FileNotFoundError:
)
# ─── in-memory LRU (FIFO 근사, query_analyzer 패턴 복제) ─
_CACHE: dict[str, SynthesisResult] = {}
# ─── in-memory 캐시 (FIFO eviction + TTL, query_analyzer 패턴 복제) ─
# R10: (ts, result) 저장 — TTL 미적용으로 원문 수정돼도 CACHE_MAXSIZE 찰 때까지 stale answer
# 반환하던 결함 수정. query_rewriter 의 expire_at TTL enforce 정본 복제.
_CACHE: dict[str, tuple[float, SynthesisResult]] = {}
def _model_version() -> str:
@@ -122,10 +124,11 @@ def get_cached(query: str, chunk_ids: list[int], backend_name: str = "gemma-macm
entry = _CACHE.get(key)
if entry is None:
return None
# TTL 체크는 elapsed_ms 를 악용할 수 없으므로 별도 저장
# 여기서는 단순 policy 로 처리: entry 가 있으면 반환 (eviction 은 FIFO 시점)
# 정확한 TTL 이 필요하면 (ts, result) tuple 로 저장해야 함.
return entry
ts, result = entry
if time.time() - ts > CACHE_TTL:
_CACHE.pop(key, None) # 만료 — 삭제 후 miss
return None
return result
def _should_cache(result: SynthesisResult) -> bool:
@@ -143,8 +146,9 @@ def set_cached(query: str, chunk_ids: list[int], result: SynthesisResult, backen
if not _should_cache(result):
return
key = _cache_key(query, chunk_ids, backend_name)
now = time.time()
if key in _CACHE:
_CACHE[key] = result
_CACHE[key] = (now, result)
return
if len(_CACHE) >= CACHE_MAXSIZE:
try:
@@ -152,7 +156,7 @@ def set_cached(query: str, chunk_ids: list[int], result: SynthesisResult, backen
_CACHE.pop(oldest, None)
except StopIteration:
pass
_CACHE[key] = result
_CACHE[key] = (now, result)
def cache_stats() -> dict[str, int]:
+2 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import os
from collections.abc import AsyncIterator
from pathlib import Path
@@ -42,7 +43,7 @@ class LocalBackend(StorageBackend):
to_read = _STREAM_CHUNK if remaining is None else min(_STREAM_CHUNK, remaining)
if to_read <= 0:
break
data = f.read(to_read)
data = await asyncio.to_thread(f.read, to_read)
if not data:
break
yield data
+9 -6
View File
@@ -252,12 +252,15 @@ async def gather_explanation_context(
client = AIClient()
query = _build_query(question)
try:
# 두 조회 병렬화 (rerank 호출이 별개라 lock 충돌 없음)
docs, questions = await asyncio.gather(
_gather_document_evidence(session, user_id, question.study_topic_id, query, client),
_gather_question_evidence(
session, user_id, question.study_topic_id, question.id, query, client
),
# 같은 AsyncSession 을 asyncio.gather 로 동시 execute 에 넘기면 SQLAlchemy async 가
# 'another operation in progress' 로 부하 의존적 비결정 크래시(이전 주석 'lock 충돌
# 없음' 은 rerank HTTP 만 보고 DB execute 동시성을 간과한 오인). 백그라운드 prefetch
# 라 순차 직렬화 — 사용자 대면 rewrite 경로(독립 세션 fan-out)와는 다른 처방.
docs = await _gather_document_evidence(
session, user_id, question.study_topic_id, query, client
)
questions = await _gather_question_evidence(
session, user_id, question.study_topic_id, question.id, query, client
)
return ExplanationContext(documents=docs, questions=questions)
finally:
+7 -3
View File
@@ -238,9 +238,13 @@ async def gather_subject_note_context(
client = AIClient()
query = _build_query(subject, scope)
try:
docs, questions = await asyncio.gather(
_gather_document_evidence(session, user_id, study_topic_id, query, client),
_gather_question_evidence(session, user_id, study_topic_id, subject, scope, query, client),
# 같은 AsyncSession 동시 execute 회피 — 순차 직렬화(백그라운드 prefetch).
# explanation_rag.gather_explanation_context 와 동형(R2 공유세션 동시성 수정).
docs = await _gather_document_evidence(
session, user_id, study_topic_id, query, client
)
questions = await _gather_question_evidence(
session, user_id, study_topic_id, subject, scope, query, client
)
return SubjectNoteContext(documents=docs, questions=questions)
finally:
+378
View File
@@ -0,0 +1,378 @@
"""arXiv 키워드 필터 수집기 — B-3 PR2 (plan safety-library-b3-1).
bespoke arXiv API(Atom) 수집기. 카테고리 RSS 통째(firehose) 아니라
cat:{category} AND (abs:키워드 ...) 안전/신뢰성/압력용기 관련분만 좁혀 수집한다.
- signal-only: 초록만 색인(embed+chunk), summarize 절대 미enqueue 맥미니 Qwen 무접촉.
- DOI 보유 paper.doi(서지 holder, partial-unique 인덱스 진입). 없으면 versionless arXiv id
dedup(향후 PR4 reconcile DOI 백필).
- etiquette: 요청 3s + HTTP 429 지수 백오프. 카테고리별 submittedDate 워터마크로 증분.
- per-run insert cap(_RUN_CAP) 광역 수집이 GPU bge-m3 embed 큐를 범람시키지 않게(적대리뷰 A major).
잔여는 silent-cap 금지(csb idiom): 누락 건수 로깅.
- keyless. enabled=False news_sources (6h 뉴스 사이클 비대상) + main.py CronTrigger(자체 폴링).
- arXiv API https 필수(http=301). UA = CRAWL_UA.
"""
import asyncio
import hashlib
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime, timezone
import httpx
from sqlalchemy import select
from core.crawl_politeness import CRAWL_UA
from core.database import async_session
from core.utils import setup_logger
from models.document import Document
from models.news_source import NewsSource
from models.queue import enqueue_stage
from services.papers.doi import arxiv_doi, normalize_doi
from services.papers.holder import find_paper_holder
from workers.news_collector import (
FeedError,
_get_or_create_health,
_record_failure,
_record_success,
)
logger = setup_logger("arxiv_collector")
_ARXIV_API = "https://export.arxiv.org/api/query"
_SOURCE_NAME = "arXiv 안전·공학 (keyword)"
# 신규 카테고리만 — 기존 RSS 행(id 62 physics.app-ph, id 64 cond-mat.mtrl-sci)과 비중복.
_CATEGORIES = (
"eess.SY", # systems & control
"physics.flu-dyn", # 유체 — 압력/유동
"physics.comp-ph", # 전산물리
"math.OC", # 최적화·제어
"math.NA", # 수치해석 (FEM 등)
"stat.AP", # 응용통계 — 신뢰성
"cs.CE", # computational engineering
)
# 압력용기·공정안전·구조건전성 도메인 키워드(abs: OR 게이트). 좁게 유지 = 관련성↑·볼륨↓ (튜너블).
_KEYWORDS = (
"pressure vessel",
"process safety",
"structural integrity",
"fracture mechanics",
"fatigue life",
"corrosion",
)
_RUN_CAP = 80 # 1회 run 신규 적재 상한(임베드 큐 보호). bulk 시 해제.
_PAGE_SIZE = 50 # max_results per request
_MAX_PAGES_PER_CAT = 4 # 카테고리당 최대 페이지(증분이라 보통 1페이지에 워터마크 도달)
_REQ_SLEEP = 3.0 # arXiv etiquette ≥3s
_MAX_RETRY = 4
_BACKOFF_BASE = 5.0
_NS = {
"a": "http://www.w3.org/2005/Atom",
"arxiv": "http://arxiv.org/schemas/atom",
"opensearch": "http://a9.com/-/spec/opensearch/1.1/",
}
_ABS_ID_RE = re.compile(r"arxiv\.org/abs/(.+?)(v\d+)?$")
_WS_RE = re.compile(r"\s+")
# ───────────────────────── 순수 파서 (fixture 단위 테스트 대상) ─────────────────────────
@dataclass
class ArxivEntry:
arxiv_id: str # versionless, 예: "1209.2405"
version: str | None # "v1" 또는 None
title: str
summary: str # 초록
published: datetime | None
doi: str | None # normalize_doi 적용
journal_ref: str | None
primary_category: str | None
categories: list = field(default_factory=list)
abs_url: str | None = None
pdf_url: str | None = None
def _clean(text: str | None) -> str:
return _WS_RE.sub(" ", text).strip() if text else ""
def _parse_id(raw_id: str | None) -> tuple[str | None, str | None]:
"""'http://arxiv.org/abs/1209.2405v1' → ('1209.2405', 'v1'). versionless id 가 dedup 키."""
m = _ABS_ID_RE.search((raw_id or "").strip())
if not m:
return None, None
return m.group(1), m.group(2)
def _parse_dt(s: str | None) -> datetime | None:
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except ValueError:
return None
def build_search_query(category: str, keywords=_KEYWORDS) -> str:
"""cat:{category} AND (abs:kw1 OR abs:"kw with space" ...). 공백 키워드는 따옴표 구절."""
kw = " OR ".join(f'abs:"{k}"' if " " in k else f"abs:{k}" for k in keywords)
return f"cat:{category} AND ({kw})"
def parse_arxiv_feed(xml_text: str) -> tuple[int, list[ArxivEntry]]:
"""arXiv Atom 응답 → (total_results, [ArxivEntry]). 순수 함수."""
root = ET.fromstring(xml_text)
raw_total = root.findtext("opensearch:totalResults", default="0", namespaces=_NS)
try:
total = int(raw_total)
except (TypeError, ValueError):
total = 0
entries: list[ArxivEntry] = []
for e in root.findall("a:entry", _NS):
aid, ver = _parse_id(e.findtext("a:id", namespaces=_NS))
if not aid:
continue
prim = e.find("arxiv:primary_category", _NS)
abs_url = pdf_url = None
for ln in e.findall("a:link", _NS):
if ln.get("rel") == "alternate" and (ln.get("type") or "").startswith("text/html"):
abs_url = ln.get("href")
elif ln.get("title") == "pdf":
pdf_url = ln.get("href")
entries.append(ArxivEntry(
arxiv_id=aid,
version=ver,
title=_clean(e.findtext("a:title", namespaces=_NS)),
summary=_clean(e.findtext("a:summary", namespaces=_NS)),
published=_parse_dt(e.findtext("a:published", namespaces=_NS)),
doi=normalize_doi(e.findtext("arxiv:doi", namespaces=_NS)),
journal_ref=_clean(e.findtext("arxiv:journal_ref", namespaces=_NS)) or None,
primary_category=prim.get("term") if prim is not None else None,
categories=[c.get("term") for c in e.findall("a:category", _NS)],
abs_url=abs_url,
pdf_url=pdf_url,
))
return total, entries
# ───────────────────────── 적재 (DB — PR2 라이브 검증) ─────────────────────────
def _build_paper_meta(source: NewsSource, entry: ArxivEntry, doi: str | None) -> dict:
"""extract_meta — license + source + paper 식별. 서지 holder 는 paper.doi(있으면) 보유."""
paper: dict = {"arxiv_id": entry.arxiv_id}
if doi:
paper["doi"] = doi # partial-unique 인덱스 진입 (교차소스 dedup)
if entry.journal_ref:
paper["journal_ref"] = entry.journal_ref
if entry.primary_category:
paper["primary_category"] = entry.primary_category
meta: dict = {
"source_id": source.id,
"source_name": source.name,
"source_region": "INT", # arXiv = 국제 preprint. paper.jurisdiction 은 NULL 유지(A-2).
"paper": paper,
# arXiv 기본 라이선스 = 비배포(보수적). restricted 부재 → 초록은 RAG 사용 가능.
# (명시 CC 검출은 OAI 인터페이스 필요 — Atom API 미포함, PR 후속/관찰.)
"license": {"scheme": "arxiv", "redistribute": False, "attribution": "arXiv"},
}
if entry.published:
meta["published_at"] = entry.published.isoformat()
return meta
async def _ingest_entry(session, source: NewsSource, entry: ArxivEntry) -> bool:
"""1건 적재. 반환 = 신규 여부. signal-only(embed+chunk, summarize 없음)."""
arxiv_hash = hashlib.sha256(f"arxiv|{entry.arxiv_id}".encode()).hexdigest()[:32]
# 재수집 dedup(arXiv id) — .first()(다중행 방어)
dup = await session.execute(
select(Document.id).where(Document.file_hash == arxiv_hash).limit(1)
)
if dup.scalars().first():
return False
# arXiv canonical DOI = 저널 DOI 또는 arXiv DataCite DOI(프리프린트도 paper.doi 보유 → PR3 와 dedup)
doi = entry.doi or arxiv_doi(entry.arxiv_id)
# 교차소스 dedup(DOI holder 이미 존재 — partial-unique 인덱스 백스톱 선제 회피)
if doi and await find_paper_holder(session, doi):
return False
body = entry.summary or entry.title
doc = Document(
file_path=f"crawl/arxiv/{entry.arxiv_id}",
file_hash=arxiv_hash,
file_format="article",
file_size=len(body.encode()),
file_type="note",
title=entry.title,
extracted_text=f"{entry.title}\n\n{body}",
extracted_at=datetime.now(timezone.utc),
extractor_version="arxiv-api-signal",
md_status="skipped",
md_extraction_error="arXiv abstract: signal-only, markdown 비대상",
source_channel="crawl",
data_origin="external",
edit_url=entry.abs_url,
review_status="approved",
material_type="paper",
jurisdiction=None, # paper = NULL 불변(A-2). 지역은 extract_meta.paper.source_region.
published_date=entry.published.date() if entry.published else None,
extract_meta=_build_paper_meta(source, entry, doi),
)
session.add(doc)
await session.flush()
# signal-only: 검색 색인만. summarize/fulltext 절대 enqueue 안 함(맥미니 큐 무접촉).
await enqueue_stage(session, doc.id, "embed")
await enqueue_stage(session, doc.id, "chunk")
return True
async def _get_or_create_source(session) -> NewsSource:
result = await session.execute(
select(NewsSource).where(NewsSource.name == _SOURCE_NAME)
)
source = result.scalars().first()
if source is None:
source = NewsSource(
name=_SOURCE_NAME, feed_url=_ARXIV_API, feed_type="atom",
fetch_method="signal-only", fulltext_policy="none",
source_channel="crawl", category="Engineering", language="en",
country=None, # paper → jurisdiction NULL (country 미전파)
material_type="paper",
license_scheme="arxiv", license_redistribute=False,
enabled=False, # 6h 뉴스 사이클 비대상 — 본 워커가 자체 폴링
)
session.add(source)
await session.flush()
return source
def _watermark(source: NewsSource, category: str) -> datetime | None:
raw = (source.selector_override or {}).get("arxiv_watermark", {}).get(category)
if not raw:
return None
return _parse_dt(raw)
def _set_watermark(source: NewsSource, category: str, value: datetime) -> None:
cfg = dict(source.selector_override or {})
wm = dict(cfg.get("arxiv_watermark") or {})
wm[category] = value.isoformat()
cfg["arxiv_watermark"] = wm
source.selector_override = cfg # JSONB 변경 감지 위해 재할당
async def _fetch(client: httpx.AsyncClient, query: str, start: int) -> str:
params = {
"search_query": query, "start": start, "max_results": _PAGE_SIZE,
"sortBy": "submittedDate", "sortOrder": "descending",
}
for attempt in range(_MAX_RETRY):
resp = await client.get(_ARXIV_API, params=params)
if resp.status_code == 429:
await asyncio.sleep(_BACKOFF_BASE * (2 ** attempt))
continue
resp.raise_for_status()
return resp.text
raise FeedError(f"arXiv 429 재시도 초과: {query[:48]}")
async def run(bulk: bool = False, limit: int = 0) -> None:
"""daily 진입점(스케줄러). bulk/limit 은 CLI 전용(bulk=cap 해제·깊은 페이징)."""
now = datetime.now(timezone.utc)
async with async_session() as session:
source = await _get_or_create_source(session)
await session.commit()
source_id = source.id
run_cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
inserted = 0
seen = 0
failures: list[str] = []
async with httpx.AsyncClient(
timeout=30.0, headers={"User-Agent": CRAWL_UA}, follow_redirects=True
) as client:
for category in _CATEGORIES:
if inserted >= run_cap:
break
query = build_search_query(category)
async with async_session() as session:
src = await session.get(NewsSource, source_id)
watermark = _watermark(src, category)
newest_seen: datetime | None = None
capped = False # 이번 run 이 cap 으로 카테고리 중도 절단됐는지 (R4)
max_pages = (10**6 if bulk else _MAX_PAGES_PER_CAT)
try:
for page in range(max_pages):
if inserted >= run_cap:
capped = True
break
xml_text = await _fetch(client, query, page * _PAGE_SIZE)
total, entries = parse_arxiv_feed(xml_text)
if not entries:
break
stop = False
for entry in entries:
seen += 1
if entry.published:
newest_seen = max(newest_seen or entry.published, entry.published)
# 증분: 워터마크 이하 도달 시 이 카테고리 종료(이미 본 구간)
if watermark and not bulk and entry.published <= watermark:
stop = True
break
async with async_session() as session:
src = await session.get(NewsSource, source_id)
if await _ingest_entry(session, src, entry):
inserted += 1
await session.commit()
else:
await session.rollback()
if inserted >= run_cap:
capped = True
break
await asyncio.sleep(_REQ_SLEEP)
if stop or (page + 1) * _PAGE_SIZE >= total:
break
# 카테고리 워터마크 전진 — cap 으로 절단된 run 은 미전진 (R4).
# 절단 시 newest_seen 으로 전진하면 [oldest-ingested, 옛 watermark] 사이
# 미적재 항목이 다음 run 의 watermark 필터(entry.published <= watermark)에
# 영구 배제(silent data loss). 미전진하면 다음 run 이 최신부터 재스캔하며
# 적재분은 dedup-skip(_ingest_entry False, cap 미소모)하고 gap 까지 내려가
# 이어 적재 → 백로그가 run 당 cap 씩 소화(livelock 회피). bulk 은 cap 무관.
if newest_seen and not capped:
async with async_session() as session:
src = await session.get(NewsSource, source_id)
_set_watermark(src, category, newest_seen)
await session.commit()
except (httpx.HTTPError, FeedError, ET.ParseError) as e:
msg = f"[{category}] {e or repr(e)}"
logger.error(f"[arxiv] {msg}")
failures.append(msg)
async with async_session() as session:
health = await _get_or_create_health(session, source_id)
if failures and inserted == 0:
_record_failure(health, "; ".join(failures)[:500], now)
else:
_record_success(health, inserted, False, now)
await session.commit()
deferred = "" if inserted < run_cap else f" (cap {run_cap} 도달 — 잔여는 다음 run 이월)"
logger.info(
f"[arxiv] {len(_CATEGORIES)}개 카테고리 스캔 {seen}건 → 신규 {inserted}{deferred}"
+ (f" / 실패 {len(failures)}" if failures else "")
)
if __name__ == "__main__":
# CLI = 수동/백필 전용. --bulk = cap 해제·깊은 페이징, --limit N = 상한 N(라이브 검증용).
import argparse
parser = argparse.ArgumentParser(description="arXiv 안전·공학 키워드 수집기")
parser.add_argument("--bulk", action="store_true", help="cap 해제 + 깊은 페이징 백필")
parser.add_argument("--limit", type=int, default=0, help="신규 적재 상한(0=기본 cap)")
args = parser.parse_args()
asyncio.run(run(bulk=args.bulk, limit=args.limit))
+72
View File
@@ -0,0 +1,72 @@
"""검토 대기(review_status='pending') 자동 검토 — 고신뢰 자동승인 + 저신뢰 수동 잔류.
classify 이미 부여한 ai_confidence 게이트로 사용 **-LLM 호출 없음**(대량 2천건에
맥미니/GPU 부하 0, 분류 confidence AI 자기-신뢰도). ai_domain 보유 +
ai_confidence >= THRESHOLD pending 문서를 review_status='approved' 자동승인하고
audit(source_metadata.auto_reviewed) 남긴다. 저신뢰/미분류는 그대로 두어 수동 검토
(/inbox) 잔류.
설계 근거(게이트 실측):
- review_status inbox 카운트(dashboard) + 수집기 ingest 에서만 사용, 검색/RAG/digest/
ask 경로 필터에 **미사용** 자동승인은 노출(검색결과) 변동 없이 검토 큐만 비운다.
- pending 2,161 ai_suggestion 보유 0 큐는 '분류 변경 제안'(accept_suggestion)
아니라 '미검토 자동분류'. 승인 = review_status 플립.
배치·interval 점진 드레인(관찰·중단 가능). 되돌리기 = source_metadata.auto_reviewed 마커로
대상 식별 review_status='pending' 복원.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from core.database import async_session
from models.document import Document
logger = logging.getLogger(__name__)
# 고신뢰 자동승인 바 (튜닝 가능). 실측 분포: >=0.9 → 1,981건 자동 / 저신뢰·미분류 ~180건 수동 잔류.
_CONFIDENCE_THRESHOLD = 0.9
# 한 틱 처리량 — 순수 DB UPDATE(LLM 없음)라 가볍지만, 2천 행 일괄 락 회피 위해 배치.
_BATCH = 300
async def run() -> None:
"""pending 고신뢰 문서를 배치 자동승인 (interval job, no-arg)."""
async with async_session() as session:
rows = (
await session.execute(
select(Document)
.where(
Document.review_status == "pending",
Document.deleted_at.is_(None),
Document.ai_domain.isnot(None),
Document.ai_confidence.isnot(None),
Document.ai_confidence >= _CONFIDENCE_THRESHOLD,
)
.order_by(Document.id)
.limit(_BATCH)
)
).scalars().all()
if not rows:
return
now = datetime.now(timezone.utc)
for doc in rows:
doc.review_status = "approved"
doc.source_metadata = {
**(doc.source_metadata or {}),
"auto_reviewed": {
"by": "confidence_gate",
"confidence": float(doc.ai_confidence),
"threshold": _CONFIDENCE_THRESHOLD,
"at": now.isoformat(),
},
}
doc.updated_at = now
await session.commit()
logger.info(
"auto_review: approved %d pending docs (ai_confidence >= %.2f)",
len(rows),
_CONFIDENCE_THRESHOLD,
)
+10 -2
View File
@@ -9,12 +9,15 @@ import asyncio
from datetime import date
from core.config import settings
from core.database import engine as db_engine
from core.utils import setup_logger
from services.background_jobs import finish_job, start_job
from services.briefing.pipeline import run_briefing_pipeline
logger = setup_logger("briefing_worker")
PIPELINE_HARD_CAP = 600
# 2026-06-15: config 단일소스 (digest 와 공유 키). 구 600s = 빠른 Gemma 기준.
PIPELINE_HARD_CAP = settings.digest_pipeline_hard_cap_s
async def run(target_date: date | None = None) -> dict | None:
@@ -26,19 +29,24 @@ async def run(target_date: date | None = None) -> dict | None:
if "briefing" in settings.pipeline_held_stages:
logger.info("[briefing] 보류 (pipeline.held_stages) — 이번 실행 skip")
return None
# 보드 가시화: 큐 밖 cron 생성 작업이라 background_jobs 로 노출 (best-effort, 맥미니 귀속)
job_id = await start_job(db_engine, "morning_briefing", label="조간 브리핑 생성")
try:
result = await asyncio.wait_for(
run_briefing_pipeline(target_date),
run_briefing_pipeline(target_date, job_id=job_id),
timeout=PIPELINE_HARD_CAP,
)
await finish_job(db_engine, job_id, state="done")
logger.info(f"[briefing] 워커 완료: {result}")
return result
except asyncio.TimeoutError:
await finish_job(db_engine, job_id, state="failed", error=f"HARD CAP {PIPELINE_HARD_CAP}s 초과")
logger.error(
f"[briefing] HARD CAP {PIPELINE_HARD_CAP}s 초과 — 워커 강제 중단. "
f"기존 briefing 은 commit 시점에만 갱신되므로 그대로 유지됨."
)
except Exception as e:
await finish_job(db_engine, job_id, state="failed", error=str(e)[:300])
logger.exception(f"[briefing] 워커 실패: {e}")
return None
+14 -9
View File
@@ -272,15 +272,20 @@ async def _lookup_news_source(
if not source_name:
return None, None, None
# news_sources에서 이름이 일치하는 레코드 찾기 (prefix match)
result = await session.execute(select(NewsSource))
sources = result.scalars().all()
for src in sources:
if source_name and (
src.name.split(" ")[0] == source_name
or src.name.startswith(source_name + " ")
):
return src.country, src.name, src.language
# news_sources prefix 매칭 — R10: 전체 로드+Python 루프 대신 DB 필터 푸시다운.
# (name == source_name) OR (name 이 "source_name " 로 시작) = 기존 split[0]==source_name 동치
# (첫 토큰 일치 = 정확일치 또는 'source_name ' prefix). autoescape 로 %/_ 안전.
result = await session.execute(
select(NewsSource)
.where(
(NewsSource.name == source_name)
| NewsSource.name.startswith(source_name + " ", autoescape=True)
)
.limit(1)
)
src = result.scalars().first()
if src is not None:
return src.country, src.name, src.language
logger.warning(
f"[chunk] news_source 매핑 실패: doc_id={doc.id} ai_sub_group={source_name!r} "
+12 -1
View File
@@ -411,6 +411,15 @@ async def process(
logger.info(f"doc {document_id}: devonagent → classify skip")
return
# 논문(material_type='paper') — 요약/분류 LLM 스킵(맥미니 큐 무접촉, B-3 signal-only 유지).
# embed/chunk/markdown 은 queue_consumer 가 chain (early-return 후에도 다음 stage enqueue).
if doc.material_type == "paper":
if not doc.ai_domain:
doc.ai_domain = "논문"
await session.commit()
logger.info(f"doc {document_id}: paper → classify skip (no summarize)")
return
if not doc.extracted_text:
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
@@ -554,7 +563,9 @@ async def process(
doc.facet_doctype = ai_doctype
# ─── ai_suggestion 저장 (자료실 승인 대기함 제안, §1) ───
if ai_doctype in LIBRARY_SUGGESTION_DOCTYPES:
# R9: 기존 제안(material_type 제안 등) 우선 — doc.ai_suggestion is None 가드 추가
# (material 제안 블록과 대칭). 없으면 거래문서 제안이 기존 제안을 clobber('기존 제안 우선' 위반).
if ai_doctype in LIBRARY_SUGGESTION_DOCTYPES and doc.ai_suggestion is None:
year = doc.facet_year or datetime.now(timezone.utc).year
doc.ai_suggestion = {
"proposed_category": "library",
+31 -19
View File
@@ -5,7 +5,8 @@ DEVONthink/OmniFocus → PostgreSQL/CalDAV 쿼리로 전환.
SMTP 발송은 2026-06-10 제거 ( 번도 전달 성공한 없는 기능 폐기 결정).
"""
from datetime import datetime, timezone
import asyncio
from datetime import datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from pathlib import Path
@@ -20,17 +21,36 @@ from models.queue import ProcessingQueue
logger = setup_logger("daily_digest")
def _write_and_rotate(digest_dir: Path, today: str, markdown: str) -> Path:
"""digest 파일 저장 + 90일 초과 아카이브 이동 (blocking — caller 가 to_thread, R8)."""
digest_dir.mkdir(parents=True, exist_ok=True)
digest_path = digest_dir / f"{today}_digest.md"
digest_path.write_text(markdown, encoding="utf-8")
archive_dir = digest_dir / "archive"
archive_dir.mkdir(exist_ok=True)
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
for old in digest_dir.glob("*_digest.md"):
if old.stat().st_mtime < cutoff:
old.rename(archive_dir / old.name)
return digest_path
async def run():
"""일일 다이제스트 생성 + 저장 + 발송"""
# KST 기준 오늘 (cron 이 KST timezone fix 후 20:00 KST 에 fire). date 객체로 비교 — Document.created_at::date 와 직접 매칭.
today = datetime.now(ZoneInfo("Asia/Seoul")).date()
# KST 기준 오늘 (cron 이 KST timezone fix 후 20:00 KST 에 fire).
kst = ZoneInfo("Asia/Seoul")
today = datetime.now(kst).date()
# KST 하루를 UTC 범위로 변환 (R8) — func.date(created_at)는 pg TimeZone(UTC) 기준 날짜라
# KST 0~9시 생성 문서(UTC 전날)가 누락되던 경계 버그. created_at(UTC저장) 범위 비교로.
start_utc = datetime.combine(today, time.min, tzinfo=kst).astimezone(timezone.utc)
end_utc = start_utc + timedelta(days=1)
sections = []
async with async_session() as session:
# ─── 1. 오늘 추가된 문서 ───
added = await session.execute(
select(Document.ai_domain, func.count(Document.id))
.where(func.date(Document.created_at) == today)
.where(Document.created_at >= start_utc, Document.created_at < end_utc)
.group_by(Document.ai_domain)
)
added_rows = added.all()
@@ -49,7 +69,8 @@ async def run():
select(Document.title)
.where(
Document.source_channel == "law_monitor",
func.date(Document.created_at) == today,
Document.created_at >= start_utc,
Document.created_at < end_utc,
)
)
law_rows = law_docs.scalars().all()
@@ -66,7 +87,8 @@ async def run():
select(func.count(Document.id))
.where(
Document.source_channel == "email",
func.date(Document.created_at) == today,
Document.created_at >= start_utc,
Document.created_at < end_utc,
)
)
email_total = email_count.scalar() or 0
@@ -101,7 +123,7 @@ async def run():
)
failed_count = failed.scalar() or 0
if failed_count > 0:
section += f"\n⚠️ **실패 {failed_count}건** — 수동 확인 필요\n"
section += f"\n**[주의] 실패 {failed_count}건** — 수동 확인 필요\n"
sections.append(section)
# ─── 5. Inbox 미분류 ───
@@ -119,18 +141,8 @@ async def run():
markdown += "\n".join(sections)
markdown += f"\n---\n*생성: {datetime.now(timezone.utc).isoformat()}*\n"
# ─── NAS 저장 ───
# ─── NAS 저장 + 90일 아카이브 (blocking 파일 I/O off-thread, R8/R5 일관) ───
digest_dir = Path(settings.nas_mount_path) / "PKM" / "Archive" / "digests"
digest_dir.mkdir(parents=True, exist_ok=True)
digest_path = digest_dir / f"{today}_digest.md"
digest_path.write_text(markdown, encoding="utf-8")
# ─── 90일 초과 아카이브 ───
archive_dir = digest_dir / "archive"
archive_dir.mkdir(exist_ok=True)
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
for old in digest_dir.glob("*_digest.md"):
if old.stat().st_mtime < cutoff:
old.rename(archive_dir / old.name)
digest_path = await asyncio.to_thread(_write_and_rotate, digest_dir, str(today), markdown)
logger.info(f"다이제스트 생성 완료: {digest_path}")
+6 -2
View File
@@ -144,9 +144,13 @@ async def process(
logger.info(f"[deep] id={document_id} 맥북 일시 불가 — 보류 (deferred)")
raise
except Exception as exc:
# 호출 실패(네트워크/API 5xx 등)는 삼키지 않고 전파 (R3) — queue_consumer 가
# attempts 소진까지 재시도 후 status=failed(dead-letter)로 가시화한다. 삼키면
# worker_fn 이 정상 반환 → 큐가 completed 로 확정 → ai_detail_summary 영구 누락 +
# tier 가 triage 에 고착(silent 영구 손실). extract/marker/fulltext/stt 정본과 일치.
# 완주 전 doc 쓰기(168~)는 일어나지 않으므로 부분 쓰기 0 (sleep-안전).
logger.warning(f"[deep] 호출 실패 id={document_id} model={used_cfg.model}: {exc}")
parse_error = "call_failed"
raw = ""
raise
finally:
await client.close()
+10 -2
View File
@@ -11,12 +11,15 @@ global_digests / digest_topics 테이블에 저장한다.
import asyncio
from core.config import settings
from core.database import engine as db_engine
from core.utils import setup_logger
from services.background_jobs import finish_job, start_job
from services.digest.pipeline import run_digest_pipeline
logger = setup_logger("digest_worker")
PIPELINE_HARD_CAP = 600 # 10분 hard cap
# 2026-06-15: config 단일소스 (구 600s = 빠른 Gemma 기준, Qwen 27B 교체 후 누락 → 초과).
PIPELINE_HARD_CAP = settings.digest_pipeline_hard_cap_s
async def run() -> None:
@@ -28,19 +31,24 @@ async def run() -> None:
if "digest" in settings.pipeline_held_stages:
logger.info("[global_digest] 보류 (pipeline.held_stages) — 이번 실행 skip")
return
# 보드 가시화: 큐 밖 cron 생성 작업이라 background_jobs 로 노출 (best-effort, 맥미니 귀속)
job_id = await start_job(db_engine, "global_digest", label="글로벌 다이제스트 생성")
try:
result = await asyncio.wait_for(
run_digest_pipeline(),
run_digest_pipeline(job_id=job_id),
timeout=PIPELINE_HARD_CAP,
)
await finish_job(db_engine, job_id, state="done")
logger.info(f"[global_digest] 워커 완료: {result}")
except asyncio.TimeoutError:
await finish_job(db_engine, job_id, state="failed", error=f"HARD CAP {PIPELINE_HARD_CAP}s 초과")
logger.error(
f"[global_digest] HARD CAP {PIPELINE_HARD_CAP}s 초과 — 워커 강제 중단. "
f"기존 digest 는 commit 시점에만 갱신되므로 그대로 유지됨. "
f"다음 cron 실행에서 재시도."
)
except Exception as e:
await finish_job(db_engine, job_id, state="failed", error=str(e)[:300])
logger.exception(f"[global_digest] 워커 실패: {e}")
+65
View File
@@ -0,0 +1,65 @@
"""delete_file=true 로 요청된 문서의 NAS 원본을 grace 후 물리삭제 (R7 retention sweep).
purge_requested_at 마커 기준(deleted_at 아님 일반 soft-delete/숨김은 파일 보존, undelete
가능). grace(30) 경과 + 파일 존재 unlink + AUDIT 로그. 파일 존재 체크로 멱등
(재실행 이미 삭제된 skip). 요청 경로(DELETE) 동기 비가역 op 0 모두 cron 으로.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import select
from core.config import settings
from core.database import async_session
from models.document import Document
logger = logging.getLogger("purge_sweep")
PURGE_GRACE_DAYS = 30
def _unlink_if_exists(p: Path) -> bool:
"""파일이 있으면 unlink (blocking — caller 가 to_thread). 존재 여부 반환(멱등)."""
if p.exists():
p.unlink()
return True
return False
async def run() -> int:
"""purge 요청 + grace 경과 문서의 NAS 원본 물리삭제. 삭제 건수 반환."""
cutoff = datetime.now(timezone.utc) - timedelta(days=PURGE_GRACE_DAYS)
async with async_session() as session:
rows = (
await session.execute(
select(Document.id, Document.file_path, Document.purge_requested_at).where(
Document.purge_requested_at.is_not(None),
Document.purge_requested_at < cutoff,
Document.file_path.is_not(None),
)
)
).all()
purged = 0
for doc_id, file_path, requested_at in rows:
nas_path = Path(settings.nas_mount_path) / file_path
try:
existed = await asyncio.to_thread(_unlink_if_exists, nas_path)
if existed:
purged += 1
# AUDIT — 물리삭제 기록 (가시화). doc_id / 경로 / 요청일 / grace.
logger.warning(
"PURGE doc_id=%s file=%s requested_at=%s grace_days=%s",
doc_id,
file_path,
requested_at.isoformat() if requested_at else None,
PURGE_GRACE_DAYS,
)
except OSError as e:
logger.error("PURGE 실패 doc_id=%s file=%s: %s", doc_id, file_path, e)
if purged:
logger.info("[purge_sweep] NAS 원본 %d건 물리삭제 (grace %d일)", purged, PURGE_GRACE_DAYS)
return purged
+14 -3
View File
@@ -17,6 +17,7 @@ Web/Blog ingest (devonagent 트랙, plan db-snuggly-petal.md):
- sidecar (.json) 누락 : skip 하고 ingest, web_meta.sidecar_missing=true
"""
import asyncio
import hashlib
import json
from pathlib import Path
@@ -136,6 +137,10 @@ def _canonicalize_url(url: str) -> str:
같은 글의 utm 변형 (`?utm_source=foo`) fragment 변형 (`#section`) 을
row 수렴시키기 위해 file_hash 산출 반드시 거친다.
R11c: news_collector._normalize_url(news 채널) 의도적으로 다르다 이쪽(web_clip)
query-sort/trailing-slash/소문자화로 공격적 정규화하지만, news 쪽은 query-식별 사이트의
별개 기사 붕괴 방지를 위해 보수적이다. 함수 통합 금지(채널별 dedup 의도가 다름).
"""
if not url:
return ""
@@ -246,7 +251,8 @@ async def watch_inbox():
async with async_session() as session:
# ─── Web/ 트랙 (devonagent) — DEVONthink Smart Rule 이 떨군 .html 만 진입 ───
if web_root.exists():
for file_path in web_root.rglob("*.html"):
# rglob NFS 디렉토리 walk(blocking stat 다발)를 off-thread 로 수집 (R5).
for file_path in await asyncio.to_thread(lambda: list(web_root.rglob("*.html"))):
if not file_path.is_file() or should_skip(file_path):
continue
rel_path = str(file_path.relative_to(nas_root))
@@ -264,7 +270,8 @@ async def watch_inbox():
Path(sub).name, (None, None, None)
)
for file_path in scan_root.rglob("*"):
# NFS 디렉토리 walk(blocking) off-thread 수집 (R5).
for file_path in await asyncio.to_thread(lambda: list(scan_root.rglob("*"))):
if not file_path.is_file() or should_skip(file_path):
continue
@@ -278,7 +285,11 @@ async def watch_inbox():
continue
rel_path = str(file_path.relative_to(nas_root))
fhash = file_hash(file_path)
# GB 파일 SHA-256 은 이벤트 루프를 점유 → 같은 루프의 모든 1분 주기 consumer
# + FastAPI 요청이 수십초~분 동시 정지. to_thread 오프로드. 스캔 루프가 이미
# 순차라 file_hash 는 한 번에 하나만 실행(직렬화) — 병렬 해싱 X = NFS 2.5GbE
# 대역폭·버퍼 메모리 blowup 방지 (R5).
fhash = await asyncio.to_thread(file_hash, file_path)
result = await session.execute(
select(Document).where(Document.file_path == rel_path)
+8
View File
@@ -297,6 +297,10 @@ async def collect_disaster_cases(session) -> int:
await _ingest_attachment(session, boardno, filenm, filepath)
except FeedError as e:
logger.warning(f"[kosha] 첨부 실패 skip ({boardno}/{filenm}): {e}")
# 케이스 단위 commit (R4) — 이후 페이지/케이스의 _api_get 실패가 앞서 적재한
# 케이스까지 전체 rollback 하지 않게 부분 적재 보존 (csb/api_standards idiom).
await session.commit()
if page_all_dup:
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
@@ -374,6 +378,8 @@ async def collect_fatal_accidents(session) -> int:
await enqueue_stage(session, doc.id, "embed")
await enqueue_stage(session, doc.id, "chunk")
new_count += 1
# 케이스 단위 commit (R4) — 이후 페이지 실패가 앞 케이스 전체 rollback 방지.
await session.commit()
if page_all_dup:
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
@@ -450,6 +456,8 @@ async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
await session.flush()
await enqueue_stage(session, doc.id, "extract")
ingested += 1
# 항목 단위 commit (R4) — 다운로드 실패가 앞서 적재한 GUIDE 항목 전체 rollback 방지.
await session.commit()
# silent cap 금지 — 잔량 가시화 (자동 점진 백필: 내일 cap 만큼 또 소화)
logger.info(f"[kosha] GUIDE 신규/개정 {len(new_specs)}건 중 {ingested}건 ingest"
+110
View File
@@ -0,0 +1,110 @@
"""메모 → 문서 승격 시 거친 메모를 구조화된 마크다운 문서로 정리 (26B, P2).
`POST /memos/{id}/promote-to-document` `source_metadata.needs_draft=true` 마커를
찍으면 스케줄 워커가 집어 AIClient.call_primary(26B Mac mini = 로컬, 과금규칙 부합)
md_content 생성한다. markdown canonical Phase 1A 스키마 재사용:
- content_origin='ai_drafted' + md_draft_status='draft'
(migration 212 제약: md_draft_status NOT NULL content_origin='ai_drafted' 필수)
- md_status='success', md_extraction_engine='ai_draft'
원본 메모는 extracted_text 보존(검색/청크는 원문 사용). "필요시" = 이미 정돈된 메모는
프롬프트가 형식만 다듬고, 거친 메모는 구조화하도록 지시(사실 추가 금지).
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from ai.client import AIClient, strip_thinking
from core.database import async_session
from models.document import Document
from services.search.llm_gate import Priority, acquire_mlx_gate
logger = logging.getLogger(__name__)
# 한 번에 처리할 승격 문서 수 (26B 콜 = 무겁다 → 소량 순차). interval 잡이라 다음 틱에 이어 처리.
_BATCH = 2
# 너무 짧은 메모는 문서화 의미 없음 — 마커만 정리하고 md 생성 스킵.
_MIN_CHARS = 20
_DRAFT_SYSTEM = (
"당신은 사용자의 거친 메모를 사실 추가 없이 깔끔한 마크다운 문서로 정리하는 도우미입니다."
)
_DRAFT_PROMPT = """다음은 사용자가 빠르게 적은 메모입니다. 이를 정식 자료 문서로 정리하세요.
규칙:
- 메모에 있는 정보만 사용하고, 내용·사실을 추가하거나 추측하지 마세요.
- 이미 정돈돼 있으면 형식만 다듬고, 거친 메모면 제목·소제목·목록으로 구조화하세요.
- 원문 언어를 유지하세요(한국어는 한국어, 영어는 영어).
- 출력은 마크다운 본문만. 인사말·메타 설명 없이 문서 내용만 출력하세요.
--- 메모 ---
{content}
--- ---"""
async def _ids_needing_draft() -> list[int]:
async with async_session() as session:
rows = (
await session.execute(
select(Document.id)
.where(
Document.deleted_at.is_(None),
# JSONB 마커 (json/jsonb 공통 ->> 연산자). promote 가 needs_draft=true 세팅.
Document.source_metadata.op("->>")("needs_draft") == "true",
)
.order_by(Document.id)
.limit(_BATCH)
)
).scalars().all()
return list(rows)
async def run() -> None:
"""needs_draft 마커가 찍힌 승격 문서를 26B로 문서화 (interval job, no-arg)."""
ids = await _ids_needing_draft()
if not ids:
return
client = AIClient()
for doc_id in ids:
# 문서별 독립 세션·트랜잭션 — 1건 실패가 나머지를 막지 않게.
async with async_session() as session:
try:
doc = await session.get(Document, doc_id)
if doc is None or not (doc.source_metadata or {}).get("needs_draft"):
continue # 경합/이미 처리됨
source = (doc.extracted_text or "").strip()
now = datetime.now(timezone.utc)
meta = dict(doc.source_metadata or {})
md = ""
if len(source) >= _MIN_CHARS:
# 26B 호출은 반드시 mlx gate(Semaphore 1) 안에서 — 동시 호출 pile-up 방지
# ([[feedback_llm_verification_load_pileup]]). BACKGROUND = 사용자 대면보다 양보.
async with acquire_mlx_gate(Priority.BACKGROUND):
raw = await client.call_primary(
_DRAFT_PROMPT.format(content=source), system=_DRAFT_SYSTEM
)
md = strip_thinking(raw or "").strip()
if md:
doc.md_content = md
# 제약(212): md_draft_status NOT NULL 이면 content_origin='ai_drafted' 여야 함.
doc.content_origin = "ai_drafted"
doc.md_draft_status = "draft"
doc.md_status = "success"
doc.md_extraction_engine = "ai_draft"
doc.md_generated_at = now
meta["drafted_at"] = now.isoformat()
# 성공/스킵 모두 마커 해제(무한 재시도 방지). 26B 호출 자체가 예외면 except 로 빠져 마커 유지.
meta["needs_draft"] = False
doc.source_metadata = meta
doc.updated_at = now
await session.commit()
logger.info("memo_draft doc=%s md_len=%d", doc_id, len(md))
except Exception:
logger.exception("memo_draft 실패 doc=%s (다음 틱 재시도)", doc_id)
await session.rollback()
+65 -101
View File
@@ -83,6 +83,10 @@ def _normalize_url(url: str) -> str:
query 전체 제거 금지: hada.io/topic?id= · aitimes articleView.html?idxno= ·
HN item?id= query-식별 사이트에서 별개 기사가 같은 URL 붕괴된다.
저장(edit_url)·조회 양쪽이 함수를 공유해야 dedup 성립.
R11c: file_watcher._canonicalize_url(web_clip 채널) 의도적으로 다르다 이쪽은 콘텐츠
식별 query 보존(별개 기사 붕괴 방지) 핵심이라 query-sort/trailing-slash/소문자화를 한다.
함수 통합 금지(news dedup 깨짐). 채널별 normalization 의도된 설계.
"""
parsed = urlparse(url)
kept = [
@@ -397,6 +401,55 @@ def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
}
async def _already_ingested(session, article_id: str, normalized_url: str, link: str) -> bool:
"""이미 적재된 기사인지 — file_hash 또는 정규화/raw edit_url 매칭 (3 fetch 공통, R11c).
레거시 raw URL + 교차 게시 다중 매칭 내성(first). _fetch_rss/_fetch_api_guardian/
_fetch_api_nyt 복제하던 동일 존재체크를 단일화.
"""
existing = await session.execute(
select(Document).where(
(Document.file_hash == article_id)
| (Document.edit_url.in_([normalized_url, link]))
).limit(1)
)
return existing.scalars().first() is not None
def _build_news_doc(source, ident, source_short, article_id, title, body,
extractor_version, normalized_url, pub_dt) -> Document:
"""3 fetch 공통 뉴스 Document 빌더 (R11c). 채널별 차이는 인자로만 — body(NYT=summary)·
extractor_version·ident(category 계산 차이 흡수) 다르고 22 필드 구조는 정적 동일.
edit_url 조회와 동일 정규화 저장(raw 저장 URL dedup 무력화)."""
return Document(
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
file_hash=article_id,
file_format="article",
file_size=len(body.encode()),
file_type="note",
title=title,
extracted_text=f"{title}\n\n{body}",
extracted_at=datetime.now(timezone.utc),
extractor_version=extractor_version,
# article = 텍스트 네이티브 → 생성 시점 terminal 'skipped' 명시(markdown 변환 비대상,
# 미명시 시 'pending' 영구 비수렴 → backlog 지표 오염). page 정책은 fulltext_worker 승격.
md_status="skipped",
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
source_channel=source.source_channel,
data_origin="external",
edit_url=normalized_url,
review_status="approved",
ai_domain=ident["ai_domain"],
ai_sub_group=source_short,
ai_tags=ident["ai_tags"],
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
material_type=ident["material_type"],
jurisdiction=ident["jurisdiction"],
published_date=pub_dt.date() if pub_dt else None,
extract_meta=_build_extract_meta(source, pub_dt),
)
async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
"""RSS 피드 수집 — redirect 재검증 + 크기/content-type 제한 + 조건부 GET (A-1).
@@ -515,13 +568,7 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
article_id = _article_hash(title, pub_dt.strftime("%Y%m%d"), source.name)
normalized_url = _normalize_url(link)
existing = await session.execute(
select(Document).where(
(Document.file_hash == article_id) |
(Document.edit_url.in_([normalized_url, link]))
).limit(1)
)
if existing.scalars().first():
if await _already_ingested(session, article_id, normalized_url, link):
continue
# A-6 2차: 포털 전재 dedup (first-wins — 먼저 적재된 쪽이 정본)
@@ -533,35 +580,9 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
source_short = source.name.split(" ")[0] # "경향신문 문화" → "경향신문"
ident = _doc_identity(source, source_short, category)
doc = Document(
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
file_hash=article_id,
file_format="article",
file_size=len(body.encode()),
file_type="note",
title=title,
extracted_text=f"{title}\n\n{body}",
extracted_at=datetime.now(timezone.utc),
extractor_version=extractor_version,
# article = 텍스트 네이티브(본문=extracted_text). markdown 단계 미enqueue 라
# 기본값 'pending' 이면 영구 비수렴 → backlog 지표 오염 + md_status_pending partial
# 인덱스 비대. 생성 시점에 terminal 'skipped' 로 명시(변환 비대상).
# fulltext_policy='page' 소스는 fulltext_worker 가 승격 시 success 로 갱신.
md_status="skipped",
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
source_channel=source.source_channel,
data_origin="external",
# 조회와 동일하게 정규화해 저장 — raw(tracking param 포함) 저장 시 URL dedup 무력화
edit_url=normalized_url,
review_status="approved",
ai_domain=ident["ai_domain"],
ai_sub_group=source_short,
ai_tags=ident["ai_tags"],
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
material_type=ident["material_type"],
jurisdiction=ident["jurisdiction"],
published_date=pub_dt.date() if pub_dt else None,
extract_meta=_build_extract_meta(source, pub_dt),
doc = _build_news_doc(
source, ident, source_short, article_id, title, body,
extractor_version, normalized_url, pub_dt,
)
session.add(doc)
await session.flush()
@@ -658,13 +679,7 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
normalized_url = _normalize_url(link)
# RSS 수집부와 동일: 레거시 raw URL + 교차 게시 다중 매칭 내성 (first)
existing = await session.execute(
select(Document).where(
(Document.file_hash == article_id) |
(Document.edit_url.in_([normalized_url, link]))
).limit(1)
)
if existing.scalars().first():
if await _already_ingested(session, article_id, normalized_url, link):
continue
if await _is_portal_duplicate(session, title):
@@ -675,30 +690,9 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
source_short = source.name.split(" ")[0]
ident = _doc_identity(source, source_short, category)
doc = Document(
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
file_hash=article_id,
file_format="article",
file_size=len(body.encode()),
file_type="note",
title=title,
extracted_text=f"{title}\n\n{body}",
extracted_at=datetime.now(timezone.utc),
extractor_version="guardian_api_full" if is_full else "guardian_api",
md_status="skipped",
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
source_channel=source.source_channel,
data_origin="external",
edit_url=normalized_url,
review_status="approved",
ai_domain=ident["ai_domain"],
ai_sub_group=source_short,
ai_tags=ident["ai_tags"],
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
material_type=ident["material_type"],
jurisdiction=ident["jurisdiction"],
published_date=pub_dt.date() if pub_dt else None,
extract_meta=_build_extract_meta(source, pub_dt),
doc = _build_news_doc(
source, ident, source_short, article_id, title, body,
"guardian_api_full" if is_full else "guardian_api", normalized_url, pub_dt,
)
session.add(doc)
await session.flush()
@@ -755,13 +749,7 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
normalized_url = _normalize_url(link)
# RSS 수집부와 동일: 레거시 raw URL + 교차 게시 다중 매칭 내성 (first)
existing = await session.execute(
select(Document).where(
(Document.file_hash == article_id) |
(Document.edit_url.in_([normalized_url, link]))
).limit(1)
)
if existing.scalars().first():
if await _already_ingested(session, article_id, normalized_url, link):
continue
if await _is_portal_duplicate(session, title):
@@ -772,33 +760,9 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
source_short = source.name.split(" ")[0]
ident = _doc_identity(source, source_short, category)
doc = Document(
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
file_hash=article_id,
file_format="article",
file_size=len(summary.encode()),
file_type="note",
title=title,
extracted_text=f"{title}\n\n{summary}",
extracted_at=datetime.now(timezone.utc),
extractor_version="nyt_api",
# article = 텍스트 네이티브(본문=extracted_text). markdown 단계 미enqueue 라
# 기본값 'pending' 이면 영구 비수렴 → backlog 지표 오염 + md_status_pending partial
# 인덱스 비대. 생성 시점에 terminal 'skipped' 로 명시(변환 비대상).
md_status="skipped",
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
source_channel=source.source_channel,
data_origin="external",
edit_url=normalized_url,
review_status="approved",
ai_domain=ident["ai_domain"],
ai_sub_group=source_short,
ai_tags=ident["ai_tags"],
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
material_type=ident["material_type"],
jurisdiction=ident["jurisdiction"],
published_date=pub_dt.date() if pub_dt else None,
extract_meta=_build_extract_meta(source, pub_dt),
doc = _build_news_doc(
source, ident, source_short, article_id, title, summary,
"nyt_api", normalized_url, pub_dt,
)
session.add(doc)
await session.flush()
+400
View File
@@ -0,0 +1,400 @@
"""OpenAlex 백본 수집기 — B-3 PR3 (plan safety-library-b3-1).
OpenAlex = 발견+dedup 글로벌 백본(JP/EU/US 논문 색인 + 정본 DOI). 전문은 (oa_url 포인터만).
- scaffold-first: OPENALEX_API_KEY 부재 FeedError(explicit-skip, silent fallback 금지). =무료.
- signal-only: 초록(inverted-index 복원) 색인(embed+chunk), summarize 절대 미enqueue(맥미니 무접촉).
PDF 절대 OpenAlex 경유로 받음(oa_url 링크/신호일 ).
- 관련성 사전필터 = title_and_abstract.search 키워드(서버측) + per-run insert cap(임베드 firehose 차단,
적대리뷰 A major). cursor 페이징 + from_publication_date 워터마크로 증분.
- 초록 없는 thin 레코드(주로 -OA 메타) skip Phase-1 재료 품질 유지.
- DOI paper.doi(holder, partial-unique 인덱스, 교차소스 dedup). 없으면 openalex id fallback.
- license: 명시 CC redistribute=true / OA·closed false(restricted 부재 = 초록 RAG 사용 가능).
- enabled=False news_sources + main.py CronTrigger(자체 폴링). list+filter 비용 미미($1/ 크레딧).
"""
import asyncio
import hashlib
import json
import os
from dataclasses import dataclass
from datetime import date, datetime, timezone
import httpx
from sqlalchemy import select
from core.crawl_politeness import CRAWL_UA
from core.database import async_session
from core.utils import setup_logger
from models.document import Document
from models.news_source import NewsSource
from models.queue import enqueue_stage
from services.papers.doi import normalize_doi
from services.papers.holder import find_paper_holder
from workers.news_collector import (
FeedError,
_get_or_create_health,
_record_failure,
_record_success,
)
logger = setup_logger("openalex_collector")
_API = "https://api.openalex.org/works"
_SOURCE_NAME = "OpenAlex 안전·공학 (keyword)"
_ENV_KEY = "OPENALEX_API_KEY"
# 압력용기·공정안전·구조건전성 도메인 키워드(키워드별 1쿼리 = 관련성 사전필터).
_KEYWORDS = (
"pressure vessel safety",
"process safety",
"structural integrity",
"fracture mechanics",
"fatigue life assessment",
)
# 도메인 직결 저널 ISSN 시드(OpenAlex sources 실측 확인) — 키워드 매칭 누락분까지 전수 커버.
# KR 안전/가스/기계 + JP 고압. KR/JP 관심 = OpenAlex 깨끗한 API 로 직접(KoreaScience/J-STAGE 전용
# 스크래퍼 불요 — Phase-1 메타는 OpenAlex 와 중복, 전용 수집기의 유니크 가치=무료 전문 PDF=Phase-2).
_JOURNAL_ISSNS = (
("한국안전학회지", "1738-3803"),
("한국가스학회지", "1226-8402"),
("대한기계학회논문집 A", "1226-4873"),
("대한기계학회논문집 B", "1226-4881"),
("KSME International J.", "1226-4865"),
("Review of High Pressure Sci&Tech (JP)", "0917-639X"),
)
_RUN_CAP = 60 # 1회 run 신규 적재 상한(임베드 큐 보호). bulk 시 해제.
_PER_PAGE = 50
_MAX_PAGES_PER_KW = 4 # 키워드당 최대 페이지(증분이라 보통 1페이지에 워터마크 도달)
_REQ_SLEEP = 1.0 # 페이지 간 polite 간격
_MAX_RETRY = 4
_BACKOFF_BASE = 5.0
# ───────────────────────── 순수 파서 (fixture 단위 테스트 대상) ─────────────────────────
@dataclass
class OpenAlexWork:
openalex_id: str # "W2910511816"
doi: str | None # normalize_doi 적용
title: str
abstract: str # inverted-index 복원 (없으면 "")
publication_date: str | None
oa_status: str | None # closed/green/bronze/hybrid/gold/diamond
oa_url: str | None
is_oa: bool
license: str | None # cc-by / cc-by-nc-nd / None
source_name: str | None
primary_topic: str | None
work_type: str | None
def _clean(text):
return " ".join(text.split()).strip() if text else ""
def _reconstruct_abstract(inv: dict | None) -> str:
"""abstract_inverted_index({word:[positions]}) → 평문 초록. 없으면 ''."""
if not inv:
return ""
positions = [(pos, word) for word, idxs in inv.items() for pos in idxs]
positions.sort()
return " ".join(w for _, w in positions)
def license_meta(license_str: str | None, is_oa: bool, source_name: str | None) -> dict:
"""extract_meta.license — 명시 CC/public-domain 만 redistribute=true. restricted 부재(초록 색인 자유).
redistribute=false 라도 restricted 없으면 RAG 사용 가능(초록). -CC 전문의 RAG verbatim 차단은
Phase-2 전문 승격 단계가 restricted=true 처리(L-1) Phase-1(초록) 무해.
"""
attribution = source_name or "OpenAlex"
if license_str and (license_str.startswith("cc") or license_str == "public-domain"):
return {"scheme": license_str, "redistribute": True, "attribution": attribution}
return {
"scheme": "open-unspecified" if is_oa else "proprietary",
"redistribute": False,
"attribution": attribution,
}
def parse_openalex_works(json_text: str) -> tuple[int, str | None, list[OpenAlexWork]]:
"""OpenAlex /works 응답 → (count, next_cursor, [OpenAlexWork]). 순수 함수."""
d = json.loads(json_text)
meta = d.get("meta") or {}
count = meta.get("count") or 0
next_cursor = meta.get("next_cursor")
works: list[OpenAlexWork] = []
for w in d.get("results") or []:
oid = (w.get("id") or "").rstrip("/").rsplit("/", 1)[-1]
if not oid:
continue
oa = w.get("open_access") or {}
pl = w.get("primary_location") or {}
pt = w.get("primary_topic") or {}
works.append(OpenAlexWork(
openalex_id=oid,
doi=normalize_doi(w.get("doi")),
title=_clean(w.get("title")),
abstract=_reconstruct_abstract(w.get("abstract_inverted_index")),
publication_date=w.get("publication_date"),
oa_status=oa.get("oa_status"),
oa_url=oa.get("oa_url") or None,
is_oa=bool(oa.get("is_oa")),
license=pl.get("license"),
source_name=(pl.get("source") or {}).get("display_name"),
primary_topic=pt.get("display_name"),
work_type=w.get("type"),
))
return count, next_cursor, works
def build_filter(keyword: str, from_date: str | None = None) -> str:
f = f"title_and_abstract.search:{keyword}"
if from_date:
f += f",from_publication_date:{from_date}"
return f
def build_issn_filter(issn: str, from_date: str | None = None) -> str:
f = f"primary_location.source.issn:{issn}"
if from_date:
f += f",from_publication_date:{from_date}"
return f
def _seeds() -> list[tuple[str, str, str]]:
"""수집 시드 = (라벨, 워터마크키, 종류). 도메인 저널 ISSN 우선(cap 우선권) → 키워드."""
s: list[tuple[str, str, str]] = [(label, issn, "issn") for label, issn in _JOURNAL_ISSNS]
s += [(kw, kw, "kw") for kw in _KEYWORDS]
return s
# ───────────────────────── 적재 (DB — PR3 라이브 검증) ─────────────────────────
def _build_paper_meta(source: NewsSource, w: OpenAlexWork) -> dict:
paper: dict = {"openalex_id": w.openalex_id}
if w.doi:
paper["doi"] = w.doi # partial-unique 인덱스 진입(교차소스 dedup)
if w.oa_status:
paper["oa_status"] = w.oa_status
if w.oa_url:
paper["oa_url"] = w.oa_url # 링크/신호 — 자동 fetch 안 함
if w.primary_topic:
paper["topic"] = w.primary_topic
meta: dict = {
"source_id": source.id,
"source_name": source.name,
"source_region": "INT", # OpenAlex = 글로벌. paper.jurisdiction 은 NULL 유지(A-2).
"paper": paper,
"license": license_meta(w.license, w.is_oa, w.source_name),
}
if w.publication_date:
meta["published_at"] = w.publication_date
return meta
async def _ingest_work(session, source: NewsSource, w: OpenAlexWork) -> bool:
"""1건 적재. 반환 = 신규 여부. signal-only. 초록 없으면 skip(thin 레코드 배제)."""
if not w.abstract:
return False # 초록 없는 thin 레코드(주로 비-OA 메타) — Phase-1 재료 품질 유지
oid_hash = hashlib.sha256(f"openalex|{w.openalex_id}".encode()).hexdigest()[:32]
dup = await session.execute(
select(Document.id).where(Document.file_hash == oid_hash).limit(1)
)
if dup.scalars().first():
return False
if w.doi and await find_paper_holder(session, w.doi):
return False # 교차소스 dedup(arXiv 등이 이미 holder 보유)
pub_date = None
if w.publication_date:
try:
pub_date = date.fromisoformat(w.publication_date)
except ValueError:
pub_date = None
body = w.abstract
doc = Document(
file_path=f"crawl/openalex/{w.openalex_id}",
file_hash=oid_hash,
file_format="article",
file_size=len(body.encode()),
file_type="note",
title=w.title,
extracted_text=f"{w.title}\n\n{body}",
extracted_at=datetime.now(timezone.utc),
extractor_version="openalex-signal",
md_status="skipped",
md_extraction_error="OpenAlex abstract: signal-only, markdown 비대상",
source_channel="crawl",
data_origin="external",
edit_url=w.oa_url or f"https://openalex.org/{w.openalex_id}",
review_status="approved",
material_type="paper",
jurisdiction=None,
published_date=pub_date,
extract_meta=_build_paper_meta(source, w),
)
session.add(doc)
await session.flush()
await enqueue_stage(session, doc.id, "embed")
await enqueue_stage(session, doc.id, "chunk")
return True
async def _get_or_create_source(session) -> NewsSource:
result = await session.execute(
select(NewsSource).where(NewsSource.name == _SOURCE_NAME)
)
source = result.scalars().first()
if source is None:
source = NewsSource(
name=_SOURCE_NAME, feed_url=_API, feed_type="json",
fetch_method="signal-only", fulltext_policy="none",
source_channel="crawl", category="Engineering", language="en",
country=None, material_type="paper",
license_scheme="openalex", license_redistribute=False,
enabled=False,
)
session.add(source)
await session.flush()
return source
def _api_key() -> str:
key = os.getenv(_ENV_KEY, "").strip()
if not key:
raise FeedError(f"{_ENV_KEY} 미설정 — OpenAlex 수집 불가 (scaffold-first explicit-skip)")
return key
def _watermark(source: NewsSource, keyword: str) -> str | None:
return (source.selector_override or {}).get("openalex_watermark", {}).get(keyword)
def _set_watermark(source: NewsSource, keyword: str, value: str) -> None:
cfg = dict(source.selector_override or {})
wm = dict(cfg.get("openalex_watermark") or {})
wm[keyword] = value
cfg["openalex_watermark"] = wm
source.selector_override = cfg
async def _fetch(client: httpx.AsyncClient, key: str, filter_str: str, cursor: str) -> str:
params = {
"filter": filter_str, "per-page": _PER_PAGE, "cursor": cursor,
"sort": "publication_date:desc", "api_key": key,
}
for attempt in range(_MAX_RETRY):
resp = await client.get(_API, params=params)
if resp.status_code == 429:
await asyncio.sleep(_BACKOFF_BASE * (2 ** attempt))
continue
resp.raise_for_status()
return resp.text
raise FeedError(f"OpenAlex 429 재시도 초과: {filter_str[:48]}")
async def run(bulk: bool = False, limit: int = 0) -> None:
"""daily 진입점(스케줄러). 키 부재 = explicit-skip(health 실패 기록)."""
now = datetime.now(timezone.utc)
async with async_session() as session:
source = await _get_or_create_source(session)
await session.commit()
source_id = source.id
try:
key = _api_key()
except FeedError as e:
logger.warning(f"[openalex] {e}")
async with async_session() as session:
health = await _get_or_create_health(session, source_id)
_record_failure(health, str(e), now)
await session.commit()
return
run_cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
inserted = 0
seen = 0
failures: list[str] = []
async with httpx.AsyncClient(
timeout=30.0, headers={"User-Agent": CRAWL_UA}, follow_redirects=True
) as client:
for label, wm_key, kind in _seeds():
if inserted >= run_cap:
break
async with async_session() as session:
src = await session.get(NewsSource, source_id)
watermark = None if bulk else _watermark(src, wm_key)
filter_str = (build_issn_filter(wm_key, watermark) if kind == "issn"
else build_filter(wm_key, watermark))
newest: str | None = None
capped = False # 이번 run 이 cap 으로 시드 중도 절단됐는지 (R4)
cursor = "*"
max_pages = (10**6 if bulk else _MAX_PAGES_PER_KW)
try:
for _page in range(max_pages):
if inserted >= run_cap:
capped = True
break
text = await _fetch(client, key, filter_str, cursor)
_count, next_cursor, works = parse_openalex_works(text)
if not works:
break
for w in works:
seen += 1
if w.publication_date and (newest is None or w.publication_date > newest):
newest = w.publication_date
async with async_session() as session:
src = await session.get(NewsSource, source_id)
if await _ingest_work(session, src, w):
inserted += 1
await session.commit()
else:
await session.rollback()
if inserted >= run_cap:
capped = True
break
await asyncio.sleep(_REQ_SLEEP)
if not next_cursor:
break
cursor = next_cursor
# cap 절단 시 워터마크 미전진 — 미페치 works 가 다음 run 의 watermark 필터
# (publication_date > watermark)에 영구 배제되는 silent loss 방지. 미전진하면
# 다음 run 이 옛 watermark 부터 재페치하며 적재분 dedup-skip(cap 미소모) 후
# 이어 적재 → 백로그 run 당 cap 소화 (R4). bulk 은 cap 무관.
if newest and not capped:
async with async_session() as session:
src = await session.get(NewsSource, source_id)
_set_watermark(src, wm_key, newest)
await session.commit()
except (httpx.HTTPError, FeedError, ValueError) as e:
msg = f"[{label}] {e or repr(e)}"
logger.error(f"[openalex] {msg}")
failures.append(msg)
async with async_session() as session:
health = await _get_or_create_health(session, source_id)
if failures and inserted == 0:
_record_failure(health, "; ".join(failures)[:500], now)
else:
_record_success(health, inserted, False, now)
await session.commit()
deferred = "" if inserted < run_cap else f" (cap {run_cap} 도달 — 잔여 다음 run 이월)"
logger.info(
f"[openalex] {len(_seeds())}개 시드(ISSN+키워드) 스캔 {seen}건 → 신규 {inserted}{deferred}"
+ (f" / 실패 {len(failures)}" if failures else "")
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="OpenAlex 안전·공학 키워드 백본 수집기")
parser.add_argument("--bulk", action="store_true", help="cap 해제 + 깊은 cursor 페이징 백필")
parser.add_argument("--limit", type=int, default=0, help="신규 적재 상한(0=기본 cap)")
args = parser.parse_args()
asyncio.run(run(bulk=args.bulk, limit=args.limit))
+102
View File
@@ -0,0 +1,102 @@
"""paper DOI reconcile — B-3 PR4(레거시 arXiv) + PR5(구매 PDF) (plan safety-library-b3-1).
paper.doi/parent_doi 없는 paper 행을 갈래로 정리:
- 레거시 arXiv 초록(holder): arXiv id arxiv_doi(10.48550/arxiv.{id}) 스탬프 partial-unique
인덱스 편입 재유입 차단('동일-DOI 재유입 차단만').
- 구매 PDF(child, license.restricted=true Papers_Purchased 드롭): 본문 DOI 파싱 paper.parent_doi
링크(서지 holder DOI 공유로 연결). child doi 미보유(인덱스 ) unique 무충돌.
- KEYLESS·결정적(OpenAlex 호출 0)·in-DB·enqueue 0(콘텐츠 무변경). dedup_reconcile(file_hash 캐시)
worker(적대리뷰 B·C major). 선재 DOI holder 존재 arXiv 행도 parent_doi 마킹(unique 위반 회피).
"""
import asyncio
from sqlalchemy import select
from core.database import async_session
from core.utils import setup_logger
from models.document import Document
from services.papers.doi import (
arxiv_doi,
parse_arxiv_id,
parse_doi_from_text,
with_paper_doi,
with_parent_doi,
)
from services.papers.holder import find_paper_holder
logger = setup_logger("paper_doi_reconcile")
_DOI_TEXT = Document.extract_meta[("paper", "doi")].astext
_PARENT_DOI_TEXT = Document.extract_meta[("paper", "parent_doi")].astext
def _is_restricted(meta: dict) -> bool:
return (meta.get("license") or {}).get("restricted") in (True, "true")
async def run(limit: int = 0) -> None:
"""paper.doi/parent_doi 없는 paper 행 reconcile(멱등). limit=0 = 전건."""
stamped = marked_dup = skipped_no_arxiv = 0
linked_purchased = skipped_purchased_no_doi = 0
async with async_session() as session:
q = (
select(Document)
.where(
Document.material_type == "paper",
_DOI_TEXT.is_(None),
_PARENT_DOI_TEXT.is_(None),
)
.order_by(Document.id)
)
if limit:
q = q.limit(limit)
rows = (await session.execute(q)).scalars().all()
for row in rows:
meta = dict(row.extract_meta or {})
paper = dict(meta.get("paper") or {})
# PR5: 구매 PDF(restricted) = child → 본문 DOI 파싱 → parent_doi 링크
if _is_restricted(meta):
doi = parse_doi_from_text(row.extracted_text)
if not doi:
skipped_purchased_no_doi += 1
continue
row.extract_meta = with_parent_doi(meta, doi)
linked_purchased += 1
continue
# PR4: 레거시 arXiv 초록(holder) = arXiv DataCite DOI 스탬프
arxiv_id = paper.get("arxiv_id") or parse_arxiv_id(row.extracted_text)
doi = arxiv_doi(arxiv_id)
if not doi:
skipped_no_arxiv += 1
continue
paper["arxiv_id"] = arxiv_id
meta["paper"] = paper
holder = await find_paper_holder(session, doi)
if holder is not None and holder.id != row.id:
row.extract_meta = with_parent_doi(meta, doi) # 선재 중복 → child 마킹
marked_dup += 1
else:
row.extract_meta = with_paper_doi(meta, doi) # holder 스탬프, 인덱스 진입
stamped += 1
# 콘텐츠 무변경 → enqueue 없음(summarize/embed/chunk 0)
await session.commit()
logger.info(
f"[paper_doi_reconcile] {len(rows)}행 → arXiv 스탬프 {stamped} · 선재중복 {marked_dup} · "
f"arXiv id 없음 skip {skipped_no_arxiv} / 구매PDF parent_doi 링크 {linked_purchased} · "
f"구매PDF DOI 없음 skip {skipped_purchased_no_doi}"
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="paper DOI reconcile (arXiv 레거시 + 구매 PDF, keyless)")
parser.add_argument("--limit", type=int, default=0, help="처리 상한(0=전건)")
args = parser.parse_args()
asyncio.run(run(limit=args.limit))
+123
View File
@@ -0,0 +1,123 @@
"""논문 arXiv 전문 승격 (in-place) — B-3 Phase-2 P2-PR1 (plan safety-library-b3-1).
arXiv 프리프린트 초록 (file_format='article', signal-only) 전문 PDF로 **in-place 승격**:
PDF 다운로드 file_format/file_type/file_path/md_status 갱신 'extract' enqueue 기존 파이프라인
(extract classify[paper skip summarize] embed/chunk/markdown) 전문 검색 청크 + md_content(marker 표시)
+ hier 절구조를 생성. 1-Document(2 분리 회피, 기존 display 스택 재사용).
- arXiv = 공개 프리프린트(arxiv.org/pdf/{id}, friendly host) 전문 검색/RAG 무난, restricted 불요.
(유료 구매 논문은 Papers_Purchased 경로가 restricted=true 별개 처리.)
- per-run cap (marker GPU ~10GB + embed 부하 보호, 4070 16GB 빡빡 idle-unload·증분). keyless.
- 요약 0 (classify paper-skip 가드). file_hash·extract_meta.paper 보존(수집기 dedup 무영향).
- CLI 전용(Phase-2 deliberate 승격, GPU 부하 사용자 통제). 스케줄 미등록.
"""
import argparse
import asyncio
import random
from pathlib import Path
import httpx
from sqlalchemy import or_, select
from core.config import settings
from core.crawl_politeness import CRAWL_UA
from core.database import async_session
from core.utils import setup_logger
from models.document import Document
from models.queue import enqueue_stage
logger = setup_logger("paper_fulltext_promote")
_ARXIV_PDF = "https://arxiv.org/pdf/{id}"
_MAX_FILE_BYTES = 50 * 1024 * 1024
_DOWNLOAD_DELAY = (2.0, 5.0)
_RUN_CAP = 10 # 1회 승격 상한(marker/embed GPU 보호). bulk 시 해제.
_ARXIV_ID_EXPR = Document.extract_meta[("paper", "arxiv_id")].astext
_OA_URL_EXPR = Document.extract_meta[("paper", "oa_url")].astext
_OA_STATUS_EXPR = Document.extract_meta[("paper", "oa_status")].astext
_REAL_OA = ("gold", "hybrid", "green", "diamond")
async def _download(url: str, dest: Path) -> int:
"""arXiv PDF 다운로드 — 크기 cap + PDF 헤더 검증 + 연속 간격(kosha 패턴)."""
await asyncio.sleep(random.uniform(*_DOWNLOAD_DELAY))
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": CRAWL_UA})
if resp.status_code != 200:
raise RuntimeError(f"arXiv PDF {resp.status_code}: {url}")
if len(resp.content) > _MAX_FILE_BYTES:
raise RuntimeError(f"크기 초과 {len(resp.content)}b: {url}")
if resp.content[:5] != b"%PDF-":
raise RuntimeError(f"PDF 아님(헤더 {resp.content[:8]!r}): {url}")
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(resp.content)
return len(resp.content)
async def run(bulk: bool = False, limit: int = 0) -> None:
"""미승격 arXiv 논문(file_format='article')을 전문 PDF로 in-place 승격."""
cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
async with async_session() as session:
q = (
select(Document.id)
.where(
Document.material_type == "paper",
Document.file_format == "article",
or_(
_ARXIV_ID_EXPR.isnot(None),
Document.extract_meta[("paper", "oa_url")].astext.isnot(None),
),
)
.order_by(Document.id.desc())
.limit(cap)
)
ids = [r[0] for r in (await session.execute(q)).all()]
promoted = failed = 0
for doc_id in ids:
async with async_session() as session:
doc = await session.get(Document, doc_id)
if doc is None or doc.file_format != "article":
continue
paper = (doc.extract_meta or {}).get("paper") or {}
arxiv_id = paper.get("arxiv_id")
oa_status = (paper.get("oa_status") or "").lower()
if arxiv_id:
url = _ARXIV_PDF.format(id=arxiv_id)
key = arxiv_id.replace("/", "_")
elif paper.get("oa_url") and oa_status in _REAL_OA:
url = paper["oa_url"] # doi.org/KISTI/PMC (friendly OA). 비-OA·paywall 은 헤더검증서 skip
key = (paper.get("openalex_id") or paper.get("doi") or "oa").replace("/", "_")
else:
continue
rel_path = f"crawl_raw/papers/{key}.pdf"
dest = Path(settings.nas_mount_path) / rel_path
try:
size = await _download(url, dest)
except Exception as e: # noqa: BLE001 — 다운로드 실패 격리
logger.error(f"[promote] {key} 다운로드 실패: {e}")
failed += 1
continue
# in-place 승격: 초록 행 → 전문 PDF 행 (file_hash·extract_meta.paper 보존)
doc.file_path = rel_path
doc.file_format = "pdf"
doc.file_type = "immutable"
doc.file_size = size
doc.md_status = "pending" # marker 재실행(기존 'skipped' 해제)
doc.md_extraction_error = None
await enqueue_stage(session, doc.id, "extract")
await session.commit()
promoted += 1
logger.info(f"[promote] {key} → 전문 PDF in-place (doc {doc.id}, {size}b)")
logger.info(f"[paper_fulltext_promote] 승격 {promoted} · 실패 {failed} (cap {cap})")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="논문 arXiv 전문 승격 (in-place, keyless)")
parser.add_argument("--bulk", action="store_true", help="cap 해제(전건 백필 — GPU 부하 주의)")
parser.add_argument("--limit", type=int, default=0, help="승격 상한(0=기본 cap 10)")
args = parser.parse_args()
asyncio.run(run(bulk=args.bulk, limit=args.limit))
-142
View File
@@ -1,142 +0,0 @@
"""Phase 2A 후보 임베딩 백필 CLI (embedding-phase2a-1 E-1).
docker compose exec -T fastapi python -m workers.phase2a_cand_backfill \
--target qwen06 --doc-id-max 41944 --chunk-id-max 104140 [--batch 32]
설계 원칙 (plan r3):
- resumable/idempotent: 대상 = NOT EXISTS(후보 테이블) 중단/재실행 이어서.
배치 단위 커밋. C-1 백필 게이트 = "후보 카운트 == 동결셋 카운트".
- 동결셋: id <= *_id_max AND 베이스라인 embedding IS NOT NULL (AND docs.deleted_at IS NULL).
cand 테이블은 동결 범위로만 INSERT (retrieval cand path snapshot filter 타는 전제).
- 문서/청크 입력 = production 경로와 동일 구성(embed_worker._build_embed_input /
chunk_worker [제목][섹션][본문]) + plain (instruct prefix 쿼리 전용 G-1 불변식).
- 임베딩 = Ollama /api/embed 배치 호출 (G-1 fixture: 정규화 출력).
- qwen4m CLI 대상이 아님 qwen4 적재 SQL 파생(subvector+l2_normalize), plan E-1.
"""
import argparse
import asyncio
import hashlib
import time
import httpx
from sqlalchemy import text
from core.database import async_session
from core.utils import setup_logger
from models.document import Document
from workers.embed_worker import _build_embed_input
logger = setup_logger("phase2a_cand_backfill")
OLLAMA_EMBED = "http://ollama:11434/api/embed"
TARGETS = {
"qwen06": {
"model": "qwen3-embedding:0.6b", "dim": 1024,
"docs": "documents_cand_qwen06", "chunks": "document_chunks_cand_qwen06",
},
"qwen4": {
"model": "qwen3-embedding:4b", "dim": 2560,
"docs": "documents_cand_qwen4", "chunks": "document_chunks_cand_qwen4",
},
}
async def _embed_batch(client: httpx.AsyncClient, model: str, texts: list[str]) -> list[list[float]]:
r = await client.post(OLLAMA_EMBED, json={"model": model, "input": texts}, timeout=600)
r.raise_for_status()
embs = r.json()["embeddings"]
if len(embs) != len(texts):
raise RuntimeError(f"embed count mismatch: {len(embs)} != {len(texts)}")
return embs
async def backfill_docs(target: dict, doc_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
total = 0
while True:
async with async_session() as session:
rows = (await session.execute(text(f"""
SELECT d.id FROM documents d
WHERE d.id <= :m AND d.embedding IS NOT NULL AND d.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM {target['docs']} c WHERE c.doc_id = d.id)
ORDER BY d.id LIMIT :b
"""), {"m": doc_id_max, "b": batch})).scalars().all()
if not rows:
break
docs = [(await session.get(Document, i)) for i in rows]
inputs = [_build_embed_input(d) for d in docs]
embs = await _embed_batch(http, target["model"], inputs)
for d, inp, e in zip(docs, inputs, embs):
await session.execute(text(f"""
INSERT INTO {target['docs']} (doc_id, embed_input_hash, embedding)
VALUES (:i, :h, cast(:e AS vector))
ON CONFLICT (doc_id) DO NOTHING
"""), {"i": d.id, "h": hashlib.sha256(inp.encode()).hexdigest()[:16], "e": str(e)})
await session.commit()
total += len(rows)
if total % (batch * 10) < batch:
logger.info(f"[{target['docs']}] +{total} (last id={rows[-1]})")
return total
async def backfill_chunks(target: dict, chunk_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
total = 0
while True:
async with async_session() as session:
rows = (await session.execute(text(f"""
SELECT c.id, c.doc_id, c.chunk_index, c.section_title, c.text, d.title
FROM corpus_chunks c JOIN documents d ON d.id = c.doc_id
WHERE c.id <= :m AND c.embedding IS NOT NULL AND d.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM {target['chunks']} k WHERE k.id = c.id)
ORDER BY c.id LIMIT :b
"""), {"m": chunk_id_max, "b": batch})).all()
if not rows:
break
inputs = [
f"[제목] {r.title or ''}\n[섹션] {r.section_title or ''}\n[본문] {r.text}"
for r in rows
]
embs = await _embed_batch(http, target["model"], inputs)
for r, e in zip(rows, embs):
await session.execute(text(f"""
INSERT INTO {target['chunks']} (id, doc_id, chunk_index, section_title, text, embedding)
VALUES (:i, :d, :x, :s, :t, cast(:e AS vector))
ON CONFLICT (id) DO NOTHING
"""), {"i": r.id, "d": r.doc_id, "x": r.chunk_index,
"s": r.section_title, "t": r.text, "e": str(e)})
await session.commit()
total += len(rows)
if total % (batch * 10) < batch:
logger.info(f"[{target['chunks']}] +{total} (last id={rows[-1]})")
return total
async def run(target_key: str, doc_id_max: int, chunk_id_max: int, batch: int) -> None:
target = TARGETS[target_key]
start = time.monotonic()
async with httpx.AsyncClient() as http:
nd = await backfill_docs(target, doc_id_max, batch, http)
nc = await backfill_chunks(target, chunk_id_max, batch, http)
mins = (time.monotonic() - start) / 60
async with async_session() as session:
cd = (await session.execute(text(f"SELECT count(*) FROM {target['docs']}"))).scalar_one()
cc = (await session.execute(text(f"SELECT count(*) FROM {target['chunks']}"))).scalar_one()
logger.info(
f"[{target_key}] 완료 — 이번 run docs +{nd} chunks +{nc} ({mins:.1f}분) · "
f"누적 docs {cd} / chunks {cc} (동결 게이트 = 베이스라인 동결셋 카운트와 일치 확인)"
)
def main() -> None:
p = argparse.ArgumentParser(description="Phase 2A 후보 임베딩 백필 (resumable)")
p.add_argument("--target", required=True, choices=sorted(TARGETS))
p.add_argument("--doc-id-max", type=int, required=True)
p.add_argument("--chunk-id-max", type=int, required=True)
p.add_argument("--batch", type=int, default=32)
a = p.parse_args()
asyncio.run(run(a.target, a.doc_id_max, a.chunk_id_max, a.batch))
if __name__ == "__main__":
main()
+45 -3
View File
@@ -47,10 +47,15 @@ MARKDOWN_STALE_THRESHOLD_MINUTES = int(os.getenv("MARKDOWN_STALE_MINUTES", "120"
# STT 도 장기 작업 가능성이 있으나 본 PR 범위 밖 — main 에 유지(follow-up).
MAIN_QUEUE_STAGES = [
"extract", "classify", "summarize",
"preview", "stt", "thumbnail", "deep_summary", "fulltext",
"preview", "stt", "thumbnail", "fulltext",
]
MARKDOWN_QUEUE_STAGES = ["markdown"]
# 2026-06-15: deep_summary(26B, 콜당 70~300s)를 메인 루프에서 분리 (markdown/fast 선례).
# 단일 deep 호출이 1분 틱을 초과해 메인 consume_queue 가 영구 coalesce 되고 extract/
# classify 등 경량 stage 까지 굶던 문제 제거. 집합 disjoint(자기 집합만 stale reset).
DEEP_QUEUE_STAGES = ["deep_summary"]
# 고속(비-LLM·경량 GPU) stage — LLM 사이클(분 단위)에서 분리해 1분 잡 전용 소비.
# embed/chunk 는 건당 <1s 라 main 루프에 두면 classify(~190s×3) 뒤에서 굶는다
# (2026-06-12 실측: 적체 3,570 · 4070 가동률 0%). markdown 분리(05-01)와 동일 패턴.
@@ -270,7 +275,15 @@ async def _process_stage(stage, worker_fn):
item.status = "completed"
item.completed_at = datetime.now(timezone.utc)
await skip_session.commit()
await enqueue_next_stage(document_id, stage)
# 완료 커밋 후 enqueue — 실패가 outer except 로 전파돼 completed 재오픈
# 되지 않게 격리 (R3, 정상 완료 경로와 동일 처리).
try:
await enqueue_next_stage(document_id, stage)
except Exception as enq_err:
logger.error(
f"[{stage}] document_id={document_id} skip(note) 완료됐으나 "
f"다음 단계 enqueue 실패: {enq_err}"
)
logger.info(f"[{stage}] document_id={document_id} skip (note)")
continue
@@ -288,7 +301,15 @@ async def _process_stage(stage, worker_fn):
item.completed_at = datetime.now(timezone.utc)
await session.commit()
await enqueue_next_stage(document_id, stage)
# 완료는 이미 커밋됨. enqueue_next_stage 실패가 outer except 로 전파되면
# completed 항목을 재오픈(pending/failed)해 같은 단계를 재실행 = 비싼 작업 중복
# + 부분 재쓰기. 자체 try 로 격리하고 ERROR 로 가시화한다 (R3).
try:
await enqueue_next_stage(document_id, stage)
except Exception as enq_err:
logger.error(
f"[{stage}] document_id={document_id} 완료됐으나 다음 단계 enqueue 실패: {enq_err}"
)
logger.info(f"[{stage}] document_id={document_id} 완료")
except StageDeferred as defer:
@@ -405,3 +426,24 @@ async def consume_markdown_queue():
for stage in MARKDOWN_QUEUE_STAGES:
await _process_stage(stage, workers[stage])
async def consume_deep_queue():
"""deep_summary 전용 큐 소비자 (2026-06-15) — 26B 심층요약을 메인 파이프라인과 분리.
deep_summary 1콜이 70~300s(맥미니 Qwen 27B 폴백) 메인 consume_queue(1 ) 안에
있으면 틱이 interval 초과해 영구 "maximum running instances" coalesce 되고
extract/classify 경량 stage 까지 함께 굶었다. 분리 = deep 자기 1 잡에서
coalesce, 나머지 메인 루프는 완료. max_instances=1 동시 deep 2건은 방지.
"""
workers = _load_workers()
try:
await reset_stale_items(DEEP_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
except Exception:
logger.exception("deep stale reset failed, but continuing queue consumption")
for stage in DEEP_QUEUE_STAGES:
if stage in settings.pipeline_held_stages:
continue
await _process_stage(stage, workers[stage])
+3 -1
View File
@@ -102,7 +102,9 @@ async def _process_one(session: AsyncSession, qid: int, client: AIClient) -> boo
try:
async with asyncio.timeout(EMBED_TIMEOUT_S):
vec = await client.embed(text)
except (asyncio.TimeoutError, Exception) as e:
except asyncio.CancelledError:
raise # 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
except Exception as e:
logger.warning("study_q_embed_failed qid=%s err=%s: %s", qid, type(e).__name__, e)
# 실패 — status='failed'. 직전 embedding 보존.
q.embedding_status = "failed"
+6 -1
View File
@@ -121,7 +121,12 @@ async def process(document_id: int, session: AsyncSession) -> None:
ok = _extract_thumbnail(source, output, seek)
if not ok:
return
# 썸네일 추출 실패(ffmpeg)는 삼키지 않고 raise (R3) — queue_consumer 가 attempts
# 소진까지 재시도 후 status=failed 로 가시화. silent return 이면 큐가 completed 로
# 확정 + 썸네일 영구 누락 + 재시도/추적 0 (silent skip). 손상 영상이면 failed 로 안착.
raise RuntimeError(
f"thumbnail 추출 실패: document_id={document_id} source={source}"
)
doc.thumbnail_path = str(output)
doc.updated_at = datetime.now(timezone.utc)
+8
View File
@@ -52,6 +52,11 @@ DOMAIN_PRIORITY: list[tuple[str, str]] = [
("manual", "source_channel = 'manual'"),
]
# R12: filter_clause 는 SQL 에 직접 보간되므로 이 allowlist(DOMAIN_PRIORITY 출처) 통과분만
# 허용 — 현재 모듈 상수라 injection 경로 0 이나, 외부 입력화 시 즉시 차단하는 final gate
# (retrieval_service 의 _VALID_DOCS_TABLE allowlist 정본 대비 비대칭 해소).
_ALLOWED_FILTER_CLAUSES: frozenset[str] = frozenset(c for _, c in DOMAIN_PRIORITY)
async def _classify_pending(session: AsyncSession) -> int:
return int(await session.scalar(text("""
@@ -66,6 +71,9 @@ async def _enqueue_domain(session: AsyncSession, filter_clause: str, limit: int)
extracted_text 문자열 (LENGTH=0) 제외 classify_worker not doc.extracted_text
truthy 체크라 문자열에서 ValueError raise. 무한 retry 루프 방지.
"""
# R12: SQL 직접 보간 전 allowlist final gate.
if filter_clause not in _ALLOWED_FILTER_CLAUSES:
raise ValueError(f"비허용 filter_clause (allowlist 외): {filter_clause!r}")
sql = text(f"""
INSERT INTO processing_queue (document_id, stage, status, attempts, max_attempts)
SELECT id, 'classify', 'pending', 0, 3
+13 -9
View File
@@ -1,8 +1,6 @@
# hyungi_Document_Server 설정
ai:
gateway:
endpoint: "http://ai-gateway:8080"
models:
# ─── 단일 generation 호스트 routing (2026-05-14 GPU LLM 제거) ───
@@ -13,7 +11,7 @@ ai:
# triage: 상시 분류·요약·근거 선별. Mac mini Qwen 27B (primary 와 동일 endpoint, 짧은 max_tokens).
triage:
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
model: "mlx-community/Qwen3.6-27B-6bit"
max_tokens: 4096
timeout: 480 # 프리필 실측 ~112 tok/s — 120K자 장문 커버 (2026-06-11)
@@ -22,7 +20,7 @@ ai:
# primary: 에스컬레이션 전용. Qwen 27B MLX (맥미니 Semaphore(1) 보호 대상).
primary:
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
model: "mlx-community/Qwen3.6-27B-6bit"
max_tokens: 8192
timeout: 900 # 프리필 실측 ~112 tok/s — 260K자 상한 장문 커버 (2026-06-11)
@@ -72,7 +70,7 @@ ai:
# Phase 3.5a answerability classifier. 2026-05-14 GPU LLM 제거 후 Mac mini 26B 로 swap.
# classifier_service 가 hasattr 체크로 optional 이므로 이 섹션 제거 시 classifier gate 는 자동 skip (score-only).
classifier:
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
model: "mlx-community/Qwen3.6-27B-6bit" # 2026-06-11 B안 동승 — gemma id 잔존 시 mlx 서버가 Gemma 를 재로드(이중 적재) 위험
max_tokens: 512
timeout: 30 # 2026-05-17: 15s 도 동시 부하 시 elapsed 14.4s 직전이라 tight — 30s 로 2x 마진. classifier_service.LLM_TIMEOUT_MS=30000 와 align (초과 = score-only skip, graceful)
@@ -199,8 +197,14 @@ schedule:
# 이력: 2026-06-11 맥미니 모델 확정까지 8키 홀드 → 同日 Qwen3.6-27B-6bit 전환과 함께 해제([]).
pipeline:
held_stages: []
# mlx gate 동시 실행 상한 (2026-06-12 fair-share): 구 "1 고정" 룰의 전제(single-inference
# 서버)가 소멸 — 현 mlx_vlm 은 continuous batching (2026-06-11 밤 6~8 concurrent 실측 정상).
# 2 = 워커 LLM 호출과 인터랙티브(ask/eid)가 서로 안 막힘 + 집계 throughput ~1.8배.
# 게이트(상한+우선순위)는 유지 — thundering herd 방지. 1 로 되돌리면 구 동작.
# mlx gate 동시 실행 상한 (config.mlx_gate_concurrency). 현 mlx_vlm = continuous batching
# (2026-06-11 밤 6~8 concurrent 실측 정상). 2026-06-15: 2→4 — digest/briefing 합성을
# 이 단일 게이트(BACKGROUND 우선순위)로 라우팅하며 digest(클러스터 44~68)가 하드캡 내
# 완료되도록 동시성 확보. ask/eid(FOREGROUND)는 큐 점프라 영향 최소. 되돌리면 구 동작.
mlx_gate_concurrency: 2
# 2026-06-15: digest/briefing 생성 LLM 파라미터 (모델 교체 후 단일소스, 상세 = config.py).
# 구 하드코딩 25s(빠른 Gemma)가 Qwen 27B(콜당 ~90~300s) 교체 sweep 누락 → digest 600s
# 초과·briefing 4/4 폴백. 동시성은 위 mlx_gate_concurrency 가 담당(별 키 없음).
digest_llm_timeout_s: 300
digest_llm_attempts: 2
digest_pipeline_hard_cap_s: 5400
-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:
+11
View File
@@ -213,3 +213,14 @@ body {
/* Phase 1C: frontmatter 박스 — 본문 위 메타 표시 */
.md-frontmatter dt { font-weight: 500; }
/* AI 요약(TL;DR 등) 마크다운 렌더 — 좁은 카드에 맞게 문단/리스트 마진 압축 */
.summary-md > :first-child { margin-top: 0; }
.summary-md > :last-child { margin-bottom: 0; }
.summary-md p { margin: 0 0 0.45em; }
.summary-md ul, .summary-md ol { margin: 0.25em 0; padding-left: 1.2em; }
.summary-md ul { list-style: disc; }
.summary-md ol { list-style: decimal; }
.summary-md li { margin: 0.1em 0; }
.summary-md strong { font-weight: 700; }
.summary-md code { background: rgba(0, 0, 0, 0.05); padding: 0 0.3em; border-radius: 3px; }
@@ -12,6 +12,7 @@
-->
<script lang="ts">
import { api } from '$lib/api';
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
import Badge from '$lib/components/ui/Badge.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
@@ -104,9 +105,7 @@
</div>
{#if tldr}
<p class="text-xs font-medium text-text leading-relaxed mb-2">
{tldr}
</p>
<div class="summary-md text-xs font-medium text-text leading-relaxed mb-2">{@html renderDocMarkdown(tldr)}</div>
{/if}
{#if bullets && bullets.length > 0}
@@ -1,16 +1,18 @@
<script lang="ts">
// 처리 머신 보드 v2파이프라인 흐름 뷰 (plan ds-board-engines-1, R2 통합안).
// 메인 = 좌→우 흐름 노드(병목 amber·실패 뱃지), 노드 클릭 = 상세 패널(안1 변형),
// 실패 뱃지 클릭 = 실패 처리 드로어 (재시도/건너뛰기 — 영구 실패의 유일한 조치 경로).
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어 열 때).
// 처리 머신 보드 v3통합안 (plan ds-board-merged: C2 머신레인 + C3 번다운/정직ETA).
// · 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드(맥북 합류) 가시화
// · 지배 백로그 번다운 패널 = "언제 끝나나" + 유입 차감한 정직 ETA(summarize_eta)
// · 신선도 '갱신 N초 전' + stale 경고 / 실패 드로어·상세 패널은 v2 자산 재사용.
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어).
import { api } from '$lib/api';
import { refreshQueueOverview } from '$lib/stores/queueOverview';
import { refreshQueueOverview, queueUpdatedAt } from '$lib/stores/queueOverview';
import { addToast } from '$lib/stores/toast';
import {
AUX_NODES,
FLOW_NODES,
MACHINE_META,
type FlowNodeDef,
type FlowMachine,
etaShort,
flowStageLabel,
formatAgeSec,
@@ -20,6 +22,7 @@
FailedItem,
FailedListResponse,
MachineCurrentItem,
MachineOverview,
QueueOverview,
QueueStageRow,
RetryResponse,
@@ -82,14 +85,6 @@
);
const totalFailed = $derived(overview.totals.failed);
// 머신 스트립 — overview.machines 의 state/처리율 + 정적 모델 메타
const machineStrip = $derived(
overview.machines.map((m) => ({
...m,
meta: MACHINE_META[m.key],
})),
);
// ─── 선택 상태 (노드 상세 / 실패 드로어 — 동시에 하나만) ───
let selected = $state<string | null>(null);
let failOpen = $state(false);
@@ -194,22 +189,122 @@
await Promise.all([loadFailures(), refreshQueueOverview()]);
}
// ─── trend_24h 스파크라인 (summarize 유입 vs 소화 — API 가 주는데 미렌더이던 슬롯) ───
const spark = $derived.by(() => {
// ─── 머신 레인 (C2) — mainNodes 를 머신별로 그룹 + 머신 카드(state/처리율) 결합 ───
const machineByKey = $derived(
new Map<FlowMachine, MachineOverview>(overview.machines.map((m) => [m.key as FlowMachine, m])),
);
const LANE_ORDER: FlowMachine[] = ['gpu', 'macmini', 'macbook'];
const lanes = $derived(
LANE_ORDER.map((key) => ({
key,
meta: MACHINE_META[key],
card: machineByKey.get(key) ?? null,
nodes: mainNodes.filter((n) => n.def.machine === key),
})),
);
// 요약 오프로드 분담 — 맥미니 vs 맥북 (A-1 summarize_by_machine)
const split = $derived(overview.summarize_by_machine);
const splitTotal1h = $derived(Math.max(1, split.macmini.done_1h + split.macbook.done_1h));
const macbookSharePct = $derived(Math.round((split.macbook.done_1h / splitTotal1h) * 100));
// 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
const offloadActive = $derived(split.macbook.done_1h > 0);
// ─── 백그라운드 작업 (큐 밖 스크립트 backfill) — processing_queue 사각지대 노출 ───
const bgJobs = $derived(overview.background_jobs ?? []);
const runningBg = $derived(bgJobs.filter((j) => j.state === 'running'));
function bgForMachine(key: string) {
return runningBg.filter((j) => j.machine === key);
}
function fmtElapsed(s: number): string {
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
}
function bgDot(j: { state: string; stale: boolean }): string {
if (j.state === 'running') return j.stale ? 'bg-warning' : 'bg-success';
if (j.state === 'failed') return 'bg-error';
return 'bg-faint';
}
// ─── 지배 백로그 = 요약. 정직 ETA(유입 차감) — summarize_eta ───
const eta = $derived(overview.summarize_eta);
// 정직 ETA 라벨: eta_minutes null = 유입이 소화를 앞섬(소진 불가)
const honestEtaLabel = $derived(
eta.pending === 0
? '비어 있음'
: eta.eta_minutes != null
? etaShort(eta.eta_minutes)
: '소진 불가',
);
const honestEtaWarn = $derived(eta.pending > 0 && eta.eta_minutes == null);
/** 단계별 정직 ETA(순소화율) — 노드용. 유입>소화면 null(소진 불가) */
function netEtaLabel(n: NodeStats): string | null {
if (n.pending === 0) return '한가';
const net = n.done1h - n.created1h;
if (net > 0) return etaShort(Math.round((n.pending / net) * 60));
if (n.created1h > n.done1h) return '유입 우세';
return null;
}
// ─── 신선도 (B-4) — '갱신 N초 전' + stale 경고 (폴링 60s) ───
let now = $state(Date.now());
$effect(() => {
const id = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(id);
});
const ageSec = $derived(
$queueUpdatedAt != null ? Math.max(0, Math.round((now - $queueUpdatedAt) / 1000)) : null,
);
const stale = $derived(ageSec != null && ageSec > 90);
const freshLabel = $derived(
ageSec == null
? '갱신 대기'
: ageSec < 60
? `갱신 ${ageSec}초 전`
: `갱신 ${Math.round(ageSec / 60)}분 전`,
);
// ─── 24h 번다운 (C3) — 요약 유입 vs 소화 + 맥북 합류 변곡점 마커 ───
const burn = $derived.by(() => {
const t = overview.trend_24h;
if (!t || t.length === 0) return null;
const max = Math.max(1, ...t.map((b) => Math.max(b.inflow, b.done)));
const w = 120;
const h = 24;
const w = 300;
const h = 64;
const step = w / Math.max(1, t.length - 1);
const pts = (sel: (b: (typeof t)[number]) => number) =>
t.map((b, i) => `${(i * step).toFixed(1)},${(h - (sel(b) / max) * (h - 3) + 1).toFixed(1)}`).join(' ');
return { inflow: pts((b) => b.inflow), done: pts((b) => b.done) };
const y = (v: number) => (h - (v / max) * (h - 8) + 4).toFixed(1);
const line = (sel: (b: (typeof t)[number]) => number) =>
t.map((b, i) => `${(i * step).toFixed(1)},${y(sel(b))}`).join(' ');
const doneLine = line((b) => b.done);
const area = `0,${h} ${doneLine} ${w.toFixed(1)},${h}`;
// 합류 변곡점 = done 최대 버킷 (맥북 야간 drain 합류 추정)
let mi = 0;
t.forEach((b, i) => {
if (b.done > t[mi].done) mi = i;
});
return {
w,
h,
area,
doneLine,
inflowLine: line((b) => b.inflow),
markX: (mi * step).toFixed(1),
markHour: t[mi].hour,
markDone: t[mi].done,
peak: max,
};
});
// 머신 상태 dot 색 클래스
function dotClass(state: string): string {
return state === 'active' ? 'bg-success' : state === 'deferred' ? 'bg-warning' : 'bg-faint';
}
</script>
<div class="mt-5">
<!-- 헤더: 타이틀 + 요약 24h 스파크라인 + 실패 합계 -->
<!-- 헤더: 타이틀 + 신선도 + 실패 합계 -->
<div class="flex items-center justify-between gap-3 mb-3">
<div class="text-[11px] font-bold text-dim uppercase tracking-wider">처리 머신</div>
<div class="flex items-center gap-3">
@@ -219,80 +314,108 @@
onclick={openFailures}
>실패 {totalFailed}건 처리</button>
{/if}
{#if spark}
<div class="flex items-center gap-2 text-[10px] text-faint tabular-nums" title="요약(summarize) 단계 24시간 — 유입(회색) vs 소화(녹색)">
<svg width="120" height="24" viewBox="0 0 120 24" class="block">
<polyline points={spark.inflow} fill="none" stroke="currentColor" stroke-width="1.5" class="text-faint" />
<polyline points={spark.done} fill="none" stroke="currentColor" stroke-width="1.5" class="text-success" />
</svg>
<span>요약 24h 유입/소화</span>
</div>
{/if}
<span class="flex items-center gap-1.5 text-[10px] tabular-nums {stale ? 'text-warning' : 'text-faint'}" title="60초 폴링">
<span class="w-1.5 h-1.5 rounded-full {stale ? 'bg-warning' : 'bg-success'}"></span>
{freshLabel}{#if stale} · 갱신 지연{/if}
</span>
</div>
</div>
<!-- 머신 스트립 -->
<div class="flex flex-wrap gap-2 mb-3">
{#each machineStrip as m (m.key)}
<div class="flex items-center gap-2 bg-surface border border-default rounded-full px-3.5 py-1.5 text-xs">
<span class="w-2 h-2 rounded-full shrink-0 {m.state === 'active' ? 'bg-success' : m.state === 'deferred' ? 'bg-warning' : 'bg-faint'}"></span>
<span class="font-bold text-text">{m.meta?.label ?? m.label}</span>
<span class="text-[10px] text-faint font-mono">{m.meta?.model}</span>
<span class="text-[11px] text-dim tabular-nums">{formatRate(m.done_1h)}/h</span>
{#if m.key === 'macbook' && m.deferred_pending > 0}
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {m.deferred_pending}</span>
{/if}
<!-- 지배 백로그 스트립 (요약) + 정직 ETA -->
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 bg-surface border border-warning/50 rounded-card px-3.5 py-2 mb-3">
<span class="text-[9px] font-bold text-warning border border-warning/60 rounded-full px-2 py-px">지배 백로그</span>
<span class="text-xs font-bold text-text">요약</span>
<span class="text-[11px] text-dim tabular-nums">대기 <b class="text-text">{eta.pending.toLocaleString()}</b> · 순소화 <b class="text-text">{formatRate(eta.done_rate_1h)}</b>/h · 유입 {formatRate(eta.inflow_rate_1h)}/h</span>
<span class="ml-auto flex items-center gap-1.5 border rounded-full px-2.5 py-0.5 {honestEtaWarn ? 'border-warning text-warning' : 'border-accent text-accent'}">
<span class="text-[10px] font-semibold">정직 ETA</span>
<span class="text-xs font-bold tabular-nums">{honestEtaLabel}</span>
</span>
</div>
<!-- 머신 레인 (누가 일하나 + 요약 오프로드) -->
<div class="grid gap-2 mb-3">
{#each lanes as lane (lane.key)}
<div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
<div class="flex items-center gap-2 flex-wrap mb-2">
<span class="w-2 h-2 rounded-full shrink-0 {dotClass(bgForMachine(lane.key).length > 0 ? 'active' : (lane.card?.state ?? 'idle'))}"></span>
<span class="text-[9px] font-bold rounded px-1.5 py-px mtag-{lane.key}">{lane.meta.label}</span>
<span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
<span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
{#each bgForMachine(lane.key) as j (j.id)}<span class="text-[10px] font-semibold text-success tabular-nums ml-1">생성 중: {j.label ?? j.kind}{#if j.total} {j.processed}/{j.total}{/if}</span>{/each}
{#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
{/if}
{#if lane.card?.state === 'deferred'}
<span class="text-[9px] text-warning">잠듦 — 요약은 맥미니로 복귀</span>
{/if}
</div>
<div class="flex items-stretch gap-1.5 flex-wrap">
{#each lane.nodes as n (n.def.key)}
{@const idle = n.pending + n.processing + n.doneToday + n.failed === 0}
<button
class="relative text-left rounded-lg border px-2.5 py-1.5 transition-colors cursor-pointer hover:bg-surface-hover min-w-[96px]
{idle ? 'border-dashed border-default opacity-55' : n.inflowDominant ? 'border-warning' : 'border-default'}
{selected === n.def.key ? 'node-sel' : ''}"
onclick={() => toggleNode(n.def.key)}
title="{n.def.label} — 클릭하면 상세"
>
{#if n.failed > 0}
<span class="absolute -top-1.5 -right-1 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5">{n.failed}</span>
{/if}
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">
{n.def.label}
{#if n.processing > 0}<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>{/if}
</div>
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{n.pending.toLocaleString()}<span class="text-[9px] text-faint font-normal ml-0.5">대기</span></div>
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}</div>
{#if n.def.key === 'summarize'}
<div class="mt-1 h-1 w-full rounded-full overflow-hidden flex" title="맥미니 {split.macmini.done_1h}/h · 맥북 {split.macbook.done_1h}/h">
<span class="block h-full mtag-macmini-bar" style="width:{100 - macbookSharePct}%"></span>
<span class="block h-full mtag-macbook-bar" style="width:{macbookSharePct}%"></span>
</div>
<div class="text-[9px] text-faint tabular-nums whitespace-nowrap mt-0.5">맥미니 {split.macmini.done_1h} · 맥북 {split.macbook.done_1h}/h</div>
{/if}
</button>
{/each}
{#if lane.key === 'macbook' && offloadActive}
<button
class="text-left rounded-lg border border-dashed border-warning/50 px-2.5 py-1.5 cursor-pointer hover:bg-surface-hover min-w-[96px]"
onclick={() => toggleNode('summarize')}
title="맥북이 요약을 맥미니에서 가져와 처리 중"
>
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">요약 합류 <span class="text-[8px] font-bold text-warning">OFFLOAD</span></div>
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{split.macbook.done_1h}<span class="text-[9px] text-faint font-normal ml-0.5">/h</span></div>
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">요약의 {macbookSharePct}% 담당</div>
</button>
{/if}
</div>
</div>
{/each}
</div>
<!-- 흐름 노드 — pt/px 헤드룸 = 실패 뱃지(-top/-right 돌출)가 스크롤 컨테이너에 잘리지 않게 -->
<div class="flex items-stretch overflow-x-auto pt-2.5 pb-1 px-2 -mx-2">
{#each mainNodes as n, i (n.def.key)}
{#if i > 0}
<div class="flex items-center text-faint text-sm px-1.5 shrink-0" aria-hidden="true"></div>
{/if}
<div
class="relative bg-surface border-[1.5px] rounded-card px-3 py-2.5 min-w-[124px] shrink-0 text-left transition-colors cursor-pointer hover:bg-surface-hover
{n.inflowDominant ? 'border-warning' : n.etaMinutes != null && n.def.stages.includes('chunk') ? 'border-success' : 'border-default'}
{selected === n.def.key ? 'node-sel' : ''}"
role="button"
tabindex="0"
onclick={() => toggleNode(n.def.key)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNode(n.def.key); } }}
title="{n.def.label} — 클릭하면 상세"
>
{#if n.failed > 0}
<button
class="absolute -top-2 -right-1.5 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5 py-px shadow cursor-pointer"
onclick={(e) => { e.stopPropagation(); openFailures(); }}
title="실패 {n.failed}건 — 클릭하면 실패 처리"
>{n.failed}</button>
{/if}
<span class="inline-block text-[9px] font-bold rounded px-1.5 py-px mb-1.5 mtag-{n.def.machine}">
{MACHINE_META[n.def.machine].label} · {n.def.engine}
</span>
<div class="text-xs font-bold text-text flex items-center gap-1.5">
{n.def.label}
{#if n.processing > 0}
<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse" title="처리 중 {n.processing}"></span>
{/if}
{#if n.inflowDominant}
<span class="text-[9px] font-bold text-warning">유입 우세</span>
{/if}
</div>
<div class="text-base font-extrabold tabular-nums tracking-tight leading-tight mt-0.5 text-text">
{n.pending.toLocaleString()}
</div>
<div class="text-[10px] text-dim tabular-nums">
{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}
{#if n.etaMinutes != null && !n.inflowDominant && n.pending > 0}
· <span class="text-accent font-semibold">{etaShort(n.etaMinutes)}</span>
{/if}
</div>
<!-- 번다운 / ETA 패널 -->
{#if burn}
<div class="bg-surface border border-default rounded-card px-3.5 py-3 mb-1">
<div class="flex items-center gap-2 mb-2">
<span class="text-[11px] font-bold text-text">요약 백로그 24시간</span>
<span class="text-[9px] text-faint">유입(회색) vs 소화(녹색)</span>
{#if offloadActive}<span class="text-[9px] text-warning ml-auto">맥북 합류 {burn.markHour} — 소화 급증</span>{/if}
</div>
{/each}
</div>
<svg viewBox="0 0 {burn.w} {burn.h}" class="block w-full" style="height:64px" preserveAspectRatio="none" role="img" aria-label="요약 백로그 24시간 번다운">
<polygon points={burn.area} fill="currentColor" class="text-success" opacity="0.12" />
<polyline points={burn.inflowLine} fill="none" stroke="currentColor" stroke-width="1.2" class="text-faint" />
<polyline points={burn.doneLine} fill="none" stroke="currentColor" stroke-width="1.6" class="text-success" />
{#if offloadActive}
<line x1={burn.markX} y1="0" x2={burn.markX} y2={burn.h} stroke="currentColor" stroke-width="1" stroke-dasharray="2 2" class="text-warning" opacity="0.7" />
{/if}
</svg>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-default text-[10px] text-dim tabular-nums">
{#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)}
<span class="whitespace-nowrap">{n.def.label} 대기 <b class="text-text">{n.pending.toLocaleString()}</b>{#if netEtaLabel(n)} · <span class="text-accent font-semibold">{netEtaLabel(n)}</span>{/if}</span>
{/each}
</div>
</div>
{/if}
<!-- 보조 라인 -->
<p class="text-[10px] text-faint mt-1.5 tabular-nums">
@@ -361,6 +484,32 @@
</div>
{/if}
<!-- 백그라운드 작업 (큐 밖 스크립트 backfill 등 — processing_queue 가 못 보는 사각지대) -->
{#if bgJobs.length > 0}
<div class="mt-3">
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-2">백그라운드 작업</div>
<div class="grid gap-2">
{#each bgJobs as j (j.id)}
<div class="bg-surface border rounded-card px-3.5 py-2.5 {j.stale ? 'border-warning' : j.state === 'failed' ? 'border-error' : 'border-default'}">
<div class="flex items-center gap-2 flex-wrap">
<span class="w-2 h-2 rounded-full shrink-0 {bgDot(j)}"></span>
<span class="text-[9px] font-bold rounded px-1.5 py-px bg-default text-dim font-mono">{j.kind}</span>
<span class="text-xs font-semibold text-text truncate">{j.label ?? '작업'}</span>
<span class="text-[11px] text-dim tabular-nums ml-auto">
{#if j.total}{j.processed.toLocaleString()}/{j.total.toLocaleString()}{:else}{j.processed.toLocaleString()}{/if} · {fmtElapsed(j.elapsed_sec)}
</span>
</div>
{#if j.stale}
<div class="text-[10px] text-warning mt-1.5">heartbeat 끊김 — 프로세스 중단 추정 (재개 필요할 수 있음)</div>
{:else if j.state === 'failed'}
<div class="text-[10px] text-error mt-1.5 truncate">실패{#if j.error} · {j.error}{/if}</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- 실패 처리 드로어 -->
{#if failOpen}
<div class="border border-error/40 rounded-card mt-3 overflow-hidden bg-surface">
@@ -413,6 +562,9 @@
.mtag-gpu { background: #e7eef6; color: #3b6ea5; }
.mtag-macmini { background: #efe9f7; color: #8a5fbf; }
.mtag-macbook { background: #f7eedd; color: #b07a10; }
/* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */
.mtag-macmini-bar { background: #8a5fbf; }
.mtag-macbook-bar { background: #b07a10; }
.node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; }
.detail-frame { border-color: #3b6ea5; }
.detail-head { background: #e7eef6; }
@@ -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;
@@ -43,14 +72,17 @@
{@const open = selectedId === s.chunk_id}
{@const active = activeKey != null && activeKey === s.chunk_id}
{@const typeLabel = sectionTypeLabel(s.section_type)}
{@const depth = Math.max(0, (s.level ?? 1) - 1)}
<li>
<button
type="button"
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
aria-expanded={open}
aria-current={active ? 'true' : undefined}
style="padding-left:{8 + depth * 13}px"
class={[
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
'w-full text-left pr-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
depth > 0 ? 'text-[11px]' : '',
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
].join(' ')}
>
@@ -92,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>
@@ -115,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>
+8 -1
View File
@@ -17,6 +17,11 @@ let pollHandle: ReturnType<typeof setInterval> | null = null;
let subscriberCount = 0;
let inFlight: Promise<void> | null = null;
// 마지막 성공 갱신 시각(epoch ms) — 보드 신선도 '갱신 N초 전' + stale 경고용
// (ds-board-merged B-4). 실패(null 수렴) 시엔 갱신 안 함 → age 가 늘어 stale 로 드러남.
const updatedAt = writable<number | null>(null);
export const queueUpdatedAt = { subscribe: updatedAt.subscribe };
const internal = writable<QueueOverview | null>(null, (_set) => {
subscriberCount += 1;
if (subscriberCount === 1 && browser) {
@@ -54,7 +59,9 @@ export async function refreshQueueOverview(): Promise<void> {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
internal.set(await fetchOverview());
const ov = await fetchOverview();
internal.set(ov);
if (ov) updatedAt.set(Date.now()); // 성공 시에만 신선도 갱신 (실패=stale 유지)
} finally {
inFlight = null;
}
+24 -1
View File
@@ -43,13 +43,19 @@ export interface SummarizeEta {
eta_minutes: number | null;
}
/** 시간당 유입 vs 소화 (이번 트랙 미렌더 — 후속 추세 위젯 슬롯) */
/** 시간당 유입 vs 소화 (요약 24h 추이) */
export interface TrendPoint {
hour: string;
inflow: number;
done: number;
}
/** summarize 머신별 완료 실적 분담 (오프로드 가시화 — ds-board-merged A-1) */
export interface SummarizeByMachine {
macmini: { done_1h: number; done_today: number };
macbook: { done_1h: number; done_today: number };
}
export interface QueueTotals {
pending: number;
processing: number;
@@ -69,12 +75,29 @@ export interface QueueStageRow {
oldest_pending_age_sec: number | null;
}
/** ( ) processing_queue .
* stale = running heartbeat ( ). */
export interface BackgroundJob {
id: number;
kind: string;
label: string | null;
state: 'running' | 'done' | 'failed';
machine: string;
processed: number;
total: number | null;
elapsed_sec: number;
stale: boolean;
error: string | null;
}
export interface QueueOverview {
machines: MachineOverview[];
summarize_eta: SummarizeEta;
summarize_by_machine: SummarizeByMachine;
trend_24h: TrendPoint[];
stages: QueueStageRow[];
totals: QueueTotals;
background_jobs?: BackgroundJob[];
}
/** ─── 실패 처리 (ds-board-engines-1) — GET /api/queue/failed · POST /retry|/skip ─── */
+66 -1
View File
@@ -15,6 +15,7 @@
import DOMPurify from 'dompurify';
import { Marked } from 'marked';
import katex from 'katex';
// @ts-ignore — 타입 정의 누락 시 무시
import markedKatex from 'marked-katex-extension';
// @ts-ignore — 타입 정의 누락 시 무시
@@ -64,6 +65,19 @@ docMarked.use({
`</figure>`
);
},
// 외부 링크(http/https) → 새 탭 + rel=noopener noreferrer (탭내빙 차단). 521건 실재.
// 내부/프래그먼트/상대 링크는 손대지 않음 — `#` anchor 는 gfmHeadingId/outline 경로 유지
// (클릭 인터셉터 없음 → 충돌 0), 상대 .md(코퍼스 0건)는 기본 동작(inert). marked 15 토큰객체 시그니처.
link(token: any): string {
const href = (token?.href ?? '') as string;
const text = this.parser.parseInline(token?.tokens ?? []);
const titleAttr = token?.title ? ` title="${escAttr(token.title as string)}"` : '';
const safeHref = escAttr(href);
if (/^https?:\/\//i.test(href)) {
return `<a href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
}
return `<a href="${safeHref}"${titleAttr}>${text}</a>`;
},
},
});
@@ -81,6 +95,8 @@ const SANITIZE_OPTS = {
'data-md-image-internal',
'data-md-image-alt',
'loading',
'target',
'rel',
],
ADD_TAGS: ['figure', 'figcaption'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta'],
@@ -88,10 +104,59 @@ const SANITIZE_OPTS = {
ALLOW_UNKNOWN_PROTOCOLS: false,
} as const;
// ── 수식 pre-render ──────────────────────────────────────────────────────────
// marked-katex-extension 의 토크나이저는 `$$` 가 블록 선두에 있어야 발화하는데,
// (1) 개요 anchor splice 가 `$$` 직전에 <span id="sec-N"> 를 끼우면 `$$` 가 문단 중간으로
// 밀려 블록 규칙이 깨지고, (2) 빌드/런타임 환경에 따라 확장 토크나이저가 발화하지 않으면
// `$$` 가 평문으로 새어 marked 의 백슬래시 이스케이프(\% → %, \, → ,)에 망가진다.
// → marked 가 손대기 *전에* 수식을 katex 로 직접 렌더해 placeholder 로 보호한 뒤 복원한다.
// 위치·인접 상황과 무관(전역 정규식)하므로 위 두 경우를 모두 우회한다.
const _MATH_SLOT = (i: number) => `KX0MATHSLOT${i}MATHKX0`; // marked-안전(영숫자) + 충돌 불가
const _MATH_SLOT_RE = /KX0MATHSLOT(\d+)MATHKX0/g;
const _BLOCK_MATH_RE = /\$\$([\s\S]+?)\$\$/g;
// 인라인 $...$ — 통화($5)·이스케이프(\$)·`$$` 회피. $ 직후 비공백, $ 직전 비공백.
const _INLINE_MATH_RE = /(?<![\\$\d])\$(?!\s)([^$\n]*?[^$\n\s])\$(?!\d)/g;
function _protectMath(text: string, slots: string[]): string {
const render = (tex: string, displayMode: boolean): string => {
slots.push(
katex.renderToString(tex.trim(), { displayMode, throwOnError: false, output: 'html' }),
);
return _MATH_SLOT(slots.length - 1);
};
return text
.replace(_BLOCK_MATH_RE, (m, tex) => {
try {
return render(String(tex), true);
} catch {
return m;
}
})
.replace(_INLINE_MATH_RE, (m, tex) => {
try {
return render(String(tex), false);
} catch {
return m;
}
});
}
export function renderDocMarkdown(text: string | null | undefined): string {
if (!text) return '';
try {
const html = docMarked.parse(text) as string;
const slots: string[] = [];
const protectedText = _protectMath(text, slots);
let html = docMarked.parse(protectedText) as string;
if (slots.length) {
// 블록 수식이 단독 문단이면 marked 가 <p> 로 감싸므로 그 <p> 를 벗겨 블록 수식이 문단에
// 매몰되지 않게 한다. (katex-display 는 block 이라 <p> 안에 두면 브라우저가 자동 분리.)
html = html
.replace(
new RegExp(`<p>\\s*KX0MATHSLOT(\\d+)MATHKX0\\s*</p>`, 'g'),
(m, i) => slots[Number(i)] ?? m,
)
.replace(_MATH_SLOT_RE, (m, i) => slots[Number(i)] ?? m);
}
return DOMPurify.sanitize(html, SANITIZE_OPTS);
} catch {
// 마지막 안전망: 모든 태그 제거 후 escape
+282
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';
@@ -83,6 +89,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[] = [];
@@ -122,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);
});
+210 -10
View File
@@ -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 {
@@ -69,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 태그
@@ -107,22 +125,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<string, number>();
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<number, number>(); // 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;
}
@@ -160,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;
}
+2 -5
View File
@@ -3,7 +3,7 @@
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox, PanelLeft, MessageCircle } from 'lucide-svelte';
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, StickyNote, Inbox, PanelLeft } from 'lucide-svelte';
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
import { toasts, removeToast } from '$lib/stores/toast';
import { refresh as refreshPublicConfig } from '$lib/stores/config';
@@ -151,8 +151,7 @@
{/if}
</div>
<a href="/ask" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/ask') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">질문</a>
<a href="/chat" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/chat') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">이드</a>
<a href="/memos" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/memos') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">메모</a>
<SystemStatusDot />
</div>
@@ -212,8 +211,6 @@
<nav class="lg:hidden shrink-0 flex border-t border-default bg-sidebar" aria-label="하단 탭">
<a href="/documents" aria-current={docsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {docsActive ? 'text-accent' : 'text-dim'}"><FileText size={18} strokeWidth={1.9} /> 문서</a>
<a href="/news" aria-current={newsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {newsActive ? 'text-accent' : 'text-dim'}"><Newspaper size={18} strokeWidth={1.9} /> 뉴스</a>
<a href="/ask" aria-current={isActive('/ask') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/ask') ? 'text-accent' : 'text-dim'}"><HelpCircle size={18} strokeWidth={1.9} /> 질문</a>
<a href="/chat" aria-current={isActive('/chat') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/chat') ? 'text-accent' : 'text-dim'}"><MessageCircle size={18} strokeWidth={1.9} /> 이드</a>
<a href="/memos" aria-current={isActive('/memos') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/memos') ? 'text-accent' : 'text-dim'}"><StickyNote size={18} strokeWidth={1.9} /> 메모</a>
<button onclick={() => ui.openDrawer('sidebar')} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold text-dim"><Menu size={18} strokeWidth={1.9} /> 더보기</button>
</nav>
+22 -54
View File
@@ -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 @@
<div class="flex h-full min-h-0">
<!-- ═══ 좌: 리스트 컬럼 ═══ -->
<div class="{selectedDoc ? 'hidden lg:flex' : 'flex'} flex-col w-full lg:w-[340px] lg:shrink-0 lg:border-r border-default min-h-0">
<!-- ═══ 문서 목록 (풀폭 중앙) — 클릭 시 D3 상세로 이동 ═══ -->
<div class="flex flex-col w-full max-w-5xl mx-auto min-h-0">
<UploadDropzone onupload={loadDocuments} />
<!-- 검색바 -->
@@ -487,6 +483,19 @@
{/if}
</div>
<!-- AI 답변 (질문형 검색) — 목록 상단 고정, 아래로 목록 스크롤 -->
{#if showAskCard}
<div class="px-3 py-2 shrink-0 border-b border-default max-h-[55vh] overflow-y-auto">
<AskAnswerCard
data={askData}
loading={askLoading}
error={askError}
onCitationClick={(docId) => goto(`/documents/${docId}`)}
onDismiss={() => { askDismissed = true; }}
/>
</div>
{/if}
<!-- 선택 toolbar -->
{#if selectionCount > 0}
<div class="flex flex-wrap items-center gap-2 px-3 py-2 shrink-0 bg-accent/10 border-y border-accent/30">
@@ -587,47 +596,6 @@
</div>
</div>
<!-- ═══ 중앙: 리더 ═══ -->
<div class="{selectedDoc ? 'flex' : 'hidden lg:flex'} flex-1 min-w-0 flex-col min-h-0">
{#if selectedDoc}
<!-- 리더 상단 바: (모바일) 뒤로 / (lg) 인스펙터 토글 -->
<div class="flex items-center gap-2 px-3 py-1.5 shrink-0 border-b border-default bg-sidebar">
<button type="button" onclick={() => { selectedDoc = null; if (ui.isDrawerOpen('meta')) ui.closeDrawer(); }}
class="lg:hidden flex items-center gap-1 text-xs text-accent-hover font-medium" aria-label="목록으로">
<ChevronLeft size={15} /> 문서
</button>
<div class="flex-1"></div>
<button type="button" onclick={toggleInfoPanel} aria-pressed={isPanelActive} title="문서 정보"
class="p-1.5 rounded-lg border transition-colors {isPanelActive ? 'border-accent text-accent bg-accent/10' : 'border-default text-dim hover:text-accent hover:border-accent'}">
<Info size={16} />
</button>
</div>
<div class="flex-1 min-h-0">
<DocumentViewer doc={selectedDoc} />
</div>
{:else if showAskCard}
<div class="p-4 lg:p-6 overflow-y-auto">
<AskAnswerCard
data={askData}
loading={askLoading}
error={askError}
onCitationClick={(docId) => goto(`/documents/${docId}`)}
onDismiss={() => { askDismissed = true; }}
/>
</div>
{:else}
<div class="hidden lg:flex flex-1 items-center justify-center text-dim text-sm">
왼쪽에서 문서를 선택하세요
</div>
{/if}
</div>
<!-- ═══ 우: 인스펙터 (xl+ inline) ═══ -->
{#if selectedDoc && inspectorOpen}
<aside class="hidden xl:flex flex-col w-[300px] shrink-0 border-l border-default bg-sidebar overflow-y-auto" aria-label="문서 정보">
{@render inspector(selectedDoc)}
</aside>
{/if}
</div>
<!-- < xl 폴백: Drawer (정보 하단/측면 시트) -->
+442 -445
View File
@@ -1,119 +1,90 @@
<script>
// Phase E.2 — detail 페이지 inline 편집.
// 기존 read-only 메타 패널(L138201)을 editors/* 스택으로 교체.
// + E.3 관련 문서 stub, + 헤더 affordance row.
// 문서 상세 /documents/[id] — 확정 시안(d3-deepened) 스타일을 그대로 포팅, 데이터만 바인딩.
// 데스크탑: 상단 헤더 띠 + [좌 절 트리(색바+연결선)][중 절 집중 뷰][우 슬림 레일]. 절 없으면 fallback.
// 모바일: 헤더 + 나란한 토글 pill(절구조|인사이트) + 본문 절 카드 연속(+탭 이동). 편집/필기/네비 보존.
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, getAccessToken } from '$lib/api';
import { isMdSuccess } from '$lib/utils/mdStatus';
import { resolveAnchorMap } from '$lib/utils/resolveAnchorMap';
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ExternalLink, Download, Link2, FileText, PenLine, X, ChevronLeft, ChevronRight, Check } from 'lucide-svelte';
import { ChevronRight, FileText } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte';
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
import AnalysisPanel from '$lib/components/AnalysisPanel.svelte';
import ReadCounter from '$lib/components/ReadCounter.svelte';
import SectionOutline from '$lib/components/SectionOutline.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
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 });
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
return DOMPurify.sanitize(marked(text || ''), {
USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'], FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
let doc = $state(null);
let loading = $state(true);
let error = $state(null); // 'not_found' | 'network' | null
let rawMarkdown = $state(''); // fallback: extracted_text 없을 때 원본 .md
let error = $state(null);
let rawMarkdown = $state('');
let docId = $derived($page.params.id);
// 손글씨 노트 (자료별 1:1) — "필기" 토글 시 사이드 캔버스 띄움.
// 필기
let noteOpen = $state(false);
let noteStrokes = $state(null); // { version, strokes }
let noteStrokes = $state(null);
let noteLoaded = $state(false);
async function ensureNoteLoaded() {
if (noteLoaded) return;
try {
const r = await api(`/documents/${docId}/note`);
noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] };
} catch {
noteStrokes = { version: 1, strokes: [] };
}
try { const r = await api(`/documents/${docId}/note`); noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] }; }
catch { noteStrokes = { version: 1, strokes: [] }; }
noteLoaded = true;
}
async function saveNote(strokesJson) {
try {
await api(`/documents/${docId}/note`, {
method: 'PUT',
body: JSON.stringify({ strokes_json: strokesJson }),
});
} catch (err) {
console.warn('필기 저장 실패', err);
}
}
async function toggleNote() {
if (!noteOpen) await ensureNoteLoaded();
noteOpen = !noteOpen;
}
async function saveNote(s) { try { await api(`/documents/${docId}/note`, { method: 'PUT', body: JSON.stringify({ strokes_json: s }) }); } catch (e) { console.warn(e); } }
async function toggleNote() { if (!noteOpen) await ensureNoteLoaded(); noteOpen = !noteOpen; }
// 인접 자료 (같은 library_path 내 이전/다음) — 학습 흐름 네비게이션
// 인접 자료
let neighbors = $state({ prev: null, next: null });
async function loadNeighbors() {
try {
neighbors = await api(`/documents/${docId}/library-neighbors`);
} catch {
neighbors = { prev: null, next: null };
}
async function loadNeighbors() { try { neighbors = await api(`/documents/${docId}/library-neighbors`); } catch { neighbors = { prev: null, next: null }; } }
async function readAndGoNext() {
try { await api(`/documents/${docId}/read`, { method: 'POST' }); addToast('success', '1회독 완료'); }
catch (err) { addToast('error', err?.detail || '회독 기록 실패'); return; }
if (neighbors.next) goto(`/documents/${neighbors.next.id}`);
}
// 절(hier section) 목차 — 본문 로드와 독립, 실패(404 포함) 무해.
// reqId guard: 문서 전환 race 시 stale 결과가 새 문서에 붙지 않게.
// 절 목차
let sections = $state([]);
let hasSections = $derived(sections.length > 0);
// 과대 절은 builder 가 window 조각(같은 제목·is_leaf)으로 분해하고 부모를 heading 만 남긴 split-parent 로
// 강등한다(예: 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 = []; // Phase 1 미배포 시 404 → 목차 숨김(graceful)
}
}
// "1회독 완료 + 다음 자료로" 한 번에
async function readAndGoNext() {
try {
await api(`/documents/${docId}/read`, { method: 'POST' });
addToast('success', '1회독 완료');
} catch (err) {
addToast('error', err?.detail || '회독 기록 실패');
return;
}
if (neighbors.next) {
goto(`/documents/${neighbors.next.id}`);
}
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 () => {
@@ -121,87 +92,26 @@
doc = await api(`/documents/${docId}`);
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
try {
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
if (resp.ok) rawMarkdown = await resp.text();
} catch (e) {
rawMarkdown = '';
}
try { const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`); if (resp.ok) rawMarkdown = await resp.text(); } catch { rawMarkdown = ''; }
}
} catch (err) {
error = err?.status === 404 ? 'not_found' : 'network';
} finally {
loading = false;
}
// 자료실 자료면 인접 자료 미리 fetch (학습 흐름 네비)
} catch (err) { error = err?.status === 404 ? 'not_found' : 'network'; }
finally { loading = false; }
if (doc && doc.category === 'library') loadNeighbors();
if (doc) loadSections();
});
let viewerType = $derived(
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
);
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
let canShowMarkdown = $derived(!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim()));
// 절 본문은 청크 text(절별 원문)에서 오므로 md_content 성공/존재와 무관.
// hasSections 만으로 절뷰 사용 → partial / 대형 split(md_content 5만 자 절단) 문서도 절뷰 표시.
let useSectionView = $derived(hasSections);
// PDF 분기 전용: marker_worker 가 만든 canonical markdown 이 있으면 기본으로 그것을 보여줌.
// Phase 1B 산출물의 95% 가 PDF 라 1D pilot 평가가 실사용 화면 기반이 되도록 markdown-first.
// 사용자가 "PDF 원본" 토글하면 iframe. lastDocId 로 문서 전환만 감지해서 사용자 토글이
// reactive cycle 에 덮이지 않도록 보호.
let pdfViewMode = $state('markdown'); // 'markdown' | 'pdf'
let pdfViewMode = $state('markdown');
let lastDocId = $state(null);
let canShowMarkdown = $derived(
!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim())
);
$effect(() => {
if (!doc) return;
if (doc.id !== lastDocId) {
lastDocId = doc.id;
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
}
// 같은 문서 안에서 markdown 이 사라지면 (success → failed 재처리 등) PDF 로 보호.
if (!canShowMarkdown && pdfViewMode === 'markdown') {
pdfViewMode = 'pdf';
}
});
// ── 개요 점프 (경로 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
? resolveAnchorMap(doc.md_content, sections, { trustBE: true }).anchors
: {}
);
let activeKey = $state(null);
function jumpToSection(chunkId) {
const el = document.getElementById(`sec-${chunkId}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
$effect(() => {
void anchorMap; // 문서/섹션 변화 시 재바인딩
if (typeof window === 'undefined') return;
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
let cur = null;
document.querySelectorAll('.md-anchor').forEach((a) => {
if (a.getBoundingClientRect().top <= 120) cur = a;
});
if (cur) {
const m = cur.id.match(/^sec-(\d+)$/);
if (m) activeKey = Number(m[1]);
}
});
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => {
window.removeEventListener('scroll', onScroll);
if (raf) cancelAnimationFrame(raf);
};
if (doc.id !== lastDocId) { lastDocId = doc.id; pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf'; }
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
});
function getViewerType(format) {
@@ -213,338 +123,425 @@
return 'unsupported';
}
// E.2 affordance row 핸들러
// 절 집중/모바일 상태
let selectedSectionId = $state(null);
let mTree = $state(false);
let mIns = $state(false);
let manageOpen = $state(false);
// 기본 선택 = 첫 본문 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 를
// 슬라이스했으나, 대형 split 문서는 md_content 가 앞 5만 자만 보존되고 char_start 도 NULL 이라 본문이
// 비었다. 청크 text 는 절 전체를 담으므로(절 보유 문서 344개, 본문 합 평균 68KB·max 1.6MB) 그대로 렌더.
function bodyHtml(it) { return it?.bodyText ? renderMd(it.bodyText) : ''; }
let selectedBodyHtml = $derived(bodyHtml(selectedItem));
// 모바일 연속 카드: 본문은 '본문 보기' 펼칠 때만 파싱(논리 절 수백 개 × marked 즉시 파싱 회피).
let mBodyOpen = $state({});
// 절 유형 색 (시안: 정의 청 / 절차 올리브 / 요건 황)
const TYPE_META = {
definition: { label: '정의', en: 'definition', color: '#2f7d8f' },
procedure: { label: '절차', en: 'procedure', color: '#7a8b3f' },
requirement: { label: '요건', en: 'requirement', color: '#b5840a' },
};
function typeMeta(t) { return TYPE_META[t] ?? { label: sectionTypeLabel(t) || '', en: t || '', color: '#9aa090' }; }
function isLowConf(c) { return c != null && c < 0.5; }
function isMidLow(c) { return c != null && c < 0.6; }
function confColor(c) { return c == null ? '#9aa090' : c < 0.6 ? '#b5840a' : '#1f9d6b'; }
function secTitle(s) { return cleanHeading(s.section_title) || pathSegments(s.heading_path).at(-1) || '(제목 없음)'; }
function secDepth(s) { return Math.max(0, (s.level ?? 1) - 1); }
function confPct(c) { return c == null ? 0 : Math.round(c * 100); }
// 도메인 색 (시안 도메인 팔레트)
const DOMAIN_COLOR = { Industrial_Safety: '#b5840a', Engineering: '#2f7d8f', Programming: '#3d7256', General: '#7a8b3f', Reference: '#8a6a3f', Philosophy: '#7a6a9b' };
function domainColor(d) { return DOMAIN_COLOR[(d || '').split('/')[0]] ?? '#697061'; }
function fmtColor(f) { return f === 'pdf' ? '#c0564a' : f === 'md' ? '#5a8f7a' : ['m4a', 'mp3', 'wav'].includes(f) ? '#8a6aa5' : f === 'html' ? '#c2911f' : '#697061'; }
let quality = $derived(doc?.md_extraction_quality?.metrics ?? doc?.md_extraction_quality ?? null);
function copyLink() {
const url = `${window.location.origin}/documents/${docId}`;
navigator.clipboard
.writeText(url)
.then(() => addToast('success', '링크 복사됨'))
.catch(() => addToast('error', '복사 실패'));
}
function downloadOriginal() {
window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`);
}
function downloadPdf() {
window.open(`/api/documents/${docId}/preview?token=${getAccessToken()}&download=true`);
}
function handleDocDelete() {
addToast('success', '문서가 삭제되어 목록으로 이동합니다.');
goto('/documents');
navigator.clipboard.writeText(`${window.location.origin}/documents/${docId}`).then(() => addToast('success', '링크 복사됨')).catch(() => addToast('error', '복사 실패'));
}
function downloadOriginal() { window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`); }
function handleDocDelete() { addToast('success', '문서가 삭제되어 목록으로 이동합니다.'); goto('/documents'); }
</script>
<div class="p-4 lg:p-6">
<!-- ════ 좌 트리 (시안: 색바 + 연결선 + 활성 + 저신뢰 경고) ════ -->
{#snippet treeNav(jumpMode)}
<div class="d3tree" style="font-size:14px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;">
<div style="font-size:12px;font-weight:700;color:#697061;letter-spacing:.4px;">절 구조</div>
<span style="font-size:10.5px;color:#9aa090;font-variant-numeric:tabular-nums;">{outline.length}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:6px 8px;margin-bottom:11px;padding-bottom:10px;border-bottom:1px solid #dde3d6;">
<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:#2f7d8f;"></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:#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>
{#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={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' : ''}"
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;">
<div style="display:flex;align-items:center;gap:7px;">
<span style="width:3px;height:{child ? '13px' : '16px'};border-radius:2px;background:{tm.color};flex-shrink:0;"></span>
<span class="d3title" style="font-size:{child ? '11.5px' : '12.5px'};flex:1;min-width:0;{child ? 'color:#697061;' : ''}{active ? 'color:#3d7256;font-weight:600;' : ''}overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{secTitle(s)}</span>
{#if low}
<span class="d3warn" title="저신뢰 절" style="display:inline-flex;width:14px;height:14px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;">!</span>
{:else if !child}
<span title="신뢰도 {it.confidence != null ? it.confidence.toFixed(2) : '—'}" style="width:7px;height:7px;border-radius:50%;background:{confColor(it.confidence)};flex-shrink:0;"></span>
{/if}
</div>
</svelte:element>
{/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>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;font-size:10.5px;color:#697061;font-variant-numeric:tabular-nums;">
{#if quality.headings != null}<span>headings <b style="color:#23291f;">{quality.headings}</b></span>{/if}
{#if quality.tables != null}<span>tables <b style="color:#23291f;">{quality.tables}</b></span>{/if}
{#if quality.images != null}<span>images <b style="color:#23291f;">{quality.images}</b></span>{/if}
</div>
</div>
{/if}
</div>
{/snippet}
<!-- ════ 절 집중 뷰 (데스크탑 중앙) ════ -->
{#snippet focusView()}
{#if selectedSection}
{@const tm = typeMeta(selectedItem?.sectionType)}
{@const conf = selectedItem?.confidence ?? null}
{@const summaries = selectedItem?.summaries ?? []}
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:#9aa090;margin-bottom:12px;flex-wrap:wrap;">
<span class="truncate" style="max-width:200px;">{doc.title}</span>
{#each pathSegments(selectedSection.heading_path) as seg}<span style="color:#c8d6c0;">/</span><span style="color:#697061;font-weight:600;">{seg}</span>{/each}
</div>
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:13px;">
<h2 style="margin:0;font-size:22px;font-weight:700;color:#23291f;line-height:1.3;flex:1;min-width:180px;">{secTitle(selectedSection)}</h2>
{#if tm.label}<span style="display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:{tm.color}1a;border:1px solid {tm.color}55;font-size:12px;color:{tm.color};font-weight:600;"><span style="width:8px;height:8px;border-radius:2px;background:{tm.color};"></span>{tm.label} {tm.en}</span>{/if}
</div>
{#if conf != null}
<div style="display:flex;align-items:center;gap:9px;margin-bottom:18px;">
<span style="font-size:11px;color:#697061;font-weight:600;flex-shrink:0;">신뢰도</span>
<div style="flex:1;max-width:300px;height:7px;border-radius:999px;background:#e3ebdf;overflow:hidden;"><div style="width:{confPct(conf)}%;height:100%;background:{confColor(conf)};border-radius:999px;"></div></div>
<span style="font-size:13px;font-weight:700;color:{confColor(conf)};font-variant-numeric:tabular-nums;flex-shrink:0;">{conf.toFixed(2)}</span>
</div>
{/if}
{#if isLowConf(conf)}
<div style="display:flex;align-items:flex-start;gap:8px;background:#faf3e2;border:1px solid #ecdca3;border-radius:10px;padding:10px 12px;margin-bottom:16px;font-size:12.5px;color:#8a6306;"><span style="flex-shrink:0;width:16px;height:16px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.</span></div>
{/if}
{#if summaries.length}
<div style="background:#ecf0e8;border-left:3px solid #4f8a6b;border-radius:0 10px 10px 0;padding:14px 16px;margin-bottom:20px;">
<div style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.6px;margin-bottom:6px;">절 요약{#if summaries.length > 1} · {summaries.length}개 부분{/if}</div>
{#if summaries.length === 1}
<div style="font-size:15.5px;line-height:1.6;color:#23291f;white-space:pre-line;">{summaries[0]}</div>
{:else}
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:8px;">
{#each summaries as sm, i}<li style="font-size:13.5px;line-height:1.55;color:#23291f;display:flex;gap:8px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}
</ul>
{/if}
</div>
{/if}
{#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}
<div style="display:flex;justify-content:space-between;gap:10px;margin-top:20px;padding-top:14px;border-top:1px solid #dde3d6;">
{#if selIdx > 0}
{@const pv = outline[selIdx - 1].section}
<button type="button" onclick={() => (selectedSectionId = pv.chunk_id)} style="font-size:12px;color:#697061;border:1px solid #dde3d6;border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;">{secTitle(pv)}</button>
{:else}<span></span>{/if}
{#if selIdx >= 0 && selIdx < outline.length - 1}
{@const nxIt = outline[selIdx + 1]}
{@const nx = nxIt.section}
<button type="button" onclick={() => (selectedSectionId = nx.chunk_id)} style="font-size:12px;color:{isMidLow(nxIt.confidence) ? '#8a6306' : '#697061'};border:1px solid {isMidLow(nxIt.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">{#if isMidLow(nxIt.confidence)}<span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:8px;font-weight:700;">!</span>{/if}{secTitle(nx)}</button>
{:else}<span></span>{/if}
</div>
{/if}
{/snippet}
<!-- ════ 우 슬림 레일 (시안 카드 스타일) ════ -->
{#snippet rail()}
<div style="display:flex;flex-direction:column;gap:11px;font-size:14px;">
{#if doc.ai_tldr || doc.ai_summary}
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:7px;">TL;DR</div>
<div class="summary-md" style="font-size:12px;line-height:1.5;color:#23291f;">{@html renderDocMarkdown(doc.ai_tldr || doc.ai_summary)}</div>
</div>
{/if}
{#if doc.ai_bullets && doc.ai_bullets.length}
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">핵심점</div>
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:7px;">
{#each doc.ai_bullets as b}<li style="font-size:12px;line-height:1.4;display:flex;gap:6px;"><span style="color:#b5840a;font-weight:700;flex-shrink:0;">·</span><span style="flex:1;min-width:0;color:#23291f;">{b}</span></li>{/each}
</ul>
</div>
{/if}
{#if doc.ai_detail_summary}
<div style="background:#f4f7f1;border:1px solid #c8d6c0;border-radius:14px;padding:13px;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:7px;">
<span style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.4px;">심층</span>
{#if doc.ai_analysis_tier === 'deep'}<span style="font-size:9px;color:#fff;background:#4f8a6b;border-radius:999px;padding:1px 7px;font-weight:600;">DEEP</span>{/if}
</div>
<div style="font-size:11.5px;line-height:1.5;color:#23291f;white-space:pre-line;">{doc.ai_detail_summary}</div>
</div>
{/if}
{#if doc.ai_inconsistencies && doc.ai_inconsistencies.length}
<div style="background:#fbf6e6;border:1px solid #e7d49a;border-radius:14px;padding:13px;">
<div style="font-size:10.5px;font-weight:700;color:#8a6306;letter-spacing:.4px;margin-bottom:7px;">불일치 {doc.ai_inconsistencies.length}</div>
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:5px;">{#each doc.ai_inconsistencies as inc}<li style="font-size:11.5px;line-height:1.45;color:#23291f;">· {typeof inc === 'string' ? inc : inc.desc || inc.kind}</li>{/each}</ul>
</div>
{/if}
{#if doc.ai_domain}
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">분류</div>
<div style="display:flex;flex-direction:column;gap:6px;font-size:11.5px;">
<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">도메인</span><span style="display:inline-flex;align-items:center;gap:5px;color:#23291f;font-weight:600;text-align:right;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span></div>
{#if doc.ai_sub_group}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">하위</span><span style="color:#23291f;font-weight:600;">{doc.ai_sub_group}</span></div>{/if}
{#if doc.ai_analysis_tier}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">tier</span><span style="color:#3d7256;font-weight:600;">{doc.ai_analysis_tier}</span></div>{/if}
{#if doc.ai_confidence != null}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">신뢰도</span><span style="color:#1f9d6b;font-weight:700;font-variant-numeric:tabular-nums;">{doc.ai_confidence.toFixed(2)}</span></div>{/if}
</div>
</div>
{/if}
{#if doc.ai_tags && doc.ai_tags.length}
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">태그</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;">{#each doc.ai_tags as t}<span style="font-size:11px;padding:3px 8px;border-radius:999px;background:#fff;border:1px solid #dde3d6;color:#697061;">{t}</span>{/each}</div>
</div>
{/if}
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:6px;">관련 문서</div>
<div style="font-size:11px;color:#9aa090;line-height:1.5;">벡터 유사도 기반 — 준비 중</div>
</div>
</div>
{/snippet}
<!-- ════ 절 카드 (모바일 연속 본문) ════ -->
{#snippet sectionCard(it)}
{@const s = it.section}
{@const tm = typeMeta(it.sectionType)}
<div id="m-sec-{s.chunk_id}" style="scroll-margin-top:12px;background:#f4f7f1;border:1px solid {isLowConf(it.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:14px;padding:14px 15px;">
<div style="display:flex;align-items:center;gap:7px;margin-bottom:7px;">
<h2 style="margin:0;font-size:16px;font-weight:700;color:#23291f;flex:1;min-width:0;line-height:1.3;">{secTitle(s)}</h2>
{#if tm.label}<span style="flex-shrink:0;font-size:10.5px;font-weight:650;padding:2px 8px;border-radius:999px;background:{tm.color}1a;color:{tm.color};white-space:nowrap;">{tm.label}</span>{/if}
</div>
{#if isLowConf(it.confidence)}
<div style="display:flex;align-items:flex-start;gap:7px;background:#faf3e2;border:1px solid #ecdca3;border-radius:9px;padding:8px 10px;margin-bottom:10px;font-size:12px;color:#8a6306;"><span style="flex-shrink:0;width:15px;height:15px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 — 표·수식 추출 불완전, 원본 확인 권장</span></div>
{/if}
{#if it.summaries.length}
<div style="border-left:3px solid #4f8a6b;background:#ecf0e8;border-radius:0 8px 8px 0;padding:9px 12px;margin-bottom:12px;">
<div style="font-size:9.5px;font-weight:700;color:#3d7256;letter-spacing:.5px;margin-bottom:3px;">절 요약{#if it.summaries.length > 1} · {it.summaries.length}개 부분{/if}</div>
{#if it.summaries.length === 1}
<div style="font-size:13.5px;line-height:1.55;color:#23291f;white-space:pre-line;">{it.summaries[0]}</div>
{:else}
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;">{#each it.summaries as sm, i}<li style="font-size:12.5px;line-height:1.5;color:#23291f;display:flex;gap:6px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}</ul>
{/if}
</div>
{/if}
{#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 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>
{/snippet}
<div style="background:#e7ebe4;min-height:100%;" class="p-4 lg:p-6">
<div style="max-width:1360px;margin:0 auto;">
<!-- breadcrumb -->
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
<a href="/documents" class="hover:text-text">문서</a>
<span class="text-faint">/</span>
<div class="flex items-center gap-2 text-sm mb-3 text-dim">
<a href="/documents" class="hover:text-text">문서</a><span class="text-faint">/</span>
<span class="truncate max-w-md text-text">{doc?.title || '로딩...'}</span>
</div>
{#if loading}
<div class="max-w-6xl mx-auto">
<Skeleton h="h-96" rounded="card" />
</div>
<Skeleton h="h-96" rounded="card" />
{:else if error === 'not_found'}
<EmptyState
icon={FileText}
title="문서를 찾을 수 없습니다"
description="삭제되었거나 접근 권한이 없을 수 있습니다."
>
<Button variant="ghost" size="sm" href="/documents">목록으로 돌아가기</Button>
</EmptyState>
<EmptyState icon={FileText} title="문서를 찾을 없습니다" description="삭제되었거나 접근 권한이 없을 수 있습니다."><Button variant="ghost" size="sm" href="/documents">목록으로</Button></EmptyState>
{:else if error === 'network'}
<EmptyState
icon={FileText}
title="문서를 불러올 수 없습니다"
description="네트워크 오류가 발생했습니다."
>
<Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button>
</EmptyState>
<EmptyState icon={FileText} title="문서를 불러올 없습니다" description="네트워크 오류"><Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button></EmptyState>
{:else if doc}
<div class="mx-auto grid grid-cols-1 gap-6 {hasSections ? 'max-w-7xl xl:grid-cols-[18rem_minmax(0,1fr)_20rem]' : 'max-w-6xl lg:grid-cols-3'}">
{#if hasSections}
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
<Card>
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
</Card>
</aside>
{/if}
<!-- 본문 (좌측 목차 없을 때 lg 2/3) -->
<div class="{hasSections ? '' : 'lg:col-span-2'} space-y-4">
{#if hasSections}
<!-- xl 미만: 절 목차 접이식 -->
<details class="xl:hidden">
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
</details>
{/if}
<!-- Affordance row -->
<div class="flex flex-wrap items-center gap-2">
{#if doc.edit_url}
<Button
variant="secondary"
size="sm"
icon={ExternalLink}
href={doc.edit_url}
target="_blank"
>
Synology 편집
</Button>
{/if}
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>
원본 다운로드
</Button>
{#if doc.preview_status === 'ready'}
<Button variant="secondary" size="sm" icon={FileText} onclick={downloadPdf}>
PDF 다운로드
</Button>
{/if}
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
링크 복사
</Button>
{#if doc.category === 'library'}
<Button
variant={noteOpen ? 'primary' : 'secondary'}
size="sm"
icon={noteOpen ? X : PenLine}
onclick={toggleNote}
>
{noteOpen ? '필기 닫기' : '필기'}
</Button>
{/if}
<!-- ════ 상단 띠: 문서 헤더 (시안) ════ -->
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-bottom:14px;">
<div style="display:flex;align-items:flex-start;gap:13px;flex-wrap:wrap;">
<div style="width:40px;height:40px;border-radius:10px;background:{fmtColor(doc.file_format)};color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10.5px;letter-spacing:.5px;flex-shrink:0;text-transform:uppercase;">{doc.file_format}</div>
<div style="flex:1;min-width:0;">
<div style="font-size:17px;font-weight:700;line-height:1.35;color:#23291f;">{doc.title}</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center;">
{#if doc.ai_domain}<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#23291f;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span>{/if}
{#if doc.ai_sub_group}<span style="padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#697061;">{doc.ai_sub_group}</span>{/if}
{#if doc.ai_analysis_tier === 'deep'}<span style="padding:3px 9px;border-radius:999px;background:#4f8a6b;color:#fff;font-size:11.5px;font-weight:600;letter-spacing:.3px;">tier DEEP</span>{/if}
{#if doc.ai_confidence != null}<span style="padding:3px 9px;border-radius:999px;background:#e3ebdf;border:1px solid #c8d6c0;font-size:11.5px;color:#3d7256;font-variant-numeric:tabular-nums;">신뢰도 {doc.ai_confidence.toFixed(2)}</span>{/if}
{#if canShowMarkdown}<span style="padding:3px 9px;border-radius:999px;background:#eafaf2;border:1px solid #b8e3cc;font-size:11.5px;color:#1f9d6b;">PDF→MD success</span>{/if}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;">
{#if doc.edit_url}<button type="button" onclick={() => window.open(doc.edit_url, '_blank')} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">Synology</button>{/if}
<button type="button" onclick={downloadOriginal} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">원본</button>
<button type="button" onclick={copyLink} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">링크</button>
{#if doc.category === 'library'}<button type="button" onclick={toggleNote} style="font-size:11.5px;color:{noteOpen ? '#fff' : '#697061'};border:1px solid {noteOpen ? '#4f8a6b' : '#dde3d6'};border-radius:8px;padding:5px 9px;background:{noteOpen ? '#4f8a6b' : '#fff'};cursor:pointer;">{noteOpen ? '필기 닫기' : '필기'}</button>{/if}
<button type="button" onclick={() => (manageOpen = !manageOpen)} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">관리</button>
</div>
</div>
</div>
<!-- 뷰어 — 모바일 가독성: 본문 폰트 키우고 line-height 늘림 -->
<Card class="min-h-[500px]">
{#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="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>
<!-- 모바일(<xl): 나란한 토글 pill + 패널 + 본문 연속 -->
<div class="xl:hidden">
<div style="display:flex;gap:8px;margin-bottom:10px;position:sticky;top:0;z-index:5;background:#e7ebe4;padding:6px 0;">
<button type="button" onclick={() => (mTree = !mTree)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mTree ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mTree ? '#4f8a6b' : '#dde3d6'};color:{mTree ? '#23291f' : '#697061'};">절 구조 <span style="font-size:10px;color:#9aa090;font-weight:500;">{outline.length}</span><span style="transition:transform .16s;transform:rotate({mTree ? 90 : 0}deg);color:#9aa090;font-weight:700;"></span></button>
<button type="button" onclick={() => (mIns = !mIns)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mIns ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mIns ? '#4f8a6b' : '#dde3d6'};color:{mIns ? '#23291f' : '#697061'};">인사이트<span style="transition:transform .16s;transform:rotate({mIns ? 90 : 0}deg);color:#9aa090;font-weight:700;"></span></button>
</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}
<!-- 절 없음 fallback: 절이 없어도 인사이트는 항상 보이게 (모바일=인사이트 상단 / 데스크탑=우측 레일) -->
{#snippet fbViewer()}
<div style="min-width:0;background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:18px 20px;min-height:360px;">
{#if !hasSections && canShowMarkdown}<p style="font-size:11px;color:#9aa090;margin-bottom:12px;">이 문서는 절 분석이 없어 전체 본문으로 표시합니다. 위/옆 인사이트는 그대로 제공됩니다.</p>{/if}
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
<MarkdownDoc
documentId={doc.id}
mdContent={doc.md_content}
mdFrontmatter={doc.md_frontmatter}
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
anchorMap={anchorMap}
extractedText={doc.extracted_text || rawMarkdown}
class="prose prose-invert prose-base lg:prose-sm max-w-none"
/>
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text || rawMarkdown} class="prose prose-base max-w-none" />
{:else if viewerType === 'pdf'}
<div class="mb-2 flex items-center gap-2">
<MarkdownStatusBadge
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
/>
{#if canShowMarkdown}
<Button
size="sm"
variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'}
onclick={() => (pdfViewMode = 'markdown')}
>
Markdown
</Button>
<Button
size="sm"
variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'}
onclick={() => (pdfViewMode = 'pdf')}
>
PDF 원본
</Button>
{/if}
<MarkdownStatusBadge mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} />
{#if canShowMarkdown}<Button size="sm" variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'markdown')}>Markdown</Button><Button size="sm" variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'pdf')}>PDF 원본</Button>{/if}
</div>
{#if pdfViewMode === 'markdown' && canShowMarkdown}
<MarkdownDoc
documentId={doc.id}
mdContent={doc.md_content}
mdFrontmatter={doc.md_frontmatter}
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
extractedText={doc.extracted_text}
class="prose prose-invert prose-base lg:prose-sm max-w-none"
/>
{:else}
<iframe
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
class="w-full h-[80vh] rounded"
title={doc.title}
></iframe>
{/if}
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />
{:else}<iframe src="/api/documents/{doc.id}/file?token={getAccessToken()}" class="w-full h-[80vh] rounded" title={doc.title}></iframe>{/if}
{:else if viewerType === 'image'}
<img
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
alt={doc.title}
class="max-w-full rounded"
/>
<img src="/api/documents/{doc.id}/file?token={getAccessToken()}" alt={doc.title} class="max-w-full rounded" />
{:else if viewerType === 'synology'}
<EmptyState
icon={ExternalLink}
title="Synology Office 문서"
description="외부 편집기에서 열어야 합니다."
>
<Button
variant="primary"
size="sm"
href={doc.edit_url || 'https://link.hyungi.net'}
target="_blank"
>
새 창에서 열기
</Button>
</EmptyState>
<EmptyState icon={FileText} title="Synology Office 문서" description="외부 편집기에서 열어야 합니다."><Button variant="primary" size="sm" href={doc.edit_url || 'https://link.hyungi.net'} target="_blank">새 창에서 열기</Button></EmptyState>
{:else if viewerType === 'article'}
<div>
<h1 class="text-xl font-bold text-text mb-3">{doc.title}</h1>
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
<span>출처: {doc.source_channel}</span>
<span class="text-faint">·</span>
<span>
{new Date(doc.created_at).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
{#if doc.md_content || doc.extracted_text}
<!-- article = 텍스트 네이티브(markdown 변환 비대상). md_status='skipped' 라도
"Markdown 제외" badge 를 띄우지 않도록 mdStatus 미전달(badge 는 mdStatus 로만 구동). -->
<MarkdownDoc
documentId={doc.id}
mdContent={doc.md_content}
mdFrontmatter={doc.md_frontmatter}
mdStatus={null}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
extractedText={doc.extracted_text}
class="mb-6"
/>
{/if}
{#if doc.edit_url}
<Button
variant="primary"
size="sm"
icon={ExternalLink}
href={doc.edit_url}
target="_blank"
>
원문 보기
</Button>
{/if}
</div>
{:else}
<EmptyState
icon={FileText}
title="인앱 미리보기 미지원"
description="포맷: {doc.file_format}"
/>
{/if}
</Card>
{#if doc.md_content || doc.extracted_text}<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={null} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />{/if}
{#if doc.edit_url}<div class="mt-4"><Button variant="primary" size="sm" href={doc.edit_url} target="_blank">원문 보기</Button></div>{/if}
{:else}<EmptyState icon={FileText} title="인앱 미리보기 미지원" description="포맷: {doc.file_format}" />{/if}
</div>
{/snippet}
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
{#if noteOpen && doc.category === 'library' && noteLoaded}
<Card class="overflow-hidden p-0">
<div class="h-[60vh] min-h-[400px] flex flex-col">
<HandwriteCanvas
sessionId={doc.id}
initialStrokes={noteStrokes}
onChange={(strokes) => saveNote(strokes)}
/>
</div>
</Card>
{/if}
<!-- 데스크탑: 본문 | 인사이트 레일 -->
<div class="hidden xl:grid xl:grid-cols-[minmax(0,1fr)_336px] gap-3.5 items-start">
{@render fbViewer()}
<div style="position:sticky;top:14px;">{@render rail()}</div>
</div>
<!-- 모바일: 인사이트(상단 상시) + 본문 -->
<div class="xl:hidden">
<div style="margin-bottom:12px;">{@render rail()}</div>
{@render fbViewer()}
</div>
{/if}
<!-- 오른쪽 — 메타 Tabs [정보 | AI | 관리] (카드 11개 수직 스프롤 해소) -->
<aside class="min-w-0">
<Card>
<Tabs
tabs={[
{ id: 'info', label: '정보' },
{ id: 'ai', label: 'AI' },
{ id: 'manage', label: '관리' },
]}
>
{#snippet children(tab)}
<div class="pt-3 space-y-4">
{#if tab === 'info'}
{#if doc.category === 'library'}
<ReadCounter
documentId={doc.id}
initialCount={doc.read_count ?? 0}
initialLastReadAt={doc.last_read_at ?? null}
/>
{/if}
<FileInfoView {doc} />
<ProcessingStatusView {doc} />
{:else if tab === 'ai'}
<AnalysisPanel docId={doc.id} doc={doc} />
<AIClassificationEditor {doc} />
<div>
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">관련 문서</h4>
<!-- TODO(backend): GET /documents/{id}/related?limit=10 (벡터 유사도) -->
<EmptyState
icon={FileText}
title="추후 지원"
description="관련 문서 추천은 backend 연동 후 제공됩니다."
/>
</div>
{:else}
<LibraryPathEditor {doc} />
<NoteEditor {doc} />
<EditUrlEditor {doc} />
<TagsEditor {doc} />
<div class="pt-2 border-t border-default">
<DocumentDangerZone {doc} ondelete={handleDocDelete} />
</div>
{/if}
</div>
{/snippet}
</Tabs>
</Card>
</aside>
</div>
<!-- 관리 (편집/삭제) — 헤더 '관리'로 토글 -->
{#if manageOpen}
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-top:14px;">
<div style="font-size:12px;font-weight:700;color:#697061;margin-bottom:12px;letter-spacing:.3px;">관리 · 분류 편집</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<AIClassificationEditor {doc} />
<LibraryPathEditor {doc} />
<NoteEditor {doc} />
<EditUrlEditor {doc} />
<TagsEditor {doc} />
</div>
<div class="pt-3 mt-3 border-t border-default"><DocumentDangerZone {doc} ondelete={handleDocDelete} /></div>
</div>
{/if}
{#if noteOpen && doc.category === 'library' && noteLoaded}
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;overflow:hidden;margin-top:14px;"><div class="h-[60vh] min-h-[400px] flex flex-col"><HandwriteCanvas sessionId={doc.id} initialStrokes={noteStrokes} onChange={(s) => saveNote(s)} /></div></div>
{/if}
<!-- 모바일 sticky 하단 바 — 자료실 자료의 학습 흐름 네비게이션 -->
{#if doc.category === 'library'}
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 bg-surface border-t border-default px-3 py-2 flex items-center gap-2 shadow-lg">
<button
type="button"
onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)}
disabled={!neighbors.prev}
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="이전 자료"
><ChevronLeft size={20} /></button>
<button
type="button"
onclick={readAndGoNext}
disabled={!neighbors.next}
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50"
>
<Check size={16} />
{#if neighbors.next}
1회독 완료 + 다음
{:else}
1회독 완료 (마지막 자료)
{/if}
</button>
<button
type="button"
onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)}
disabled={!neighbors.next}
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="다음 자료 (회독 카운트 안 함)"
><ChevronRight size={20} /></button>
<button type="button" onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)} disabled={!neighbors.prev} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="이전"></button>
<button type="button" onclick={readAndGoNext} disabled={!neighbors.next} class="flex-1 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50">{#if neighbors.next}1회독 완료 + 다음{:else}1회독 완료 (마지막){/if}</button>
<button type="button" onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)} disabled={!neighbors.next} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="다음"></button>
</div>
<!-- 본문이 sticky 바 뒤에 가리지 않도록 패딩 -->
<div class="lg:hidden h-20"></div>
{/if}
{/if}
</div>
</div>
<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; }
.m-secbody[open] .m-chev { transform: rotate(90deg); }
.d3warn { animation: d3pulse 2.4s ease-in-out infinite; }
@keyframes d3pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(181, 132, 10, .35); } 50% { box-shadow: 0 0 0 3px rgba(181, 132, 10, 0); } }
</style>
+24 -1
View File
@@ -3,7 +3,7 @@
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { renderMemoHtml, todayIso, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen } from 'lucide-svelte';
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen, FolderInput } from 'lucide-svelte';
import { getAccessToken } from '$lib/api';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
@@ -276,6 +276,18 @@
}
}
// 자료로 보내기 — 메모를 문서함 정식 문서로 승격(이동) + AI 분류/요약/심층/도메인.
async function promoteToDocument(memoId) {
try {
const res = await api(`/memos/${memoId}/promote-to-document`, { method: 'POST' });
addToast('success', '문서함으로 보냈습니다 · AI 분석 진행 중');
// in-place 승격이라 더는 메모가 아님 → 목록에서 제거
memos = memos.filter((m) => m.id !== memoId);
} catch (err) {
addToast('error', err?.detail || '자료로 보내기 실패');
}
}
// voice 메모 audio URL — /api/documents/{id}/file?token= 패턴 재사용
function voiceAudioUrl(memoId) {
const token = getAccessToken();
@@ -601,6 +613,17 @@
</div>
{/if}
<!-- 자료로 보내기 — 모든 메모(지식 메모 포함)에서 항상 노출 → 문서함 승격 + AI 처리 -->
{#if editingId !== memo.id && !showArchived}
<div class="mt-2">
<button onclick={() => promoteToDocument(memo.id)}
class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-accent hover:text-white transition-colors"
title="이 메모를 문서함으로 보내고 AI가 확인·정리·요약·심층분석·도메인 부여를 진행합니다">
<FolderInput size={11} /> 자료로 보내기
</button>
</div>
{/if}
<!-- 태그 + 하단 -->
{#if editingId !== memo.id}
{#if memo.user_tags?.length || memo.ai_tags?.length}
+19
View File
@@ -0,0 +1,19 @@
-- 2026-06-14 PR-Background-Jobs-Observability: 큐 밖 관리 스크립트(백필 등) 진행 가시화.
-- processing_queue 는 파이프라인 stage 전용 — hier_overnight_backfill / section_summary_pilot
-- 같은 off-queue 관리 스크립트는 여기에 진행상황을 남겨 대시보드 보드가 노출한다.
-- worker_jobs(user_id NOT NULL, worker-pool 전용)와 별개 — 이건 owner 없는 관리 작업 heartbeat.
-- 단일 statement (asyncpg multi-statement 불허 컨벤션). 인덱스는 소량 테이블이라 생략.
CREATE TABLE IF NOT EXISTS background_jobs (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL, -- 'hier_redecompose' | 'section_summary' | ...
label TEXT, -- 사람이 읽는 대상 표기 (예: 'doc 5210 (Sec VIII)')
state TEXT NOT NULL DEFAULT 'running'
CHECK (state IN ('running', 'done', 'failed')),
processed INTEGER NOT NULL DEFAULT 0, -- 처리한 단위 수 (절/leaf 등)
total INTEGER, -- 전체 단위 수 (미상이면 NULL)
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
error TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
@@ -0,0 +1,11 @@
-- 358: documents.embedding HNSW 벡터 인덱스 + hnsw.ef_search (검색 latency T3, 2026-06-15)
-- PROD 적용 = CREATE INDEX CONCURRENTLY 로 수동 빌드(40k rows 무중단, /dev/shm 회피 위해 단일 스레드)
-- + schema_migrations(358) 수동 기록 완료. runner 는 단일 트랜잭션이라 CONCURRENTLY 불가.
-- 본 파일 = fresh-init/재현용: non-concurrent IF NOT EXISTS (빈 테이블 init 시 즉시, 기존 index 존재 시 no-op).
CREATE INDEX IF NOT EXISTS idx_documents_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops)
WHERE (deleted_at IS NULL AND embedding IS NOT NULL);
-- docs vector leg LIMIT = limit*4 (기본 80) → HNSW recall 위해 ef_search >= 80 필요.
-- ivfflat.probes=20 과 동일하게 DB 레벨 GUC (ALTER DATABASE) 로 설정.
ALTER DATABASE pkm SET hnsw.ef_search = 100;
@@ -0,0 +1,6 @@
-- 359: delete_file=true 명시 삭제 요청 마커 (R7 delete_file 큐드삭제).
-- retention sweep(document_purge_sweep) 이 이 컬럼 + grace(30일) 기준으로 NAS 원본을
-- 물리삭제한다. deleted_at(단순 숨김)과 분리 — 숨김(delete_file=false)은 파일 보존(undelete
-- 가능). sweep 가 deleted_at 기준이면 모든 숨김이 30일 후 물리삭제되는 데이터 손실이 되므로
-- 명시 purge 요청만 대상으로 한다.
ALTER TABLE documents ADD COLUMN IF NOT EXISTS purge_requested_at TIMESTAMPTZ;
@@ -0,0 +1,11 @@
-- 360: Phase 2A 임베딩 후보 cand 섀도 테이블 제거 (R13).
-- Phase 2A no-go 종결(2026-06-12, 후보 전부 -0.03~-0.04) + phase2a_cand_backfill 워커 dormant.
-- retrieval_service.CANDIDATE_BACKEND_MAP / api.search allowed 슬러그 선제거 후 DROP.
-- ★single statement(콤마 구분) — init_db 의 exec_driver_sql(asyncpg)은 multi-statement 불허.
-- IF EXISTS — me5/snowflake 는 ad-hoc 생성분이라 환경별 존재 여부 다를 수 있음(멱등).
DROP TABLE IF EXISTS
document_chunks_cand_me5_large_inst, documents_cand_me5_large_inst,
document_chunks_cand_snowflake_l_v2, documents_cand_snowflake_l_v2,
document_chunks_cand_qwen06, documents_cand_qwen06,
document_chunks_cand_qwen4, documents_cand_qwen4,
document_chunks_cand_qwen4m, documents_cand_qwen4m;
@@ -0,0 +1,9 @@
-- 361: quiz 세션 내 같은 문제 이중 attempt 방지 partial UNIQUE (R9).
-- submit_attempt 의 FOR UPDATE 행잠금이 1차 방어, 이 제약은 DB 레벨 belt-and-suspenders.
-- prod 실측 중복 0 (GROUP BY (quiz_session_id, study_question_id) HAVING count>1 = 0) + fresh DB
-- 빈 테이블이라 dedup DELETE 불요 → ★single statement(init_db exec_driver_sql 은 multi-statement
-- 불허). 혹시 중복이 생긴 환경이면 이 마이그가 실패하므로(IntegrityError) 수동 dedup 후 재적용.
-- quiz_session_id IS NULL(세션 외 직접 입력)은 비대상 → partial index.
CREATE UNIQUE INDEX IF NOT EXISTS uq_attempt_session_question
ON study_question_attempts (quiz_session_id, study_question_id)
WHERE quiz_session_id IS NOT NULL;
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
"""전체 app 부팅 런타임 스모크 (GPU 격리) — deploy-blocker 게이트.
init_db 자체는 initdb_runtime_test.py(R1)·migration_smoke.sh 검증한다.
스모크는 위에서 **실제 컨테이너 부팅 경로**(main:app + lifespan startup) 실행해
py_compile 잡는 deploy-blocker 클래스를 잡는다:
`import main` = router import + FastAPI app 빌드 (router 심볼누락·순환 검출)
lifespan startup = lifespan 안의 worker import(35) + init_db + add_job 실행
(worker import-time 오류· 등록 오류 검출, **drift 0** = 실제 경로)
/health (health_check 직접 호출) = DB connected
prod/AI/NAS 무접촉을 위해 부작용 3개만 외과적으로 중립화한다 (검증 대상 로직은 그대로):
- NAS 마운트 체크 임시 디렉토리(+PKM/) 통과 ( NAS 의존 제거)
- scheduler.start() no-op (잡은 등록되지만 실행 = 워커 폴링·외부 API 호출 0)
- scheduler.shutdown() no-op (start 했으니 __aexit__ shutdown raise 하도록)
- prewarm_analyzer() no-op (AI 라우터 :8890 미호출 = 검색실험 soft-lock 안전)
실행 (worktree 루트를 마운트한 prod fastapi 이미지 컨테이너 ):
docker run --rm --network <net> -v <worktree>:/work -w /work \
-e PYTHONPATH=/work/app -e BOOT_SMOKE=1 \
-e DATABASE_URL="postgresql+asyncpg://postgres@ds-bootsmoke-pg:5432/pkm" \
<fastapi_image> python scripts/ci/boot_smoke.py
기대: IMPORTS OK LIFESPAN startup OK (jobs=N, purge_sweep 포함) schema OK HEALTH ok PASS
"""
import asyncio
import os
import tempfile
from pathlib import Path
from sqlalchemy import text
async def main() -> None:
# ── 0) 안전 가드: prod DB 오접속 차단 ─────────────────────────────────
from core.config import settings
url = settings.database_url
print("DATABASE_URL:", url)
assert os.getenv("BOOT_SMOKE") == "1", "SAFETY ABORT: BOOT_SMOKE=1 미설정"
# prod = '...@postgres:5432/pkm' (user pkm). ephemeral = bootsmoke 호스트 / localhost / postgres user.
assert "@postgres:" not in url and "@postgres/" not in url, f"SAFETY ABORT: prod DB 로 보임: {url}"
assert ("bootsmoke" in url) or ("localhost" in url) or ("127.0.0.1" in url), \
f"SAFETY ABORT: ephemeral 마커(bootsmoke/localhost) 없음: {url}"
# ── 1) 부작용 3개 중립화 (검증 대상 로직 보존) ───────────────────────
# prewarm: AI 라우터 미호출
import services.search.query_analyzer as qa
async def _noop_prewarm(*a, **k):
return None
qa.prewarm_analyzer = _noop_prewarm
# scheduler.start/shutdown no-op + start 캡처로 잡 개수 집계
from apscheduler.schedulers.asyncio import AsyncIOScheduler
captured: dict = {}
_orig_init = AsyncIOScheduler.__init__
def _init(self, *a, **k):
_orig_init(self, *a, **k)
captured["sched"] = self
AsyncIOScheduler.__init__ = _init
AsyncIOScheduler.start = lambda self, *a, **k: None
AsyncIOScheduler.shutdown = lambda self, *a, **k: None
# NAS 체크 통과용 임시 마운트
tmp = tempfile.mkdtemp(prefix="bootsmoke-nas-")
(Path(tmp) / "PKM").mkdir(parents=True, exist_ok=True)
settings.nas_mount_path = tmp
print("nas_mount_path(override):", tmp)
# ── 2) import main = 전 router import + app 빌드 ──────────────────────
import main
route_count = len(main.app.routes)
print(f"IMPORTS OK — main 빌드, app.routes={route_count}")
assert route_count > 50, f"라우트 수 비정상({route_count}) — 라우터 누락 의심"
# ── 3) lifespan startup 실행 (init_db + 전 worker import + 전 add_job) ─
cm = main.lifespan(main.app)
await cm.__aenter__()
sched = captured.get("sched")
jobs = sched.get_jobs() if sched else []
job_ids = sorted(j.id for j in jobs)
print(f"LIFESPAN startup OK — 등록 잡 {len(jobs)}")
print(" job_ids:", ", ".join(job_ids))
assert len(jobs) >= 30, f"잡 등록 수 비정상({len(jobs)})"
for required in ("purge_sweep", "auto_review", "queue_consumer", "statute_collector"):
assert required in job_ids, f"필수 잡 누락: {required}"
# ── 4) 스키마 상태 (lifespan 의 실 init_db 가 359/360/361 적용했는지) ──
from core.database import async_session, engine
async with async_session() as s:
docs = (await s.execute(text("SELECT to_regclass('public.documents') IS NOT NULL"))).scalar()
purge = (await s.execute(text(
"SELECT count(*) FROM information_schema.columns "
"WHERE table_name='documents' AND column_name='purge_requested_at'"))).scalar()
cand = (await s.execute(text(
"SELECT count(*) FROM information_schema.tables "
"WHERE table_name LIKE 'documents_cand_qwen%'"))).scalar()
uq = (await s.execute(text(
"SELECT count(*) FROM pg_indexes WHERE indexname='uq_attempt_session_question'"))).scalar()
mx = (await s.execute(text("SELECT max(version) FROM schema_migrations"))).scalar()
print(f"SCHEMA OK — max_migration={mx} documents={docs} purge_col={purge} cand_qwen={cand} attempt_uq={uq}")
assert docs and purge == 1 and cand == 0 and uq == 1 and mx == 361, "FAIL: 기대 스키마 상태 불일치"
# ── 5) /health 직접 호출 ──────────────────────────────────────────────
health = await main.health_check()
print("HEALTH:", health)
assert health["status"] == "ok" and health["database"] == "connected", "FAIL: health degraded"
# ── 6) 정리 ───────────────────────────────────────────────────────────
await cm.__aexit__(None, None, None)
await engine.dispose()
print("RESULT: PASS — 전체 app 부팅(import·init_db·잡등록·health) 검증")
asyncio.run(main())
+51
View File
@@ -0,0 +1,51 @@
"""init_db() baseline 부팅 런타임 검증 (R1) — psql migration_smoke 가 못 잡는 asyncpg 경로 확인.
migration_smoke.sh(psql) SQL 유효성만 검증한다. init_db asyncpg exec_driver_sql(prepared)
경로라 multi-statement 불허 baseline raw asyncpg 적재 skip/stamp/멱등 이걸 실측한다.
실행 (worktree 루트):
python3.11 -m venv /tmp/v && /tmp/v/bin/pip install -q "sqlalchemy[asyncio]>=2" asyncpg pydantic pyyaml
docker run -d --name idb -p 55432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust pgvector/pgvector:pg16
docker exec idb psql -U postgres -c "CREATE DATABASE pkm"
ln -sfn ../migrations app/migrations # Docker 의 /app/migrations 레이아웃 모사 (테스트 후 rm)
PYTHONPATH=app DATABASE_URL="postgresql+asyncpg://postgres@localhost:55432/pkm" /tmp/v/bin/python scripts/ci/initdb_runtime_test.py
rm -f app/migrations; docker rm -f idb
기대: 1st OK(documents=True·purge_col=1·cand_qwen=0·attempt_unique=1), 2nd 멱등동일=True.
"""
import asyncio
from sqlalchemy import text
async def main():
from core.config import settings
url = settings.database_url
print("effective DATABASE_URL:", url)
assert "localhost" in url or "127.0.0.1" in url, f"SAFETY ABORT non-local: {url}"
from core.database import init_db, async_session, engine
print("=== 1st init_db (fresh DB) ===")
await init_db()
async with async_session() as s:
cnt = (await s.execute(text("SELECT count(*) FROM schema_migrations"))).scalar()
mx = (await s.execute(text("SELECT max(version) FROM schema_migrations"))).scalar()
bl = (await s.execute(text("SELECT count(*) FROM schema_migrations WHERE name LIKE 'baseline:%'"))).scalar()
docs = (await s.execute(text("SELECT to_regclass('public.documents') IS NOT NULL"))).scalar()
purge = (await s.execute(text("SELECT count(*) FROM information_schema.columns WHERE table_name='documents' AND column_name='purge_requested_at'"))).scalar()
cand = (await s.execute(text("SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'documents_cand_qwen%'"))).scalar()
uq = (await s.execute(text("SELECT count(*) FROM pg_indexes WHERE indexname='uq_attempt_session_question'"))).scalar()
print(f" schema_migrations count={cnt} max={mx} baseline_stamped={bl}")
print(f" documents={docs} purge_col={purge} cand_qwen_tables={cand} attempt_unique={uq}")
assert docs and purge == 1 and cand == 0 and uq == 1, "FAIL: 기대 스키마 상태 불일치"
print("=== 2nd init_db (rerun = baseline skip + 멱등) ===")
await init_db()
async with async_session() as s:
cnt2 = (await s.execute(text("SELECT count(*) FROM schema_migrations"))).scalar()
assert cnt == cnt2, "FAIL: 멱등 아님 (재실행이 schema_migrations 변경)"
print(f" count={cnt2} 멱등동일={cnt == cnt2}")
print("RESULT: PASS — init_db baseline 부팅/멱등 검증")
await engine.dispose()
asyncio.run(main())
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# migration_smoke.sh — fresh-DB + DR enum-same-txn 게이트 (plan ds-backend-audit-1 R0)
#
# app/core/database.py 의 init_db() 는 모든 pending migration 을 단일 트랜잭션
# (`async with engine.begin()`) 으로 적용한다. 이 스크립트는 그 경로를 미러해
# migrations/ 전체가 빈 DB / DR 업그레이드에서 한 트랜잭션으로 적용 가능한지 검증한다.
#
# 시나리오:
# FRESH — 빈 DB 에 migrations/ 전체를 단일 트랜잭션으로 적용 (신규 환경 부팅 경로)
# DR — 001~319 를 커밋(과거 운영 DB 모사) 후 320~end 를 단일 트랜잭션으로 적용
# (pre-320 백업/지연 복제를 320 경계 너머로 catch-up 업그레이드하는 재해복구 경로)
#
# enum-same-txn 결함(ALTER TYPE ADD VALUE 한 값을 같은 트랜잭션에서 사용)이 있으면
# 두 시나리오 모두 'unsafe use of new value' 로 abort 한다.
# R1(enum-barrier) fix 후에는 두 시나리오 모두 PASS 해야 한다.
#
# prod 동일 이미지(pg16)로 핀. 의존: docker.
# 사용: scripts/ci/migration_smoke.sh (ephemeral 컨테이너 자동 기동/정리)
set -uo pipefail
IMAGE="pgvector/pgvector:pg16"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MIG_DIR="$(cd "$SCRIPT_DIR/../../migrations" && pwd)"
CNAME="ds-mig-smoke-$$"
DB="pkm" # 358 의 ALTER DATABASE pkm 가 이 이름을 요구
cleanup() { docker rm -f "$CNAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
# 버전순 마이그레이션 파일 목록 (NNN_ 3자리 zero-pad → lexical = numeric)
# bash 3.2(macOS) 호환 — mapfile 미사용
MIGS=()
while IFS= read -r _line; do MIGS+=("$_line"); done < <(ls "$MIG_DIR"/[0-9]*.sql | sort)
[ "${#MIGS[@]}" -gt 0 ] || { echo "FATAL: migrations 없음 ($MIG_DIR)"; exit 2; }
echo "migrations: ${#MIGS[@]}건 ($(basename "${MIGS[0]}") ~ $(basename "${MIGS[$((${#MIGS[@]}-1))]}"))"
psql_exec() { docker exec -i "$CNAME" psql -U postgres -v ON_ERROR_STOP=1 "$@"; }
# 주어진 파일 범위를 단일 트랜잭션 스트림으로 묶어 출력 (psql stdin 용)
# 각 파일 앞에 \echo 마커 — 실패 시 마지막 마커가 깨진 마이그레이션.
emit_single_txn() {
echo '\set ON_ERROR_STOP on'
echo 'BEGIN;'
for f in "$@"; do
echo "\\echo >>>APPLY $(basename "$f")"
cat "$f"; echo
done
echo 'COMMIT;'
}
# 자동커밋(파일별 즉시 커밋) 스트림 — DR phase1 (기존 운영 DB 모사)
emit_autocommit() {
echo '\set ON_ERROR_STOP on'
for f in "$@"; do
echo "\\echo >>>APPLY $(basename "$f")"
cat "$f"; echo
done
}
reset_db() {
psql_exec -d postgres -c "DROP DATABASE IF EXISTS $DB" >/dev/null 2>&1
psql_exec -d postgres -c "CREATE DATABASE $DB" >/dev/null
}
run_scenario() {
local name="$1"; shift
local out rc last_apply
out="$( "$@" 2>&1 )"; rc=$?
last_apply="$(printf '%s\n' "$out" | grep '>>>APPLY' | tail -1 | sed 's/>>>APPLY //')"
if [ "$rc" -eq 0 ]; then
echo " [$name] PASS — 전체 적용 성공"
return 0
else
echo " [$name] FAIL — 깨진 지점: ${last_apply:-?}"
printf '%s\n' "$out" | grep -iE 'ERROR|unsafe|HINT' | head -3 | sed 's/^/ /'
return 1
fi
}
BASELINE_CUTOFF=358
BASELINE_FILE="$MIG_DIR/_baseline/0358_schema_baseline.sql"
# post-baseline(버전 > cutoff) 마이그 파일만 출력
_post_baseline() {
local f base ver
for f in "${MIGS[@]}"; do
base="$(basename "$f")"; ver="${base%%_*}"; ver="$((10#$ver))"
[ "$ver" -gt "$BASELINE_CUTOFF" ] && printf '%s\n' "$f"
done
}
# FRESH — init_db fresh 경로 미러: baseline 적재 + post-baseline 을 단일 트랜잭션
scenario_fresh() {
reset_db
local post=(); while IFS= read -r f; do post+=("$f"); done < <(_post_baseline)
{
echo '\set ON_ERROR_STOP on'; echo 'BEGIN;'
echo "\\echo >>>APPLY _baseline"
cat "$BASELINE_FILE"; echo
for f in "${post[@]}"; do
echo "\\echo >>>APPLY $(basename "$f")"; cat "$f"; echo
done
echo 'COMMIT;'
} | psql_exec -d "$DB"
}
# INCREMENTAL — 기존 운영 DB(at cutoff) 모사: baseline 커밋 후 post-baseline 을 별 트랜잭션
scenario_dr() {
reset_db
if ! { echo '\set ON_ERROR_STOP on'; cat "$BASELINE_FILE"; } | psql_exec -d "$DB" >/dev/null 2>&1; then
printf '%s\n' ">>>APPLY _baseline"; echo "baseline 적재 실패"; return 1
fi
local post=(); while IFS= read -r f; do post+=("$f"); done < <(_post_baseline)
emit_single_txn "${post[@]}" 2>/dev/null | psql_exec -d "$DB"
}
# ── 컨테이너 기동 ──
echo "기동: $IMAGE ($CNAME)"
docker run -d --name "$CNAME" -e POSTGRES_PASSWORD=x -e POSTGRES_HOST_AUTH_METHOD=trust "$IMAGE" >/dev/null
for _ in $(seq 1 40); do docker exec "$CNAME" pg_isready -U postgres -q 2>/dev/null && break; sleep 0.5; done
echo "pg: $(docker exec "$CNAME" psql -U postgres -tAc 'show server_version' 2>/dev/null)"
echo
fail=0
echo "── FRESH (baseline 적재 + post-baseline 단일 트랜잭션 = init_db fresh 경로) ──"
run_scenario FRESH scenario_fresh || fail=1
echo
echo "── INCREMENTAL (baseline 커밋 후 post-baseline 별 트랜잭션 = 기존 DB 증분) ──"
run_scenario DR scenario_dr || fail=1
echo
if [ "$fail" -eq 0 ]; then
echo "RESULT: PASS — fresh/incremental 모두 baseline+post-baseline 적용 가능"
exit 0
else
echo "RESULT: FAIL — baseline/post-baseline 적용 불가 (위 지점)"
exit 1
fi
+20 -3
View File
@@ -32,6 +32,7 @@ from core.config import settings
from services.hier_decomp.builder import build_hier_tree
from services.hier_decomp.persist import persist_hier_tree
from services.search.llm_gate import Priority, acquire_mlx_gate
from services.background_jobs import finish_job, heartbeat, start_job
# 단일 진실: 절 분석 상수/헬퍼 (PROMPT_VERSION 일치 = 멱등 보존)
from section_summary_pilot import (
@@ -140,8 +141,10 @@ def _make_engine():
return create_async_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at):
"""doc 의 미분석 hier leaf 분석 → upsert. stop_at(epoch) 넘으면 leaf 경계 중단."""
async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at,
engine=None, job_id=None, base_processed=0):
"""doc 의 미분석 hier leaf 분석 → upsert. stop_at(epoch) 넘으면 leaf 경계 중단.
engine/job_id 주어지면 background_jobs ~10절마다 진행 heartbeat(보드 가시화)."""
rows = (await session.execute(LEAF_SQL, {"doc": doc_id, "pv": PROMPT_VERSION})).mappings().all()
ok = fail = skip = 0
timings, types = [], []
@@ -187,6 +190,8 @@ async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, s
"content_hash": r["content_hash"], "error": err,
})
await session.commit()
if job_id and (ok + fail + skip) % 10 == 0:
await heartbeat(engine, job_id, processed=base_processed + ok + fail + skip)
await session.commit()
return {"ok": ok, "fail": fail, "skip": skip, "leaves": len(rows),
"timings": timings, "types": types, "aborted": aborted}
@@ -256,6 +261,12 @@ async def cmd_run(args):
_candidate_params(allowlist, doc_ids))).mappings().all()
_log(f"후보 doc {len(cands)} 선별. 시작.")
# 관측: 큐 밖 작업이라 대시보드 보드가 못 보므로 background_jobs 에 진행 노출(best-effort)
_job_kind = "hier_redecompose" if reprocess else "hier_backfill"
_job_label = (f"doc {args.doc} {'재분해' if reprocess else '분해'}" if doc_ids
else f"{len(cands)}개 문서 {'재분해' if reprocess else '분해'}")
job_id = await start_job(engine, _job_kind, _job_label, total=None)
for c in cands:
if time.time() >= stop_at:
_log(f"⏰ deadline 버퍼 도달 — doc 경계에서 중단 (처리 {tot_docs} doc)")
@@ -272,7 +283,10 @@ async def cmd_run(args):
"timings": [], "types": [], "aborted": False}
else:
async with sm() as session:
astat = await _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at)
astat = await _analyze_doc_leaves(
session, client, doc_id, doc_domain, model_name, stop_at,
engine=engine, job_id=job_id,
base_processed=(tot_ok + tot_fail + tot_skip))
except Exception as exc:
_log(f" ✗ doc={doc_id} 처리 실패(건너뜀): {type(exc).__name__}: {repr(exc)[:160]}")
continue
@@ -280,6 +294,8 @@ async def cmd_run(args):
tot_docs += 1
tot_ok += astat["ok"]; tot_fail += astat["fail"]; tot_skip += astat["skip"]
all_timings += astat["timings"]; all_types += astat["types"]
await heartbeat(engine, job_id, processed=(tot_ok + tot_fail + tot_skip),
total=tot_leaves_created)
avg = statistics.mean(astat["timings"]) if astat["timings"] else 0
_log(f" ✓ doc={doc_id} ({len(body):,}{doc_domain.split('/')[0]}) "
f"leaf생성={leaves_created} 분석ok={astat['ok']} fail={astat['fail']} skip={astat['skip']} "
@@ -287,6 +303,7 @@ async def cmd_run(args):
if astat["aborted"]:
_log("⏰ leaf 분석 중 deadline 도달 — 중단")
break
await finish_job(engine, job_id, state="done")
finally:
await client.close()
await engine.dispose()
+383
View File
@@ -0,0 +1,383 @@
<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/" xmlns:arxiv="http://arxiv.org/schemas/atom" xmlns="http://www.w3.org/2005/Atom">
<id>https://arxiv.org/api/m9A/71G4hH6NGyarIQjqA3n6Zzk</id>
<title>arXiv Query: search_query=abs:"pressure vessel"&amp;id_list=&amp;start=0&amp;max_results=10</title>
<updated>2026-06-13T21:57:59Z</updated>
<link href="https://arxiv.org/api/query?search_query=abs:%22pressure+vessel%22&amp;start=0&amp;max_results=10&amp;id_list=" type="application/atom+xml"/>
<opensearch:itemsPerPage>10</opensearch:itemsPerPage>
<opensearch:totalResults>89</opensearch:totalResults>
<opensearch:startIndex>0</opensearch:startIndex>
<entry>
<id>http://arxiv.org/abs/1209.2405v1</id>
<title>A Survey of Pressure Vessel Code Compliance for Superconducting RF Cryomodules</title>
<updated>2012-09-11T19:34:46Z</updated>
<link href="https://arxiv.org/abs/1209.2405v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/1209.2405v1" rel="related" type="application/pdf" title="pdf"/>
<summary>Superconducting radio frequency (SRF) cavities made from niobium and cooled with liquid helium are becoming key components of many particle accelerators. The helium vessels surrounding the RF cavities, portions of the niobium cavities themselves, and also possibly the vacuum vessels containing these assemblies, generally fall under the scope of local and national pressure vessel codes. In the U.S., Department of Energy rules require national laboratories to follow national consensus pressure vessel standards or to show "a level of safety greater than or equal to" that of the applicable standard. Thus, while used for its superconducting properties, niobium ends up being treated as a low-temperature pressure vessel material. Niobium material is not a code listed material and therefore requires the designer to understand the mechanical properties for material used in each pressure vessel fabrication; compliance with pressure vessel codes therefore becomes a problem. This report summarizes the approaches that various institutions have taken in order to bring superconducting RF cryomodules into compliance with pressure vessel codes.</summary>
<category term="physics.acc-ph" scheme="http://arxiv.org/schemas/atom"/>
<published>2012-09-11T19:34:46Z</published>
<arxiv:comment>7 pp</arxiv:comment>
<arxiv:primary_category term="physics.acc-ph"/>
<author>
<name>Thomas Peterson</name>
<arxiv:affiliation>Fermilab</arxiv:affiliation>
</author>
<author>
<name>Arkadiy Klebaner</name>
<arxiv:affiliation>Fermilab</arxiv:affiliation>
</author>
<author>
<name>Tom Nicol</name>
<arxiv:affiliation>Fermilab</arxiv:affiliation>
</author>
<author>
<name>Jay Theilacker</name>
<arxiv:affiliation>Fermilab</arxiv:affiliation>
</author>
<author>
<name>Hitoshi Hayano</name>
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
</author>
<author>
<name>Eiji Kako</name>
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
</author>
<author>
<name>Hirotaka Nakai</name>
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
</author>
<author>
<name>Akira Yamamoto</name>
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
</author>
<author>
<name>Kay Jensch</name>
<arxiv:affiliation>DESY</arxiv:affiliation>
</author>
<author>
<name>Axel Matheisen</name>
<arxiv:affiliation>DESY</arxiv:affiliation>
</author>
<author>
<name>John Mammosser</name>
<arxiv:affiliation>Jefferson Lab</arxiv:affiliation>
</author>
<arxiv:doi>10.1063/1.4707088</arxiv:doi>
<link rel="related" href="https://doi.org/10.1063/1.4707088" title="doi"/>
</entry>
<entry>
<id>http://arxiv.org/abs/2003.02057v1</id>
<title>Investigation of Unit-1 Nuclear Reactor of the Fukushima Daiichi by Cosmic Muon Radiography</title>
<updated>2020-03-03T03:21:53Z</updated>
<link href="https://arxiv.org/abs/2003.02057v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/2003.02057v1" rel="related" type="application/pdf" title="pdf"/>
<summary>We have investigated the status of the nuclear fuel assemblies in Unit-1 reactor of the Fukushima Daiichi Nuclear Power plant by the method called Cosmic Muon Radiography. In this study, muon tracking detectors were placed outside of the reactor building. We succeeded in identifying the inner structure of the reactor complex such as the reactor containment vessel, pressure vessel, and other structures of the reactor building, through the concrete wall of the reactor building. We found that a large amount of fuel assemblies was missing in the original fuel loading zone inside the pressure vessel. It can be naturally interpreted that most of the nuclear fuel was melt and dropped down to the bottom of the pressure vessel or even below.</summary>
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
<category term="hep-ex" scheme="http://arxiv.org/schemas/atom"/>
<published>2020-03-03T03:21:53Z</published>
<arxiv:comment>14 pages, 17 figures</arxiv:comment>
<arxiv:primary_category term="physics.ins-det"/>
<author>
<name>Hirofumi Fujii</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Kazuhiko Hara</name>
<arxiv:affiliation>University of Tsukuba</arxiv:affiliation>
</author>
<author>
<name>Kohei Hayashi</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Hidekazu Kakuno</name>
<arxiv:affiliation>Tokyo Metropolitan University</arxiv:affiliation>
</author>
<author>
<name>Hideyo Kodama</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Kanetada Nagamine</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Kotaro Sato</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Shin-Hong Kim</name>
<arxiv:affiliation>University of Tsukuba</arxiv:affiliation>
</author>
<author>
<name>Atsuto Suzuki</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Takayuki Sumiyoshi</name>
<arxiv:affiliation>Tokyo Metropolitan University</arxiv:affiliation>
</author>
<author>
<name>Kazuki Takahashi</name>
<arxiv:affiliation>University of Tsukuba</arxiv:affiliation>
</author>
<author>
<name>Fumihiko Takasaki</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Shuji Tanaka</name>
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
</author>
<author>
<name>Satoru Yamashita</name>
<arxiv:affiliation>University of Tokyo</arxiv:affiliation>
</author>
</entry>
<entry>
<id>http://arxiv.org/abs/1609.07515v1</id>
<title>Low Background Stainless Steel for the Pressure Vessel in the PandaX-II Dark Matter Experiment</title>
<updated>2016-09-21T10:33:04Z</updated>
<link href="https://arxiv.org/abs/1609.07515v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/1609.07515v1" rel="related" type="application/pdf" title="pdf"/>
<summary>We report on the custom produced low radiation background stainless steel and the welding rod for the PandaX experiment, one of the deep underground experiments to search for dark matter and neutrinoless double beta decay using xenon. The anthropogenic 60 Co concentration in these samples is at the range of 1 mBq/kg or lower. We also discuss the radioactivity of nuclear-grade stainless steel from TISCO which has a similar background rate. The PandaX-II pressure vessel was thus fabricated using the stainless steel from CISRI and TISCO. Based on the analysis of the radioactivity data, we also made discussions on potential candidate for low background metal materials for future pressure vessel development.</summary>
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
<category term="hep-ex" scheme="http://arxiv.org/schemas/atom"/>
<published>2016-09-21T10:33:04Z</published>
<arxiv:primary_category term="physics.ins-det"/>
<author>
<name>Tao Zhang</name>
</author>
<author>
<name>Changbo Fu</name>
</author>
<author>
<name>Xiangdong Ji</name>
</author>
<author>
<name>Jianglai Liu</name>
</author>
<author>
<name>Xiang Liu</name>
</author>
<author>
<name>Xuming Wang</name>
</author>
<author>
<name>Chunfa Yao</name>
</author>
<author>
<name>Xunhua Yuan</name>
</author>
<arxiv:doi>10.1088/1748-0221/11/09/T09004</arxiv:doi>
<link rel="related" href="https://doi.org/10.1088/1748-0221/11/09/T09004" title="doi"/>
</entry>
<entry>
<id>http://arxiv.org/abs/2308.09786v1</id>
<title>Mechanical design of the optical modules intended for IceCube-Gen2</title>
<updated>2023-08-18T19:20:09Z</updated>
<link href="https://arxiv.org/abs/2308.09786v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/2308.09786v1" rel="related" type="application/pdf" title="pdf"/>
<summary>IceCube-Gen2 is an expansion of the IceCube neutrino observatory at the South Pole that aims to increase the sensitivity to high-energy neutrinos by an order of magnitude. To this end, about 10,000 new optical modules will be installed, instrumenting a fiducial volume of about 8 km^3. Two newly developed optical module types increase current sensitivity per module by a factor of three by integrating 16 and 18 newly developed four-inch PMTs in specially designed 12.5-inch diameter pressure vessels. Both designs use conical silicone gel pads to optically couple the PMTs to the pressure vessel to increase photon collection efficiency. The outside portion of gel pads are pre-cast onto each PMT prior to integration, while the interiors are filled and cast after the PMT assemblies are installed in the pressure vessel via a pushing mechanism. This paper presents both the mechanical design, as well as the performance of prototype modules at high pressure (70 MPa) and low temperature (-40 degree Celsius), characteristic of the environment inside the South Pole ice.</summary>
<category term="astro-ph.IM" scheme="http://arxiv.org/schemas/atom"/>
<category term="astro-ph.HE" scheme="http://arxiv.org/schemas/atom"/>
<published>2023-08-18T19:20:09Z</published>
<arxiv:comment>Presented at the 38th International Cosmic Ray Conference (ICRC2023). See arXiv:2307.13048 for all IceCube-Gen2 contributions</arxiv:comment>
<arxiv:primary_category term="astro-ph.IM"/>
<author>
<name>Yuya Makino</name>
<arxiv:affiliation>for the IceCube-Gen2 Collaboration</arxiv:affiliation>
</author>
</entry>
<entry>
<id>http://arxiv.org/abs/0804.0261v1</id>
<title>Circulation in Blowdown Flows</title>
<updated>2008-04-01T22:22:32Z</updated>
<link href="https://arxiv.org/abs/0804.0261v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/0804.0261v1" rel="related" type="application/pdf" title="pdf"/>
<summary> The blowdown of high pressure gas in a pressure vessel produces rapid adiabatic cooling of the gas remaining in the vessel. The gas near the wall is warmed by conduction from the wall, producing radial temperature and density gradients that affect the flow, the mass efflux rate and the thermodynamic states of both the outflowing and the contained gas. The resulting buoyancy-driven flow circulates gas through the vessel and reduces, but does not eliminate, these gradients. The purpose of this note is to estimate when blowdown cooling is rapid enough that the gas in the pressure vessel is neither isothermal nor isopycnic, though it remains isobaric. I define a dimensionless number, the buoyancy circulation number BC, that parametrizes these effects.</summary>
<category term="physics.flu-dyn" scheme="http://arxiv.org/schemas/atom"/>
<published>2008-04-01T22:22:32Z</published>
<arxiv:comment>5 pp., no figures</arxiv:comment>
<arxiv:primary_category term="physics.flu-dyn"/>
<arxiv:journal_ref>J. Pressure Vessel Tech. 131, 034501 (2009)</arxiv:journal_ref>
<author>
<name>J. I. Katz</name>
</author>
</entry>
<entry>
<id>http://arxiv.org/abs/1204.0234v1</id>
<title>Substantiation of Thermodynamic Criteria of Explosion Safety in Process of Severe Accidents in Pressure Vessel Reactors</title>
<updated>2012-03-27T11:21:14Z</updated>
<link href="https://arxiv.org/abs/1204.0234v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/1204.0234v1" rel="related" type="application/pdf" title="pdf"/>
<summary>The paper represents original development of thermodynamic criteria of occurrence conditions of steam-gas explosions in the process of severe accidents. The received results can be used for modelling of processes of severe accidents in pressure vessel reactors.</summary>
<category term="physics.gen-ph" scheme="http://arxiv.org/schemas/atom"/>
<published>2012-03-27T11:21:14Z</published>
<arxiv:comment>5 pages, 1 figure</arxiv:comment>
<arxiv:primary_category term="physics.gen-ph"/>
<author>
<name>V. I. Skalozubov</name>
</author>
<author>
<name>V. N. Vashchenko</name>
</author>
<author>
<name>S. S. Jarovoj</name>
</author>
<author>
<name>V. Yu. Kochnyeva</name>
</author>
</entry>
<entry>
<id>http://arxiv.org/abs/2511.11485v1</id>
<title>Data-efficient U-Net for Segmentation of Carbide Microstructures in SEM Images of Steel Alloys</title>
<updated>2025-11-14T17:01:02Z</updated>
<link href="https://arxiv.org/abs/2511.11485v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/2511.11485v1" rel="related" type="application/pdf" title="pdf"/>
<summary>Understanding reactor-pressure-vessel steel microstructure is crucial for predicting mechanical properties, as carbide precipitates both strengthen the alloy and can initiate cracks. In scanning electron microscopy images, gray-value overlap between carbides and matrix makes simple thresholding ineffective. We present a data-efficient segmentation pipeline using a lightweight U-Net (30.7~M parameters) trained on just \textbf{10 annotated scanning electron microscopy images}. Despite limited data, our model achieves a \textbf{Dice-Sørensen coefficient of 0.98}, significantly outperforming the state-of-the-art in the field of metallurgy (classical image analysis: 0.85), while reducing annotation effort by one order of magnitude compared to the state-of-the-art data efficient segmentation model. This approach enables rapid, automated carbide quantification for alloy design and generalizes to other steel types, demonstrating the potential of data-efficient deep learning in reactor-pressure-vessel steel analysis.</summary>
<category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
<category term="cond-mat.mtrl-sci" scheme="http://arxiv.org/schemas/atom"/>
<published>2025-11-14T17:01:02Z</published>
<arxiv:primary_category term="cs.LG"/>
<arxiv:journal_ref>Machine Learning and the Physical Sciences Workshop @ NeurIPS 2025 https://openreview.net/forum?id=xYY5pn4f8N</arxiv:journal_ref>
<author>
<name>Alinda Ezgi Gerçek</name>
</author>
<author>
<name>Till Korten</name>
</author>
<author>
<name>Paul Chekhonin</name>
</author>
<author>
<name>Maleeha Hassan</name>
</author>
<author>
<name>Peter Steinbach</name>
</author>
</entry>
<entry>
<id>http://arxiv.org/abs/2511.09689v1</id>
<title>An ASME-Compliant Helium-4 Evaporation Refrigerator for the SpinQuest Experiment</title>
<updated>2025-11-12T19:45:47Z</updated>
<link href="https://arxiv.org/abs/2511.09689v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/2511.09689v1" rel="related" type="application/pdf" title="pdf"/>
<summary>This paper presents the design, safety basis, and commissioning results of a 1 K liquid helium-4 (4He) evaporation refrigerator developed for the Fermilab SpinQuest Experiment (E1039). The system represents the first high power helium evaporation refrigerator operated in a fixed target scattering experiment at Fermilab and was engineered to comply with the Fermilab ES\&amp;H Manual (FESHM) requirements governing pressure vessels, piping, cryogenic systems, and vacuum vessels. The design is mapped to ASME B31.3 (Process Piping) and the ASME Boiler and Pressure Vessel Code (BPVC) for pressure boundary integrity and overpressure protection, with documented compliance to FESHM Chapters 5031 (Pressure Vessels), 5031.1 (Piping Systems), and 5033 (Vacuum Vessels). This work documents the methodology used to reach compliance and approval for the 4He evaporation refrigerator at Fermilab which the field lacks. Design considerations specific to the high radiation target-cave environment including remotely located instrumentation approximately 20 m from the cryostat are summarized, together with the relief-system sizing methodology used to accommodate transient heat loads from dynamic nuclear polarization microwaves and the high-intensity proton beam. Commissioning data from July 2024 confirms that the system satisfies all thermal performance and safety objectives.</summary>
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
<published>2025-11-12T19:45:47Z</published>
<arxiv:comment>For IEEE Transactions in Nuclear Physics, 11 pages, 14 figures</arxiv:comment>
<arxiv:primary_category term="physics.ins-det"/>
<author>
<name>Jordan D. Roberts</name>
</author>
<author>
<name>Vibodha Bandara</name>
</author>
<author>
<name>Kenichi Nakano</name>
</author>
<author>
<name>Dustin Keller</name>
</author>
</entry>
<entry>
<id>http://arxiv.org/abs/1507.04072v1</id>
<title>High-Voltage Terminal Test of Test Stand for 1-MV Electrostatic Accelerator</title>
<updated>2015-07-15T02:41:11Z</updated>
<link href="https://arxiv.org/abs/1507.04072v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/1507.04072v1" rel="related" type="application/pdf" title="pdf"/>
<summary>The Korea Multipurpose Accelerator Complex (KOMAC) has been developing a 300-kV test stand for a 1-MV electrostatic accelerator ion source. The ion source and accelerating tube will be installed in a high-pressure vessel. The ion source in the high-pressure vessel is required to have a high reliability. The test stand has been proposed and developed to confirm the stable operating conditions of the ion source. The ion source will be tested at the test stand to verify the long-time operating conditions. The test stand comprises a 300-kV high-voltage terminal, a battery for the ion-source power, a 60-Hz inverter, 200-MHz RF power, a 5-kV extraction power supply, a 300-kV accelerating tube, and a vacuum system. The results of the 300-kV high-voltage terminal tests are presented in this paper.</summary>
<category term="physics.acc-ph" scheme="http://arxiv.org/schemas/atom"/>
<published>2015-07-15T02:41:11Z</published>
<arxiv:comment>International Conference on Accelerators and Beam Utilization (ICABU2014)</arxiv:comment>
<arxiv:primary_category term="physics.acc-ph"/>
<arxiv:journal_ref>Yong-Sub Cho KNS (2014); W. Sima IEEE (2004) 480-483; LA-UR-87-126 (1987); Jeong-tae Kim KNS (2014)</arxiv:journal_ref>
<author>
<name>Sae-Hoon Park</name>
</author>
<author>
<name>Yu-Seok Kim</name>
</author>
<arxiv:doi>10.3938/jkps</arxiv:doi>
<link rel="related" href="https://doi.org/10.3938/jkps" title="doi"/>
</entry>
<entry>
<id>http://arxiv.org/abs/2005.05585v1</id>
<title>Investigation of the Status of Unit 2 Nuclear Reactor of the Fukushima Daiichi by the Cosmic Muon Radiography</title>
<updated>2020-05-12T07:26:37Z</updated>
<link href="https://arxiv.org/abs/2005.05585v1" rel="alternate" type="text/html"/>
<link href="https://arxiv.org/pdf/2005.05585v1" rel="related" type="application/pdf" title="pdf"/>
<summary>We have investigated the status of the nuclear debris in the Unit-2 Nuclear Reactor of the Fukushima Daiichi Nuclear Power plant by the method called Cosmic Muon Radiography. In this measurement, the muon detector was placed outside of the reactor building as was the case of the measurement for the Unit-1 Reactor. Compared to the previous measurements, the detector was down-sized, which made us possible to locate it closer to the reactor and to investigate especially the lower part of the fuel loading zone. We identified the inner structures of the reactor such as the containment vessel, pressure vessel and other objects through the thick concrete wall of the reactor building. Furthermore, the observation showed existence of heavy material at the bottom of the pressure vessel, which can be interpreted as the debris of melted nuclear fuel dropped from the loading zone.</summary>
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
<published>2020-05-12T07:26:37Z</published>
<arxiv:comment>11 figures and 2 tables</arxiv:comment>
<arxiv:primary_category term="physics.ins-det"/>
<author>
<name>Hirofumi Fujii</name>
</author>
<author>
<name>Kazuhiko Hara</name>
</author>
<author>
<name>Shugo Hashimoto</name>
</author>
<author>
<name>Kohei Hayashi</name>
</author>
<author>
<name>Hidekazu Kakuno</name>
</author>
<author>
<name>Hideyo Kodama</name>
</author>
<author>
<name>Gi Meiki</name>
</author>
<author>
<name>Masato Mizokami</name>
</author>
<author>
<name>Shinya Mizokami</name>
</author>
<author>
<name>Kanetada Nagamine</name>
</author>
<author>
<name>Kotaro Sato</name>
</author>
<author>
<name>Shunsuke Sekita</name>
</author>
<author>
<name>Hiroshi Shirai</name>
</author>
<author>
<name>Shin-Hong Kim</name>
</author>
<author>
<name>Takayuki Sumiyoshi</name>
</author>
<author>
<name>Atsuto Suzuki</name>
</author>
<author>
<name>Yoshihisa Takada</name>
</author>
<author>
<name>Kazuki Takahashi</name>
</author>
<author>
<name>Yu Takahashi</name>
</author>
<author>
<name>Fumihiko Takasaki</name>
</author>
<author>
<name>Daichi Yamada</name>
</author>
<author>
<name>Satoru Yamashita</name>
</author>
</entry>
</feed>
File diff suppressed because one or more lines are too long
+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",
+75
View File
@@ -0,0 +1,75 @@
"""B-3 PR2 — arXiv 파서·쿼리빌더 순수 단위 테스트 (plan safety-library-b3-1).
fixture = arXiv API 실응답 박제(abs:"pressure vessel" relevance 10
DOI 보유 / journal_ref 보유 / 없음 3경로 포함). run()/적재(DB) PR2 라이브 검증.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
from workers.arxiv_collector import ( # noqa: E402
build_search_query,
parse_arxiv_feed,
)
FIX = Path(__file__).parent / "fixtures" / "arxiv_search_pressure_vessel.xml"
def _entries():
total, entries = parse_arxiv_feed(FIX.read_text(encoding="utf-8"))
return total, {e.arxiv_id: e for e in entries}, entries
# ─── 피드 레벨 ───
def test_feed_total_and_count():
total, by_id, entries = _entries()
assert total == 89 # fixture totalResults (페이징 재료)
assert len(entries) == 10
def test_versionless_ids():
_, by_id, entries = _entries()
# arxiv_id 는 versionless (버전 접미는 .version 으로 분리)
assert all("/" not in e.arxiv_id for e in entries)
assert "1209.2405" in by_id and by_id["1209.2405"].version == "v1"
# ─── DOI 보유 entry ───
def test_entry_with_doi():
_, by_id, _ = _entries()
e = by_id["1209.2405"]
assert e.doi == "10.1063/1.4707088" # normalize_doi 적용(소문자·정규화)
assert e.journal_ref is None
assert e.primary_category == "physics.acc-ph"
assert e.title.startswith("A Survey of Pressure Vessel")
assert len(e.summary) > 200 # 초록 본문
assert e.published is not None
assert e.abs_url and "/abs/" in e.abs_url
assert e.pdf_url and "pdf" in e.pdf_url
# ─── journal_ref 만 (DOI 없음) — 압력용기 저널 출판분 ───
def test_entry_journal_ref_without_doi():
_, by_id, _ = _entries()
e = by_id["0804.0261"]
assert e.doi is None
assert e.journal_ref and "Pressure Vessel" in e.journal_ref
# ─── 둘 다 없음(최근 preprint) 경로도 존재 ───
def test_entry_neither_doi_nor_journal_ref_exists():
_, _, entries = _entries()
assert any(e.doi is None and e.journal_ref is None for e in entries)
# ─── 쿼리 빌더 ───
def test_build_search_query():
q = build_search_query("eess.SY", ["pressure vessel", "safety"])
assert q == 'cat:eess.SY AND (abs:"pressure vessel" OR abs:safety)'
+13 -39
View File
@@ -37,8 +37,7 @@ from services.search.freshness_decay import (
NOW = datetime(2026, 5, 3, 12, 0, 0, tzinfo=timezone.utc)
def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | None = None,
material_type: str | None = None) -> _DocMeta:
def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | None = None) -> _DocMeta:
if days_ago is None:
created = None
elif days_ago < 0:
@@ -46,8 +45,7 @@ def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | N
created = NOW + timedelta(days=-days_ago)
else:
created = NOW - timedelta(days=days_ago)
return _DocMeta(source_channel=channel, content_origin=origin, created_at=created,
material_type=material_type)
return _DocMeta(source_channel=channel, content_origin=origin, created_at=created)
# ─── policy dispatcher ────────────────────────────────────────────
@@ -57,15 +55,8 @@ def test_policy_news():
assert freshness_policy(_meta("news")) == "news_90d"
def test_policy_law_monitor_now_unaffected():
# C-1 후속: law_365d 폐기 → law_monitor 비적용 (현행성은 version_status 가 처리)
assert freshness_policy(_meta("law_monitor")) is None
def test_policy_incident():
# C-1 후속: 재해사례/사망사고(material_type='incident') → news_90d 흡수 (source 무관)
assert freshness_policy(_meta("crawl", material_type="incident")) == "news_90d"
assert freshness_policy(_meta("inbox_route", material_type="incident")) == "news_90d"
def test_policy_law_monitor():
assert freshness_policy(_meta("law_monitor")) == "law_365d"
def test_policy_manual_unaffected():
@@ -132,9 +123,8 @@ def test_decay_at_half_life_news():
assert compute_decay(90.0, "news_90d") == pytest.approx(0.5, rel=1e-6)
def test_decay_law_365d_removed_returns_one():
# C-1 후속: law_365d 폐기 → HALF_LIFE_DAYS 미등록 policy → decay 1.0 (no-op)
assert compute_decay(365.0, "law_365d") == 1.0
def test_decay_at_half_life_law():
assert compute_decay(365.0, "law_365d") == pytest.approx(0.5, rel=1e-6)
def test_decay_age_zero_full():
@@ -222,38 +212,22 @@ async def test_apply_news_recent_vs_old_recent_higher():
@pytest.mark.asyncio
async def test_apply_law_monitor_now_unaffected():
# C-1 후속: law_monitor freshness 폐기 → recent/old 동일 score (재정렬 없음)
async def test_apply_law_monitor_recent_vs_old_recent_higher():
# 가드 2: law_monitor recent 가 위
base = 0.50
rows = [
{"id": 1, "source_channel": "law_monitor", "content_origin": "extracted",
"material_type": "law", "created_at": NOW - timedelta(days=10)},
"created_at": NOW - timedelta(days=10)},
{"id": 2, "source_channel": "law_monitor", "content_origin": "extracted",
"material_type": "law", "created_at": NOW - timedelta(days=730)},
]
session = _MockSession(rows)
results = [_result(1, base), _result(2, base)]
out = await apply_freshness_decay(results, session, now=NOW)
assert out[0].score == base and out[1].score == base
assert out[0].freshness_debug["freshness_policy"] is None
@pytest.mark.asyncio
async def test_apply_incident_recent_vs_old_recent_higher():
# C-1 후속: 재해사례(incident) recent 가 위 (news_90d 흡수, source_channel='crawl')
base = 0.50
rows = [
{"id": 1, "source_channel": "crawl", "content_origin": "extracted",
"material_type": "incident", "created_at": NOW - timedelta(days=5)},
{"id": 2, "source_channel": "crawl", "content_origin": "extracted",
"material_type": "incident", "created_at": NOW - timedelta(days=400)},
"created_at": NOW - timedelta(days=730)}, # 2년
]
session = _MockSession(rows)
results = [_result(1, base), _result(2, base)]
out = await apply_freshness_decay(results, session, now=NOW)
assert out[0].id == 1
assert out[0].score > out[1].score
assert out[0].freshness_debug["freshness_policy"] == "news_90d"
assert out[0].freshness_debug["freshness_policy"] == "law_365d"
# 2년 → law_365d 반감기 1년 → decay ~0.25 → multiplier ~ 0.775
assert out[1].score < out[0].score
@pytest.mark.asyncio
+106
View File
@@ -0,0 +1,106 @@
"""B-3 PR3 — OpenAlex 파서·초록복원·license 순수 단위 테스트 (plan safety-library-b3-1).
fixture = OpenAlex /works 실응답 박제(process safety/pressure vessel OA 5
cc-by/cc-by-nc-nd/license None, 초록 있음/없음). run()/적재(DB) PR3 라이브 검증.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
from workers.openalex_collector import ( # noqa: E402
_reconstruct_abstract,
_seeds,
build_filter,
build_issn_filter,
license_meta,
parse_openalex_works,
)
FIX = Path(__file__).parent / "fixtures" / "openalex_works_response.json"
def _works():
count, cursor, works = parse_openalex_works(FIX.read_text(encoding="utf-8"))
return count, {w.openalex_id: w for w in works}, works
# ─── 피드 레벨 ───
def test_count_and_results():
count, by_id, works = _works()
assert count == 1111
assert len(works) == 5
assert all(w.openalex_id.startswith("W") and "/" not in w.openalex_id for w in works)
# ─── 초록 보유 + CC 라이선스 ───
def test_work_with_abstract_and_cc():
_, by_id, _ = _works()
w = by_id["W2910511816"]
assert w.doi and w.doi.startswith("10.") and w.doi == w.doi.lower() # normalize_doi
assert len(w.abstract) > 50 # inverted-index 복원
assert w.oa_status == "diamond" and w.is_oa is True
assert w.license == "cc-by"
assert license_meta(w.license, w.is_oa, w.source_name)["redistribute"] is True
# ─── 초록 없는 thin 레코드(skip 대상) ───
def test_work_without_abstract():
_, by_id, _ = _works()
w = by_id["W3107397139"]
assert w.abstract == "" # inverted-index 부재 → 빈 초록
lm = license_meta(w.license, w.is_oa, w.source_name)
assert lm["redistribute"] is False # license None → 비배포
# ─── cc-by-nc-nd 도 CC 계열 → redistribute True ───
def test_cc_variant_redistribute():
_, by_id, _ = _works()
w = by_id["W4391130399"]
assert w.license == "cc-by-nc-nd"
assert license_meta(w.license, w.is_oa, w.source_name)["redistribute"] is True
# ─── 초록 inverted-index 복원 순서 ───
def test_reconstruct_abstract_order():
inv = {"Safety": [0], "of": [1, 4], "pressure": [2], "vessels": [3], "design": [5]}
assert _reconstruct_abstract(inv) == "Safety of pressure vessels of design"
assert _reconstruct_abstract(None) == ""
assert _reconstruct_abstract({}) == ""
# ─── license_meta 분기 ───
def test_license_meta_branches():
assert license_meta("cc-by", True, "X")["redistribute"] is True
assert license_meta("cc0", True, "X")["redistribute"] is True
none_oa = license_meta(None, True, "X")
assert none_oa["redistribute"] is False and none_oa["scheme"] == "open-unspecified"
closed = license_meta(None, False, "X")
assert closed["redistribute"] is False and closed["scheme"] == "proprietary"
# ─── 쿼리 빌더 ───
def test_build_filter():
assert build_filter("process safety") == "title_and_abstract.search:process safety"
assert build_filter("process safety", "2026-06-01") == \
"title_and_abstract.search:process safety,from_publication_date:2026-06-01"
# ─── PR6: ISSN 소스 시드 (KR/JP 안전 저널 직접 커버) ───
def test_build_issn_filter_and_seeds():
assert build_issn_filter("1738-3803") == "primary_location.source.issn:1738-3803"
assert build_issn_filter("1738-3803", "2026-01-01") == \
"primary_location.source.issn:1738-3803,from_publication_date:2026-01-01"
seeds = _seeds()
kinds = [k for _, _, k in seeds]
assert kinds[0] == "issn" # ISSN 시드가 키워드보다 먼저(cap 우선권)
assert any(v == "1738-3803" and k == "issn" for _, v, k in seeds) # 한국안전학회지 포함
+141
View File
@@ -0,0 +1,141 @@
"""B-3 PR1 — 논문 DOI 코어 순수 단위 테스트 (plan safety-library-b3-1).
holder.find_paper_holder(DB 조회) PR2 arXiv 실수집 라이브 검증 여기선 순수 함수만.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
from services.papers.doi import ( # noqa: E402
arxiv_doi,
normalize_doi,
paper_doi_hash,
parse_arxiv_id,
parse_doi_from_text,
read_paper_doi,
with_paper_doi,
with_parent_doi,
)
# ─── normalize_doi: 단일 함수(저장=조회) ───
def test_normalize_strips_url_and_lowercases():
assert normalize_doi("https://doi.org/10.1585/PFR.15.2402039") == "10.1585/pfr.15.2402039"
assert normalize_doi("http://dx.doi.org/10.1115/1.4045678") == "10.1115/1.4045678"
assert normalize_doi("doi:10.1016/j.jlp.2020.104321") == "10.1016/j.jlp.2020.104321"
assert normalize_doi("DOI: 10.1234/ABC") == "10.1234/abc"
def test_normalize_trims_whitespace_and_citation_noise():
assert normalize_doi(" https://doi.org/10.1234/abc ") == "10.1234/abc"
assert normalize_doi("10.1234/abc.") == "10.1234/abc"
assert normalize_doi("10.1234/abc;") == "10.1234/abc"
def test_normalize_preserves_parens_in_doi():
# 괄호는 DOI 일부일 수 있어 보존 (과삭제 = 다른 논문 병합 = 데이터 손상, near-dup 보다 위험)
assert normalize_doi("10.1016/s0010-8650(00)80003-2") == "10.1016/s0010-8650(00)80003-2"
assert normalize_doi("https://doi.org/10.1016/S0010-8650(00)80003-2") == "10.1016/s0010-8650(00)80003-2"
def test_normalize_rejects_non_doi():
assert normalize_doi(None) is None
assert normalize_doi("") is None
assert normalize_doi(" ") is None
assert normalize_doi("not-a-doi") is None
assert normalize_doi("arXiv:2606.08108") is None # arXiv id 는 DOI 아님
def test_normalize_is_idempotent_store_equals_lookup():
# 저장측·조회측이 같은 함수를 거치면 표기 차이가 한 값으로 붕괴 (dedup 성립 조건)
forms = [
"https://doi.org/10.1/X",
"doi:10.1/x",
"10.1/X",
" HTTPS://DOI.ORG/10.1/x ",
]
assert {normalize_doi(f) for f in forms} == {"10.1/x"}
assert normalize_doi(normalize_doi("https://doi.org/10.1/X")) == "10.1/x" # 멱등
# ─── paper_doi_hash: holder file_hash 키 ───
def test_paper_doi_hash_deterministic_len32():
h = paper_doi_hash("10.1234/abc")
assert len(h) == 32
assert h == paper_doi_hash("10.1234/abc")
def test_paper_doi_hash_distinct_per_doi():
assert paper_doi_hash("10.1/a") != paper_doi_hash("10.1/b")
# ─── 2-Document extract_meta 계약 (holder doi / child parent_doi 상호 배타) ───
def test_with_paper_doi_holder_shape_and_merge_safe():
meta = with_paper_doi({"license": {"scheme": "cc_by"}, "source_id": 7}, "10.1/x")
assert meta["paper"]["doi"] == "10.1/x"
assert "parent_doi" not in meta["paper"]
assert meta["license"]["scheme"] == "cc_by" # 타 키 보존
assert meta["source_id"] == 7
def test_with_parent_doi_child_shape_no_doi():
meta = with_parent_doi({"license": {"scheme": "proprietary"}}, "10.1/holder")
assert meta["paper"]["parent_doi"] == "10.1/holder"
assert "doi" not in meta["paper"] # child 는 doi 미보유 (partial-unique 인덱스 밖)
assert meta["license"]["scheme"] == "proprietary"
def test_holder_child_mutually_exclusive():
child = with_parent_doi({}, "10.1/p")
promoted = with_paper_doi(child, "10.1/self")
assert promoted["paper"]["doi"] == "10.1/self"
assert "parent_doi" not in promoted["paper"]
def test_input_not_mutated():
src = {"paper": {"doi": "10.1/old"}}
with_parent_doi(src, "10.1/new")
assert src["paper"]["doi"] == "10.1/old" # 원본 dict 불변
# ─── read_paper_doi: 인덱스 식의 조회측 거울 ───
def test_read_paper_doi():
assert read_paper_doi({"paper": {"doi": "10.1/x"}}) == "10.1/x"
assert read_paper_doi({"paper": {"doi": "https://doi.org/10.1/X"}}) == "10.1/x" # 방어적 재정규화
assert read_paper_doi({}) is None
assert read_paper_doi(None) is None
assert read_paper_doi({"paper": {"parent_doi": "10.1/p"}}) is None # child 는 doi 없음
assert read_paper_doi({"paper": {}}) is None
# ─── PR4: arXiv id 파싱 + arXiv DataCite DOI (교차소스 dedup 통일 키) ───
def test_parse_arxiv_id():
assert parse_arxiv_id("Title arXiv:2606.10236v1 Announce Type: new Abstract") == "2606.10236"
assert parse_arxiv_id("see arXiv:2601.02852 for details") == "2601.02852"
assert parse_arxiv_id("arXiv:cond-mat/0703470v2") == "cond-mat/0703470"
assert parse_arxiv_id("no arxiv here") is None
assert parse_arxiv_id(None) is None
def test_arxiv_doi_canonical():
# OpenAlex canonical 실측 일치: 10.48550/arxiv.{id} (소문자)
assert arxiv_doi("2606.10236") == "10.48550/arxiv.2606.10236"
assert arxiv_doi(None) is None
# 수집기·reconcile 가 같은 함수 → 같은 paper.doi (교차소스 dedup 성립)
assert arxiv_doi(parse_arxiv_id("x arXiv:2606.10236v1 y")) == "10.48550/arxiv.2606.10236"
# ─── PR5: 구매 PDF 본문 DOI 파싱 (parent_doi 링크용, PDF 구조 무관) ───
def test_parse_doi_from_text():
assert parse_doi_from_text("ref https://doi.org/10.1016/j.jlp.2024.105474 end") == "10.1016/j.jlp.2024.105474"
assert parse_doi_from_text("DOI 10.1115/1.4045678. Next.") == "10.1115/1.4045678"
assert parse_doi_from_text("no doi here") is None
assert parse_doi_from_text(None) is None
+27 -2
View File
@@ -26,7 +26,8 @@ def _fake_consumer_env(monkeypatch, held):
lambda: {
s: object()
for s in (queue_consumer.MAIN_QUEUE_STAGES
+ queue_consumer.FAST_QUEUE_STAGES + ["markdown"])
+ queue_consumer.FAST_QUEUE_STAGES
+ queue_consumer.DEEP_QUEUE_STAGES + ["markdown"])
},
)
monkeypatch.setattr(queue_consumer, "_hold_logged", False)
@@ -83,13 +84,37 @@ async def test_fast_consumer_respects_hold(monkeypatch):
assert processed == ["chunk"]
@pytest.mark.asyncio
async def test_deep_consumer_processes_deep_only(monkeypatch):
"""deep 컨슈머(2026-06-15 분리) = deep_summary 전용 (메인 루프와 디커플)."""
processed = _fake_consumer_env(monkeypatch, [])
await queue_consumer.consume_deep_queue()
assert processed == ["deep_summary"]
@pytest.mark.asyncio
async def test_deep_consumer_respects_hold(monkeypatch):
"""deep_summary 홀드 시 deep 컨슈머가 claim 안 함."""
processed = _fake_consumer_env(monkeypatch, ["deep_summary"])
await queue_consumer.consume_deep_queue()
assert processed == []
def test_fast_split_invariants():
""" 컨슈머 stage 집합 disjoint + embed/chunk 배치 상향 회귀 가드."""
""" 컨슈머 stage 집합 disjoint + embed/chunk 배치 상향 + deep split 회귀 가드."""
main = set(queue_consumer.MAIN_QUEUE_STAGES)
fast = set(queue_consumer.FAST_QUEUE_STAGES)
md = set(queue_consumer.MARKDOWN_QUEUE_STAGES)
deep = set(queue_consumer.DEEP_QUEUE_STAGES)
assert not (main & fast) and not (main & md) and not (fast & md)
assert not (main & deep) and not (fast & deep) and not (md & deep)
assert fast == {"embed", "chunk"}
assert deep == {"deep_summary"}
assert "deep_summary" not in main # 2026-06-15 split 회귀 가드
assert queue_consumer.BATCH_SIZE["embed"] >= 10
assert queue_consumer.BATCH_SIZE["chunk"] >= 10
+26
View File
@@ -103,6 +103,32 @@ def test_summarize_pool_split_attribution():
assert macbook["pending"] == 0 # 풀 pending 은 macmini 만
def test_summarize_by_machine_projection():
"""build_summarize_by_machine = split 의 done_1h/done_today 를 머신별로 투영
(done_15m 제외 내부 state 판정 전용)."""
from services.queue_overview import build_summarize_by_machine
split = _split(
macbook={"done_1h": 226, "done_today": 312, "done_15m": 60},
macmini={"done_1h": 37, "done_today": 94, "done_15m": 9},
)
sbm = build_summarize_by_machine(split)
assert sbm == {
"macmini": {"done_1h": 37, "done_today": 94},
"macbook": {"done_1h": 226, "done_today": 312},
}
assert "done_15m" not in sbm["macbook"]
def test_compose_overview_includes_summarize_by_machine():
"""compose_overview 응답 계약에 summarize_by_machine 포함 (FE 레인 분담 재료)."""
now_kst = datetime(2026, 6, 13, 13, 0, tzinfo=KST)
stats = {"summarize": _stage(pending=1317, done_1h=264)}
split = _split(macbook={"done_1h": 226, "done_today": 312}, macmini={"done_1h": 37, "done_today": 94})
ov = compose_overview(stats, split, {}, {}, [], deep_enabled=True, now_kst=now_kst)
assert ov["summarize_by_machine"]["macbook"]["done_1h"] == 226
assert ov["summarize_by_machine"]["macmini"]["done_today"] == 94
def test_deep_disabled_deep_summary_counts_to_macmini():
stats = {"deep_summary": _stage(pending=2, processing=1, done_1h=3, done_today=4)}
machines = build_machines(stats, _split(), [], deep_enabled=False)