Compare commits

...

70 Commits

Author SHA1 Message Date
hyungi d89f046121 fix(ds-watch): 실기기 설치용 서명 허용 — 프로젝트 기본 CODE_SIGNING 차단 제거
헤드리스 시뮬 빌드용으로 박아둔 CODE_SIGNING_ALLOWED/REQUIRED=NO 가 Xcode 실기기
설치를 막아, 프로젝트 기본에서 제거(자동 서명 유지). 시뮬 빌드는 CLI 플래그로 계속 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:11:47 +09:00
hyungi 91a540d533 feat(ds-shell): 맥·iOS 웹 래퍼 앱 — document.hyungi.net WKWebView + DS 아이콘
- 맥·iOS 2타깃, WKWebView 로 웹 UI 100% 재사용(2026-06-15 결정: 맥/아이폰=웹 래퍼)
- 영속 쿠키(로그인 유지), 첨부 응답 다운로드 처리, 업로드는 네이티브 피커 자동
- 맥 창 off-screen 가드(분리 모니터 좌표 저장 시 중앙 복귀)
- DS 초록 라운드 아이콘(맥=라운드/iOS=풀블리드, 1024px 생성)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:05:14 +09:00
hyungi c79bf41a76 feat(ds-watch): Apple Watch 앱 신규 — 4기능 셸 + 공부/할일/브리핑/이드 라이브 결선 + DS 아이콘
- standalone watchOS(WKApplication + WKWatchOnly), 다크 OLED, xcodegen 단일 타깃
- 4기능 = 이드(AI채팅)·공부(암기카드)·할일·브리핑
- 라이브: 공부 /study-cards(due·rate·flag) · 할일 /events(today·complete)
  · 브리핑 /briefing/latest · 이드 /eid/chat(SSE 누적, unavailable 처리)
- 1회 로그인(access 메모리 + refresh 쿠키 7일 영속) + 401 자동 refresh+재시도
- 햅틱 피드백 + 정직한 로딩/빈/오류 상태 + DS 초록 아이콘(원형 마스킹)
- 맥·아이폰은 웹 래퍼로(2026-06-15 결정), 순수 네이티브는 워치 전용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:05:14 +09:00
hyungi f527c63232 feat(ds-app): macOS 앱 마무리 — 업로드·다운로드·로그아웃 + 4섹션 페이지
- FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바)
- FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리)
- 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴)
- 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저
  (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all
- 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:52:29 +09: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 8a3bea6b31 feat(safety): C-1 후속 — version_status decorate + facets 집계
검색 엔드포인트 wrapper decoration(run_search 코어 무접촉·ranking 무관):
- version_status: 법령 결과(material_type=law)에 legal_meta.version_status 부착
  (decorate_version_status, law 무결과 시 query skip). SearchResult.version_status 신설.
- facets=true: top-K 결과 분류 축(material_type/jurisdiction/version_status) 분포 라벨
  (compute_facets). 미요청=None(byte 불변). SearchResponse.facets 신설.
- result_decorate.py 신설. 단위 4건.
freshness incident 변경(law_365d 제거+흡수)=ranking 변경이라 별 슬라이스 defer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:07:57 +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 a6db6c999b fix(safety): B-4 리뷰 반영 — 단일 술어 중앙화 + study/briefing 경로 커버
적대 리뷰(10에이전트) 확정 반영:
- license_filter.py 신설 — restricted_exclude_sql(raw)/restricted_exclude_orm(ORM)
  단일 정의. retrieval _license_sql·digest·briefing·study 풀이가 공유(드리프트 방지).
- major: explanation_rag(study 문제 AI 풀이 RAG)에 술어 누락 → doc_meta 쿼리에 ORM
  적용(valid_doc_ids 경유로 청크도 차단). briefing/loader 2쿼리에 누락 → digest 와
  동일 술어 추가(news restricted 부재=방어적·경로 일관성).
- blocker(low-impact): file_watcher changed-doc 경로 material/license 보정(merge 주입·
  license 부재 시만 — extract_meta clobber 회피, pre-B-4 적재분 동기화).
- 테스트: 단일-source 검증 + ORM 구성 스모크 2건 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:52:04 +09:00
hyungi ed7740beee feat(safety): B-4 PR①② — licensed_restricted 차단 술어 + watch 폴더 license 주입
PR① licensed_restricted 단일 술어(_license_sql) — retrieval 3-leg(text/vec-doc/
vec-chunk) + digest loader 공유. a안(U-2①): 색인 허용·구매자료 verbatim 을 RAG 증거/
digest 발행에서 구조적 제외. 술어=COALESCE(extract_meta->'license'->>'restricted',
'false')<>'true' (restricted 부재/false 미제외 → 기존 코퍼스 결과 불변). 개인 파일
열람 미차단. chunk leg 는 outer 의 documents JOIN(항상) 활용 post-rank(restricted 소수).
PR② file_watcher _TARGET_AXIS 확장 — Books/Papers_Purchased=restricted / Manuals=
non-restricted(사용자 결정) / KGS=law·KR·kogl. ingest 시 extract_meta.license
deterministic 주입(classify material IS NULL 일 때만 제안·meta 미기록=보존).
PR③(KGS 버전 flip)=별 슬라이스 deferred(파일 포맷 조사 선행).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:34:42 +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
hyungi 235aa648ad feat(safety): B-2 KOSHA 사망사고 속보 수집기 (callApiId=1040)
data.go.kr 15119137 활용신청 전파 완료 → news_api02/getNews_api02 라이브.
collect_fatal_accidents: arno dedup(kosha-fatal|{arno}) + material_type=incident/
jurisdiction=KR + license=kogl. contents=HTML → _clean_html, published_date =
arno 접두 8자리(YYYYMMDD 등록일, 2019~ 라이브 전수 동형 검증). 첨부 API·business
필드 없는 별 채널(1040). run() 일일 잡(06:40 KST) 튜플 합류 — 소스별 실패 격리 유지.
순수 헬퍼 _fatal_fields + fixture 테스트(tests/test_kosha_fatal.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:42:12 +09:00
hyungi 60cb48bbe4 fix(safety): C-1 fusion 재구성 시 분류 축 메타 전파 — 3 SearchResult 재생성 지점
fusion legacy(line 66)·RRF(122)·multi_query rewrite(pipeline 456)가 명시 필드 나열로
SearchResult 재구성 → material_type/jurisdiction/published_date 누락(필터는 정확, D-1
유형 표시만 None). 세 지점 동기화. 흉터: SearchResult 필드 추가 시 재구성 지점 전수 동기 필요.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:03:27 +09:00
hyungi 79deae0644 feat(safety): C-1 검색 명시 필터 — material_type/jurisdiction/year 3-leg 동등 + documents exclude 해제
plan safety-library-1 C-1 (검색 핵심 경로 — byte 불변 invariant):
- AxisFilter + _axis_sql 헬퍼: 미지정 시 모든 SQL 절 빈 문자열(run_eval 회귀 0 보장)
- 3 leg 동등 적용: search_text(JOIN 후 WHERE) / _search_vector_docs(prod+cand) /
  _search_vector_chunks(★inner topk JOIN — R6 결정: outer post-filter면 ANN top-k 후
  좁은 필터 후보 붕괴. 미지정 시 JOIN 없음=byte 불변)
- SearchResult + material_type/jurisdiction/published_date (3 leg SELECT additive)
- year = COALESCE(published_date, created_at) (freshness 동일 사상)
- GET /documents/: material_type 지정 시 기본 exclude(news·law_monitor·note) 해제
- _axis_sql 단위 테스트 PASS (미지정=빈문자열+param0 / active 4절 / alias 분기)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:59:18 +09:00
hyungi 9a7e231dcc fix(safety): verify_statute_chain sys.path — /app 루트 자동 탐지 (workers import)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:44:58 +09:00
hyungi 1646617a31 feat(safety): B-1 PR③ — 법령 체인 검증 3술어 스크립트 (read-only 진단)
plan safety-library-1 B-1 PR③. E-1 법령 게이트 도구 겸용 (반복 실행 안전):
- ① 존재성: watch family 각 primary current 정확 1건 + annex 시리즈당 ≤1
- ② 노출 유일성: primary current 보유 family당 노출 1건 (③a에 흡수)
- ③ 고아 그물: 정규화 동등 매핑 — flip 누락(current family 노출 레거시)·무매핑(매핑 구멍) 0
- repealed family ①② 면제. 종료코드 0/1 (관찰 게이트용)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:44:25 +09:00
hyungi bacb36924b feat(safety): B-1 PR② — fetch_version(payload 리스트) + ingest 4축 + 생애주기 잡 통째 + 부트스트랩
plan safety-library-1 B-1 PR② (R8-B1: 승격·supersede·스윕·repeal = 잡 코드 통째 배포):
- kr.fetch_version: 전문 1콜 → primary+annex payload 리스트 (R4-M4)
  ★fixture 가 잡은 결함 2: 별표구분(별표/서식) 차원 누락 시 (번호,가지) 4건 충돌
  → version_key='MST|{구분}{번호}-{가지}' / 삭제 tombstone 3건(별표10·서식1·2) skip
  — KR 별표 삭제 = absence 아닌 명시 tombstone (R7-M3 absence 추론 불요 확정)
- ingest: 전 버전 pending 적재 + 4축(law/KR/COALESCE날짜/public_domain) + backfill 마커
- 생애주기 잡: 버전 시리즈 단위 승격·supersede(R7-B1) + 상태 기반 레거시 스윕(primary
  current 보유 한정) + repeal(레거시 매핑분 포함, R7-M2) — 단일 트랜잭션·KST
- 법령명 매핑: 정규화 동등 비교(prefix 금지 — 시행령 오폭 차단), 가운뎃점·공백 흡수
- 워터마크 = 파싱 검증 통과 후에만 / 스케줄 daily 07:00 KST (law_monitor 슬롯 승계)
- 테스트 14/14 (매핑 표본·시리즈 키·payload fixture)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:37:51 +09:00
hyungi a28f12b12e feat(safety): B-1 PR① — law_monitor 스케줄 제거 + statute KR poll_changes + fixture 박제 (mig 356)
plan safety-library-1 B-1 PR① (fixture-first):
- law.go.kr 라이브 fixture 5종 박제 (OC 새니타이즈 검증 — 응답 법령상세링크에 키 포함 함정)
- R7-M3 판정: 전문 1콜 XML = 조문 853+별표 23 전체 스냅샷(부분 실패 개념 없음)
  + 별표번호/가지번호 = 구조화 필드 — 조문 취득 = 전문 1콜+로컬 파싱 확정(R2-m1)
- legal_acts KR 시드 26행(법령ID 라이브 실측, watch=26 전부, FK 계열 9그룹)
  ★ '유해ㆍ위험작업...' 정식명 = 가운뎃점 — law_monitor 하드코딩(점 없음)은 영구 미매칭 잠복
- statute_adapters/kr.py: poll_changes(lawSearch MST diff) — 순수 파서 분리, fixture 테스트 8/8
- statute_collector.py: 관찰 전용 코어(워터마크 영속 0 — ingest=PR②), 스케줄 미등록(R8-B1)
- main.py: law_monitor 스케줄 제거 — 버전 체인 밖 레거시 매일 증식의 유일 경로 차단

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:01:21 +09:00
hyungi 0c8fb41366 fix(safety): backfill text() 콜론 bind 오인 — exec_driver_sql 로 교체
정규식 '(?:' 의 콜론을 text() 가 bind param 으로 해석 (migration 러너 동일 함정).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 06:49:58 +09:00
hyungi e5ddd0e4d6 feat(safety): A-3 backfill 스크립트 — 기존 코퍼스 분류 축 소급 (교정 술어)
plan safety-library-1 A-3. prod 실측 반영:
- KGS frontmatter = 'code' 키 확정(117/118, kgs_code 0) → 경로 술어
- 레거시 law 243건 — extract_meta 빈값, title '(YYYYMMDD)' 공포일 추출
- GUIDE ofancYmd = 'YYYY-MM-DD' 실측
- KOSHA 본문 = source_id JOIN (kind='case' 부재 — R2 blocker 교정 그대로)
- dry-run = 트랜잭션 ROLLBACK 방식 (정확 rowcount + 검증표, 변경 0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 06:48:30 +09:00
hyungi 3feddd012b feat(safety): A-2 수집기 ingest 시점 분류 축 부여 — 레지스트리 전파 + 승인 가드 (mig 352~355)
plan safety-library-1 A-2 (classify-skip 경로 전수 커버):
- news_sources 에 material_type/license_scheme/license_redistribute + 안전·공학 12행 시드
- news_collector: 레지스트리 → documents 전파 (_material_axis — paper 는 jurisdiction NULL 강제)
- kosha(사례·첨부=incident, GUIDE=guide)/csb(incident·US)/api_std(standard·US)/law_monitor(law·KR)
  /file_watcher(KGS=law·KR 타깃 매핑) deterministic 부여 + extract_meta.license 주입
- published_date: 소스별 가용 날짜 (GUIDE 공표일·CSB lastmod·API 공지일·법령 공포일·뉴스 발행일)
- classify_worker: document_type→material_type 결정적 매핑 제안 (자동 전이 금지)
- accept-suggestion: material 제안 적용 + law=jurisdiction 필수(기본값 없음) + 청크 미러 1문 동기화
- chunk_worker: 비뉴스 문서 country=jurisdiction 미러 (R3-m3: 검색측 country 소비자 0 실측)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 06:23:22 +09:00
hyungi 5da94213ec feat(safety): 분류 축 A-1 — material_type/jurisdiction/published_date + legal_acts/legal_meta (mig 340~351)
안전 자료실 plan safety-library-1 A-1 (r3 계약 반영):
- documents 3컬럼 (TEXT+CHECK, nullable additive) + law→jurisdiction NOT NULL 구조 강제
- legal_acts 단일 레지스트리(워치리스트 겸, watermark·repeal_detected_at 포함)
- legal_meta 최소형 (version_key 합성형 UNIQUE, 전 버전 pending 적재 계약)
- partial 인덱스 2 + family 인덱스 + paper DOI partial UNIQUE (doi=서지 단일 보유 계약)
- ephemeral PG16 스모크: 12파일 적용 + CHECK/UNIQUE 계약 6종 검증 PASS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:25:04 +09:00
hyungi 85304878f4 test(eval): Phase 2A eval 산출물 — baseline exact 0.6315 vs qwen06/-0.041 qwen4/-0.032 qwen4m/-0.033 (no-go)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:53:20 +00:00
hyungi adce639445 ops(eval): Phase 2A eval 종료 — embed/chunk hold 복원 (E-4 = no-go, bge-m3 유지)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:03:39 +09:00
hyungi d05e41128a fix(board): 실패 뱃지 잘림(스크롤 컨테이너 헤드룸) + 구 단계별 현황 섹션 제거 + ETA 48h+ 일 표기
- 흐름 컨테이너 pt/px 헤드룸 — -top/-right 돌출 뱃지가 overflow-x-auto 에 잘리던 문제
- 단계별 현황 details = 흐름 보드가 대체(R2 통합안 의도) — 전용 파생값/헬퍼/chevron 동반 제거
- etaShort: 48시간 이상은 일 단위 (약 131시간 → 약 5.5일)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:02:04 +00:00
hyungi 2bbdf63d86 ops(eval): Phase 2A eval 동결 — embed/chunk 일시 hold (eval 후 복원)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:17:42 +09:00
hyungi 5581d3f1ce feat(board): 처리 보드 v2 — 파이프라인 흐름 뷰·엔진 구분·실패 재시도/건너뛰기 (ds-board-engines-1)
- 흐름 뷰 메인: 좌→우 노드(머신·엔진 태그, 유입 우세 amber, 실패 뱃지) + 머신 스트립(모델 표기) + trend_24h 스파크라인 첫 렌더
- 노드 클릭 상세 패널: KV 4칸 + 다중 stage 행 + 지금 처리 중
- 실패 처리 드로어: 에러 패턴 그룹 + 재시도/건너뛰기 (영구 실패의 첫 사용자 조치 경로)
- API: stages[].done_1h/created_1h 노출 + GET /api/queue/failed + POST /api/queue/retry|/skip (uq_queue_active 충돌 skip, 건너뛰기는 enqueue_next_stage 미호출)
- 엔진/모델 표기 = queueDisplay.ts 정적 맵 단일 지점 (모델 교체 시 1곳)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:05:04 +00:00
hyungi 8ac1dbf4a8 test(eval): Phase 2A E-4 비교기 — per-query win/loss/tie(ε)·부트스트랩 CI·카테고리 분해
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:34:18 +09:00
hyungi c3d237766d feat(search): Phase 2A E-1 — Qwen 후보 3종 백필 CLI + eval 디스패처 확장 (마이그 328~333)
- 후보 섀도 테이블 6종(전부 vector 타입 — eval=exact scan 이라 인덱스 불요, halfvec 은 C-1 소관)
- workers/phase2a_cand_backfill: resumable(NOT EXISTS)·배치 커밋·동결셋 한정(--doc/chunk-id-max),
  문서/청크 입력 = production 경로 동일 구성 + plain
- CANDIDATE_BACKEND_MAP += cand_qwen06/qwen4/qwen4m (embed_kind=ollama, 쿼리측 instruct prefix
  G-1 핀 문자열, qwen4m = dimensions 1024 MRL)
- qwen4m 적재는 qwen4 에서 SQL 파생(subvector+l2_normalize) — 본 CLI 비대상

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:29:53 +09:00
hyungi 5bc68c95f6 test(eval): Phase 2A G-1 — Qwen3-Embedding 서빙 fixture 박제 (Ollama 0.20.0, /api/embed)
0.6b=1024d/4b=2560d 정규화 출력, MRL dimensions 옵션 지원(재정규화 포함),
비대칭 instruct prefix 효과 실측(+0.016), instruct 문자열 핀.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:14:24 +09:00
hyungi 5dca5b5d28 ops(pipeline): embed/chunk 고속 컨슈머 분리 + 배치 1→10 — LLM 사이클 인질 해소
진단(2026-06-12 용량 평가): 단일 루프에서 classify(~190s×3)가 사이클을 점유,
건당 <1s 인 embed/chunk 가 사이클당 1건 캡 → 실효 ~580/일 vs 수요 최대 2,700/일,
적체 3,570 + 신규 문서 벡터 미적재(RAG 검색 누락). 4070 가동률 0% = 순수 구조 캡.
수리 = markdown 분리(05-01) 선례: consume_fast_queue 1분 잡 + 배치 10(GPU 공유 보수값,
캡 ~14,400/일). 세 컨슈머 stage 집합 disjoint(stale reset 이중 복구 방지). retrieval
로직·임베딩 모델 무접촉.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:50:07 +09:00
hyungi 9c9ff6eeba test(drain): classify 합류 반영 — 거부 케이스를 extract 로 교체
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:22:47 +09:00
hyungi d667545185 fix(classify): 적대 리뷰 반영 — use_deep 스레딩(B1)·StageDeferred 전파(B2)·legacy 호출 deep 경유(M3)
- _run_tier_triage(use_deep) 스레딩 — 미배선 NameError(전 classify 파괴) fix
- process 의 triage try 에 except StageDeferred: raise 선행 (drain 보류 시멘틱 복구)
- legacy classify()/summarize() 에 cfg 파라미터 — use_deep 시 deep 슬롯 경유 +
  is_deferrable_error → StageDeferred 변환(첫 호출 = 최저비용 지점에서 보류, doc 쓰기 0)
- ai_model_version = 실제 처리 경로 모델 (drain=qwen-macbook 귀속)
- analyze_event model_name 스레딩 + deep triage cfg 에 top_p 동승

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:12:40 +09:00
hyungi 235bbf9881 ops(pipeline): fair-share 번들 — drain classify 합류 + deep 맥미니 폴백 + mlx 게이트 동시 2
사용자 '공평하게 동일한 작업' 지적의 비대칭 잔재 2건 + 예고된 배칭 레버:
- queue_drain --stage classify (use_deep: deep 슬롯 endpoint + triage sampling,
  완료 시 enqueue_next_stage 로 embed/chunk/markdown 연쇄 — DAG 단절 방지)
- deep_summary consumer = 맥북 우선, 불가 시 맥미니 primary 즉시 처리(동일 모델 —
  강등 아님). drain 은 defer_on_deep_unavailable=True 로 기존 보류-종료 유지
- llm_gate capacity 일반화 (config pipeline.mlx_gate_concurrency, 기본 1, 운영 2) —
  'MLX_CONCURRENCY=1 고정' 영구 룰의 전제(single-inference 서버) 소멸을 docstring 에 개정 박제
- analyze_events FK(users) CLI 컨텍스트 INSERT 실패 fix (models.user 명시 import)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 06:56:02 +09:00
hyungi 30200a4e49 ops(ai): deep 슬롯 재도입 — 맥북 야간 night-drain 레버 (Qwen3.6-27B-6bit)
사용자 지시: 자기 전 night-drain 한 번 실행 → 맥북이 밤새 summarize/deep_summary 분담.
보류 시멘틱(StageDeferred)·drain CLI·라우터 wake preflight = 기존 검증 자산 재사용.
맥북 측 = RunAtLoad=false 수동 기동 + 서버 수명 한정 caffeinate + idle-watch 자동 종료.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:49:12 +09:00
hyungi eff2c3b7d3 ops(search): Qwen 27B 속도 반영 — synthesis 30s→120s, classifier 슬롯 모델 동승 교체
- config classifier 모델 gemma 잔존 = mlx 서버 Gemma 재로드(이중 적재) 위험 → Qwen 6bit 로 동승 교체
- synthesis 는 timeout 시 graceful skip 이 없는 답변 본체라 단독 상향 (classifier/query_analyzer/
  rewriter 의 30s/15s 캡은 초과 시 skip·원쿼리 폴백으로 degrade — 관찰 후 별도 튜닝)
- ask.backend.timeout_read_s 30→120 align

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:31:26 +09:00
hyungi 3d79002dfa ops(ai): Qwen 27B 프리필 실측(~112 tok/s) 반영 timeout 상향 — triage 480 / primary 900
장문(context_char_limit 상한급) 프리필이 수 분 걸려 기존 120/300s 로는 timeout 실패 churn.
단일 코루틴 컨슈머라 장문 1건이 사이클을 수 분 점유하는 것은 수용(관찰 후 배칭/컨텍스트 튜닝 PR).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:29:45 +09:00
hyungi 3d60008965 ops(ai)!: 맥미니 생성 모델 Qwen3.6-27B-6bit 전환 + 생성 LLM 홀드 해제
B안(사용자 2026-06-11): Gemma 26B-A4B → Qwen3.6-27B-6bit 풀교체.
- config.yaml triage/primary model 교체 + dense 감속 반영 timeout 상향(30→120/180→300)
- held_stages [] (홀드 해제 — 적체 자연 드레인, deep_summary 는 primary 복귀)
- eid deep 모드 = mac-mini-default 재지정(맥북 백지화). llm_gate '예외 없이 gate' invariant 에
  따라 deep 도 alias 조건으로 자동 게이트 (구 무게이트 = 맥북 별 endpoint 예외였음)
- deep probe 실패 reason = router_unreachable 로 정정 + 테스트 동기화
잔여(별 PR): ask 표면 qwen-macbook 옵션/백엔드 클래스/처리보드 맥북 카드 정리

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:19:35 +09:00
hyungi cd0040925a ops(pipeline): 생성 LLM 홀드 게이트 held_stages — 맥미니 모델 확정까지 보류
맥북 LLM 백지화 + 맥미니 모델 재결정에 따라 DS 의 생성 LLM 소비를 일괄 보류.
held = classify/summarize/deep_summary(큐, claim 미발생·attempts 미소모) +
digest(04:00)/briefing(05:10) cron + study explanation/session_analysis/memo_card 컨슈머.
GPU 특화 스테이지·수집기·인터랙티브(ask/eid chat)는 무영향. 기본값 [] = 무동작.
/api/digest/regenerate 는 홀드 중 409 명시. 해제 = config held_stages 비우고 fastapi 재기동.
exec plan: ~/.claude/plans/ds-llm-hold-exec-20260611.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:52:46 +09:00
hyungi fdac449a48 Merge pull request 'Feat/eid chat' (#35) from feat/eid-chat into main
Reviewed-on: #35
2026-06-11 15:14:43 +09:00
hyungi 40f5b5fe9e Merge pull request 'Feat/ds processing board' (#33) from feat/ds-processing-board into main
Reviewed-on: #33
2026-06-11 15:14:24 +09:00
hyungi 250896cdfa feat(eid): deep 모드 = ReAct 자동검색 + 근거 카드 (ds-eid-ask-absorb P1)
- deep 분기 _eid_chat_deep: 비생성 probe → phase:searching → agentic_ask_loop
  (tool_choice=auto 가 검색 여부 자율 판단, 검색 불요는 early-exit 대화) → final_answer
  + eid_sources envelope → DONE. heartbeat {phase:ping}(~10s, 프록시 idle timeout 차단)
  · mid-stream BackendUnavailable → in-stream error envelope · disconnect 시 task.cancel()
  + await(고아화·27B 점유 방지).
- daily = call_stream 무변경(맥미니 대화). deep = 맥북 27B ReAct (tool calling 27B 전용,
  맥미니 26B token-leak 미검증). 멀티턴 = 메시지 단독 처리(agentic_ask_loop query: str,
  history 2단계 백로그).
- EidEvidenceCard.svelte 접이식 근거 카드(sources 순서번호·제목·점수) + 프론트 SSE 파서
  확장(ping/searching/error/eid_sources) + 검색 중 표시 + 이력 보존.
- 테스트: deep 4건(검색성/대화성/probe-503/mid-stream-error) + 기존 call_stream 회귀 daily
  로 이전 = 29 passed.
- 동반(이전 eid-chat 세션 미커밋): /api/eid/status endpoint + llm_gate.gate_status +
  test_eid_status (채팅 대기 UI 의 '대기 vs 고장' 구분용, 5 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:51:00 +09:00
hyungi 5e8b998a11 feat(documents): hier analyze 서브커맨드 — 재분해와 독립한 절분석 self-heal (g3-t3 갭)
re-decompose 의 char_start 완료마커는 'jump-target char_start 보유'라 컨테이너 recreate/deadline 으로
analyze 가 잘린 doc(char_start 있으나 일부 leaf 미분석)을 재선별 못 함 → rail summary 영구 미수렴 갭.
`analyze` 가 LEAF_SQL(미분석 leaf 보유) 기준 독립 선별로 수렴(멱등, --doc 제한 가능, jump 무관).
sweep 로그도 `analyze` 커맨드 안내로 갱신. (2026-06-10 백필서 recreate 로 잘린 5 doc·53 leaf 수동 처리한 케이스 항구화.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:32:10 +09:00
hyungi 53999b2825 fix(documents): g-measure junk 검출 all-caps 과탐 제거 + verdict=coarse 스크린 명시
전부-대문자 휴리스틱이 기술문서 정상 heading(GENERAL REQUIREMENTS/WELDING) 130건 과탐 →
windowed/clean doc 거짓 A_better 강등. 회사-접미사(INC./LLC…)만, cover 영역(앞 4노드)+미stored 게이트.
verdict 는 coarse 스크린(감사용)이고 실집행 결정 = 결정적 partition + 적대 워크플로임을 docstring 박제.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:58:36 +09:00
170 changed files with 10560 additions and 1155 deletions
+23 -9
View File
@@ -150,15 +150,26 @@ def is_deferrable_error(exc: Exception) -> bool:
return isinstance(exc, httpx.TransportError)
async def call_deep_or_defer(client: "AIClient", prompt: str, system: str | None = None) -> str:
async def call_deep_or_defer(
client: "AIClient",
prompt: str,
system: str | None = None,
cfg: "AIModelConfig | None" = None,
) -> str:
"""call_deep + 보류 변환 — 맥북 불가(503/연결/절단)는 StageDeferred 로 raise.
deep_summary_worker / summarize_worker(drain) 가 공유. StageDeferred 는 queue_consumer/
queue_drain 이 attempts 미소모 + deferred_until 백오프로 처리한다 (sleep-안전 불변식).
deep_summary_worker / summarize_worker(drain) / classify_worker(drain) 가 공유.
StageDeferred 는 queue_consumer/queue_drain 이 attempts 미소모 + deferred_until
백오프로 처리한다 (sleep-안전 불변식).
cfg: 지정 시 deep 슬롯 대신 이 config 로 호출 (classify drain — deep 슬롯의
endpoint 는 쓰되 triage 의 temperature/max_tokens 를 적용한 변형).
"""
from models.queue import StageDeferred
try:
if cfg is not None:
return await client._request(cfg, prompt, system=system)
return await client.call_deep(prompt, system=system)
except Exception as exc:
if is_deferrable_error(exc):
@@ -231,26 +242,29 @@ class AIClient:
# ─── Legacy API (classify_worker 교체 시 제거 예정) ───────────────────
async def classify(self, text: str) -> dict:
async def classify(self, text: str, cfg=None) -> dict:
"""[DEPRECATED] 기존 classify_worker 전용. B-1 에서 summary_triage 로 대체.
호출부 정리 전 존속. 신규 코드는 call_triage + prompt_render 를 쓸 것.
cfg (2026-06-12 fair-share): 지정 시 primary 대신 해당 config 로 호출 —
drain classify 가 deep 슬롯(맥북) 경유에 사용. cfg != ai.primary 라
_call_chat 의 primary→fallback 자동 전환은 발동하지 않는다 (에러 raw 전파).
"""
prompt = CLASSIFY_PROMPT.replace("{document_text}", text)
response = await self._call_chat(self.ai.primary, prompt)
response = await self._call_chat(cfg or self.ai.primary, prompt)
return response
async def summarize(self, text: str, force_premium: bool = False) -> str:
"""[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체."""
async def summarize(self, text: str, force_premium: bool = False, cfg=None) -> str:
"""[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체. cfg = classify() 와 동일."""
if force_premium:
return await self._call_chat(self.ai.premium, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
return await self._call_chat(self.ai.primary, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
return await self._call_chat(cfg or self.ai.primary, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
async def embed(self, text: str) -> list[float]:
"""벡터 임베딩 — 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"]
+8
View File
@@ -244,7 +244,15 @@ async def regenerate(
user: Annotated[User, Depends(require_admin)],
):
"""수동 트리거 — 백그라운드 태스크로 워커 실행 (admin 필요)."""
from core.config import settings
from workers.digest_worker import run
# 홀드 중 silent no-op 방지 — 워커 게이트와 동일 조건을 표면에서 명시.
if "digest" in settings.pipeline_held_stages:
raise HTTPException(
status_code=409,
detail="global_digest 보류 중 (config.yaml pipeline.held_stages) — 해제 후 재시도",
)
asyncio.create_task(run())
return {"status": "started", "message": "global_digest 워커 백그라운드 실행 시작"}
+65 -7
View File
@@ -210,8 +210,14 @@ class DocumentDetailResponse(DocumentResponse):
class AcceptSuggestionRequest(BaseModel):
"""§1 accept-suggestion 요청 body — stale payload / doc 수정 검출."""
"""§1 accept-suggestion 요청 body — stale payload / doc 수정 검출.
jurisdiction: 안전 자료실 A-2 — material_type 제안 승인 시 사용자가 지정하는 관할.
law 승인은 필수 (기본값 없음 — KR 자동 부여 시 외국 자료가 KR 법령으로 오염되는
경로를 차단, plan A-2 계약).
"""
expected_source_updated_at: datetime
jurisdiction: str | None = None
class DocumentUpdate(BaseModel):
@@ -537,6 +543,8 @@ async def list_documents(
category: str | None = Query(None, description="doc_category enum — 지정 시 기본 news/memo 제외 해제"),
has_suggestion: bool | None = Query(None, description="true: ai_suggestion IS NOT NULL"),
proposed_category: str | None = Query(None, description="ai_suggestion.proposed_category 필터"),
material_type: str | None = Query(None, description="안전 자료실 C-1: 자료유형. 지정 시 기본 exclude 해제"),
jurisdiction: str | None = Query(None, description="안전 자료실 C-1: 관할 (KR/US/...)"),
):
"""문서 목록 조회 (페이지네이션 + 필터).
@@ -550,6 +558,10 @@ async def list_documents(
if category:
# 명시적 카테고리 필터 — 기본 exclude 해제
query = query.where(Document.category == category)
elif material_type:
# 안전 자료실 C-1: material_type 지정 = 기본 exclude(news·law_monitor·note) 해제.
# 안전 코퍼스 본체(KOSHA 사례·CSB·법령 등)가 전부 note/crawl 채널이라 exclude 면 빈 화면.
query = query.where(Document.material_type == material_type)
else:
# 기본 목록: 뉴스/메모/법령 제외 (문서함 용도)
query = query.where(
@@ -558,6 +570,9 @@ async def list_documents(
Document.file_type != "note",
)
if jurisdiction:
query = query.where(Document.jurisdiction == jurisdiction)
if has_suggestion is True:
query = query.where(Document.ai_suggestion.isnot(None))
elif has_suggestion is False:
@@ -665,7 +680,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
@@ -704,12 +724,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
@@ -1244,11 +1264,49 @@ async def accept_suggestion(
# payload 적용
proposed_category = doc.ai_suggestion.get("proposed_category")
proposed_path = doc.ai_suggestion.get("proposed_path")
# 안전 자료실 A-2 — material_type 제안 (classify 의 document_type 결정적 매핑)
proposed_material = doc.ai_suggestion.get("proposed_material_type")
if not proposed_category:
raise HTTPException(status_code=422, detail="proposed_category 누락된 suggestion")
if not proposed_category and not proposed_material:
raise HTTPException(
status_code=422,
detail="proposed_category/proposed_material_type 둘 다 누락된 suggestion",
)
doc.category = proposed_category
if proposed_category:
doc.category = proposed_category
if proposed_material:
_MATERIAL_TYPES = {"law", "paper", "book", "incident", "manual", "standard", "guide"}
_JURISDICTIONS = {"KR", "US", "EU", "JP", "GB", "INT"}
if proposed_material not in _MATERIAL_TYPES:
raise HTTPException(
status_code=422, detail=f"허용 밖 material_type: {proposed_material}"
)
jur = body.jurisdiction or doc.ai_suggestion.get("proposed_jurisdiction")
if jur is not None and jur not in _JURISDICTIONS:
raise HTTPException(status_code=422, detail=f"허용 밖 jurisdiction: {jur}")
# law = 국가 필수 입력, 기본값 없음 (plan A-2 — KR 자동 부여 시 외국 법령 오염.
# DB CHECK(chk_documents_law_jurisdiction) 도 거부하지만 422 로 명시 안내).
if proposed_material == "law" and not jur:
raise HTTPException(
status_code=422,
detail="법령(law) 승인은 jurisdiction 필수 — body.jurisdiction 으로 국가를 지정하세요 (기본값 없음)",
)
doc.material_type = proposed_material
doc.jurisdiction = jur
# 미러 동기화 1문 — jurisdiction 부여/정정 시 청크 country 동반 UPDATE
# (leg 간 국가 불일치 방지, plan A-2 계약. 단일 지점 = 본 승인 경로).
if jur:
from sqlalchemy import update as sa_update
from models.chunk import DocumentChunk
await session.execute(
sa_update(DocumentChunk)
.where(DocumentChunk.doc_id == doc.id)
.values(country=jur)
)
# user_tags append (중복 방지, normalize + dedup 통과)
if proposed_path:
+160 -6
View File
@@ -2,8 +2,9 @@
확정 결정:
- D-1 경로 = /api/eid/chat (main.py prefix=/api/eid + 본 라우터 POST /chat)
- D-2 mode 닫힌 어휘: daily(mac-mini-default) / deep(qwen-macbook). 클라는 mode 만 보냄 —
claude-cloud / auto 금지 (Literal 로 422 차단). 심층(deep) 모드 무게이트.
- D-2 mode 닫힌 어휘: daily / deep — 둘 다 mac-mini-default (맥북 백지화 2026-06-11,
맥미니 Qwen 27B 단일 호스트. deep = ReAct 자동검색 모드 구분). 클라는 mode 만 보냄 —
claude-cloud / auto 금지 (Literal 로 422 차단). 게이트 = alias 기준 자동 적용(무게이트 폐지).
- D-3 독립 /chat 라우트 (frontend) — 본 모듈은 백엔드 API 만.
- D-5 LLM 호출 = EidAIClient.call_stream 한 곳 (이드 egress 봉쇄 불변식 #5,
RouterBackend 직접 호출 금지).
@@ -18,24 +19,58 @@ backend 실패는 /api/search/ask 와 동일 shape 의 503 + error_reason 매핑
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from typing import Annotated, Literal
import httpx
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from core.utils import setup_logger
from eid import compose as eid_compose
from eid.ai import EidAIClient
from models.user import User
from services.llm.backends import BackendUnavailable
from services.llm.backends import BackendUnavailable, _router_url, get_backend
from services.search import llm_gate
from services.search.react_loop import agentic_ask_loop
logger = setup_logger("eid_chat")
router = APIRouter()
# ── ds-eid-ask-absorb P1: deep 모드 = ReAct 자동검색 (맥미니 Qwen 27B, 2026-06-11~) ──
# 비생성 reachability probe — router 도달만 확인(coarse). 27B(맥북) 자체 미가용은
# 첫 generate_with_tools 호출의 BackendUnavailable → mid-stream error envelope 로 커버
# (plan: probe 정밀도 불필요, TOCTOU 는 in-stream error 가 처리). ~2s 타임아웃·생성 슬롯 비점유.
_DEEP_PROBE_TIMEOUT = httpx.Timeout(connect=2.0, read=2.0, write=2.0, pool=2.0)
# heartbeat: ReAct 다회 tool call 시 수십초 무출력 → 프록시 idle timeout 차단.
# `{"phase":"ping"}` no-op 이벤트 (프론트 envelope 파서가 자연 스킵 — `: ping` comment 는
# POST SSE fetch 파서가 처리 보장 안 됨).
_HEARTBEAT_INTERVAL_S = 10.0
async def _probe_router_reachable() -> bool:
"""router(:8890) /v1/models GET — 도달 확인(비생성). 실패/비200 = 미가용."""
url = f"{_router_url().rstrip('/')}/v1/models"
try:
async with httpx.AsyncClient(timeout=_DEEP_PROBE_TIMEOUT) as client:
resp = await client.get(url)
return resp.status_code == 200
except Exception:
return False
def _sse(obj: dict) -> bytes:
"""SSE 이벤트 1건 — data: <json>\\n\\n. final_answer 는 OpenAI 호환 choices.delta.content
로, sources/phase 는 별 envelope 키로(프론트가 분기). model/usage 머신 메타 미포함."""
return b"data: " + json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n\n"
class ChatMessage(BaseModel):
"""채팅 턴 1건. role=system 은 Literal 밖 → 422 (system 합본은 서버 compose 만 주입)."""
@@ -71,16 +106,130 @@ class ChatRequest(BaseModel):
return self
@router.get("/status")
async def eid_status(
user: Annotated[User, Depends(get_current_user)],
):
"""이드 backend 점유 상태 스냅샷 — GET /api/eid/status (UI 의 "대기 vs 고장" 구분용).
daily(맥미니 MLX) 의 DS 프로세스 내부 llm_gate 점유만 본다 — 외부 소비자
(맥미니 자체 derived-worker·Hermes 등)의 endpoint 점유는 미포착.
따라서 busy=true 는 확실(지금 줄이 있다), false 는 근사(외부 점유 가능성 잔존).
가벼움 보장: DB 0 / LLM 0 / 본문 로깅 0 — 폴링 대상으로 안전.
자동 fallback 판단 근거로 쓰지 않는다 (모드 전환 = 명시 버튼만, 정책).
"""
snap = llm_gate.gate_status()
inflight = bool(snap["inflight"])
waiters = int(snap["waiters"])
return {
"daily": {
"busy": inflight or waiters > 0,
"inflight": inflight,
"waiters": waiters,
}
}
def _backend_unavailable_response(body: ChatRequest, reason: str, backend_name: str) -> JSONResponse:
"""스트림 시작 전 27B 미가용 → ask 컨벤션과 동일 shape 503 (자동 fallback 0)."""
logger.warning(
"eid_chat backend_unavailable mode=%s turns=%d status=503 reason=%s",
body.mode, len(body.messages), reason,
)
return JSONResponse(
status_code=503,
content={
"error": "backend_unavailable",
"error_reason": reason,
"backend_requested": backend_name,
"detail": (
"심층 엔진(검색)이 일시적으로 응답할 수 없습니다. "
"잠시 후 다시 시도하거나 일상 모드로 물어보세요."
),
},
)
async def _eid_chat_deep(body: ChatRequest, session: AsyncSession) -> StreamingResponse | JSONResponse:
"""deep 모드 = ReAct 자동검색. ReAct(`tool_choice=auto`)가 검색 여부를 LLM 자율 판단 —
검색 불요 질문은 early-exit 으로 대화 답변. substrate(persona+rules+react_ask task)는
agentic_ask_loop 내부 compose("react_ask") 가 주입(evidence-first 자동 상속).
멀티턴 = 1단계는 마지막 user 메시지 단독 처리(agentic_ask_loop 가 query: str — history
미지원). 후속 질문 대명사 해소는 2단계 백로그.
"""
# ① 첫 SSE 바이트(=HTTP 200 확정) 전 비생성 probe — router 도달 실패 시 503 (재매핑 가능 구간)
if not await _probe_router_reachable():
return _backend_unavailable_response(body, "router_unreachable", "mac-mini-default")
query = body.messages[-1].content # 메시지 단독 처리 (마지막 user 턴)
backend = get_backend("mac-mini-default")
async def _stream() -> AsyncIterator[bytes]:
# ② phase:searching 방출 = HTTP 200 확정. 이후 미가용은 503 불가 → in-stream error.
yield _sse({"phase": "searching"})
task = asyncio.create_task(agentic_ask_loop(session, query, backend=backend))
try:
# heartbeat: task 미완 동안 ~10s 마다 ping (shield 로 wait_for 취소가 task 안 죽임)
while not task.done():
try:
await asyncio.wait_for(asyncio.shield(task), timeout=_HEARTBEAT_INTERVAL_S)
except asyncio.TimeoutError:
yield _sse({"phase": "ping"})
result = task.result() # BackendUnavailable 은 여기서 raise (mid-stream)
# final_answer = OpenAI 호환 1청크(프론트 기존 content 누적 경로 재사용)
yield _sse({"choices": [{"delta": {"content": result.final_answer}}]})
# 근거 = 별 envelope (citation 번호 없음 — 프론트가 순서 기반). partial = 근거 부족 표식
yield _sse({"eid_sources": result.sources, "partial": result.partial})
yield b"data: [DONE]\n\n"
logger.info(
"eid_chat deep ok turns=%d sources=%d partial=%s iters=%d",
len(body.messages), len(result.sources), result.partial, result.iterations,
)
except BackendUnavailable as exc:
# mid-stream 미가용(검색 중 AC 분리·뚜껑 닫힘) — 200 이미 송신, in-stream error envelope.
# error 뒤 [DONE] = 프론트 sawDone 로 '중단' 오경보 방지(명시 error notice 유지).
logger.warning(
"eid_chat deep mid-stream unavailable turns=%d reason=%s",
len(body.messages), exc.reason,
)
yield _sse({"phase": "error", "error_reason": exc.reason})
yield b"data: [DONE]\n\n"
except asyncio.CancelledError:
raise # 클라 disconnect — finally 가 task 정리
except Exception:
logger.exception("eid_chat deep stream failed turns=%d", len(body.messages))
yield _sse({"phase": "error", "error_reason": "deep_failed"})
yield b"data: [DONE]\n\n"
finally:
# 클라 disconnect 시 ReAct task 고아화 방지 — cancel + await(전파 완료 보장).
# 안 하면 27B 가 닫힌 연결 위해 수분 점유, router 동시성상 다음 검색 대기.
if not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
return StreamingResponse(
_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@router.post("/chat")
async def eid_chat(
body: ChatRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""이드 채팅 — router SSE 스트리밍 pass-through.
"""이드 채팅 — daily = router SSE pass-through(대화) / deep = ReAct 자동검색(근거).
503 경로 (둘 다 자동 fallback 없음):
503 경로 (모두 자동 fallback 없음):
- substrate_degraded: rules.md 부재 (D-6 fail-closed, 채팅 진행 거부)
- backend_unavailable: 스트림 시작 전 backend 실패 (ask 컨벤션과 동일 shape)
- backend_unavailable: 스트림 시작 전 backend 실패 (daily/deep 공통, ask 컨벤션 shape)
"""
# D-6: rules 부재 = fail-closed. 채팅은 안전·정책 가드 없이 진행하지 않는다(배너 X).
if not eid_compose.rules_present():
@@ -99,6 +248,11 @@ async def eid_chat(
},
)
# deep = ReAct 자동검색 (별 흐름 — probe + 동기 ReAct → SSE 변환)
if body.mode == "deep":
return await _eid_chat_deep(body, session)
# daily = 순수 대화 SSE pass-through (기존)
system = eid_compose.compose("eid_chat", task="")
client = EidAIClient()
stream = client.call_stream(
+124 -7
View File
@@ -1,20 +1,30 @@
"""처리 머신 보드 API — GET /api/queue/overview (plan ds-processing-ui-6an).
"""처리 머신 보드 API — /api/queue/* (plan ds-processing-ui-6an → ds-board-engines-1).
홈 stage 평면 테이블을 "머신 관점 보드(누가 일하나)"로 — 집계 로직은
services/queue_overview.py (순수 판정부 분리). 응답 스키마는 FE 와 계약 고정.
응답에 raw 모델명 노출 금지 — 머신 label 만.
- GET /overview: 홈 stage 평면 테이블을 "머신 관점 보드(누가 일하나)"로 — 집계
로직은 services/queue_overview.py (순수 판정부 분리). 응답 스키마는 FE 와
계약 고정. 응답에 raw 모델명 노출 금지 — 머신 label 만 (엔진/모델 표기는
FE 정적 맵 책임).
- GET /failed + POST /retry|/skip: 실패 처리 (ds-board-engines-1) — 영구 실패
(자동 재시도 3회 소진)의 유일한 사용자 조치 경로. 일괄 조치는 FE 가 그룹의
id 목록을 모아 보낸다 (서버측 패턴 매칭 없음 — raw 식별자/패턴 미수신).
"""
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.user import User
from services.queue_overview import build_overview
from services.queue_overview import (
build_overview,
fetch_failed_items,
retry_failed,
skip_failed,
)
router = APIRouter()
@@ -49,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
@@ -64,21 +88,77 @@ class Totals(BaseModel):
class StageRow(BaseModel):
"""단계별 현황 행 — '단계 상세' 패널용 (완료 가시화)."""
"""단계별 현황 행 — 흐름 노드/상세 패널용.
done_1h/created_1h = 처리율·유입률 (유입 우세 판정 + ETA 의 FE 재료,
ds-board-engines-1 추가 — 수집 SQL 에 이미 있던 값의 노출).
"""
stage: str
pending: int
processing: int
failed: int
done_1h: int
created_1h: int
done_today: int
oldest_pending_age_sec: int | None
class BackgroundJobItem(BaseModel):
"""큐 밖 관리 스크립트(백필 등) 작업 — processing_queue 가 못 보는 사각지대 노출.
stale = running 인데 heartbeat 가 오래 끊김(프로세스 사망 추정)."""
id: int
kind: 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):
"""영구 실패 행 — 실패 드로어 표시 단위."""
id: int
stage: str
document_id: int
title: str
attempts: int
max_attempts: int
error_message: str | None
failed_at: datetime | None
class FailedListResponse(BaseModel):
items: list[FailedItem]
total: int
class QueueActionRequest(BaseModel):
"""재시도/건너뛰기 대상 — 실패 행 id 목록 (FE 가 그룹핑 후 전달)."""
ids: list[int] = Field(min_length=1, max_length=300)
class RetryResponse(BaseModel):
requested: int
retried: int
not_retried: int
class SkipResponse(BaseModel):
requested: int
skipped: int
not_skipped: int
@router.get("/overview", response_model=QueueOverviewResponse)
@@ -88,3 +168,40 @@ async def get_queue_overview(
):
"""머신 관점 처리 보드 + summarize ETA 집계 (라이브 계산, 신규 테이블 0)"""
return QueueOverviewResponse.model_validate(await build_overview(session))
@router.get("/failed", response_model=FailedListResponse)
async def get_failed_items(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""영구 실패 행 목록 (문서 제목 포함, 최대 300건)"""
items = await fetch_failed_items(session)
return FailedListResponse(
items=[FailedItem.model_validate(i) for i in items],
total=len(items),
)
@router.post("/retry", response_model=RetryResponse)
async def retry_failed_items(
body: QueueActionRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""실패 행 재시도 — attempts 리셋 + pending 복귀.
not_retried = 같은 (문서, 단계) 의 active 행 충돌(uq_queue_active) 또는
이미 failed 가 아닌 행 (중복 클릭 등) — 건드리지 않고 건수만 보고.
"""
return RetryResponse.model_validate(await retry_failed(session, body.ids))
@router.post("/skip", response_model=SkipResponse)
async def skip_failed_items(
body: QueueActionRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""실패 행 건너뛰기 — completed 마킹(payload.skipped_by_user) + 연쇄 없음"""
return SkipResponse.model_validate(await skip_failed(session, body.ids))
+34
View File
@@ -12,6 +12,7 @@
import asyncio
import hmac
import time
from datetime import date
from typing import Annotated, Literal
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Query
@@ -31,6 +32,8 @@ from services.search.fusion_service import DEFAULT_FUSION
from services.search.grounding_check import check as grounding_check
from services.search.refusal_gate import RefusalDecision, decide as refusal_decide
from services.search import query_rewriter
from services.search.retrieval_service import AxisFilter
from services.search.result_decorate import compute_facets, decorate_version_status
from services.search.search_pipeline import PipelineResult, run_search
from services.search.synthesis_service import SynthesisResult, synthesize
from services.search.verifier_service import VerifierResult, verify
@@ -70,6 +73,14 @@ class SearchResult(BaseModel):
# PR-RAG-Time-1: freshness decay 디버그 메타. apply_freshness_decay 가 채움.
# 비적용 row 도 채워짐(freshness_policy=None). base_score 는 항상 보존.
freshness_debug: dict | None = None
# 안전 자료실 C-1: 분류 축 메타 (3 leg SELECT 에서 채움 — additive, ranking 무관).
# D-1 UI 결과 카드 유형별 렌더 + 해외 법령(B-5) 가동 시 국가 무표지 혼재 차단의 선행 조건.
material_type: str | None = None
jurisdiction: str | None = None
published_date: date | None = None
# 안전 자료실 C-1 후속: 법령 버전 상태(legal_meta.version_status) — wrapper 1회 decorate.
# law 결과만 채워짐(legal_meta 위성), 그 외/무매핑 law = None. D-1 버전 뱃지 선행.
version_status: str | None = None
# ─── Phase 0.4: 디버그 응답 스키마 ─────────────────────────
@@ -101,6 +112,9 @@ class SearchResponse(BaseModel):
query: str
mode: str
debug: SearchDebug | None = None
# 안전 자료실 C-1 후속: facets=true 일 때만 채워짐(미요청=None, byte 불변).
# top-K 결과 내 분류 축 분포 라벨 {axis: {label: count}}.
facets: dict[str, dict[str, int]] | None = None
def _to_debug_candidates(rows: list[SearchResult], n: int = 20) -> list[DebugCandidate]:
@@ -205,9 +219,23 @@ async def search(
"분리용. production 검색에는 사용 금지 (latency 큼)."
),
),
material_type: str | None = Query(
None, description="안전 자료실 C-1: 자료유형 필터 CSV (law,paper,incident,...). material_type = ANY"),
jurisdiction: str | None = Query(
None, description="안전 자료실 C-1: 관할 필터 (KR/US/EU/JP/GB/INT)"),
year_from: int | None = Query(None, ge=1900, le=2100, description="published_date 연도 하한 (NULL=created_at fallback)"),
year_to: int | None = Query(None, ge=1900, le=2100, description="published_date 연도 상한"),
facets: bool = Query(False, description="안전 자료실 C-1 후속: top-K 결과 분류 축 분포(material_type/jurisdiction/version_status)를 응답 facets 에 집계. 미지정=계산/노출 0"),
):
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 3.1 이후 run_search wrapper)"""
try:
axis = AxisFilter(
material_types=[m.strip() for m in material_type.split(",") if m.strip()]
if material_type else None,
jurisdiction=jurisdiction,
year_from=year_from,
year_to=year_to,
)
pr = await run_search(
session,
q,
@@ -223,6 +251,7 @@ async def search(
rewrite_backend=rewrite_backend,
corpus_variant=corpus_variant,
exact_knn=exact_knn,
axis=axis,
)
except ValueError as e:
# _resolve_backend / _resolve_reranker / _resolve_rewrite_backend / _resolve_corpus_variant unknown slug → HTTP 400
@@ -313,12 +342,17 @@ async def search(
debug_obj = _build_search_debug(pr) if debug else None
# 안전 자료실 C-1 후속 — wrapper decoration (검색 코어 무접촉, ranking 무관)
await decorate_version_status(session, pr.results) # 법령 결과에 version_status
facets_obj = compute_facets(pr.results) if facets else None
return SearchResponse(
results=pr.results,
total=len(pr.results),
query=q,
mode=pr.mode,
debug=debug_obj,
facets=facets_obj,
)
+28
View File
@@ -158,6 +158,17 @@ class Settings(BaseModel):
# 업로드 한도 (authoritative policy)
upload: UploadConfig = UploadConfig()
# 생성 LLM 홀드 (2026-06-11): config.yaml pipeline.held_stages 에 든 이름의
# 컨슈머/워커는 claim 자체를 하지 않는다 (attempts 미소모, pending 적체 = 의도).
# 유효 키 = 큐 stage 명(classify/summarize/deep_summary) + cron/컨슈머 키(digest,
# briefing, study_explanation, study_session_analysis, study_memo_card).
# 빈 리스트 = 무동작 (기존 동작 그대로).
pipeline_held_stages: list[str] = []
# mlx gate 동시 실행 상한 (2026-06-12, config.yaml pipeline.mlx_gate_concurrency).
# 1 = 구 single-inference 동작. 2 = continuous batching 활용 (llm_gate docstring 참조).
mlx_gate_concurrency: int = 1
# PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini
# GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거.
study_explanation_enabled: bool = True
@@ -244,6 +255,21 @@ def load_settings() -> Settings:
)
)
pipeline_held_stages: list[str] = []
mlx_gate_concurrency = 1
if config_path.exists() and raw and "pipeline" in raw:
held_raw = (raw.get("pipeline") or {}).get("held_stages") or []
# 스칼라(문자열) 오기입 시 char-split 방지 — 단일 항목 리스트로 수용.
if not isinstance(held_raw, (list, tuple)):
held_raw = [held_raw]
pipeline_held_stages = [str(s) for s in held_raw]
try:
mlx_gate_concurrency = max(
1, int((raw.get("pipeline") or {}).get("mlx_gate_concurrency", 1))
)
except (TypeError, ValueError):
mlx_gate_concurrency = 1
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
upload_cfg = (
@@ -272,6 +298,8 @@ def load_settings() -> Settings:
study_explanation_enabled=study_explanation_enabled,
study_card_extract_enabled=study_card_extract_enabled,
internal_worker_token=internal_worker_token,
pipeline_held_stages=pipeline_held_stages,
mlx_gate_concurrency=mlx_gate_concurrency,
)
+10 -7
View File
@@ -29,16 +29,19 @@ import httpx
from ai.client import AIClient
from services.llm.backends import (
MAC_MINI_DEFAULT,
QWEN_MACBOOK,
BackendUnavailable,
_router_url, # router URL 단일 출처 재사용 (settings → env LLM_ROUTER_URL → MVP default)
)
from services.search.llm_gate import Priority, acquire_mlx_gate
# 이드 채팅 mode → router alias 닫힌 매핑 (D-2). 클라는 mode 만 보냄 — claude-cloud/auto 금지.
# 2026-06-11 맥북 백지화: deep 도 mac-mini-default (맥미니 Qwen 27B 단일 호스트).
# mode 구분은 유지 — deep = ReAct 자동검색 경로(모델이 아니라 동작이 다름).
# 게이트는 alias==MAC_MINI_DEFAULT 조건이라 deep 도 자동으로 mlx gate 적용
# (llm_gate "예외 없이 gate 획득 필수" invariant 충족 — 구 무게이트는 맥북 예외였음).
_CHAT_ALIAS: dict[str, str] = {
"daily": MAC_MINI_DEFAULT, # router tier_b → Mac mini :8801 gemma-4-26b
"deep": QWEN_MACBOOK, # router named upstream → M5 Max Qwen3.6-27B (무게이트, D-2)
"daily": MAC_MINI_DEFAULT, # router tier_b → Mac mini :8801
"deep": MAC_MINI_DEFAULT, # 맥북 폐기로 동일 upstream — ReAct 검색 모드 구분만 유지
}
# read 는 per-chunk 적용이라 MacBook wake(24s)+토큰 생성 간격 커버. connect 는 내부 router 라 짧게.
@@ -161,10 +164,10 @@ class EidAIClient(AIClient):
_rewrite_sse_line 으로 model 치환(mode 어휘)·usage 제거만 하고 프레이밍은 보존.
취소/disconnect 시 AsyncExitStack 이 response·client 정리(upstream 닫힘 보장).
daily(mac-mini-default)는 Mac mini MLX 단일 inference 영구 룰(llm_gate docstring
"예외 없이 gate 획득 필수")에 따라 acquire_mlx_gate(FOREGROUND) 안에서 스트리밍 —
RouterBackend 의 requires_gate=True 와 동일한 client-side mutex 효과.
deep(qwen-macbook)은 별 endpoint 라 무게이트 (D-2, RouterBackend 동형).
daily/deep 모두 mac-mini-default(2026-06-11 맥북 백지화) → Mac mini MLX 단일
inference 영구 룰(llm_gate docstring "예외 없이 gate 획득 필수")에 따라
acquire_mlx_gate(FOREGROUND) 안에서 스트리밍 — 게이트 조건이 alias 기준이라
deep 도 자동 적용 (구 무게이트는 맥북 별 endpoint 시절 예외였음).
중계 전체(업스트림 진입~종료)는 asyncio.timeout(_STREAM_DEADLINE_S) wall-clock
deadline 안 — llm_gate 계약 "timeout 은 gate 안쪽" 준수(gate 대기엔 미적용).
+20 -3
View File
@@ -53,15 +53,18 @@ async def lifespan(app: FastAPI):
from workers.dedup_reconcile import run as dedup_reconcile_run
from workers.digest_worker import run as global_digest_run
from workers.file_watcher import watch_inbox
from workers.law_monitor import run as law_monitor_run
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_markdown_queue
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_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
@@ -95,6 +98,9 @@ async def lifespan(app: FastAPI):
# 대형 PDF split 변환(수십 분)이 메인 consume_queue 를 점유해 전 파이프라인을
# stall 시키던 문제 제거. max_instances=1(기본) 으로 동시 marker 변환 2건은 방지.
scheduler.add_job(consume_markdown_queue, "interval", minutes=1, id="markdown_consumer")
# 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")
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
scheduler.add_job(cleanup_orphan_uploads, "interval", minutes=10, id="upload_cleanup")
# PR-4: study_questions 자동 임베딩 (status='none/failed/stale' 행을 batch=10 처리).
@@ -117,7 +123,9 @@ async def lifespan(app: FastAPI):
# safety > law > manual 우선순위로 25건씩. 6720 레거시 → 야간당 ~150건 → 약 45일 소화.
scheduler.add_job(tier_backfill_run, "interval", minutes=30, id="tier_backfill")
# 일일 스케줄 (KST)
scheduler.add_job(law_monitor_run, CronTrigger(hour=7, timezone=KST), id="law_monitor")
# statute_collector = 구 law_monitor 대체 (safety-library-1 B-1 PR②) — poll→ingest→
# 생애주기 잡(버전 시리즈 승격·supersede·레거시 스윕·repeal) 통째 (R8-B1).
scheduler.add_job(statute_run, CronTrigger(hour=7, timezone=KST), id="statute_collector")
scheduler.add_job(mailplus_run, CronTrigger(hour=7, timezone=KST), id="mailplus_morning")
scheduler.add_job(mailplus_run, CronTrigger(hour=18, timezone=KST), id="mailplus_evening")
scheduler.add_job(daily_digest_run, CronTrigger(hour=20, timezone=KST), id="daily_digest")
@@ -134,6 +142,9 @@ 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")
# 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 + 워터마크 점진 백필).
@@ -142,6 +153,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.
+5
View File
@@ -14,6 +14,11 @@ from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
# FK("users.id") 해석에 users 테이블 메타데이터 필요 — fastapi 앱은 어차피 전 모델을
# import 하지만, CLI 단독 실행(queue_drain 등)은 본 모듈만 끌어와 INSERT 시
# "could not find table 'users'" 로 실패했다 (2026-06-12 drain 로그 실측). 명시 import.
from models.user import User # noqa: F401
class AnalyzeEvent(Base):
__tablename__ = "analyze_events"
+12 -2
View File
@@ -1,9 +1,9 @@
"""documents 테이블 ORM"""
from datetime import datetime
from datetime import date, datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -146,6 +146,16 @@ class Document(Base):
# /accept-suggestion 승인 시에만 category / user_tags 반영 (자동 전이 금지)
ai_suggestion: Mapped[dict | None] = mapped_column(JSONB)
# === 안전 자료실 분류 축 (plan safety-library-1, migrations 340~345) ===
# 자료유형 — law/paper/book/incident/manual/standard/guide (TEXT+CHECK, enum 아님).
# 수집기 ingest 시점 deterministic 부여 (classify-skip 경로 다수 — classify_worker 의존 금지).
# AI 라우팅(subject_domain) 매칭 키 사용 금지 (axis separation — category 와 동일 불변식).
material_type: Mapped[str | None] = mapped_column(Text)
# 관할 — KR/US/EU/JP/GB/INT. law 는 CHECK 로 jurisdiction NOT NULL 구조 강제 (migration 344).
jurisdiction: Mapped[str | None] = mapped_column(Text)
# 유형별 대표 날짜 — 법령=COALESCE(시행일, 공포일) / 논문=발행일 / 재해=발생일
published_date: Mapped[date | None] = mapped_column(Date)
# PR-B B-1: summary_triage (4B, 상시) / summary_deep (26B, 에스컬레이션) 분할 산출
ai_tldr: Mapped[str | None] = mapped_column(Text) # ≤60자 TL;DR
ai_bullets: Mapped[list | None] = mapped_column(JSONB) # 3~5개 핵심 bullets
+73
View File
@@ -0,0 +1,73 @@
"""legal_acts / legal_meta 테이블 ORM — 법령 레지스트리(워치리스트 겸) + 버전 위성
plan: safety-library-1 (migrations 346~347).
- legal_acts = 폴링 순회 대상 목록이 테이블 (news_sources 패턴의 법령판).
KOSHA GUIDE(비법령)·KGS Code(watch-폴더 단독 트랙) 비대상.
- legal_meta = 법령 문서 1버전(또는 별표·해석례 1) 1, documents 1:0..1 위성.
version_status 전이는 statute_collector 일일 잡이 유일한 코드 지점
( 버전 pending 적재 잡이 승격·supersede·repeal 트랜잭션 처리).
"""
from datetime import date, datetime
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class LegalAct(Base):
__tablename__ = "legal_acts"
# 'kr-law:{법령ID}' / 'us-cfr:29-1910' 형식. KGS 는 시드 비대상 (R3-M5).
family_id: Mapped[str] = mapped_column(Text, primary_key=True)
# 어댑터 상수 고정값 — 파싱 결과에서 추론 금지 (코어가 적재 직전 assert)
jurisdiction: Mapped[str] = mapped_column(Text, nullable=False)
# statute(법률) / decree(시행령) / rule(시행규칙·부령) / admin_rule(고시·예규) / code(법정 위임 상세기준)
law_level: Mapped[str] = mapped_column(Text, nullable=False)
title: Mapped[str] = mapped_column(Text, nullable=False)
title_ko: Mapped[str | None] = mapped_column(Text)
# 법률 → 시행령 → 시행규칙 계층
parent_family_id: Mapped[str | None] = mapped_column(ForeignKey("legal_acts.family_id"))
# 법령ID / CFR part / CELEX / e-Gov law_id 등 소스 고유 식별자
native_id: Mapped[str] = mapped_column(Text, nullable=False)
# 'law.go.kr' / 'ecfr' / 'cellar' / 'egov_v2' / 'leg_gov_uk'
source_api: Mapped[str] = mapped_column(Text, nullable=False)
# 시드 26개 전부 true — '우선순위'는 정렬일 뿐 watch 제외 아님 (R3-B1)
watch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
poll_cycle: Mapped[str] = mapped_column(Text, nullable=False, default="daily")
# 변경이력 폴링 워터마크 — 파싱 검증 통과 후에만 영속
watermark: Mapped[str | None] = mapped_column(Text)
# 어댑터는 폐지 감지 마킹만, repealed 전이는 일일 잡 (R3-M3)
repeal_detected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
)
class LegalMeta(Base):
__tablename__ = "legal_meta"
__table_args__ = (
# 버전 dedup 구조 강제 — annex 는 version_key='MST|별표N' 합성형 (R3-M4)
UniqueConstraint("family_id", "law_doc_kind", "version_key", name="uq_legal_meta_version"),
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
family_id: Mapped[str] = mapped_column(
ForeignKey("legal_acts.family_id"), nullable=False
)
# primary(본문) / annex(별표·서식) / interpretation(해석례)
law_doc_kind: Mapped[str] = mapped_column(Text, nullable=False, default="primary")
version_key: Mapped[str] = mapped_column(Text, nullable=False)
promulgation_date: Mapped[date | None] = mapped_column(Date)
effective_date: Mapped[date | None] = mapped_column(Date)
# pending → current → superseded / repealed. 전이는 일일 잡 단일 지점, KST 기준.
version_status: Mapped[str] = mapped_column(Text, nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
+9
View File
@@ -53,3 +53,12 @@ class NewsSource(Base):
name="source_channel"),
default="news",
)
# ── 안전 자료실 분류 축 (plan safety-library-1 A-2, migrations 352~355) ──
# 자료유형 기본값 — documents.material_type 으로 ingest 시점 전파 (NULL=비대상).
# jurisdiction 은 별도 컬럼 없이 country 전파, 단 paper 는 코드에서 NULL 강제.
material_type: Mapped[str | None] = mapped_column(Text)
# extract_meta.license 주입용 — kogl/ogl/public_domain/proprietary/unknown.
# 미확정 = 보수적(unknown + redistribute=false), 근거 확보 시 완화.
license_scheme: Mapped[str | None] = mapped_column(Text)
license_redistribute: Mapped[bool | None] = mapped_column(Boolean)
+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}")
+7 -2
View File
@@ -15,11 +15,12 @@ from sqlalchemy import text
from core.database import async_session
from core.utils import setup_logger
from services.search.license_filter import restricted_exclude_sql
logger = setup_logger("briefing_loader")
_NEWS_WINDOW_SQL = text("""
_NEWS_WINDOW_SQL = text(f"""
SELECT
d.id,
d.title,
@@ -41,6 +42,8 @@ _NEWS_WINDOW_SQL = text("""
AND d.created_at < :window_end
AND d.embedding IS NOT NULL
AND d.ai_summary IS NOT NULL
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (digest 동일 공유 술어, 경로 일관성)
AND {restricted_exclude_sql("d")}
""")
@@ -49,7 +52,7 @@ _SOURCE_COUNTRY_SQL = text("""
""")
_HISTORICAL_CANDIDATES_SQL = text("""
_HISTORICAL_CANDIDATES_SQL = text(f"""
SELECT
d.id,
d.title,
@@ -63,6 +66,8 @@ _HISTORICAL_CANDIDATES_SQL = text("""
AND d.created_at < :hist_end
AND d.embedding IS NOT NULL
AND d.ai_summary IS NOT NULL
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (공유 술어)
AND {restricted_exclude_sql("d")}
""")
+5 -1
View File
@@ -15,11 +15,12 @@ from sqlalchemy import text
from core.database import async_session
from core.utils import setup_logger
from services.search.license_filter import restricted_exclude_sql
logger = setup_logger("digest_loader")
_NEWS_WINDOW_SQL = text("""
_NEWS_WINDOW_SQL = text(f"""
SELECT
d.id,
d.title,
@@ -41,6 +42,9 @@ _NEWS_WINDOW_SQL = text("""
AND d.created_at < :window_end
AND d.embedding IS NOT NULL
AND d.ai_summary IS NOT NULL
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (모든 경로 공유 술어 = license_filter).
-- news 채널엔 현재 restricted 부재 = 방어적 게이트(미래 유료 news 소스 대비, 경로 누락 방지).
AND {restricted_exclude_sql("d")}
""")
+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
+38
View File
@@ -0,0 +1,38 @@
"""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)
.limit(1)
)
return result.scalars().first()
+154 -2
View File
@@ -22,7 +22,7 @@ from datetime import datetime, timedelta
from posixpath import basename
from zoneinfo import ZoneInfo
from sqlalchemy import text
from sqlalchemy import bindparam, text
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import settings
@@ -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],
@@ -258,6 +268,8 @@ def build_stages(stage_stats: dict[str, dict], now=None) -> list[dict]:
"pending": st["pending"],
"processing": st["processing"],
"failed": st["failed"],
"done_1h": st["done_1h"],
"created_1h": st["created_1h"],
"done_today": st["done_today"],
"oldest_pending_age_sec": age,
})
@@ -290,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),
}
@@ -399,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},
@@ -408,3 +421,142 @@ 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
_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"],
}
for r in rows
]
# ─── 실패 처리 (plan ds-board-engines-1) ─────────────────────────────────────
# 실패 = 자동 재시도(max_attempts=3) 소진 후 영구 정지 상태. 여기 함수들은
# 사용자 명시 조치 전용 — 자동 호출 경로 없음 (보드 실패 드로어가 유일 호출자).
# 실패 행은 completed_at 이 비어 있을 수 있어(소비자 실패 경로가 미기록)
# started_at 을 시각 fallback 으로 쓴다.
_FAILED_LIST_SQL = """
SELECT q.id, q.stage, q.document_id, q.attempts, q.max_attempts,
q.error_message,
COALESCE(q.completed_at, q.started_at) AS failed_at,
d.title, d.original_filename, d.file_path
FROM processing_queue q
JOIN documents d ON d.id = q.document_id
WHERE q.status = 'failed'
ORDER BY q.stage, COALESCE(q.completed_at, q.started_at) DESC NULLS LAST
LIMIT 300
"""
# 재시도: failed → pending (attempts 리셋 = 자동 재시도 3회 새로 부여).
# error_message 는 감사용으로 보존 — 성공 시 완료 행에 남아도 무해.
# uq_queue_active((doc,stage) pending/processing 부분 유니크)와 충돌하는 행 —
# 같은 문서·단계가 이미 재enqueue 된 경우 — 는 건드리지 않고 건수만 보고.
_RETRY_SQL = """
UPDATE processing_queue q
SET status = 'pending', attempts = 0,
started_at = NULL, completed_at = NULL
WHERE q.id IN :ids
AND q.status = 'failed'
AND NOT EXISTS (
SELECT 1 FROM processing_queue p
WHERE p.document_id = q.document_id
AND p.stage = q.stage
AND p.status IN ('pending', 'processing')
AND p.id <> q.id
)
RETURNING q.id
"""
# 건너뛰기: failed → completed + payload 마킹 (감사 추적).
# enqueue_next_stage 는 의도적으로 호출하지 않는다 — 실패 문서(빈 텍스트 등)가
# 하류 단계로 흘러가는 것 방지. 후속 단계가 필요하면 재시도가 정상 경로.
_SKIP_SQL = """
UPDATE processing_queue
SET status = 'completed', completed_at = NOW(),
payload = COALESCE(payload, '{}'::jsonb)
|| jsonb_build_object('skipped_by_user', true,
'skipped_at', NOW()::text)
WHERE id IN :ids AND status = 'failed'
RETURNING id
"""
async def fetch_failed_items(session: AsyncSession) -> list[dict]:
"""영구 실패 행 목록 (문서 제목 포함, 최대 300건)."""
rows = (await session.execute(text(_FAILED_LIST_SQL))).all()
return [
{
"id": r[0],
"stage": r[1],
"document_id": r[2],
"attempts": int(r[3] or 0),
"max_attempts": int(r[4] or 0),
"error_message": r[5],
"failed_at": r[6],
"title": display_title({
"document_id": r[2],
"title": r[7],
"original_filename": r[8],
"file_path": r[9],
}),
}
for r in rows
]
async def retry_failed(session: AsyncSession, ids: list[int]) -> dict:
"""failed → pending 복귀. not_retried = active 충돌 + 이미 failed 아님."""
unique_ids = list(set(ids))
stmt = text(_RETRY_SQL).bindparams(bindparam("ids", expanding=True))
retried = (await session.execute(stmt, {"ids": unique_ids})).all()
await session.commit()
return {
"requested": len(unique_ids),
"retried": len(retried),
"not_retried": len(unique_ids) - len(retried),
}
async def skip_failed(session: AsyncSession, ids: list[int]) -> dict:
"""failed → completed(건너뛰기 마킹). 후속 단계 연쇄 없음."""
unique_ids = list(set(ids))
stmt = text(_SKIP_SQL).bindparams(bindparam("ids", expanding=True))
skipped = (await session.execute(stmt, {"ids": unique_ids})).all()
await session.commit()
return {
"requested": len(unique_ids),
"skipped": len(skipped),
"not_skipped": len(unique_ids) - len(skipped),
}
+8
View File
@@ -72,6 +72,10 @@ class LegacyWeightedSum(FusionStrategy):
score=existing.score + r.score * 0.5,
snippet=existing.snippet,
match_reason=f"{existing.match_reason}+vector",
# C-1: 분류 축 메타 전파 (재구성 시 누락 = D-1 유형 표시 None)
material_type=existing.material_type,
jurisdiction=existing.jurisdiction,
published_date=existing.published_date,
)
elif r.score > 0.3:
merged[r.id] = r
@@ -128,6 +132,10 @@ class RRFOnly(FusionStrategy):
score=rrf_score,
snippet=base.snippet,
match_reason="+".join(reasons),
# C-1: 분류 축 메타 전파 (재구성 시 누락 = D-1 유형 표시 None)
material_type=base.material_type,
jurisdiction=base.jurisdiction,
published_date=base.published_date,
)
)
return merged[:limit]
+28
View File
@@ -0,0 +1,28 @@
"""안전 자료실 B-4 — licensed_restricted 단일 술어 (a안 U-2①, 모든 경로 공유 정의).
색인은 허용하되 restricted=true(구매 전자책·유료자료) verbatim span RAG 증거·발행물
(검색/ask·digest·morning_briefing·study 풀이) 들어가는 모든 경로를 구조적으로 차단.
경로마다 술어를 복붙하지 않고 정의를 공유 가드 누락/드리프트 방지
([[feedback_structural_integrity_over_path_discipline]]).
개인 파일 열람(GET /documents/{id}?download) a안상 허용 = 미적용.
표현(raw SQL / ORM) 의미 동일: restricted 부재·false·extract_meta NULL = COALESCE
미제외(redistribute=false 여도 restricted 부재면 미제외 redistributerestricted 핵심).
"""
def restricted_exclude_sql(alias: str = "") -> str:
"""raw text() 쿼리용 bare 술어('AND' 미포함). alias='' = 컬럼 직접 참조."""
p = (alias + ".") if alias else ""
return f"COALESCE({p}extract_meta -> 'license' ->> 'restricted', 'false') <> 'true'"
def restricted_exclude_orm():
"""SQLAlchemy ORM .where() 절 — restricted_exclude_sql 과 동일 의미(JSONB extract_meta)."""
from sqlalchemy import func
from models.document import Document
return func.coalesce(
Document.extract_meta["license"]["restricted"].astext, "false"
) != "true"
+40 -12
View File
@@ -26,8 +26,11 @@ PR-MacBook-RAG-Backend-1 부터 `services.llm.QwenMacBookBackend` 는 별 endpoi
- **fallback(Claude Sonnet 4 API) 경로는 gate 제외**. PR #20 이후 fallback = Claude API. 단 현재
구현상 `AIClient._call_chat` 내부에서 primaryfallback 전환이 일어나므로
fallback도 gate 점유 상태로 실행된다. 허용 가능(fallback 빈도 낮음).
- **MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**. 모델이 바뀌어도 single-
inference 특성이 깨지지 않는 값을 올리지 .
- ~~**MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**~~ **2026-06-12 개정**:
룰의 전제(서버 = single-inference) 소멸 mlx_vlm server continuous
batching 으로 동시 스트림 흡수(실측). 상한은 config `pipeline.mlx_gate_concurrency`
(기본 1, 운영 2). **게이트 자체(상한+우선순위 ) 영구 유지** thundering herd
(23 concurrent 22 timeout 사고) 방지는 계속 상한이 담당. 무제한 금지.
## 우선순위 정책 (B-1, 2026-05-17)
@@ -80,8 +83,22 @@ from core.utils import setup_logger
logger = setup_logger("llm_gate")
# MLX primary는 single-inference → 1
MLX_CONCURRENCY = 1
def _capacity() -> int:
"""게이트 동시 실행 상한 — config.yaml `pipeline.mlx_gate_concurrency` (기본 1).
2026-06-12 일반화: "MLX_CONCURRENCY = 1 고정" 영구 룰의 전제( 서버 = single-
inference, 23 concurrent 22 timeout 실측) 소멸 mlx_vlm server
continuous batching 으로 동시 스트림을 흡수(2026-06-11 6~8 concurrent 실측
정상). 게이트 자체(상한 + 우선순위) 유지하고 상한만 config thundering
herd 재발 방지는 상한이 계속 담당한다. 런타임 acquire 조회라
config 변경 + 프로세스 재기동으로 반영, 테스트는 settings monkeypatch.
"""
from core.config import settings
try:
return max(1, int(getattr(settings, "mlx_gate_concurrency", 1)))
except (TypeError, ValueError):
return 1
# Background waiter wait_ms 가 이 값 초과 시 WARN (starvation 신호, aging mitigation 은 Phase 2)
STARVATION_WARN_MS = 300_000 # 5 min
@@ -101,7 +118,7 @@ DEFAULT_PRIORITY: Priority = Priority.BACKGROUND
# Tuple format: (priority: int, seq: int, future: asyncio.Future, enqueue_ts: float)
_waiters: list[tuple[int, int, asyncio.Future, float]] = []
_seq = itertools.count()
_inflight: bool = False
_inflight_n: int = 0 # 동시 실행 수 (구 bool — capacity 일반화로 카운터)
_lock: asyncio.Lock | None = None
@@ -143,7 +160,7 @@ async def acquire_mlx_gate(
`asyncio.timeout` 반드시 gate 안쪽 (Future await ) .
"""
global _inflight, _waiters
global _inflight_n, _waiters
lock = _get_lock()
seq = next(_seq)
@@ -152,9 +169,9 @@ async def acquire_mlx_gate(
fut: asyncio.Future | None = None
async with lock:
if not _inflight and not _waiters:
if _inflight_n < _capacity() and not _waiters:
# fast path — 즉시 inflight 진입, Future 생성 안 함
_inflight = True
_inflight_n += 1
else:
# 대기열 진입
fut = asyncio.get_event_loop().create_future()
@@ -194,8 +211,8 @@ async def acquire_mlx_gate(
async with lock:
next_fut = _dispatch_next_locked()
if next_fut is None:
_inflight = False
# _inflight 는 True 유지 (다음 waiter 가 진입 예정)
_inflight_n = max(0, _inflight_n - 1)
# next_fut 가 있으면 슬롯 handover — 카운트 유지 (다음 waiter 가 진입 예정)
logger.debug(
"mlx_gate release duration_ms=%.0f priority=%s seq=%d",
duration_ms, priority.name, seq,
@@ -222,13 +239,24 @@ def get_mlx_gate():
return acquire_mlx_gate(DEFAULT_PRIORITY)
# ── Read-only status (UI 표시용) ─────────────────────────────────────────────
def gate_status() -> dict:
"""현재 gate 점유 스냅샷 (read-only, lock-free 근사치 — UI 표시용).
inflight = 동시 실행 (int). 기존 소비자(eid status) bool() 캐스팅이라 호환.
"""
return {"inflight": _inflight_n, "waiters": len(_waiters)}
# ── Test helpers (conftest reset) ────────────────────────────────────────────
def _reset_for_test() -> None:
"""테스트 fixture 가 fresh loop 마다 호출. production code 에서 사용 X."""
global _waiters, _inflight, _lock, _seq
global _waiters, _inflight_n, _lock, _seq
_waiters = []
_inflight = False
_inflight_n = 0
_lock = None
_seq = itertools.count()
+55
View File
@@ -0,0 +1,55 @@
"""안전 자료실 C-1 후속 — 검색 결과 wrapper decoration (version_status + facets).
엔드포인트 wrapper 에서 run_search() 결과에 1 적용 검색 코어(run_search) 무접촉(r3).
- version_status: 법령 결과(material_type='law') legal_meta.version_status
(current/superseded/pending/repealed) 부착. legal_meta.document_id 1:0..1 위성
매핑 없는 law(레거시 ) None 유지. law 결과 없으면 query skip.
- facets: top-K 결과 분류 (material_type/jurisdiction/version_status) 분포 라벨(r2-M4).
facets=true 때만 계산(미요청 None = byte 불변·ranking 무관).
"""
from __future__ import annotations
from collections import Counter
from typing import TYPE_CHECKING
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from api.search import SearchResult
async def decorate_version_status(
session: AsyncSession, results: list["SearchResult"]
) -> None:
"""법령 결과에 legal_meta.version_status 부착 (in-place). law 결과 없으면 query skip."""
law_ids = [r.id for r in results if r.material_type == "law" and r.id is not None]
if not law_ids:
return
rows = await session.execute(
text(
"SELECT document_id, version_status FROM legal_meta "
"WHERE document_id = ANY(:ids)"
),
{"ids": law_ids},
)
status_by_id = {row.document_id: row.version_status for row in rows}
for r in results:
if r.id in status_by_id:
r.version_status = status_by_id[r.id]
def compute_facets(results: list["SearchResult"]) -> dict[str, dict[str, int]]:
"""top-K 결과의 분류 축 분포 라벨. None 값은 제외(present 라벨만, 빈 축은 미포함)."""
axes = {
"material_type": [r.material_type for r in results],
"jurisdiction": [r.jurisdiction for r in results],
"version_status": [getattr(r, "version_status", None) for r in results],
}
facets: dict[str, dict[str, int]] = {}
for axis, vals in axes.items():
counter = Counter(v for v in vals if v is not None)
if counter:
facets[axis] = dict(counter.most_common())
return facets
+170 -16
View File
@@ -24,6 +24,7 @@ import asyncio
import hashlib
import re
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from sqlalchemy import text
@@ -63,8 +64,98 @@ CANDIDATE_BACKEND_MAP: dict[str, dict[str, str] | None] = {
"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,
},
}
# G-1 핀 고정 instruct 문자열 (inventory 2026-06-12-c 기록과 동일해야 함 —
# 문구 변경 = 저장=조회 불변식 위반과 동급. 쿼리 측 전용, 문서 적재는 plain).
QWEN3_QUERY_INSTRUCT = (
"Instruct: Given a web search query, retrieve relevant passages that answer the query"
"\nQuery: "
)
# ─── 안전 자료실 C-1: 분류 축 명시 필터 (3 leg 동등, byte 불변) ───────────────
# 미지정(active=False) 시 모든 SQL 절이 빈 문자열 → 기존 SQL byte 불변(run_eval 회귀 0).
# year 는 published_date NULL fallback created_at (freshness 와 동일 COALESCE 사상).
@dataclass
class AxisFilter:
material_types: list[str] | None = None # CSV → list, material_type = ANY
jurisdiction: str | None = None
year_from: int | None = None
year_to: int | None = None
def active(self) -> bool:
return bool(self.material_types or self.jurisdiction
or self.year_from is not None or self.year_to is not None)
def _axis_sql(alias: str, af: "AxisFilter | None", params: dict) -> str:
"""alias 기준 axis 필터 SQL — 미지정 시 '' (byte 불변). 반환 형태 ' AND ...'.
alias='' 이면 컬럼 직접 참조(단일 테이블 FROM documents 경로). 파라미터는 af_ prefix
호출측 기존 bind 충돌 방지.
"""
if af is None or not af.active():
return ""
p = (alias + ".") if alias else ""
cl: list[str] = []
if af.material_types:
cl.append(f"{p}material_type = ANY(:af_mt)")
params["af_mt"] = af.material_types
if af.jurisdiction:
cl.append(f"{p}jurisdiction = :af_jur")
params["af_jur"] = af.jurisdiction
if af.year_from is not None:
cl.append(f"COALESCE({p}published_date, {p}created_at::date) >= make_date(:af_yf, 1, 1)")
params["af_yf"] = af.year_from
if af.year_to is not None:
cl.append(f"COALESCE({p}published_date, {p}created_at::date) <= make_date(:af_yt, 12, 31)")
params["af_yt"] = af.year_to
return " AND " + " AND ".join(cl)
# ─── 안전 자료실 B-4: licensed_restricted 단일 술어 (a안 U-2① — 항상 적용) ──────
def _license_sql(alias: str) -> str:
"""licensed_restricted(extract_meta.license.restricted=true) 문서를 retrieval 에서 제외.
a안: 색인은 허용하되, 구매 전자책/유료자료의 verbatim span RAG 증거·digest 발행에
들어가는 경로를 구조적으로 차단. 단일 술어를 모든 retrieval leg + digest loader
공유 경로별 가드 누락 방지([[feedback_structural_integrity_over_path_discipline]]).
개인 파일 열람(GET /documents/{id}?download) a안상 허용이라 미적용.
axis 필터(조건부) 달리 항상 적용. restricted 부재/false = COALESCE 미제외
기존 코퍼스(restricted=true 0)에서 결과 불변. 반환 ' AND ...' (alias='' = 컬럼 직접).
술어 정의 = license_filter.restricted_exclude_sql 공유(digest/briefing/study 풀이와 단일 source).
"""
from services.search.license_filter import restricted_exclude_sql
return " AND " + restricted_exclude_sql(alias)
# 2단계 gate (R2-B1) — SQL string interpolation 직전 final allowlist.
_VALID_DOCS_TABLE = re.compile(r"^(documents|documents_cand_[a-z0-9_]+)$")
# corpus_chunks = document_chunks WHERE in_corpus=true 뷰 (Hier-Decomp-1 c2 choke point).
@@ -137,6 +228,34 @@ async def _embed_query_via_tei(endpoint: str, text_: str) -> list[float] | None:
return None
async def _embed_query_via_ollama(cfg: dict, text_: str) -> list[float] | None:
"""Phase 2A 후보 쿼리 임베딩 — Ollama /api/embed + 비대칭 instruct prefix.
쿼리 전용: QWEN3_QUERY_INSTRUCT 선두에 붙인다 (문서 적재 = plain).
embed_dimensions 지정(qwen4m) Ollama dimensions 옵션 = MRL truncate+재정규화
(G-1 fixture: 1024 출력 L2=1.0 실측). cache 미사용 slug 분포 상이.
"""
if not text_:
return None
import httpx
body: dict = {"model": cfg["embed_model"], "input": [QWEN3_QUERY_INSTRUCT + text_]}
if cfg.get("embed_dimensions"):
body["dimensions"] = cfg["embed_dimensions"]
try:
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(cfg["embed_endpoint"], json=body)
r.raise_for_status()
embs = r.json().get("embeddings")
if not isinstance(embs, list) or not embs or not isinstance(embs[0], list):
raise ValueError("unexpected /api/embed shape")
return embs[0]
except Exception as exc:
logger.warning(
"candidate ollama embed failed model=%s err=%r", cfg.get("embed_model"), exc
)
return None
def _query_embed_key(text_: str) -> str:
return hashlib.sha256(f"{text_}|bge-m3".encode("utf-8")).hexdigest()
@@ -174,7 +293,7 @@ def query_embed_cache_stats() -> dict[str, int]:
async def search_text(
session: AsyncSession, query: str, limit: int
session: AsyncSession, query: str, limit: int, *, axis: "AxisFilter | None" = None
) -> list["SearchResult"]:
"""FTS + trigram 필드별 가중치 검색 (Phase 1.2-B UNION 분해).
@@ -205,8 +324,12 @@ async def search_text(
# SQLAlchemy async session 내 두 execute는 같은 connection 사용
await session.execute(text("SELECT set_limit(0.15)"))
_params: dict[str, Any] = {"q": query, "limit": limit}
# license(항상) + axis(조건부). license 가 항상 ' AND ...' 이라 WHERE 는 늘 존재.
_where = _license_sql("d") + _axis_sql("d", axis, _params)
result = await session.execute(
text("""
text(f"""
WITH candidates AS (
-- title trigram (idx_documents_title_trgm)
SELECT id FROM documents
@@ -238,7 +361,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',
@@ -246,7 +369,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
@@ -257,15 +380,17 @@ 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
END AS match_reason,
d.material_type, d.jurisdiction, d.published_date
FROM documents d
JOIN candidates c ON d.id = c.id
WHERE{_where[4:]}
ORDER BY score DESC
LIMIT :limit
"""),
{"q": query, "limit": limit},
_params,
)
return [SearchResult(**row._mapping) for row in result]
@@ -280,6 +405,7 @@ async def search_vector(
snapshot_chunk_id_max: int | None = None,
corpus_variant: str | None = None,
exact_knn: bool = False,
axis: "AxisFilter | None" = None,
) -> list["SearchResult"]:
"""Hybrid 벡터 검색 — doc + chunks 동시 retrieval (Phase 1.2-G).
@@ -323,7 +449,10 @@ async def search_vector(
else:
docs_table = cfg["docs_table"]
chunks_table = cfg["chunks_table"]
query_embedding = await _embed_query_via_tei(cfg["embed_endpoint"], query)
if cfg.get("embed_kind") == "ollama":
query_embedding = await _embed_query_via_ollama(cfg, query)
else:
query_embedding = await _embed_query_via_tei(cfg["embed_endpoint"], query)
logger.info(
"[embedding-dispatch] backend=%s docs_table=%s chunks_table=%s snapshot_doc_id_max=%s "
@@ -351,6 +480,7 @@ async def search_vector(
docs_table=docs_table,
snapshot_doc_id_max=snapshot_doc_id_max,
exact_knn=exact_knn,
axis=axis,
)
async def _chunks_call() -> list["SearchResult"]:
@@ -360,6 +490,7 @@ async def search_vector(
chunks_table=chunks_table,
snapshot_chunk_id_max=snapshot_chunk_id_max,
exact_knn=exact_knn,
axis=axis,
)
doc_results, chunk_results = await asyncio.gather(_docs_call(), _chunks_call())
@@ -375,6 +506,7 @@ async def _search_vector_docs(
docs_table: str = "documents",
snapshot_doc_id_max: int | None = None,
exact_knn: bool = False,
axis: "AxisFilter | None" = None,
) -> list["SearchResult"]:
"""documents (또는 documents_cand_<slug>).embedding 직접 검색.
@@ -399,28 +531,34 @@ async def _search_vector_docs(
if snapshot_doc_id_max is not None:
snapshot_clause = " AND id <= :snapshot_doc_id_max"
params["snapshot_doc_id_max"] = snapshot_doc_id_max
axis_clause = _axis_sql("", axis, params) # alias 없음 (단일 FROM documents)
license_clause = _license_sql("") # B-4: restricted 항상 제외
sql = f"""
SELECT id, title, ai_domain, ai_summary, file_format,
(1 - (embedding <=> cast(:embedding AS vector))) AS score,
left(extracted_text, 1200) AS snippet,
'vector_doc' AS match_reason,
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title,
material_type, jurisdiction, published_date
FROM documents
WHERE embedding IS NOT NULL AND deleted_at IS NULL{snapshot_clause}
WHERE embedding IS NOT NULL AND deleted_at IS NULL{snapshot_clause}{axis_clause}{license_clause}
ORDER BY embedding <=> cast(:embedding AS vector)
LIMIT :limit
"""
else:
# candidate: docs_table 은 (doc_id, embed_input, embed_input_hash, embedding) 만 보유 → JOIN documents
axis_clause = _axis_sql("d", axis, params)
license_clause = _license_sql("d") # B-4: restricted 항상 제외
sql = f"""
SELECT d.id, d.title, d.ai_domain, d.ai_summary, d.file_format,
(1 - (c.embedding <=> cast(:embedding AS vector))) AS score,
left(d.extracted_text, 1200) AS snippet,
'vector_doc' AS match_reason,
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title,
d.material_type, d.jurisdiction, d.published_date
FROM {docs_table} c
JOIN documents d ON d.id = c.doc_id
WHERE d.deleted_at IS NULL
WHERE d.deleted_at IS NULL{axis_clause}{license_clause}
ORDER BY c.embedding <=> cast(:embedding AS vector)
LIMIT :limit
"""
@@ -436,6 +574,7 @@ async def _search_vector_chunks(
chunks_table: str = "document_chunks",
snapshot_chunk_id_max: int | None = None,
exact_knn: bool = False,
axis: "AxisFilter | None" = None,
) -> list["SearchResult"]:
"""document_chunks (또는 document_chunks_cand_<slug>).embedding window partition.
@@ -461,12 +600,25 @@ async def _search_vector_chunks(
snapshot_clause = " AND c.id <= :snapshot_chunk_id_max"
params["snapshot_chunk_id_max"] = snapshot_chunk_id_max
# C-1: axis 필터는 inner topk 에 JOIN (R6 결정 — outer post-filter 면 ANN top-:inner_k
# 후보를 뽑은 뒤 거르므로 좁은 필터(GB 법령 등)에서 후보 붕괴). 미지정 시 JOIN 없음 = byte 불변.
if axis and axis.active():
chunk_join = " JOIN documents df ON df.id = c.doc_id"
chunk_axis = _axis_sql("df", axis, params)
else:
chunk_join = ""
chunk_axis = ""
# B-4: restricted 제외 — outer 가 documents d 를 항상 JOIN 하므로 post-rank 위치.
# restricted 는 소수(구매자료)라 inner topk 후 제외해도 candidate collapse 없음(axis 와 상이).
license_clause = _license_sql("d")
sql = f"""
WITH topk AS (
SELECT c.id AS chunk_id, c.doc_id, c.chunk_index, c.section_title, c.text,
c.embedding <=> cast(:embedding AS vector) AS dist
FROM {chunks_table} c
WHERE c.embedding IS NOT NULL{snapshot_clause}
FROM {chunks_table} c{chunk_join}
WHERE c.embedding IS NOT NULL{snapshot_clause}{chunk_axis}
ORDER BY c.embedding <=> cast(:embedding AS vector)
LIMIT :inner_k
),
@@ -479,10 +631,12 @@ async def _search_vector_chunks(
d.ai_summary AS ai_summary, d.file_format AS file_format,
(1 - r.dist) AS score, left(r.text, 1200) AS snippet,
'vector_chunk' AS match_reason,
r.chunk_id AS chunk_id, r.chunk_index AS chunk_index, r.section_title AS section_title
r.chunk_id AS chunk_id, r.chunk_index AS chunk_index, r.section_title AS section_title,
d.material_type AS material_type, d.jurisdiction AS jurisdiction,
d.published_date AS published_date
FROM ranked r
JOIN documents d ON d.id = r.doc_id
WHERE r.rn <= 2 AND d.deleted_at IS NULL
WHERE r.rn <= 2 AND d.deleted_at IS NULL{license_clause}
ORDER BY r.dist
LIMIT :limit
"""
+9 -1
View File
@@ -47,6 +47,7 @@ from .rerank_service import (
rerank_chunks,
)
from .retrieval_service import (
AxisFilter,
compress_chunks_to_docs,
search_text,
search_vector,
@@ -148,6 +149,7 @@ async def run_search(
rewrite_backend: str | None = None,
corpus_variant: str | None = None,
exact_knn: bool = False,
axis: AxisFilter | None = None,
) -> PipelineResult:
"""검색 파이프라인 실행.
@@ -275,6 +277,7 @@ async def run_search(
snapshot_chunk_id_max=snapshot_chunk_id_max,
corpus_variant=corpus_variant,
exact_knn=exact_knn,
axis=axis,
)
timing["vector_ms"] = (time.perf_counter() - t0) * 1000
if not raw_chunks:
@@ -284,7 +287,7 @@ async def run_search(
results = vector_results
else:
t0 = time.perf_counter()
text_results = await search_text(session, q, limit)
text_results = await search_text(session, q, limit, axis=axis)
timing["text_ms"] = (time.perf_counter() - t0) * 1000
if mode == "hybrid":
@@ -306,6 +309,7 @@ async def run_search(
snapshot_chunk_id_max=snapshot_chunk_id_max,
corpus_variant=corpus_variant,
exact_knn=exact_knn,
axis=axis,
)
timing["vector_ms"] = (time.perf_counter() - t1) * 1000
@@ -458,6 +462,10 @@ def _rrf_fuse_variants(
score=rrf_score,
snippet=doc.snippet,
match_reason=f"{doc.match_reason}+multi_query_rrf",
# C-1: 분류 축 메타 전파 (SearchResult 재구성 지점 — fusion 2곳과 동기)
material_type=doc.material_type,
jurisdiction=doc.jurisdiction,
published_date=doc.published_date,
))
return fused[:limit]
+1 -1
View File
@@ -47,7 +47,7 @@ logger = setup_logger("synthesis")
# ─── 상수 (plan 영구 룰) ─────────────────────────────────
PROMPT_VERSION = "v2"
LLM_TIMEOUT_MS = 30000 # 2026-05-17 B-3: 15s 시 동시 부하 (Mac mini 26B classifier+evidence+synthesis serialized) 빈발 timeout — classifier (30s) 와 align
LLM_TIMEOUT_MS = 120000 # 2026-06-11 Qwen3.6-27B-6bit 전환: 프리필 ~112 tok/s·디코드 ~11.7 tok/s 실측 — 30s 면 synthesis(답변 본체) 상시 timeout. synthesis 는 graceful skip 불가(=답변 실패)라 단독 상향, config ask.backend.timeout_read_s=120 와 align
CACHE_TTL = 3600 # 1h (answer 는 원문 변경에 민감 → query_analyzer 24h 보다 짧게)
CACHE_MAXSIZE = 300
MAX_ANSWER_CHARS = 600
+4
View File
@@ -24,6 +24,7 @@ from models.chunk import DocumentChunk
from models.document import Document
from models.study_question import StudyQuestion
from models.study_topic import StudyTopicDocument
from services.search.license_filter import restricted_exclude_orm
logger = logging.getLogger(__name__)
@@ -124,11 +125,14 @@ async def _gather_document_evidence(
return []
# 매핑된 documents 메타 (제목·요약 표기)
# B-4: licensed_restricted 제외 → valid_doc_ids 에서 빠지므로 아래 청크 쿼리(doc_id IN)도
# 자동 차단. study 풀이 RAG 도 retrieval/digest 와 동일 단일 술어 공유(a안 U-2①).
doc_meta_rows = (
await session.execute(
select(Document.id, Document.title, Document.ai_summary).where(
Document.id.in_(doc_ids),
Document.deleted_at.is_(None),
restricted_exclude_orm(),
)
)
).all()
+6
View File
@@ -175,10 +175,16 @@ async def _ingest_detail(session, source: NewsSource, url: str) -> str:
ai_domain="Engineering",
ai_sub_group=_SOURCE_NAME,
ai_tags=["Engineering/API 표준 공지"],
# 안전 자료실 A-2 — 표준 '공지' = standard (코드 본문 아님 — ASME/API 본문은 paywall)
material_type="standard",
jurisdiction="US",
published_date=pub_dt.date() if pub_dt else None,
extract_meta={
"source_id": source.id,
"source_name": _SOURCE_NAME,
"published_at": pub_dt.isoformat() if pub_dt else None,
"license": {"scheme": "proprietary", "redistribute": False,
"attribution": "American Petroleum Institute"},
"fulltext": {
"status": "api_announcement",
"engine": engine,
+370
View File
@@ -0,0 +1,370 @@
"""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
max_pages = (10**6 if bulk else _MAX_PAGES_PER_CAT)
try:
for page in range(max_pages):
if inserted >= run_cap:
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:
break
await asyncio.sleep(_REQ_SLEEP)
if stop or (page + 1) * _PAGE_SIZE >= total:
break
# 카테고리 워터마크 전진(이번 run 최신 발행일)
if newest_seen:
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))
+4
View File
@@ -8,6 +8,7 @@
import asyncio
from datetime import date
from core.config import settings
from core.utils import setup_logger
from services.briefing.pipeline import run_briefing_pipeline
@@ -22,6 +23,9 @@ async def run(target_date: date | None = None) -> dict | None:
Args:
target_date: KST 기준 briefing_date (None = 오늘). API regenerate 명시 지정 가능.
"""
if "briefing" in settings.pipeline_held_stages:
logger.info("[briefing] 보류 (pipeline.held_stages) — 이번 실행 skip")
return None
try:
result = await asyncio.wait_for(
run_briefing_pipeline(target_date),
+4
View File
@@ -311,6 +311,10 @@ async def process(document_id: int, session: AsyncSession) -> None:
country, source, src_lang = await _lookup_news_source(session, doc)
if src_lang:
language = src_lang
# 안전 자료실 A-2 — 뉴스 lookup 미해당(crawl/law/업로드) 문서는 jurisdiction 을
# chunk.country 미러로 (leg 간 국가 일치. EU/INT 도 이 경로로 첫 유입 — String(10) 수용).
if country is None and doc.jurisdiction:
country = doc.jurisdiction
domain_category = "news" if doc.source_channel == "news" else "document"
# 기존 chunks 삭제 (재처리)
+99 -14
View File
@@ -31,12 +31,18 @@ from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import text as sql_text
from sqlalchemy.ext.asyncio import AsyncSession
from ai.client import AIClient, parse_json_response, strip_thinking
from ai.client import (
AIClient,
call_deep_or_defer,
is_deferrable_error,
parse_json_response,
strip_thinking,
)
from ai.envelope import EscalationEnvelope
from core.config import settings
from core.utils import setup_logger
from models.document import Document
from models.queue import enqueue_stage
from models.queue import StageDeferred, enqueue_stage
from policy.prompt_render import render_4b, policy_version as compute_policy_version
from policy.routing import decide_routing
from services.document_telemetry import record_analyze_event
@@ -56,6 +62,15 @@ FACET_DOCTYPES = {"발주서", "세금계산서", "명세표", "도면", "증명
# 자료실 자동 분류 제안 대상 (거래 하위)
LIBRARY_SUGGESTION_DOCTYPES = {"발주서", "세금계산서", "명세표"}
# 안전 자료실 A-2 — document_type → material_type 결정적 매핑 (제안 전용, 자동 전이 금지).
# 모호한 doctype(Reference/Report 등)은 매핑하지 않음 — 무리한 전수 분류 시도 금지 (plan 0-1).
_DOCTYPE_TO_MATERIAL = {
"Law_Document": "law",
"Academic_Paper": "paper",
"Manual": "manual",
"Standard": "standard",
}
# PR-B prompt_version task 이름
SUMMARY_TRIAGE_TASK = "p3a_short_summary"
@@ -345,13 +360,20 @@ _FRONTMATTER_PRESERVED_KEYS = {
# ───────────────────────── main process ────────────────────────────────
async def process(document_id: int, session: AsyncSession) -> None:
async def process(
document_id: int, session: AsyncSession, *, use_deep: bool = False
) -> None:
"""문서 분류 + 요약 + tier triage.
1) Legacy: classify() ai_domain/document_type/ai_tags/ai_confidence/ai_suggestion
2) Legacy: summarize() ai_summary
3) PR-B B-1: summary_triage (4B) ai_tldr/ai_bullets/ai_analysis_tier='triage'
use_deep (2026-06-12 fair-share, queue_drain 전용): triage LLM 호출을 deep 슬롯
(맥북, 라우터 경유)으로 보낸다 sampling triage temperature/max_tokens
유지(분류 결정성), endpoint 교체. 맥북 불가 = StageDeferred 전파(drain
보류 처리). False(기본/consumer) = 기존 call_triage(맥미니 직접) 그대로.
예외 source_channel='law_monitor':
법령은 외부 source-of-truth (law.go.kr) 보유 + immutable + 자동 재수집.
AI 분류는 무가치 + 본문 해석 환각 위험. 26B legacy + 4B triage 전부 skip.
@@ -389,6 +411,15 @@ async def process(document_id: int, session: AsyncSession) -> None:
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가 비어있음")
@@ -446,10 +477,20 @@ async def process(document_id: int, session: AsyncSession) -> None:
logger.info(f"doc {document_id}: frontmatter 부분 인식 → LLM 으로 미설정 필드 보완")
client = AIClient()
# fair-share (2026-06-12): use_deep 시 legacy classify/summarize 도 deep 슬롯(맥북)
# 경유 — 그래야 drain 의 "맥북 분담" 이 실제로 성립 (triage 만 보내면 50K 요약
# 프리필이 맥미니에 남는다). deep 슬롯 sampling = primary 와 동일(0.3/0.9/8192).
legacy_cfg = settings.ai.deep if (use_deep and settings.ai.deep is not None) else None
try:
# ─── 1. Legacy classify (primary 26B) ───
# ─── 1. Legacy classify (primary 또는 deep) ───
truncated = doc.extracted_text[:MAX_CLASSIFY_TEXT]
raw_response = await client.classify(truncated)
try:
raw_response = await client.classify(truncated, cfg=legacy_cfg)
except Exception as exc:
if legacy_cfg is not None and is_deferrable_error(exc):
# 맥북 불가 — 첫 호출(최저 비용 지점)에서 보류로 전환, doc 쓰기 0
raise StageDeferred(f"macbook_unavailable:{type(exc).__name__}") from exc
raise
parsed = parse_json_response(raw_response)
if not parsed:
@@ -469,6 +510,24 @@ async def process(document_id: int, session: AsyncSession) -> None:
if not doc.document_type:
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note"
# ─── 안전 자료실 A-2: material_type 제안 (업로드 경로 — LLM 직접 부여 금지) ───
# document_type → material_type 결정적 매핑만 제안으로 적재 (프롬프트 변경 0).
# 승인(accept-suggestion) 시에만 전이 — law 는 국가 필수 입력 (KR 기본값 오염 차단,
# 자동 전이 금지 사상은 category 와 동일). 수집기 deterministic 경로는 이미 채워져
# 있어(material_type IS NOT NULL) 본 제안 비대상. 거래문서 제안(ai_suggestion 점유)과
# 충돌 시 기존 제안 우선 (두 제안이 겹치는 문서는 실무상 없음 — 거래 vs 안전자료).
_mt_prop = _DOCTYPE_TO_MATERIAL.get(doc.document_type or "")
if _mt_prop and doc.material_type is None and doc.ai_suggestion is None:
doc.ai_suggestion = {
"proposed_material_type": _mt_prop,
"proposed_jurisdiction": None,
"confidence": doc.ai_confidence,
"source_updated_at": (
doc.updated_at.isoformat() if doc.updated_at else None
),
"reason": "document_type→material_type 결정적 매핑",
}
# confidence
confidence = parsed.get("confidence", 0.5)
doc.ai_confidence = max(0.0, min(1.0, float(confidence)))
@@ -517,12 +576,17 @@ async def process(document_id: int, session: AsyncSession) -> None:
"reason": "classify pipeline",
}
# ─── 2. Legacy 요약 (primary 26B) ───
summary = await client.summarize(doc.extracted_text[:50000])
# ─── 2. Legacy 요약 (primary 또는 deep) ───
try:
summary = await client.summarize(doc.extracted_text[:50000], cfg=legacy_cfg)
except Exception as exc:
if legacy_cfg is not None and is_deferrable_error(exc):
raise StageDeferred(f"macbook_unavailable:{type(exc).__name__}") from exc
raise
doc.ai_summary = strip_thinking(summary)
# ─── 메타데이터 (legacy 완료) ───
doc.ai_model_version = settings.ai.primary.model
# ─── 메타데이터 (legacy 완료) — 실제 처리 머신 귀속 (drain=qwen-macbook) ───
doc.ai_model_version = (legacy_cfg or settings.ai.primary).model
doc.ai_processed_at = datetime.now(timezone.utc)
logger.info(
@@ -533,7 +597,9 @@ async def process(document_id: int, session: AsyncSession) -> None:
# ─── 3. PR-B B-1 — tier triage (4B, 실패는 legacy 결과 보존) ───
try:
await _run_tier_triage(client, doc, session)
await _run_tier_triage(client, doc, session, use_deep=use_deep)
except StageDeferred:
raise # 보류는 실패가 아님 — drain/consumer 가 attempts 미소모 처리
except Exception as exc:
logger.exception(f"[triage] id={document_id} 전체 실패 — legacy 유지: {exc}")
@@ -541,8 +607,10 @@ async def process(document_id: int, session: AsyncSession) -> None:
await client.close()
async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSession) -> None:
"""summary_triage (p3a_short_summary) 경로."""
async def _run_tier_triage(
client: AIClient, doc: Document, session: AsyncSession, *, use_deep: bool = False
) -> None:
"""summary_triage (p3a_short_summary) 경로. use_deep = process() 에서 전달 (drain 전용)."""
document_id = doc.id
text = doc.extracted_text or ""
input_chars = len(text)
@@ -550,6 +618,14 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
triage_start = time.perf_counter()
parse_error: str | None = None
triage_out = TriageOutput()
# drain 경유 시 triage 도 deep 슬롯(맥북) — sampling 은 triage 것 유지(결정성).
deep_triage_cfg = None
if use_deep and settings.ai.deep is not None:
deep_triage_cfg = settings.ai.deep.model_copy(update={
"temperature": settings.ai.triage.temperature,
"top_p": settings.ai.triage.top_p,
"max_tokens": settings.ai.triage.max_tokens,
})
# 입력이 triage 한도 초과면 호출 생략하고 long_context 로 escalate
if input_chars > TRIAGE_TEXT_LIMIT:
@@ -590,7 +666,14 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
prompt = rendered.replace("{extracted_text}", text[:TRIAGE_TEXT_LIMIT])
try:
raw_triage = await client.call_triage(prompt)
if deep_triage_cfg is not None:
# drain 전용 — deep 슬롯 endpoint + triage sampling. 맥북 불가(StageDeferred)
# 는 아래 generic except 에 먹히지 않게 먼저 전파.
raw_triage = await call_deep_or_defer(client, prompt, cfg=deep_triage_cfg)
else:
raw_triage = await client.call_triage(prompt)
except StageDeferred:
raise # drain 이 attempts 미소모 + 백오프로 처리 (sleep-안전)
except Exception as exc:
logger.warning(
"[triage] 4B 호출 실패 id=%s type=%s repr=%r",
@@ -656,6 +739,7 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
escalation_reason=escalation_reason,
parse_error=parse_error,
routing_decision=routing_decision,
model_name=(deep_triage_cfg.model if deep_triage_cfg is not None else None),
)
@@ -670,6 +754,7 @@ async def _apply_triage_result(
escalation_reason: str | None,
parse_error: str | None,
routing_decision=None,
model_name: str | None = None, # fair-share: 실제 호출 경로 모델 (None=triage 기본)
) -> None:
"""TriageOutput → Document 필드 + R2 suppression + envelope enqueue + audit.
@@ -760,7 +845,7 @@ async def _apply_triage_result(
layers_returned=["tldr", "bullets"] if not parse_error else [],
cached=False,
latency_ms=latency_ms,
model_name=settings.ai.triage.model,
model_name=(model_name or settings.ai.triage.model),
prompt_version=(f"{SUMMARY_TRIAGE_TASK}@{pv}" if pv else SUMMARY_TRIAGE_TASK),
error_code=parse_error,
source="document_server",
+12 -1
View File
@@ -202,7 +202,12 @@ async def _ingest_pdf(session, page_slug: str, pdf_url: str) -> bool:
import_source="csb_sitemap",
edit_url=pdf_url,
ai_tags=["Safety/CSB/보고서"],
extract_meta={"csb": {"page_slug": page_slug, "kind": "report_pdf"}},
# 안전 자료실 A-2 — ingest 시점 deterministic. CSB = 미 연방기관 = public domain.
material_type="incident",
jurisdiction="US",
extract_meta={"csb": {"page_slug": page_slug, "kind": "report_pdf"},
"license": {"scheme": "public_domain", "redistribute": True,
"attribution": "U.S. Chemical Safety Board"}},
)
session.add(doc)
await session.flush()
@@ -290,10 +295,16 @@ async def _ingest_url(session, source: NewsSource, url: str, lastmod: datetime)
ai_domain="Safety",
ai_sub_group=_SOURCE_NAME,
ai_tags=["Safety/CSB"],
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
material_type="incident",
jurisdiction="US",
published_date=lastmod.date() if lastmod else None,
extract_meta={
"source_id": source.id,
"source_name": _SOURCE_NAME,
"published_at": lastmod.isoformat(),
"license": {"scheme": "public_domain", "redistribute": True,
"attribution": "U.S. Chemical Safety Board"},
"fulltext": {
"status": "csb_sitemap",
"engine": engine,
+27 -7
View File
@@ -54,8 +54,18 @@ class DeepSummaryOutput(BaseModel):
confidence: float = 0.5
async def process(document_id: int, session: AsyncSession) -> None:
"""deep_summary 큐 pickup → 26B 호출 → 필드 저장."""
async def process(
document_id: int, session: AsyncSession, *, defer_on_deep_unavailable: bool = False
) -> None:
"""deep_summary 큐 pickup → LLM 호출 → 필드 저장.
defer_on_deep_unavailable:
False (기본, consumer 경로) = 맥북(deep 슬롯) 우선 시도, 불가 즉시
맥미니 primary 처리. 2026-06-12 fair-share: 머신이 동일 모델
(Qwen3.6-27B-6bit)이라 폴백 = 품질 강등이 아니라 단순 분배.
True (queue_drain 전용) = 맥북 불가를 StageDeferred 올려 drain
보류 run 멈춘다 (drain = 맥북 분담 전용 레버 시멘틱 유지).
"""
doc = await session.get(Document, document_id)
if not doc:
raise ValueError(f"deep_summary: document id={document_id} 없음")
@@ -111,16 +121,26 @@ async def process(document_id: int, session: AsyncSession) -> None:
try:
start = time.perf_counter()
if deep_cfg is not None:
# 맥북 경유 — 맥미니 mlx gate 미점유(게이트는 맥미니 보호 목적). 맥북 불가
# (503/연결/생성 중 sleep 절단)는 StageDeferred = 보류, 맥미니 강등 없음.
# doc 쓰기는 완주+파싱 후에만 일어나므로 어느 시점에 끊겨도 부분 쓰기 0.
raw = await call_deep_or_defer(client, prompt)
# 맥북 우선 — 맥미니 mlx gate 미점유(별 endpoint). doc 쓰기는 완주+파싱
# 후에만 일어나므로 어느 시점에 끊겨도 부분 쓰기 0.
try:
raw = await call_deep_or_defer(client, prompt)
except StageDeferred:
if defer_on_deep_unavailable:
raise # drain 전용 — 맥북 레버 시멘틱 (보류 후 run 종료)
# consumer 경로: 동일 모델이라 강등 아님 — 맥미니가 즉시 처리 (2026-06-12)
logger.info(
f"[deep] id={document_id} 맥북 불가 → 맥미니 primary 처리 (fair-share)"
)
used_cfg = settings.ai.primary
async with acquire_mlx_gate(Priority.BACKGROUND):
raw = await client.call_primary(prompt)
else:
async with acquire_mlx_gate(Priority.BACKGROUND): # 2026-05-17 B-1: classify-escalate worker
raw = await client.call_primary(prompt)
latency_ms = int((time.perf_counter() - start) * 1000)
except StageDeferred:
# 보류는 실패가 아님 — analyze_event 미기록(가짜 완료 방지), consumer 가 백오프 기록.
# 보류는 실패가 아님 — analyze_event 미기록(가짜 완료 방지), drain 이 백오프 기록.
logger.info(f"[deep] id={document_id} 맥북 일시 불가 — 보류 (deferred)")
raise
except Exception as exc:
+4
View File
@@ -10,6 +10,7 @@ global_digests / digest_topics 테이블에 저장한다.
import asyncio
from core.config import settings
from core.utils import setup_logger
from services.digest.pipeline import run_digest_pipeline
@@ -24,6 +25,9 @@ async def run() -> None:
pipeline 자체는 timeout 으로 감싸지 않음 (per-call timeout summarizer 처리).
여기서는 전체 hard cap 강제.
"""
if "digest" in settings.pipeline_held_stages:
logger.info("[global_digest] 보류 (pipeline.held_stages) — 이번 실행 skip")
return
try:
result = await asyncio.wait_for(
run_digest_pipeline(),
+38
View File
@@ -58,6 +58,23 @@ SCAN_TARGETS: list[tuple[str, str | None]] = [
("Videos", "video"),
]
# 안전 자료실 A-2/B-4 — watch 타깃별 (material_type, jurisdiction, license) deterministic 축.
# 키 = 타깃 경로의 마지막 성분. license = extract_meta.license 에 그대로 주입(None=미주입).
# restricted=true → retrieval_service._license_sql 가 RAG 증거·digest 에서 제외(a안 U-2① —
# 구매자료 verbatim span 차단, 색인 자체는 허용. 개인 파일 열람은 미차단).
# 사용자 결정(2026-06-13): Books/Papers=proprietary+restricted / Manuals=proprietary·restricted=false
# (검색·RAG 활용) / KGS=법정 위임 상세기준 law/KR·KOGL 공공·restricted 아님.
_TARGET_AXIS: dict[str, tuple[str, str | None, dict | None]] = {
"KGS_Code": ("law", "KR", {"scheme": "kogl", "redistribute": True,
"restricted": False, "attribution": "한국가스안전공사(KGS)"}),
"Books": ("book", None, {"scheme": "proprietary", "redistribute": False,
"restricted": True, "attribution": "구매 도서"}),
"Papers_Purchased": ("paper", None, {"scheme": "proprietary", "redistribute": False,
"restricted": True, "attribution": "구매 논문"}),
"Manuals": ("manual", None, {"scheme": "proprietary", "redistribute": False,
"restricted": False, "attribution": "기술 매뉴얼"}),
}
def should_skip(path: Path) -> bool:
if path.name in SKIP_NAMES or path.name.startswith("._"):
@@ -242,6 +259,11 @@ async def watch_inbox():
if not scan_root.exists():
continue
# 안전 자료실 A-2/B-4 — 타깃 폴더 기반 (material, jurisdiction, license)
target_mt, target_jur, target_license = _TARGET_AXIS.get(
Path(sub).name, (None, None, None)
)
for file_path in scan_root.rglob("*"):
if not file_path.is_file() or should_skip(file_path):
continue
@@ -275,7 +297,14 @@ async def watch_inbox():
source_channel="drive_sync",
category=category,
needs_conversion=needs_conversion,
# 안전 자료실 A-2/B-4 — watch 타깃 매핑 (KGS=law/KR 등, 비대상=NULL)
material_type=target_mt,
jurisdiction=target_jur,
)
# B-4 — 타깃 폴더 license 주입(restricted 포함, 비대상=미주입). classify 는
# material_type IS NULL 일 때만 제안 + extract_meta 미기록이라 주입 보존.
if target_license:
doc.extract_meta = {"license": dict(target_license)}
session.add(doc)
await session.flush()
@@ -291,6 +320,15 @@ async def watch_inbox():
existing.category = category
if needs_conversion and not getattr(existing, "needs_conversion", False):
existing.needs_conversion = True
# B-4 — 축/license 보정(B-4 이전 적재분이 재변경 시): material 미설정 시 주입,
# license 부재 시에만 merge 주입(clobber 회피 — 기존 extract_meta 키 보존).
if existing.material_type is None and target_mt is not None:
existing.material_type = target_mt
existing.jurisdiction = target_jur
if target_license and not (existing.extract_meta or {}).get("license"):
meta = dict(existing.extract_meta or {})
meta["license"] = dict(target_license)
existing.extract_meta = meta
if next_stage:
await enqueue_stage(session, existing.id, next_stage)
+141 -4
View File
@@ -1,14 +1,17 @@
"""C-2 KOSHA Open API 수집 워커 (plan crawl-24x7-1).
3 API (2026-06-10 실키 live 검증 + fixture 박제 tests/fixtures/kosha_*_response.json):
4 API (2026-06-10/06-13 실키 live 검증 + fixture 박제 tests/fixtures/kosha_*_response.json):
재해사례 게시판: GET /B552468/disaster_api02/getdisaster_api02 callApiId=1060
재해사례 첨부: GET /B552468/disaster_attach_api02/Disaster_attach_api02 callApiId=1070
KOSHA GUIDE: GET /B552468/koshaguide/getKoshaGuide callApiId=1050
사망사고 속보: GET /B552468/news_api02/getNews_api02 callApiId=1040
daily 스케줄 1 (main.py):
재해사례 = 최근 페이지만 diff (boardno dedup) 사례 본문 Document(텍스트 네이티브)
+ 첨부 PDF/HWP 다운로드 /documents/crawl_raw/kosha/{boardno}/ 저장
파일 Document + extract enqueue (kordoc HWP/PDF 기존 파이프라인 재사용).
사망사고 = 최근 페이지만 diff (arno dedup) 속보 본문 Document(HTML _clean_html).
첨부 API 없음·business 필드 없음. 등록일 = arno 접두 8자리(YYYYMMDD).
GUIDE = 전체 레지스트리 메타 diff (1039, 100/page = 11 call) 신규/개정만,
일일 ingest cap(기본 25) = backlog 자동 점진 백필(~6) + 부하 평탄화.
cap 으로 미처리 잔량은 매회 로그 (silent cap 금지).
@@ -23,7 +26,7 @@ import hashlib
import os
import random
import re
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from pathlib import Path
import httpx
@@ -38,6 +41,7 @@ from models.news_source import NewsSource
from models.queue import enqueue_stage
from workers.news_collector import (
FeedError,
_clean_html,
_get_or_create_health,
_record_failure,
_record_success,
@@ -49,17 +53,36 @@ _BASE = "https://apis.data.go.kr/B552468"
_BOARD_EP = f"{_BASE}/disaster_api02/getdisaster_api02"
_ATTACH_EP = f"{_BASE}/disaster_attach_api02/Disaster_attach_api02"
_GUIDE_EP = f"{_BASE}/koshaguide/getKoshaGuide"
_FATAL_EP = f"{_BASE}/news_api02/getNews_api02"
_CASE_SOURCE = "KOSHA 재해사례"
_GUIDE_SOURCE = "KOSHA GUIDE"
_FATAL_SOURCE = "KOSHA 사망사고"
_CASE_PAGES = 2 # daily diff 범위 (30×2 = 최근 60건 — 등록일 역순 API)
_CASE_ROWS = 30
_FATAL_PAGES = 2 # 사망사고 속보 daily diff (30×2 = 최근 60건 — 등록일 역순)
_FATAL_ROWS = 30
_GUIDE_ROWS = 100
_GUIDE_DAILY_CAP = int(os.getenv("KOSHA_GUIDE_DAILY_CAP", "25"))
_MAX_FILE_BYTES = 50 * 1024 * 1024
_DOWNLOAD_DELAY = (2.0, 5.0) # portal.kosha.or.kr 파일서버 — 연속 다운로드 간격
# 안전 자료실 A-2 — KOSHA 산출물 라이선스 (KOGL 유형 미확정 → 보수적 redistribute=False,
# 근거 확보 시 완화. 0-3 license 메타 deterministic 주입).
_KOSHA_LICENSE = {"scheme": "kogl", "redistribute": False, "attribution": "한국산업안전보건공단(KOSHA)"}
def _ymd_to_date(ymd: str | None) -> date | None:
"""'YYYYMMDD'/'YYYY-MM-DD' → date. 형식 불일치는 None (fail-quiet — 날짜는 보조 축)."""
digits = re.sub(r"\D", "", ymd or "")
if len(digits) != 8:
return None
try:
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
except ValueError:
return None
def _api_key() -> str:
key = os.getenv("KOSHA_API_KEY", "")
@@ -93,6 +116,29 @@ def _items(payload: dict) -> list[dict]:
return [item] if isinstance(item, dict) else list(item)
def _fatal_fields(item: dict) -> dict | None:
"""사망사고 item(arno/keyword/contents 3필드 고정) → Document 필드 매핑.
순수 함수(httpx/DB 불요 fixture 단위 테스트 대상). 필수 = arno+keyword,
부재 None(skip). 날짜 전용 필드가 없어 등록 식별자 arno 접두에서 유도:
arno = 'YYYYMMDDHHMMSS' + 임의 6 (2019~ 라이브 전수 동형 검증). 접두 8자리=KST
등록일 published_date, 14자리=등록시각 reg_dt(원문 그대로, tz 해석 미주장).
"""
arno = str(item.get("arno") or "").strip()
title = (item.get("keyword") or "").strip()
if not arno or not title:
return None
text = _clean_html(item.get("contents") or "", max_len=None)
reg_dt = arno[:14] if re.fullmatch(r"\d{14}", arno[:14]) else None
return {
"arno": arno,
"title": title,
"text": text,
"published_date": _ymd_to_date(arno[:8]),
"reg_dt": reg_dt,
}
def _safe_filename(name: str) -> str:
"""NAS 파일명 정화 — 경로분리자/제어문자/공백연쇄 제거 (쉘 함정 회피)."""
name = re.sub(r"[/\\\x00-\x1f]", "_", name).strip()
@@ -155,7 +201,11 @@ async def _ingest_attachment(session, boardno: str, filenm: str, filepath: str)
import_source="kosha_api",
edit_url=filepath,
ai_tags=["Safety/KOSHA재해사례/첨부"],
extract_meta={"kosha": {"boardno": boardno, "kind": "case_attachment"}},
# 안전 자료실 A-2 — ingest 시점 deterministic (classify 경유해도 LLM 비의존)
material_type="incident",
jurisdiction="KR",
extract_meta={"kosha": {"boardno": boardno, "kind": "case_attachment"},
"license": dict(_KOSHA_LICENSE)},
)
session.add(doc)
await session.flush()
@@ -213,12 +263,16 @@ async def collect_disaster_cases(session) -> int:
ai_domain="Safety",
ai_sub_group=_CASE_SOURCE,
ai_tags=[f"Safety/KOSHA재해사례/{business or '기타'}"],
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
material_type="incident",
jurisdiction="KR",
extract_meta={
"source_id": source.id,
"source_name": _CASE_SOURCE,
"published_at": None,
"kosha": {"boardno": boardno, "business": business,
"atcflcnt": item.get("atcflcnt")},
"license": dict(_KOSHA_LICENSE),
},
)
session.add(doc)
@@ -250,6 +304,83 @@ async def collect_disaster_cases(session) -> int:
return new_count
async def collect_fatal_accidents(session) -> int:
"""사망사고 속보 daily diff — 최근 _FATAL_PAGES 페이지, arno dedup.
재해사례(1060) 채널(1040): business 필드·첨부 API 없음, contents=HTML.
본문 = 텍스트 네이티브(_clean_html) md 변환 비대상, summarize/embed/chunk .
"""
key = _api_key()
source = await _get_or_create_source(session, _FATAL_SOURCE, _FATAL_EP)
new_count = 0
for page in range(1, _FATAL_PAGES + 1):
payload = await _api_get(
f"{_FATAL_EP}?serviceKey={key}&callApiId=1040&pageNo={page}&numOfRows={_FATAL_ROWS}"
)
items = _items(payload)
if not items:
break
page_all_dup = True
for item in items:
fields = _fatal_fields(item)
if fields is None:
continue
arno = fields["arno"]
fhash = hashlib.sha256(f"kosha-fatal|{arno}".encode()).hexdigest()[:32]
existing = await session.execute(
select(Document).where(Document.file_hash == fhash).limit(1)
)
if existing.scalars().first():
continue
page_all_dup = False
text = fields["text"]
now = datetime.now(timezone.utc)
doc = Document(
file_path=f"crawl/{_FATAL_SOURCE}/{arno}",
file_hash=fhash,
file_format="article",
file_size=len(text.encode()),
file_type="note",
title=fields["title"],
extracted_text=f"{fields['title']}\n\n{text}",
extracted_at=now,
extractor_version="kosha_api",
md_status="skipped",
md_extraction_error="kosha fatal: 텍스트 네이티브, markdown 변환 비대상",
source_channel="crawl",
data_origin="external",
review_status="approved",
ai_domain="Safety",
ai_sub_group=_FATAL_SOURCE,
ai_tags=["Safety/KOSHA사망사고"],
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
material_type="incident",
jurisdiction="KR",
published_date=fields["published_date"],
extract_meta={
"source_id": source.id,
"source_name": _FATAL_SOURCE,
"published_at": None,
"kosha": {"arno": arno, "kind": "fatal_accident",
"reg_dt": fields["reg_dt"]},
"license": dict(_KOSHA_LICENSE),
},
)
session.add(doc)
await session.flush()
await enqueue_stage(session, doc.id, "summarize")
await enqueue_stage(session, doc.id, "embed")
await enqueue_stage(session, doc.id, "chunk")
new_count += 1
if page_all_dup:
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
logger.info(f"[kosha] 사망사고 신규 {new_count}")
return new_count
async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
"""GUIDE 레지스트리 전체 메타 diff → 신규/개정만 다운로드 (일일 cap 점진 백필)."""
key = _api_key()
@@ -307,8 +438,13 @@ async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
import_source="kosha_api",
edit_url=spec["url"],
ai_tags=["Safety/KOSHA GUIDE"],
# 안전 자료실 A-2 — GUIDE = 구속력 없는 권고 기술지침 (law 아님, plan 0-1)
material_type="guide",
jurisdiction="KR",
published_date=_ymd_to_date(spec["ymd"]),
extract_meta={"kosha": {"kind": "guide", "techGdlnNo": spec["no"],
"ofancYmd": spec["ymd"]}},
"ofancYmd": spec["ymd"]},
"license": dict(_KOSHA_LICENSE)},
)
session.add(doc)
await session.flush()
@@ -325,6 +461,7 @@ async def run() -> None:
"""daily 1회 — 소스별 실패 격리 (재해사례 실패가 GUIDE 를 막지 않게)."""
now = datetime.now(timezone.utc)
for name, collector in ((_CASE_SOURCE, collect_disaster_cases),
(_FATAL_SOURCE, collect_fatal_accidents),
(_GUIDE_SOURCE, collect_kosha_guide)):
async with async_session() as session:
result = await session.execute(select(NewsSource).where(NewsSource.name == name))
+18 -1
View File
@@ -6,7 +6,7 @@
import os
import re
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from pathlib import Path
from xml.etree import ElementTree as ET
@@ -262,6 +262,16 @@ async def _save_law_split(
f"개정구분: {revision_type}"
)
# 안전 자료실 A-2 — 공포일 파싱 (law published_date = COALESCE(시행일, 공포일) 계약,
# 본 레거시 워커는 공포일만 보유 — 시행일 기반 버전 체인은 B-1 statute_collector 소관)
_digits = re.sub(r"\D", "", str(proclamation_date or ""))
pub_date = None
if len(_digits) == 8:
try:
pub_date = date(int(_digits[:4]), int(_digits[4:6]), int(_digits[6:8]))
except ValueError:
pub_date = None
doc = Document(
file_path=rel_path,
file_hash=file_hash(file_path),
@@ -272,6 +282,13 @@ async def _save_law_split(
source_channel="law_monitor",
data_origin="work",
category="law",
# 안전 자료실 A-2 — ingest 시점 deterministic. 법령 텍스트 = 저작권법 제7조
# 비보호 저작물 (public domain). 본 워커는 휴면(LAW_OC 미설정)이나 코드 경로 유지.
material_type="law",
jurisdiction="KR",
published_date=pub_date,
extract_meta={"license": {"scheme": "public_domain", "redistribute": True,
"attribution": "국가법령정보센터"}},
user_note=note or None,
)
session.add(doc)
+42 -1
View File
@@ -341,11 +341,35 @@ def _entry_body(source: NewsSource, entry, summary: str) -> tuple[str, str]:
def _build_extract_meta(source: NewsSource, pub_dt: datetime) -> dict:
"""fulltext_worker / 패널이 쓰는 출처 메타 (documents 에 source FK 가 없어 여기 기록)."""
return {
meta = {
"source_id": source.id,
"source_name": source.name,
"published_at": pub_dt.isoformat(),
}
# 안전 자료실 A-2: 소스 레지스트리의 라이선스를 deterministic 주입 (0-3 license 메타).
# P3 다이제스트/발행류가 redistribute=false 소스를 구조적으로 제외하는 게이트 입력.
if source.license_scheme:
meta["license"] = {
"scheme": source.license_scheme,
"redistribute": bool(source.license_redistribute),
"attribution": source.name,
}
return meta
def _material_axis(source: NewsSource) -> tuple[str | None, str | None]:
"""안전 자료실 분류 축 (material_type, jurisdiction) — 레지스트리 deterministic.
- material_type = news_sources.material_type (NULL = 비대상, 뉴스/철학 )
- jurisdiction = source.country 전파. paper NULL 강제
(국제 학술지에 관할 개념 부적합 plan 0-1 계약. 레지스트리 country=US 여도 미전파).
"""
mt = source.material_type
if not mt:
return None, None
if mt == "paper":
return mt, None
return mt, source.country
def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
@@ -354,17 +378,22 @@ def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
file_path 접두사가 채널 디렉토리. ai_domain 다이제스트/검색 필터의 분기 축이라
crawl 채널이 'News' 오염시키지 않게 분리 (0-5 채널 레벨 분리 사상).
"""
material_type, jurisdiction = _material_axis(source)
if source.source_channel == "crawl":
domain = category if category and category != "Other" else "Domain"
return {
"path_prefix": "crawl",
"ai_domain": domain,
"ai_tags": [f"{domain}/{source_short}"],
"material_type": material_type,
"jurisdiction": jurisdiction,
}
return {
"path_prefix": "news",
"ai_domain": "News",
"ai_tags": [f"News/{source_short}/{category}"],
"material_type": material_type,
"jurisdiction": jurisdiction,
}
@@ -528,6 +557,10 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
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),
)
session.add(doc)
@@ -661,6 +694,10 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
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),
)
session.add(doc)
@@ -757,6 +794,10 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
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),
)
session.add(doc)
+393
View File
@@ -0,0 +1,393 @@
"""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
cursor = "*"
max_pages = (10**6 if bulk else _MAX_PAGES_PER_KW)
try:
for _page in range(max_pages):
if inserted >= run_cap:
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:
break
await asyncio.sleep(_REQ_SLEEP)
if not next_cursor:
break
cursor = next_cursor
if newest:
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
@@ -0,0 +1,142 @@
"""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()
+46 -3
View File
@@ -13,18 +13,25 @@ from sqlalchemy import select, update, delete, exists
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import aliased
from core.config import settings
from core.database import async_session
from core.utils import setup_logger
from models.queue import ProcessingQueue, StageDeferred, enqueue_stage, not_deferred_condition
logger = setup_logger("queue_consumer")
# pipeline.held_stages 안내 로그는 1분 사이클마다 반복하지 않고 최초 1회만.
_hold_logged = False
# stage별 배치 크기
# stt 는 GPU 단일 점유 + 회의 30분짜리도 가능 → 배치 1. thumbnail 은 ffmpeg subprocess 로 가벼움.
# deep_summary (PR-B B-1) 는 MLX 26B 단일 Semaphore(1) 경유 → 배치 1.
# fulltext 는 politeness 지연(같은 도메인 5–15s)이 배치 내 직렬로 걸린다 — 배치 3 이면
# 같은 도메인 최악 ~45s/사이클, 메인 큐 1m 간격(max_instances=1, coalesce)이 흡수.
BATCH_SIZE = {"extract": 5, "classify": 3, "summarize": 3, "embed": 1, "chunk": 1,
# embed/chunk 1→10 (2026-06-12 fast-consumer): 건당 <1s 실측 — Phase 0.1 초기 보수값이
# LLM 사이클에 인질로 잡혀 실효 ~580/일 vs 수요 최대 2,700/일 → 적체 원인이었음.
# 10 = TEI/marker 와 GPU 공유 고려한 보수 상향(전용 1분 잡 기준 캡 ~14,400/일).
BATCH_SIZE = {"extract": 5, "classify": 3, "summarize": 3, "embed": 10, "chunk": 10,
"preview": 2, "stt": 1, "thumbnail": 3, "deep_summary": 1, "markdown": 1,
"fulltext": 3}
STALE_THRESHOLD_MINUTES = 10
@@ -34,14 +41,21 @@ STALE_THRESHOLD_MINUTES = 10
# 따라서 markdown consumer 는 별도의 generous 임계를 쓴다.
MARKDOWN_STALE_THRESHOLD_MINUTES = int(os.getenv("MARKDOWN_STALE_MINUTES", "120"))
# consume_queue(메인) 가 담당하는 stage. markdown 은 consume_markdown_queue 로 분리.
# consume_queue(메인) 가 담당하는 stage. markdown 은 consume_markdown_queue,
# embed/chunk 는 consume_fast_queue (2026-06-12) 로 분리 — 세 집합은 disjoint
# (reset_stale_items 가 자기 집합만 reset, 교차 시 이중 복구 위험).
# STT 도 장기 작업 가능성이 있으나 본 PR 범위 밖 — main 에 유지(follow-up).
MAIN_QUEUE_STAGES = [
"extract", "classify", "summarize", "embed", "chunk",
"extract", "classify", "summarize",
"preview", "stt", "thumbnail", "deep_summary", "fulltext",
]
MARKDOWN_QUEUE_STAGES = ["markdown"]
# 고속(비-LLM·경량 GPU) stage — LLM 사이클(분 단위)에서 분리해 1분 잡 전용 소비.
# embed/chunk 는 건당 <1s 라 main 루프에 두면 classify(~190s×3) 뒤에서 굶는다
# (2026-06-12 실측: 적체 3,570 · 4070 가동률 0%). markdown 분리(05-01)와 동일 패턴.
FAST_QUEUE_STAGES = ["embed", "chunk"]
async def reset_stale_items(stages, threshold_minutes=STALE_THRESHOLD_MINUTES):
"""processing 상태로 오래 방치된 항목 복구 (지정 stage 한정)
@@ -335,14 +349,43 @@ async def _process_stage(stage, worker_fn):
async def consume_queue():
"""메인 큐 소비자 — markdown 제외 전 stage 를 1분 간격으로 처리."""
global _hold_logged
workers = _load_workers()
held = [s for s in MAIN_QUEUE_STAGES if s in settings.pipeline_held_stages]
if held and not _hold_logged:
logger.info(f"pipeline.held_stages 보류 중: {held} — claim 하지 않음 (pending 적체 = 의도)")
_hold_logged = True
try:
await reset_stale_items(MAIN_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
except Exception:
logger.exception("stale reset failed, but continuing queue consumption")
for stage in MAIN_QUEUE_STAGES:
if stage in settings.pipeline_held_stages:
continue
await _process_stage(stage, workers[stage])
async def consume_fast_queue():
"""embed/chunk 전용 고속 소비자 — LLM 사이클과 완전 디커플 (2026-06-12).
main 루프는 classify/summarize/deep 사이클을 단위로 점유해 건당 <1s 짜리
embed/chunk 사이클당 1번씩만 기회를 얻었다 (실효 ~60/ = 적체 원인).
분리 = 1 × 배치 10 ~600/. APScheduler max_instances=1 이라
배치가 1분을 넘으면 다음 fire coalesce (폭주 방지).
"""
workers = _load_workers()
try:
await reset_stale_items(FAST_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
except Exception:
logger.exception("fast stale reset failed, but continuing queue consumption")
for stage in FAST_QUEUE_STAGES:
if stage in settings.pipeline_held_stages:
continue
await _process_stage(stage, workers[stage])
+18 -6
View File
@@ -30,9 +30,12 @@ from models.queue import ProcessingQueue, StageDeferred, not_deferred_condition
logger = setup_logger("queue_drain")
# summarize = 맥미니 백로그 본체 / deep_summary = 심층 (consumer 도 deep 슬롯 시 맥북 경유).
# classify 는 triage 경량 호출이라 맥미니 적합 — 대상에서 제외 (plan Q-4).
DRAIN_STAGES = ("summarize", "deep_summary")
# summarize = 맥미니 백로그 본체 / deep_summary = 심층 / classify = triage 분류.
# classify 는 2026-06-12 fair-share 로 합류 — 구 제외 사유(plan Q-4 "triage 경량 = 맥미니
# 적합")는 Gemma a4b(42 tok/s) 전제. Qwen 27B 전환 후 classify 가 장문 프리필로 컨슈머
# 사이클을 점유하는 최대 병목이라, 맥북(프리필 ~5배)이 가장 효과적인 분담처다.
# classify 완료 시 enqueue_next_stage(embed/chunk/markdown) 필수 — 누락 = DAG 단절.
DRAIN_STAGES = ("summarize", "deep_summary", "classify")
async def _claim_one(stage: str) -> tuple[int, int] | None:
@@ -98,14 +101,16 @@ async def _mark_failed(queue_id: int, exc: Exception) -> None:
async def drain(stage: str, limit: int, defer_retries: int = 5, defer_wait: int = 120) -> None:
if stage not in DRAIN_STAGES:
raise SystemExit(f"--stage 는 {DRAIN_STAGES} 만 허용 (classify 등은 맥미니 적합 — plan Q-4)")
raise SystemExit(f"--stage 는 {DRAIN_STAGES} 만 허용")
if settings.ai.deep is None:
raise SystemExit(
"config.yaml ai.models.deep 슬롯 미구성 — drain 은 맥북 분담 전용 레버라 진행하지 않음"
" (맥미니로의 silent 강등 금지)"
)
from workers.classify_worker import process as classify_process
from workers.deep_summary_worker import process as deep_summary_process
from workers.queue_consumer import enqueue_next_stage
from workers.summarize_worker import process as summarize_process
done = failed = 0
@@ -121,11 +126,18 @@ async def drain(stage: str, limit: int, defer_retries: int = 5, defer_wait: int
async with async_session() as worker_session:
if stage == "summarize":
await summarize_process(document_id, worker_session, use_deep=True)
elif stage == "classify":
await classify_process(document_id, worker_session, use_deep=True)
else:
# deep_summary 는 deep 슬롯 구성 시 워커가 자체적으로 맥북 경유
await deep_summary_process(document_id, worker_session)
# deep_summary: drain 은 맥북 전용 레버 — 불가 시 보류(폴백은 consumer 만)
await deep_summary_process(
document_id, worker_session, defer_on_deep_unavailable=True
)
await worker_session.commit()
await _mark_completed(queue_id)
# 다음 stage 연쇄 — classify 는 embed/chunk/markdown enqueue (consumer 와 동형,
# summarize/deep_summary 는 next_stages 미등록이라 no-op)
await enqueue_next_stage(document_id, stage)
done += 1
consecutive_defers = 0
logger.info(f"[drain:{stage}] {done}/{limit} doc={document_id} 완료")
+43
View File
@@ -0,0 +1,43 @@
"""statute_collector 나라별 어댑터 패키지 (plan safety-library-1 B-1).
어댑터 계약 (2함수 + 상수):
JURISDICTION: str 어댑터 상수 고정. 코어가 적재 직전 assert (파싱 결과 추론 금지).
poll_changes(client, watch_rows) -> list[ChangeEvent] 개정 감지만 (경량 호출).
fetch_version(client, act, change) -> list[VersionPayload] PR②.
payload 리스트: primary + annex 각각 자기 version_key (R4-M4).
ChangeEvent.kind: amend / repeal / bootstrap(합성 PR② 부트스트랩이 amend
동일 ingest 경로 재사용, R6-m2).
"""
from dataclasses import dataclass, field
@dataclass
class ChangeEvent:
"""개정 감지 이벤트 — poll_changes 산출물."""
family_id: str
kind: str # amend / repeal / bootstrap
new_version_key: str # KR = MST (법령일련번호)
title: str
promulgation_date: str | None = None # YYYYMMDD
effective_date: str | None = None # YYYYMMDD (목록 시행일자 — 조문별 차등 시행 주의)
revision_type: str | None = None # 제개정구분명
@dataclass
class VersionPayload:
"""fetch_version 산출물 1건 — primary 또는 annex 각자 자기 version_key (R4-M4).
전문 1 스냅샷 의미론(R7-M3 fixture 판정): 응답에서 primary + annex 전부 생성.
annex version_key = 'MST|{별표번호}-{별표가지번호}' (zero-padded 구조화 필드 그대로
suffix 문자열 파싱 아닌 필드 기반, R7-B1 a 업그레이드).
"""
law_doc_kind: str # primary / annex
version_key: str
title: str
content: str # 조문/별표 markdown 텍스트
promulgation_date: str | None = None # YYYYMMDD (본문 기본정보)
effective_date: str | None = None # YYYYMMDD (본문 기본정보 — 목록값과 다를 수 있음)
annex_label: str | None = None # '별표1' / '별표5의2' (표시용)
meta: dict = field(default_factory=dict)
+213
View File
@@ -0,0 +1,213 @@
"""KR 법령 어댑터 — 국가법령정보센터 (law.go.kr DRF) (plan safety-library-1 B-1 PR①).
poll_changes = lawSearch 목록 diff: 워치리스트 행별 정식 법령명 exact 조회
MST(법령일련번호) != watermark 이면 ChangeEvent. law_monitor 검증된 호출 형태 재사용.
fixture (2026-06-13 라이브 박제, tests/fixtures/statute_kr/):
- lawsearch_*.xml 목록 필드: 법령ID(불변)·법령일련번호(MST)·공포일자·시행일자·제개정구분명
- lawservice_*.xml.gz 전문 1 XML: 조문단위 853(산안기준규칙) + 별표단위 23 전부 포함
= 스냅샷 의미론 확정(R7-M3 : annex 부분 fetch 실패 개념 없음 같은 응답에 없는
별표 = 삭제 간주 가능). 별표번호+별표가지번호 = 구조화 필드(R7-M3 suffix 문자열
파싱 불요, version_key 합성은 필드 기반. PR② fetch_version 소관).
- 조문 취득 방식 판정(R2-m1): 전문 1 + 로컬 파싱 확정 lawjosub 단위 호출이면
산안기준규칙(853) 개정당 호출 폭증. lawjosub fixture 보조 박제.
주의: 응답의 '법령상세링크' 필드에 OC 키가 포함됨 fixture/로그에 raw 응답을 남길
새니타이즈 필수 (repo fixture __OC_REDACTED__ 처리됨).
"""
import asyncio
import os
import xml.etree.ElementTree as ET
import httpx
from core.crawl_politeness import CRAWL_UA
from core.utils import setup_logger
from workers.statute_adapters import ChangeEvent, VersionPayload
logger = setup_logger("statute_kr")
JURISDICTION = "KR"
SOURCE_API = "law.go.kr"
LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
# 같은 도메인 연속 호출 간격 (일 1회 x 26콜 — 보수적)
_POLL_DELAY_S = 1.5
def _oc() -> str:
oc = os.getenv("LAW_OC", "")
if not oc:
raise RuntimeError("LAW_OC 미설정 — statute KR 어댑터 사용 불가")
return oc
def parse_search_hit(xml_text: str, official_title: str) -> dict | None:
"""lawSearch XML 에서 정식 법령명 exact match 1건 추출 (순수 함수 — fixture 테스트 대상).
정식명 기준 exact match 워치리스트 title 정식명(가운뎃점 포함)이므로 안전.
(law_monitor 하드코딩 '유해위험작업...'( 없음) 영구 미매칭이던 함정의 교훈:
조회 키는 반드시 레지스트리의 정식명을 쓴다.)
"""
root = ET.fromstring(xml_text)
for law in root.findall(".//law"):
if (law.findtext("법령명한글") or "").strip() != official_title:
continue
mst = (law.findtext("법령일련번호") or "").strip()
if not mst:
continue
return {
"mst": mst,
"law_id": (law.findtext("법령ID") or "").strip(),
"promulgation_date": (law.findtext("공포일자") or "").strip() or None,
"effective_date": (law.findtext("시행일자") or "").strip() or None,
"revision_type": (law.findtext("제개정구분명") or "").strip() or None,
"status_code": (law.findtext("현행연혁코드") or "").strip() or None,
}
return None
def detect_change(hit: dict | None, act_family_id: str, act_title: str,
watermark: str | None) -> ChangeEvent | None:
"""목록 hit + 워터마크 → ChangeEvent (순수 함수 — fixture 테스트 대상).
- hit 없음 = 감지 불가 (None 호출측이 fail-loud 로그. 폐지 단정 금지:
검색 누락/표기 변경 가능성과 구분 불가하므로 repeal 제개정구분명 기준만)
- MST == watermark = 변경 없음
- 제개정구분명에 '폐지' = repeal, = amend
"""
if hit is None:
return None
if watermark and hit["mst"] == watermark:
return None
kind = "repeal" if (hit.get("revision_type") or "").find("폐지") >= 0 else "amend"
return ChangeEvent(
family_id=act_family_id,
kind=kind,
new_version_key=hit["mst"],
title=act_title,
promulgation_date=hit.get("promulgation_date"),
effective_date=hit.get("effective_date"),
revision_type=hit.get("revision_type"),
)
def _article_markdown(art: ET.Element) -> str:
"""조문단위 1건 → 텍스트. 조문내용(이미 '제N조(제목) ...' 형태) + 항/호/목 전체.
메타 필드(조문번호/조문여부/조문시행일자 ) 제외 조문내용과 서브트리만.
"""
parts = []
body = (art.findtext("조문내용") or "").strip()
if body:
parts.append(body)
for hang in art.findall(""):
text = "\n".join(t.strip() for t in hang.itertext() if t.strip())
if text:
parts.append(text)
return "\n".join(parts)
def parse_service_payloads(xml_text: str, official_title: str, mst: str) -> list[VersionPayload]:
"""lawService 전문 XML → VersionPayload 리스트 (순수 함수 — fixture 테스트 대상).
스냅샷 의미론: 응답에 있는 별표가 버전의 별표 전체 (R7-M3 fixture 판정).
- primary 1: 조문 markdown (조문여부 != '조문' = / 헤더 '## ' 처리)
- annex N건: 별표단위별 version_key = 'MST|{별표번호}-{가지번호}' (zero-padded 그대로)
"""
root = ET.fromstring(xml_text)
base = root.find(".//기본정보")
prom = (base.findtext("공포일자") or "").strip() or None if base is not None else None
eff = (base.findtext("시행일자") or "").strip() or None if base is not None else None
lines: list[str] = [f"# {official_title}", ""]
for art in root.findall(".//조문단위"):
is_article = (art.findtext("조문여부") or "").strip() == "조문"
text = _article_markdown(art)
if not text:
continue
if is_article:
lines.append(f"### {text}" if not text.startswith("") else text)
else:
lines.append(f"## {text}")
lines.append("")
primary_content = "\n".join(lines).strip()
payloads = [VersionPayload(
law_doc_kind="primary",
version_key=mst,
title=official_title,
content=primary_content,
promulgation_date=prom,
effective_date=eff,
)]
for annex in root.findall(".//별표단위"):
no = (annex.findtext("별표번호") or "").strip()
sub = (annex.findtext("별표가지번호") or "").strip() or "00"
kind = (annex.findtext("별표구분") or "별표").strip() # 별표 / 서식 — 별도 차원!
a_title = (annex.findtext("별표제목") or "").strip()
a_body = (annex.findtext("별표내용") or "").strip()
if not no:
continue
# 삭제 tombstone — KR 은 별표/서식 삭제가 absence 가 아니라 '삭제 <날짜>' 명시 행
# (fixture 실측: 산안기준규칙 서식1·2). 내용 없는 tombstone 은 적재 skip.
# 시리즈의 구버전 current 잔존 처리 = PR③ 관찰 후보 (absence 추론은 불요 확정).
if a_title.startswith("삭제") and len(a_body) < 50:
continue
label = f"{kind}{int(no)}" + (f"{int(sub)}" if sub not in ("", "0", "00") else "")
payloads.append(VersionPayload(
law_doc_kind="annex",
# 구분 차원 포함 — (번호,가지)만으로는 별표1 vs 서식1 충돌 (fixture 실측)
version_key=f"{mst}|{kind}{no}-{sub}",
title=f"{official_title} {label} {a_title}".strip(),
content=f"# {official_title} {label}\n## {a_title}\n\n{a_body}".strip(),
promulgation_date=prom,
effective_date=eff,
annex_label=label,
))
return payloads
async def fetch_version(client: httpx.AsyncClient, act, change: ChangeEvent) -> list[VersionPayload]:
"""전문 1콜 → payload 리스트 (R2-m1 판정: lawjosub 조 단위 호출 안 함 — 853조 폭증 회피)."""
resp = await client.get(
LAW_SERVICE_URL,
params={"OC": _oc(), "target": "law", "MST": change.new_version_key, "type": "XML"},
headers={"User-Agent": CRAWL_UA},
)
resp.raise_for_status()
payloads = parse_service_payloads(resp.text, act.title, change.new_version_key)
if not payloads or len(payloads[0].content) < 200:
# 파싱 검증 floor — 미달 시 예외 = 워터마크 미영속 (재시도 가능 상태 유지)
raise ValueError(f"전문 파싱 결과 빈약 ({act.family_id}): payloads={len(payloads)}")
return payloads
async def poll_changes(client: httpx.AsyncClient, watch_rows: list) -> list[ChangeEvent]:
"""워치리스트 행별 lawSearch diff. 행 단위 실패 격리 (한 법령 실패가 나머지를 막지 않음)."""
oc = _oc()
events: list[ChangeEvent] = []
for act in watch_rows:
try:
resp = await client.get(
LAW_SEARCH_URL,
params={"OC": oc, "target": "law", "type": "XML", "query": act.title},
headers={"User-Agent": CRAWL_UA},
)
resp.raise_for_status()
hit = parse_search_hit(resp.text, act.title)
if hit is None:
# fail-loud: 정식명 미매칭 = 표기 변경/검색 누락 의심 — 침묵 skip 금지
logger.warning(f"[statute-kr] 목록 미매칭: {act.family_id} {act.title!r}")
else:
ev = detect_change(hit, act.family_id, act.title, act.watermark)
if ev:
events.append(ev)
except Exception as e:
logger.error(f"[statute-kr] poll 실패 ({act.family_id}): {type(e).__name__}: {e!r}")
await asyncio.sleep(_POLL_DELAY_S)
return events
+381
View File
@@ -0,0 +1,381 @@
"""statute_collector — 법령 수집 코어 (plan safety-library-1 B-1, PR②).
구성 ( 코드 통째 R8-B1: 승격과 스윕의 PR 분리 = 배포 이중 노출 윈도):
poll_changes(어댑터) fetch_version(전문 1, payload 리스트) ingest( 버전
pending 적재 + 4 주입) 생애주기 (버전 시리즈 단위 승격·supersede + 상태 기반
레거시 스윕 + repeal 단일 트랜잭션, KST 기준).
핵심 계약 (카드 = 스펙):
- 워터마크 영속 = ingest 파싱 검증 통과 후에만 (실패 다음 폴링이 재감지)
- 승격·supersede 단위 = 버전 시리즈 = (family_id, law_doc_kind, annex 식별자)
R7-B1: family 단위 구현 금지 (annex 승격이 primary 소거하는 본문 소실 경로)
- 레거시 스윕 = 상태 기반: 실행, primary 시리즈 current 보유 + repeal 미감지
family 법령명 매핑 레거시(law_monitor 스냅샷) 청크 in_corpus=false (멱등)
- 매핑 = 정확 일치 가정 금지: title '법령명 (YYYYMMDD)' 패턴에서 법령명 추출
정규화(공백·가운뎃점 변형 흡수) **동등** 비교 prefix 비교 금지 ('산업안전보건법'
'산업안전보건법 시행령' 레거시를 오폭하는 경로 차단)
- ingest 4 (R8-M1): material_type='law' / jurisdiction=어댑터 상수 /
published_date=COALESCE(시행일, 공포일) / license=public_domain(저작권법 제7조)
- 부트스트랩(--bootstrap) = kind='bootstrap' 합성 이벤트, amend 동일 경로 +
extract_meta.backfill=true (E-1 게이트 집계 제외 마커)
- 가시성: source_health 성공/실패 기록 (HC.io 2026-05-30 알림 레이어 폐기로 부재
silent-skip 가드 정신은 crawl-health 보드 + health 행으로 대체)
실행:
스케줄 = daily 07:00 KST (main.py law_monitor 슬롯 승계)
수동 = docker compose exec -T fastapi python -m workers.statute_collector [--bootstrap]
"""
import argparse
import asyncio
import hashlib
import re
import unicodedata
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import select, update
from core.database import async_session
from core.utils import setup_logger
from models.chunk import DocumentChunk
from models.document import Document
from models.legal_act import LegalAct, LegalMeta
from models.news_source import NewsSource
from models.queue import enqueue_stage
from workers.news_collector import _get_or_create_health, _record_failure, _record_success
from workers.statute_adapters import ChangeEvent, VersionPayload
from workers.statute_adapters import kr
logger = setup_logger("statute_collector")
_KST = ZoneInfo("Asia/Seoul")
_SOURCE_NAME = "KR 법령 (law.go.kr)"
_LICENSE = {"scheme": "public_domain", "redistribute": True, "attribution": "국가법령정보센터"}
_FETCH_DELAY_S = 2.5 # lawService 전문(최대 ~1.3MB) 연속 호출 간격
# jurisdiction → 어댑터 모듈 (Phase 1 = KR 단독, 해외는 B-5 게이트 뒤)
_ADAPTERS = {"KR": kr}
# ─── 법령명 매핑 (R8-m1: 정확 일치 가정 금지 — 변형 흡수 정규화 + 동등 비교) ───
_LEGACY_TITLE_RE = re.compile(r"^(.*?)\s*\((\d{8})\)")
def normalize_law_name(name: str) -> str:
"""공백·가운뎃점 변형 흡수 — NFC 정규화 후 공백/ㆍ·・ 제거."""
s = unicodedata.normalize("NFC", name or "")
return re.sub(r"[\sㆍ·・]", "", s)
def legacy_law_name(title: str) -> str | None:
"""레거시 law_monitor title('법령명 (YYYYMMDD) 섹션')에서 법령명 추출."""
m = _LEGACY_TITLE_RE.match(title or "")
return m.group(1).strip() if m else None
def series_suffix(version_key: str) -> str | None:
"""버전 시리즈의 annex 식별자 — version_key 'MST|NNNN-SS''|' 뒤 (primary=None)."""
return version_key.split("|", 1)[1] if "|" in version_key else None
def _to_date(ymd: str | None) -> date | None:
digits = re.sub(r"\D", "", ymd or "")
if len(digits) != 8:
return None
try:
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
except ValueError:
return None
# ─── ingest (전 버전 pending 적재 — R2-B2/R3 계약) ──────────────────────────────
async def _ingest_payload(session, act: LegalAct, ev: ChangeEvent,
payload: VersionPayload, backfill: bool) -> bool:
"""payload 1건 → Document + legal_meta(pending). 반환 = 신규 여부 (dedup 멱등)."""
fhash = hashlib.sha256(
f"statute|{act.jurisdiction}|{act.native_id}|{payload.version_key}".encode()
).hexdigest()[:32]
existing = await session.execute(
select(Document.id).where(Document.file_hash == fhash).limit(1)
)
if existing.scalars().first():
return False
prom = _to_date(payload.promulgation_date or ev.promulgation_date)
eff = _to_date(payload.effective_date or ev.effective_date)
now = datetime.now(timezone.utc)
extra = {"backfill": True} if backfill else {}
doc = Document(
file_path=f"crawl/statute/{act.family_id}/{payload.version_key.replace('|', '_')}",
file_hash=fhash,
file_format="article",
file_size=len(payload.content.encode()),
file_type="note",
title=f"{payload.title} ({payload.promulgation_date or ev.promulgation_date or ''})".strip(),
extracted_text=payload.content,
extracted_at=now,
extractor_version="statute_kr@law.go.kr",
md_status="skipped",
md_extraction_error="statute: 텍스트 네이티브, markdown 변환 비대상",
source_channel="crawl",
data_origin="external",
review_status="approved",
ai_domain="법령",
ai_sub_group=act.title,
ai_tags=[f"법령/KR/{act.title}"],
# 안전 자료실 ingest 4축 (R8-M1 — classify-skip 경로라 ingest 시점 필수)
material_type="law",
jurisdiction=kr.JURISDICTION,
published_date=eff or prom,
extract_meta={
"statute": {"family_id": act.family_id, "law_id": act.native_id,
"kind": payload.law_doc_kind, "version_key": payload.version_key,
"annex_label": payload.annex_label,
"event_kind": ev.kind, "revision_type": ev.revision_type},
"license": dict(_LICENSE),
**extra,
},
)
session.add(doc)
await session.flush()
session.add(LegalMeta(
document_id=doc.id,
family_id=act.family_id,
law_doc_kind=payload.law_doc_kind,
version_key=payload.version_key,
promulgation_date=prom,
effective_date=eff,
version_status="pending", # 전 버전 pending 적재 — 승격은 생애주기 잡만
))
# summarize 안 함 (조문 자체가 정본 — 맥미니 부하 0), embed+chunk 만
await enqueue_stage(session, doc.id, "embed")
await enqueue_stage(session, doc.id, "chunk")
return True
# ─── 생애주기 잡 (전이·supersede·스윕·repeal 의 유일한 코드 지점) ────────────────
async def _flip_chunks(session, doc_ids: list[int]) -> int:
if not doc_ids:
return 0
result = await session.execute(
update(DocumentChunk)
.where(DocumentChunk.doc_id.in_(doc_ids), DocumentChunk.in_corpus.is_(True))
.values(in_corpus=False)
)
return result.rowcount or 0
async def _legacy_doc_ids(session, act: LegalAct) -> list[int]:
"""법령명 매핑 레거시(law_monitor) 문서 id — 정규화 동등 비교 (prefix 금지)."""
result = await session.execute(
select(Document.id, Document.title).where(
Document.source_channel == "law_monitor",
Document.deleted_at.is_(None),
)
)
want = normalize_law_name(act.title)
ids = []
for doc_id, title in result.all():
name = legacy_law_name(title or "")
if name and normalize_law_name(name) == want:
ids.append(doc_id)
return ids
async def run_lifecycle(session) -> dict:
"""일 1회 생애주기 잡 — 호출측이 단일 트랜잭션 commit. KST 기준, 멱등."""
today = datetime.now(_KST).date()
stats = {"promoted": 0, "superseded": 0, "repealed": 0,
"legacy_flipped_docs": 0, "legacy_flipped_chunks": 0}
acts_result = await session.execute(select(LegalAct).where(LegalAct.watch.is_(True)))
acts = {a.family_id: a for a in acts_result.scalars().all()}
lm_result = await session.execute(
select(LegalMeta).where(LegalMeta.family_id.in_(list(acts.keys())))
)
metas = lm_result.scalars().all()
# 1) repeal — 마킹된 family: current+pending 전부 repealed + 청크 flip + 레거시 flip (R7-M2)
repeal_families = {fid for fid, a in acts.items() if a.repeal_detected_at is not None}
for fid in repeal_families:
rows = [m for m in metas if m.family_id == fid and m.version_status in ("pending", "current")]
for m in rows:
m.version_status = "repealed"
stats["repealed"] += 1
await _flip_chunks(session, [m.document_id for m in rows])
legacy_ids = await _legacy_doc_ids(session, acts[fid])
stats["legacy_flipped_chunks"] += await _flip_chunks(session, legacy_ids)
# 2) 승격 + supersede — 버전 시리즈 단위 (R7-B1 a: family 단위 금지)
series: dict[tuple, list[LegalMeta]] = {}
for m in metas:
if m.family_id in repeal_families:
continue
series.setdefault(
(m.family_id, m.law_doc_kind, series_suffix(m.version_key)), []
).append(m)
for key, rows in series.items():
due = sorted(
(m for m in rows if m.version_status == "pending"
and (m.effective_date or m.promulgation_date)
and (m.effective_date or m.promulgation_date) <= today),
key=lambda m: (m.effective_date or m.promulgation_date),
)
for m in due:
prev = [c for c in rows if c.version_status == "current" and c is not m]
for c in prev:
c.version_status = "superseded"
stats["superseded"] += 1
await _flip_chunks(session, [c.document_id for c in prev])
m.version_status = "current"
stats["promoted"] += 1
# 3) 레거시 스윕 — 상태 기반 (R6-B1 a / R7-B1 b: primary 시리즈 current 보유 한정)
for fid, act in acts.items():
if fid in repeal_families:
continue
has_primary_current = any(
m.family_id == fid and m.law_doc_kind == "primary" and m.version_status == "current"
for m in metas
)
if not has_primary_current:
continue # R3-B1 ② 내장 — fetch 실패 family 의 레거시 보존
legacy_ids = await _legacy_doc_ids(session, act)
flipped = await _flip_chunks(session, legacy_ids)
if flipped:
stats["legacy_flipped_docs"] += len(legacy_ids)
stats["legacy_flipped_chunks"] += flipped
return stats
# ─── 메인 런 ─────────────────────────────────────────────────────────────────────
async def run(bootstrap: bool = False) -> None:
"""poll → fetch → ingest(가족 단위 커밋) → 생애주기 잡. 가족 단위 실패 격리."""
async with async_session() as session:
result = await session.execute(
select(LegalAct).where(LegalAct.watch.is_(True)).order_by(LegalAct.family_id)
)
rows = result.scalars().all()
if not rows:
logger.warning("[statute] 워치리스트 비어 있음 — 시드(migration 356) 미적용?")
return
source = await _get_source(session)
await session.commit()
source_id = source.id
ingested = 0
failed = 0
by_jur: dict[str, list] = {}
for row in rows:
by_jur.setdefault(row.jurisdiction, []).append(row)
async with httpx.AsyncClient(timeout=60) as client:
for jur, acts in by_jur.items():
adapter = _ADAPTERS.get(jur)
if adapter is None:
logger.warning(f"[statute] 어댑터 없는 jurisdiction skip: {jur}")
continue
assert adapter.JURISDICTION == jur, \
f"어댑터/행 jurisdiction 불일치: {adapter.JURISDICTION} != {jur}"
events = await adapter.poll_changes(client, acts)
acts_by_id = {a.family_id: a for a in acts}
for ev in events:
if bootstrap:
ev.kind = "bootstrap" # 합성 이벤트 — amend 와 동일 경로 (R6-m2)
act_ref = acts_by_id[ev.family_id]
try:
payloads = await adapter.fetch_version(client, act_ref, ev)
async with async_session() as session:
act = await session.get(LegalAct, ev.family_id)
new_docs = 0
for p in payloads:
if await _ingest_payload(session, act, ev, p, backfill=bootstrap):
new_docs += 1
# 워터마크 영속 = 파싱 검증(payload floor) 통과 후에만
act.watermark = ev.new_version_key
if ev.kind == "repeal":
act.repeal_detected_at = datetime.now(timezone.utc)
await session.commit()
ingested += new_docs
logger.info(f"[statute] ingest {ev.family_id} ({ev.kind}): "
f"payload {len(payloads)}건 중 신규 {new_docs}")
except Exception as e:
failed += 1
logger.error(f"[statute] ingest 실패 ({ev.family_id}): "
f"{type(e).__name__}: {e!r} — 워터마크 미영속, 다음 폴링 재감지")
await asyncio.sleep(_FETCH_DELAY_S)
# 생애주기 잡 — 수집 사이클 직후, 단일 트랜잭션 (0-2 ②)
async with async_session() as session:
stats = await run_lifecycle(session)
await session.commit()
logger.info(f"[statute] lifecycle: {stats}")
# health — fail-loud 가시성 (HC.io 폐기로 보드/health 행이 1차 관측면)
async with async_session() as session:
h = await _get_or_create_health(session, source_id)
now = datetime.now(timezone.utc)
if failed:
_record_failure(h, f"ingest 실패 {failed}", now)
else:
_record_success(h, ingested, False, now)
await session.commit()
logger.info(f"[statute] run 완료 — 신규 문서 {ingested}건, 실패 {failed}"
+ (" (bootstrap)" if bootstrap else ""))
async def _get_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=kr.LAW_SEARCH_URL, feed_type="rss",
fetch_method="api", fulltext_policy="none", source_channel="crawl",
category="Safety", language="ko", country="KR",
enabled=False, # 6h 뉴스 사이클 비대상 — 본 워커가 daily 폴링
)
session.add(source)
await session.flush()
return source
async def poll_once() -> int:
"""관찰 전용 폴링 (PR① 잔존 CLI — 상태 변경 0)."""
async with async_session() as session:
result = await session.execute(
select(LegalAct).where(LegalAct.watch.is_(True)).order_by(LegalAct.family_id)
)
rows = result.scalars().all()
total = 0
async with httpx.AsyncClient(timeout=30) as client:
events = await kr.poll_changes(client, [r for r in rows if r.jurisdiction == "KR"])
for ev in events:
logger.info(f"[statute] 변경 감지 ({ev.kind}): {ev.family_id} {ev.title} "
f"MST={ev.new_version_key}")
total = len(events)
logger.info(f"[statute] poll 완료 — 변경 {total}건 (관찰 전용)")
return total
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--bootstrap", action="store_true",
help="26 family 현행판 1회 부트스트랩 (backfill 마커, R4-M1)")
parser.add_argument("--poll-only", action="store_true", help="관찰 전용 폴링")
args = parser.parse_args()
if args.poll_only:
asyncio.run(poll_once())
else:
asyncio.run(run(bootstrap=args.bootstrap))
@@ -14,6 +14,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy.exc import SQLAlchemyError
from core.config import settings
from core.database import async_session
from core.utils import setup_logger
from models.study_memo_card_job import StudyMemoCardJob
@@ -50,6 +51,10 @@ async def reset_stale_card_jobs() -> None:
async def consume_study_memo_card_queue() -> None:
"""APScheduler 진입점. pending card_extract job 을 BATCH_SIZE 만큼 처리."""
# 생성 LLM 홀드: claim 자체를 하지 않음 (1분 주기라 로그는 debug).
if "study_memo_card" in settings.pipeline_held_stages:
logger.debug("study_memo_card 보류 (pipeline.held_stages)")
return
await reset_stale_card_jobs()
async with async_session() as session:
+5
View File
@@ -59,6 +59,11 @@ async def reset_stale_study_jobs() -> None:
async def consume_study_queue() -> None:
"""APScheduler 진입점. pending job BATCH_SIZE 만큼 처리."""
# 생성 LLM 홀드: env(study_explanation_enabled) 와 별개의 self-contained 게이트.
# pending 은 그대로 유지 (Mac mini derived-worker 흡수 경로도 본 게이트와 무관).
if "study_explanation" in settings.pipeline_held_stages:
logger.debug("study_explanation 보류 (pipeline.held_stages)")
return
await reset_stale_study_jobs()
async with async_session() as session:
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy.exc import SQLAlchemyError
from core.config import settings
from core.database import async_session
from core.utils import setup_logger
from models.study_quiz_session_job import StudyQuizSessionJob
@@ -48,6 +49,10 @@ async def reset_stale_session_jobs() -> None:
async def consume_study_session_queue() -> None:
"""APScheduler 진입점. pending session_jobs 를 BATCH_SIZE 만큼 처리."""
# 생성 LLM 홀드: claim 자체를 하지 않음 (1분 주기라 로그는 debug).
if "study_session_analysis" in settings.pipeline_held_stages:
logger.debug("study_session_analysis 보류 (pipeline.held_stages)")
return
await reset_stale_session_jobs()
async with async_session() as session:
@@ -0,0 +1,63 @@
import AppKit
import Foundation
/// macOS + . AppKit(NSOpenPanel/NSSavePanel) AppFeature
/// (OS UI ) DSKit ( iOS/watchOS). @MainActor.
@MainActor
enum FilePanels {
/// 1 . nil.
static func pickFileToUpload() -> URL? {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.message = "업로드할 문서를 선택하세요"
panel.prompt = "업로드"
return panel.runModal() == .OK ? panel.url : nil
}
/// . nil. = (files.user-selected).
static func pickSaveDestination(suggestedName: String) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = suggestedName
panel.message = "원본 파일을 저장할 위치"
panel.prompt = "저장"
return panel.runModal() == .OK ? panel.url : nil
}
}
/// . URL ?token= ( ),
/// URL / . NSSavePanel .
@MainActor
enum FileDownloader {
enum Outcome: Equatable {
case saved(URL)
case cancelled
case failed(String)
}
/// `url` = DSDownload.fileURL ?token= URL. `suggestedName` = .
static func download(from url: URL, suggestedName: String) async -> Outcome {
guard let dest = FilePanels.pickSaveDestination(suggestedName: suggestedName) else {
return .cancelled
}
do {
let (temp, response) = try await URLSession.shared.download(from: url)
// (async download )
// . move temp removeItem no-op.
defer { try? FileManager.default.removeItem(at: temp) }
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
// URL/ .
return .failed("다운로드 실패 (HTTP \(http.statusCode))")
}
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: temp, to: dest)
return .saved(dest)
} catch {
// URLError/ localizedDescription URL .
return .failed("저장 실패: \((error as NSError).localizedDescription)")
}
}
}
@@ -1,85 +0,0 @@
import SwiftUI
import AIFabric
/// RAG proof page: routes corpusAsk through AIService (-> AIRouter -> MockAIProvider). Explicit backend
/// pick sets explicitProvider; an explicit-unavailable result renders a visible, non-retrying error.
struct AskView: View {
@Environment(AppModel.self) private var model
@State private var backend: BackendChoice = .auto
var body: some View {
@Bindable var model = model
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Picker("백엔드", selection: $backend) {
ForEach(BackendChoice.allCases) { Text($0.label).tag($0) }
}
.pickerStyle(.segmented)
HStack(spacing: 8) {
TextField("코퍼스 전체에 질문", text: $model.askQuery)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await model.runAsk(backend: backend.provider) } }
Button("질문") { Task { await model.runAsk(backend: backend.provider) } }
.buttonStyle(.borderedProminent)
}
if let result = model.askResult {
switch result {
case .success(let response):
AICompletionView(response: response) { docID in
model.section = .documents
Task { await model.openDocument(docID) }
}
if let meta = model.askMeta {
HStack(spacing: 6) {
Chip("완성도 \(meta.completeness)", Sage.muted)
if let aspects = meta.coveredAspects {
ForEach(aspects, id: \.self) { Chip($0, Sage.brand) }
}
}
}
case .failure(let err):
ErrorBanner(text: message(for: err))
}
} else {
EmptyState(text: "질문을 입력하세요").frame(minHeight: 160)
}
}
.padding(16)
}
.background(Sage.surface)
}
private func message(for error: AIServiceError) -> String {
switch error {
case .explicitUnavailable(let id):
return "\(id.displayName) 백엔드를 쓸 수 없습니다 — 다른 백엔드로 자동 전환하지 않았습니다. 다른 백엔드를 고르세요."
case .notConfigured(let id): return "\(id.displayName) 백엔드 미구성"
case .noneAvailable: return "응답 가능한 백엔드가 없습니다."
case .providerFailed(let s): return "응답 실패: \(s)"
case .unknown(let s): return "오류: \(s)"
}
}
}
enum BackendChoice: String, CaseIterable, Identifiable {
case auto, onDevice, localMLX, remoteDS
var id: String { rawValue }
var label: String {
switch self {
case .auto: return "자동"
case .onDevice: return "온디바이스"
case .localMLX: return "맥미니"
case .remoteDS: return "원격 DS"
}
}
var provider: AIProviderID? {
switch self {
case .auto: return nil
case .onDevice: return .onDevice
case .localMLX: return .localMLX
case .remoteDS: return .remoteDS
}
}
}
@@ -1,51 +1,386 @@
import SwiftUI
import DSKit
/// Corpus-health overview (not a dumped table). Stat hero + domain distribution bars; tapping a
/// domain jumps to Documents (cross-page nav proof).
/// = ( 1). detail 1000pt , 2.
/// ( + + ) (·)/(·).
struct DashboardView: View {
@Environment(AppModel.self) private var model
var body: some View {
ScrollView {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) {
if let s = model.stats {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
StatCard(title: "전체", value: s.total, color: Sage.brand)
StatCard(title: "문서", value: s.counts["document"] ?? 0, color: Sage.brand)
StatCard(title: "승인 대기", value: s.libraryPendingSuggestions, color: Sage.amber)
}
VStack(alignment: .leading, spacing: 10) {
Text("카테고리 분포").font(.headline).foregroundStyle(Sage.ink)
ForEach(s.counts.sorted { $0.value > $1.value }, id: \.key) { key, value in
DomainBar(name: Self.categoryLabel(key), count: value, max: s.counts.values.max() ?? 1)
.contentShape(Rectangle())
.onTapGesture { model.section = .documents }
}
}
.padding(16)
.background(Sage.card, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Sage.line))
} else {
GreetingHeader()
if model.stats == nil && model.tree.isEmpty {
ProgressView().frame(maxWidth: .infinity, minHeight: 200)
} else {
TodayStrip()
HStack(alignment: .top, spacing: 18) {
VStack(alignment: .leading, spacing: 18) {
CaptureCard()
ActivityTimeline()
}
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 18) {
DomainDistribution()
PinnedItems()
}
.frame(width: 312)
}
}
}
.padding(20)
.frame(maxWidth: 1000, alignment: .leading)
.padding(.horizontal, 30)
.padding(.vertical, 26)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(Sage.surface)
}
}
/// category enum ( raw ).
static func categoryLabel(_ key: String) -> String {
switch key {
case "document": return "문서"
case "library": return "자료실"
case "news": return "뉴스"
case "law": return "법령"
case "memo": return "메모"
case "audio": return "오디오"
case "video": return "비디오"
default: return key
// MARK: - Greeting
private struct GreetingHeader: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("안녕하세요, \(model.currentUser?.username ?? "사용자")")
.font(.system(size: 22, weight: .bold)).kerning(-0.4).foregroundStyle(Sage.ink)
Text("오늘도 지식 쌓는 날.").font(.callout).foregroundStyle(Sage.muted)
}
Text(Self.today).font(.caption).foregroundStyle(Sage.muted.opacity(0.8))
}
.padding(.bottom, 4)
}
static var today: String {
let f = DateFormatter()
f.locale = Locale(identifier: "ko_KR")
f.dateFormat = "y년 M월 d일 EEEE"
return f.string(from: Date())
}
}
// MARK: - Today strip (hero)
private struct TodayStrip: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(spacing: 14) {
HStack(alignment: .top, spacing: 0) {
reviewQueue
.frame(minWidth: 150, alignment: .leading)
Rectangle().fill(Sage.line).frame(width: 1).padding(.horizontal, 22)
digestTeaser
.frame(maxWidth: .infinity, alignment: .leading)
}
Divider().overlay(Sage.line)
statRow
}
.dashCard(padding: 20)
}
private var reviewQueue: some View {
VStack(alignment: .leading, spacing: 4) {
Text(model.reviewPendingCount.map(String.init) ?? "")
.font(.system(size: 38, weight: .bold)).kerning(-1.5).monospacedDigit()
.foregroundStyle(Sage.amber)
Text("검토 대기 문서").font(.caption).foregroundStyle(Sage.muted)
Button { model.section = .documents } label: {
Text("검토 시작 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
}
.buttonStyle(.plain)
}
}
@ViewBuilder private var digestTeaser: some View {
if let t = topTopic {
Button { model.section = .digest } label: {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Chip("속보", Sage.danger)
Text("\(model.digest?.digestDateDisplay ?? "") 브리핑")
.font(.caption2).foregroundStyle(Sage.muted)
}
Text(t.label).font(.system(size: 15)).foregroundStyle(Sage.ink)
.lineLimit(2).fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
Text(t.meta).font(.caption2).foregroundStyle(Sage.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
} else {
Text("오늘 브리핑이 아직 없습니다").font(.callout).foregroundStyle(Sage.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var statRow: some View {
HStack(spacing: 0) {
StatCell(value: model.stats?.total ?? 0, label: "전체", color: Sage.brand)
StatCell(value: model.stats?.counts["document"] ?? 0, label: "문서")
StatCell(value: domainCount("Industrial_Safety"), label: "산업안전",
color: Sage.domainColor("Industrial_Safety"))
StatCell(value: domainCount("Engineering"), label: "엔지니어링",
color: Sage.domainColor("Engineering"))
StatCell(value: domainCount("General"), label: "자료실", color: Sage.domainColor("General"))
StatCell(value: model.stats?.counts["memo"] ?? model.memoList.count, label: "메모")
}
}
private func domainCount(_ name: String) -> Int {
model.tree.first { $0.name == name }?.count ?? 0
}
private var topTopic: (label: String, meta: String)? {
guard let digest = model.digest else { return nil }
var best: (TopicResponse, String)?
for c in digest.countries {
for t in c.topics where best == nil || (t.importanceScore ?? 0) > (best!.0.importanceScore ?? 0) {
best = (t, c.country)
}
}
guard let (t, country) = best else { return nil }
let arts = t.articleCount ?? t.articles.count
var meta = "관련 기사 \(arts)"
if let imp = t.importanceScore { meta += " · 중요도 \(String(format: "%.0f", imp))" }
if !country.isEmpty { meta += " · \(country)" }
return (t.topicLabel, meta)
}
}
// MARK: - Left column
private struct CaptureCard: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var m = model
VStack(alignment: .leading, spacing: 12) {
SectionLabel("빠른 캡처")
HStack(spacing: 8) {
TextField("메모 한 줄 남기기…", text: $m.captureText)
.textFieldStyle(.plain)
.padding(.horizontal, 14).frame(height: 38)
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line))
.onSubmit { Task { await model.saveMemo() } }
Button { Task { await model.saveMemo() } } label: {
Text("저장").font(.callout.weight(.semibold)).foregroundStyle(.white)
.padding(.horizontal, 18).frame(height: 38)
.background(Sage.brand, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(model.captureText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Button {
guard let url = FilePanels.pickFileToUpload() else { return }
Task { await model.uploadPicked(url) }
} label: {
Text(" 파일 업로드").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Sage.brand.opacity(0.12), in: Capsule())
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct ActivityTimeline: View {
@Environment(AppModel.self) private var model
private var recent: [DocumentResponse] {
model.documentList
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
.prefix(5).map { $0 }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
SectionLabel("최근 활동")
Spacer()
Button { model.section = .documents } label: {
Text("전체 보기 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
}
.buttonStyle(.plain)
}
if recent.isEmpty {
Text("최근 활동이 없습니다").font(.caption).foregroundStyle(Sage.muted)
} else {
VStack(spacing: 0) {
ForEach(Array(recent.enumerated()), id: \.element.id) { idx, doc in
ActivityRow(doc: doc, isLast: idx == recent.count - 1)
if idx != recent.count - 1 { Divider().overlay(Sage.line) }
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct ActivityRow: View {
@Environment(AppModel.self) private var model
let doc: DocumentResponse
let isLast: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(Self.relative(doc.updatedAt))
.font(.caption2).foregroundStyle(Sage.muted)
.frame(width: 54, alignment: .trailing)
VStack(spacing: 0) {
Circle().fill(Sage.domainColor(doc.aiDomain)).frame(width: 8, height: 8).padding(.top, 4)
if !isLast { Rectangle().fill(Sage.line).frame(width: 1).frame(maxHeight: .infinity) }
}
.frame(width: 14)
VStack(alignment: .leading, spacing: 3) {
Text("\(localizedDomain(doc.aiDomain)) · \(doc.displayFormat.uppercased())")
.font(.caption2.weight(.bold)).foregroundStyle(Sage.domainColor(doc.aiDomain))
Text(doc.title ?? doc.downloadLabel).font(.callout).foregroundStyle(Sage.ink).lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, isLast ? 0 : 10)
}
.contentShape(Rectangle())
.onTapGesture { model.section = .documents; Task { await model.openDocument(doc.id) } }
}
static func relative(_ date: Date?) -> String {
guard let date else { return "" }
let f = RelativeDateTimeFormatter()
f.locale = Locale(identifier: "ko_KR")
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
}
// MARK: - Right column
private struct DomainDistribution: View {
@Environment(AppModel.self) private var model
private var domains: [DomainTreeNode] { model.tree.sorted { $0.count > $1.count } }
private var domainTotal: Int { domains.reduce(0) { $0 + $1.count } }
private var sum: Int { max(1, domainTotal) } // 0- ( )
var body: some View {
VStack(alignment: .leading, spacing: 12) {
SectionLabel("도메인 분포")
// = / ( ) .
HStack(alignment: .firstTextBaseline, spacing: 3) {
Text("분류").font(.caption).foregroundStyle(Sage.muted)
Text("\(domainTotal)").font(.system(size: 18, weight: .semibold))
.monospacedDigit().foregroundStyle(Sage.ink)
Text("").font(.caption).foregroundStyle(Sage.muted)
}
GeometryReader { geo in
HStack(spacing: 2) {
ForEach(domains) { d in
Rectangle().fill(Sage.domainColor(d.name))
.frame(width: max(2, geo.size.width * CGFloat(d.count) / CGFloat(sum)))
}
}
}
.frame(height: 8)
.clipShape(RoundedRectangle(cornerRadius: 4))
VStack(spacing: 7) {
ForEach(domains) { d in
Button {
model.section = .documents
Task { await model.loadDocuments(domain: d.path) }
} label: {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2).fill(Sage.domainColor(d.name)).frame(width: 10, height: 10)
Text(localizedDomain(d.name)).font(.caption).foregroundStyle(Sage.ink)
.lineLimit(1).frame(maxWidth: .infinity, alignment: .leading)
Text("\(d.count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted)
}
}
.buttonStyle(.plain)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct PinnedItems: View {
@Environment(AppModel.self) private var model
private var docs: [DocumentResponse] { model.documentList.filter { $0.pinned == true } }
private var memos: [MemoResponse] { model.memoList.filter { $0.isPinned } }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionLabel("고정 항목")
Spacer()
Button { model.section = .documents } label: {
Text("관리 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
}
.buttonStyle(.plain)
}
if docs.isEmpty && memos.isEmpty {
Text("고정된 항목이 없습니다").font(.caption).foregroundStyle(Sage.muted)
} else {
VStack(spacing: 8) {
ForEach(docs) { d in
PinRow(kind: "문서", kindColor: Sage.domainColor("Engineering"),
title: d.title ?? d.downloadLabel, date: d.updatedAtRaw) {
model.section = .documents; Task { await model.openDocument(d.id) }
}
}
ForEach(memos) { m in
PinRow(kind: "메모", kindColor: Sage.brand,
title: m.title ?? (m.content ?? "메모"), date: m.updatedAtRaw ?? "") {
model.section = .memos; Task { await model.openMemo(m.id) }
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct PinRow: View {
let kind: String
let kindColor: Color
let title: String
let date: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(alignment: .top, spacing: 10) {
Chip(kind, kindColor)
Text(title).font(.caption).foregroundStyle(Sage.ink).lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
Text(date.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
}
.padding(10)
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
#if DEBUG
#Preview("Dashboard") {
@Previewable @State var model = AppModel.preview
DashboardView()
.environment(model)
.frame(width: 1100, height: 760)
.task { await model.bootstrap() }
}
#endif
@@ -1,91 +1,367 @@
import SwiftUI
import DSKit
struct DocumentListView: View {
/// = DEVONthink . () , detail
/// HSplitView 3-pane = | MD | ().
/// model.loadDocuments(domain:) .
struct DocumentsBrowser: View {
@Environment(AppModel.self) private var model
@State private var showInspector = true
@State private var sortOrder = [KeyPathComparator(\DocumentResponse.sortUpdated, order: .reverse)]
var body: some View {
HSplitView {
DocumentListTable(sortOrder: $sortOrder)
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
DocumentReader(showInspector: $showInspector)
.frame(minWidth: 420, maxWidth: .infinity)
if showInspector, let d = model.documentDetail {
DocumentInspector(detail: d)
.frame(minWidth: 280, idealWidth: 320, maxWidth: 360)
}
}
.task { await model.ensureDocumentsLoaded() } // load-all
}
}
// MARK: - Column list (sortable Table)
private extension DocumentResponse {
var sortTitle: String { title ?? downloadLabel }
var sortFormat: String { (originalFormat ?? fileFormat ?? "").lowercased() }
var sortUpdated: String { updatedAtRaw }
/// "PDFMD" / "MD" .
var formatBadge: String {
if let orig = originalFormat, orig.lowercased() != (fileFormat ?? "").lowercased() {
return "\(orig.uppercased())→MD"
}
return displayFormat.uppercased()
}
}
struct DocumentListTable: View {
@Environment(AppModel.self) private var model
@Binding var sortOrder: [KeyPathComparator<DocumentResponse>]
private var documents: [DocumentResponse] { model.documentList.sorted(using: sortOrder) }
var body: some View {
let selection = Binding<Int?>(
get: { model.selectedDocumentID },
set: { if let id = $0 { Task { await model.openDocument(id) } } }
)
List(model.documentList, selection: selection) { doc in
DocumentRow(doc: doc)
}
.listStyle(.inset)
.background(Sage.surface)
}
}
struct DocumentRow: View {
let doc: DocumentResponse
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Chip(doc.displayFormat.uppercased(), Sage.formatColor(doc.displayFormat))
Text(doc.title ?? doc.downloadLabel)
.font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1)
Spacer()
if doc.pinned == true { Text("고정").font(.caption2).foregroundStyle(Sage.amber) }
}
HStack(spacing: 6) {
if let d = doc.aiDomain { Chip(d, Sage.domainColor(d)) }
if let r = doc.reviewStatus {
Text(r).font(.caption2).foregroundStyle(Sage.reviewStatusColor(r))
Group {
if model.documentList.isEmpty {
EmptyState(text: "문서가 없습니다")
} else {
Table(documents, selection: selection, sortOrder: $sortOrder) {
TableColumn("제목", value: \.sortTitle) { doc in
VStack(alignment: .leading, spacing: 2) {
Text(doc.title ?? doc.downloadLabel)
.font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Sage.ink).lineLimit(1)
Text(localizedDomain(doc.aiDomain))
.font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
}
.padding(.vertical, 2)
}
TableColumn("종류", value: \.sortFormat) { doc in
Chip(doc.formatBadge, Sage.formatColor(doc.originalFormat ?? doc.displayFormat))
}
.width(min: 66, ideal: 74, max: 96)
TableColumn("수정", value: \.sortUpdated) { doc in
Text(doc.updatedAtRaw.prefix(10))
.font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
}
.width(min: 78, ideal: 86, max: 110)
}
Spacer()
Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
.tint(Sage.brand)
}
}
.padding(.vertical, 4)
.background(Sage.card)
}
}
/// MD-first detail: render md_content when renderable, else extracted_text fallback + 'MD '
/// badge + emphasized original-download button. (Download builds a real-shaped ?token= URL.)
struct DocumentDetailView: View {
// MARK: - Reader
struct DocumentReader: View {
@Environment(AppModel.self) private var model
@Binding var showInspector: Bool
var body: some View {
Group {
if let detail = model.documentDetail {
VStack(spacing: 0) {
ReaderHeader(detail: detail, showInspector: $showInspector)
ReaderBody(detail: detail)
}
} else {
EmptyState(text: "문서를 선택하세요")
}
}
.background(Sage.card)
}
}
private struct ReaderHeader: View {
let detail: DocumentDetailResponse
@Binding var showInspector: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(crumb).font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(detail.base.title ?? detail.base.downloadLabel)
.font(.system(size: 18, weight: .heavy)).foregroundStyle(Sage.ink).lineLimit(2)
Spacer()
DownloadButton(doc: detail.base, compact: true)
inspectorToggle
}
metaBadges
tagRow
}
.padding(.horizontal, 26).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Sage.card)
.overlay(alignment: .bottom) { Rectangle().fill(Sage.line).frame(height: 1) }
}
private var crumb: String {
let dom = localizedDomain(detail.base.aiDomain)
if let sub = detail.base.aiSubGroup, !sub.isEmpty { return "\(dom) \(sub)" }
return dom
}
/// : · · tier DEEP · · PDFMD success.
@ViewBuilder private var metaBadges: some View {
let b = detail.base
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
if let d = b.aiDomain { Chip(localizedDomain(d), Sage.domainColor(d)) }
if let t = b.documentType, !t.isEmpty { Chip(t, Sage.muted) }
if b.aiAnalysisTier == "deep" { Chip("tier DEEP", Sage.brand) }
if let c = b.aiConfidence { Chip("신뢰도 \(String(format: "%.2f", c))", Sage.brandDark) }
if detail.mdIsRenderable { Chip("PDF→MD success", Sage.mdStatusColor("completed")) }
}
}
}
private var inspectorToggle: some View {
Button { withAnimation(.easeInOut(duration: 0.2)) { showInspector.toggle() } } label: {
Image(systemName: "info.circle").font(.system(size: 15))
.foregroundStyle(showInspector ? Sage.brandDark : Sage.muted)
.frame(width: 30, height: 30)
.background(showInspector ? Sage.brand.opacity(0.14) : Sage.card, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(showInspector ? Sage.brand : Sage.line))
}
.buttonStyle(.plain)
.help("인스펙터")
}
@ViewBuilder private var tagRow: some View {
let tags = detail.base.aiTags ?? []
if detail.mdStatus != nil || !tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
if let st = detail.mdStatus { Chip("MD \(st)", Sage.mdStatusColor(st)) }
ForEach(tags, id: \.self) { Chip($0, Sage.brand) }
}
}
}
}
}
private struct ReaderBody: View {
let detail: DocumentDetailResponse
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(detail.base.title ?? detail.base.downloadLabel)
.font(.title2.weight(.bold)).foregroundStyle(Sage.ink)
HStack(spacing: 8) {
if let d = detail.base.aiDomain { Chip(d, Sage.domainColor(d)) }
Chip(detail.base.displayFormat.uppercased(), Sage.formatColor(detail.base.displayFormat))
if let conf = detail.base.aiConfidence {
Chip("AI \(String(format: "%.0f%%", conf * 100))", Sage.muted)
}
Spacer()
if let url = model.downloadURL(for: detail.base) {
Link(detail.base.downloadLabel, destination: url).font(.callout.weight(.semibold))
}
}
if let tags = detail.base.aiTags, !tags.isEmpty {
HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } }
}
Divider()
if detail.mdIsRenderable, let md = detail.mdContent {
MarkdownView(md)
} else {
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
Text(detail.extractedText ?? "본문 없음")
.font(.body).foregroundStyle(Sage.muted)
.frame(maxWidth: .infinity, alignment: .leading)
if let url = model.downloadURL(for: detail.base) {
Link("원본 다운로드 — \(detail.base.downloadLabel)", destination: url)
.font(.callout.weight(.semibold))
HStack(spacing: 0) {
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 14) {
if detail.mdIsRenderable, let md = detail.mdContent {
MarkdownView(md)
} else {
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
Text(detail.extractedText ?? "본문 없음")
.font(.body).foregroundStyle(Sage.muted)
.frame(maxWidth: .infinity, alignment: .leading)
DownloadButton(doc: detail.base, compact: false)
}
}
.frame(maxWidth: 700, alignment: .leading)
Spacer(minLength: 0)
}
.padding(.horizontal, 28).padding(.top, 22).padding(.bottom, 44)
}
.background(Sage.card)
}
}
// MARK: - Inspector
struct DocumentInspector: View {
let detail: DocumentDetailResponse
private var base: DocumentResponse { detail.base }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
// ( : TL;DR · · · )
if let tldr = (base.aiTldr ?? base.aiSummary), !tldr.isEmpty {
InspectorSection("TL;DR") {
Text(tldr).font(.system(size: 12)).foregroundStyle(Sage.ink).lineSpacing(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
if let bullets = base.aiBullets, !bullets.isEmpty {
InspectorSection("핵심점") {
VStack(alignment: .leading, spacing: 6) {
ForEach(bullets, id: \.self) { b in
HStack(alignment: .top, spacing: 6) {
Text("·").font(.system(size: 12, weight: .bold)).foregroundStyle(Sage.amber)
Text(b).font(.system(size: 12)).foregroundStyle(Sage.ink)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
if let deep = base.aiDetailSummary, !deep.isEmpty {
InspectorSection("심층") {
VStack(alignment: .leading, spacing: 6) {
if base.aiAnalysisTier == "deep" { Chip("DEEP", Sage.brand) }
Text(deep).font(.system(size: 11.5)).foregroundStyle(Sage.ink).lineSpacing(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
if let inc = base.aiInconsistencies, !inc.isEmpty {
InspectorSection("불일치 \(inc.count)") {
VStack(alignment: .leading, spacing: 5) {
ForEach(inc, id: \.self) { x in
Text("· \(x)").font(.system(size: 11.5)).foregroundStyle(Sage.ink)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
//
InspectorSection("정보") {
VStack(spacing: 0) {
KV("종류", base.formatBadge)
KV("도메인", localizedDomain(base.aiDomain))
KV("하위", base.aiSubGroup ?? "")
KV("수정", String(base.updatedAtRaw.prefix(10)))
if let size = base.fileSize {
KV("원본", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
}
if let st = detail.mdStatus { KV("md 상태", st, color: Sage.mdStatusColor(st)) }
if let tier = base.aiAnalysisTier { KV("tier", tier, color: Sage.brandDark) }
if let c = base.aiConfidence { KV("신뢰도", String(format: "%.2f", c), color: Sage.brand) }
KV("읽음", "\(base.reads)")
}
}
if let tags = base.aiTags, !tags.isEmpty {
InspectorSection("태그") { TagWrap(tags: tags) }
}
}
.padding(.horizontal, 16).padding(.vertical, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Sage.sidebar)
.overlay(alignment: .leading) { Rectangle().fill(Sage.line).frame(width: 1) }
}
}
private struct InspectorSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
init(_ title: String, @ViewBuilder content: () -> Content) { self.title = title; self.content = content() }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.system(size: 10, weight: .heavy)).tracking(0.8)
.textCase(.uppercase).foregroundStyle(Sage.muted.opacity(0.8))
content
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct KV: View {
let k: String
let v: String
var color: Color = Sage.ink
init(_ k: String, _ v: String, color: Color = Sage.ink) { self.k = k; self.v = v; self.color = color }
var body: some View {
HStack {
Text(k).font(.system(size: 12)).foregroundStyle(Sage.muted)
Spacer()
Text(v).font(.system(size: 12, weight: .semibold)).foregroundStyle(color)
.multilineTextAlignment(.trailing)
}
.padding(.vertical, 3)
}
}
/// (2 Layout ).
private struct TagWrap: View {
let tags: [String]
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(stride(from: 0, to: tags.count, by: 2)), id: \.self) { i in
HStack(spacing: 6) {
Chip(tags[i], Sage.brand)
if i + 1 < tags.count { Chip(tags[i + 1], Sage.brand) }
Spacer(minLength: 0)
}
}
}
}
}
// MARK: - Native download button (preserved)
/// . ?token= URL NSSavePanel (
/// ). + / . note .
struct DownloadButton: View {
@Environment(AppModel.self) private var model
let doc: DocumentResponse
/// compact = () / false = .
var compact: Bool
@State private var busy = false
@State private var status: String?
@State private var isError = false
var body: some View {
if let url = model.downloadURL(for: doc) {
HStack(spacing: 8) {
Button {
Task {
busy = true; status = nil; isError = false
let outcome = await FileDownloader.download(from: url, suggestedName: doc.downloadLabel)
busy = false
switch outcome {
case .saved(let dest): status = "저장됨: \(dest.lastPathComponent)"; isError = false
case .cancelled: status = nil
case .failed(let msg): status = msg; isError = true
}
}
} label: {
Label(compact ? doc.downloadLabel : "원본 다운로드 — \(doc.downloadLabel)",
systemImage: "arrow.down.circle")
.font(.callout.weight(.semibold))
}
.buttonStyle(.borderless)
.disabled(busy)
if busy { ProgressView().controlSize(.small) }
if let s = status {
Text(s).font(.caption)
.foregroundStyle(isError ? Sage.danger : Sage.muted)
.lineLimit(1)
}
}
.padding(20)
}
.background(Sage.surface)
}
}
@@ -13,11 +13,10 @@ struct MemoListView: View {
.textFieldStyle(.roundedBorder)
Button("저장") {
let content = draft
draft = ""
Task { _ = try? await model.client.createMemo(MemoCreate(content: content)) }
Task { if await model.saveMemo(content) { draft = "" } }
}
.buttonStyle(.bordered)
.disabled(draft.isEmpty)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(12)
@@ -1,50 +0,0 @@
import SwiftUI
import DSKit
/// Distinct from the Documents table: relevance-forward result cards (score bar + match_reason).
struct SearchView: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
TextField("검색어를 입력하세요", text: $model.searchQuery)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await model.runSearch() } }
Button("검색") { Task { await model.runSearch() } }
.buttonStyle(.borderedProminent)
}
.padding(12)
if let response = model.searchResponse {
List(response.results) { result in
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
if let d = result.aiDomain { Chip(d, Sage.domainColor(d)) }
Text(result.title ?? "문서 \(result.id)")
.font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1)
Spacer()
if let m = result.matchReason {
Text(m).font(.caption2).foregroundStyle(Sage.muted)
}
}
Text(result.snippet ?? result.aiSummary ?? "")
.font(.caption).foregroundStyle(Sage.muted).lineLimit(2)
if let score = result.score { ScoreBar(score: score) }
}
.padding(.vertical, 4)
.contentShape(Rectangle())
.onTapGesture {
model.section = .documents
Task { await model.openDocument(result.id) }
}
}
.listStyle(.inset)
} else {
EmptyState(text: "검색어를 입력하세요")
}
}
.background(Sage.surface)
}
}
@@ -1,5 +1,58 @@
import SwiftUI
/// raw (/ enum ) . Sage.domainColor(raw) raw
/// raw, . .
func localizedDomain(_ raw: String?) -> String {
guard let raw, !raw.isEmpty else { return "미분류" }
// (Philosophy/Aesthetics) leaf , leaf
let leaf = raw.split(separator: "/").last.map(String.init) ?? raw
let map: [String: String] = [
"Engineering": "엔지니어링", "Industrial_Safety": "산업안전", "General": "자료실",
"Programming": "프로그래밍", "법령": "법령", "Philosophy": "철학",
]
return map[raw] ?? map[leaf] ?? leaf
}
/// / (·heavy·muted) / .
struct SectionLabel: View {
let text: String
init(_ text: String) { self.text = text }
var body: some View {
Text(text)
.font(.caption.weight(.heavy))
.textCase(.uppercase)
.kerning(0.7)
.foregroundStyle(Sage.muted)
}
}
/// (Sage.card + corner 12 + Sage.line stroke + ).
struct DashCard: ViewModifier {
var padding: CGFloat = 18
func body(content: Content) -> some View {
content
.padding(padding)
.background(Sage.card, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line))
}
}
extension View { func dashCard(padding: CGFloat = 18) -> some View { modifier(DashCard(padding: padding)) } }
/// ( ). StatCard .
struct StatCell: View {
let value: Int
let label: String
var color: Color = Sage.ink
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text("\(value)").font(.system(size: 20, weight: .semibold)).kerning(-0.6)
.monospacedDigit().foregroundStyle(color)
Text(label).font(.caption2).foregroundStyle(Sage.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct StatCard: View {
let title: String
let value: Int
@@ -1,9 +1,10 @@
import SwiftUI
import DSKit
/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment
/// (no shell-level auto-inherit). macOS-only target.
/// : checking( refresh ) loggedOut(LoginView) ready(3-pane ).
/// 2-column ( + detail). detail
/// (= / = HSplitView 3-pane / =+). 3-column
/// . macOS-only.
/// : checking(refresh ) loggedOut(LoginView) ready().
public struct RootView: View {
@Environment(AppModel.self) private var model
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@@ -29,38 +30,45 @@ public struct RootView: View {
private var shell: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
} content: {
ContentColumn()
.navigationSplitViewColumnWidth(min: 300, ideal: 380)
.navigationSplitViewColumnWidth(min: 200, ideal: 215, max: 270)
} detail: {
DetailColumn()
SectionDetail()
}
.navigationSplitViewStyle(.balanced)
.tint(Sage.brand)
.toolbar {
ToolbarItem(placement: .primaryAction) { UploadToolbarButton() }
ToolbarItem(placement: .primaryAction) { AccountMenu() }
}
.safeAreaInset(edge: .bottom) {
// (no-silent-fallback) .
if let err = model.errorText {
HStack(spacing: 10) {
Text(err)
.font(.callout)
.foregroundStyle(.white)
.lineLimit(2)
Spacer()
Button("닫기") { model.errorText = nil }
.buttonStyle(.plain)
.foregroundStyle(.white.opacity(0.85))
VStack(spacing: 0) {
UploadStatusBar()
// (no-silent-fallback) .
if let err = model.errorText {
HStack(spacing: 10) {
Text(err)
.font(.callout)
.foregroundStyle(.white)
.lineLimit(2)
Spacer()
Button("닫기") { model.errorText = nil }
.buttonStyle(.plain)
.foregroundStyle(.white.opacity(0.85))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Sage.danger)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Sage.danger)
}
}
}
}
// MARK: - Sidebar
struct Sidebar: View {
@Environment(AppModel.self) private var model
private let navSections: [AppModel.Section] = [.dashboard, .documents, .digest, .memos]
var body: some View {
let selection = Binding<AppModel.Section?>(
@@ -68,73 +76,132 @@ struct Sidebar: View {
set: { if let v = $0 { model.section = v } }
)
List(selection: selection) {
BrandRow().selectionDisabled()
Section {
ForEach(AppModel.Section.allCases) { s in
Text(s.title).tag(s)
ForEach(navSections) { s in
Label(s.title, systemImage: Self.icon(s)).tag(s)
}
}
if model.section == .documents, !model.tree.isEmpty {
Section("도메인") {
ForEach(model.tree) { node in
DomainRow(node: node)
}
}
// ( 4- ).
if model.section == .documents {
DocumentsSourceSidebar()
}
}
.listStyle(.sidebar)
.background(Sage.sidebar)
}
}
struct DomainRow: View {
@Environment(AppModel.self) private var model
let node: DomainTreeNode
var body: some View {
HStack(spacing: 8) {
Circle().fill(Sage.domainColor(node.name)).frame(width: 8, height: 8)
Text(node.name).font(.callout).foregroundStyle(Sage.ink)
Spacer()
Text("\(node.count)").font(.caption).foregroundStyle(Sage.muted)
static func icon(_ s: AppModel.Section) -> String {
switch s {
case .dashboard: return "house"
case .documents: return "folder"
case .digest: return "newspaper"
case .memos: return "note.text"
}
.contentShape(Rectangle())
.onTapGesture { model.section = .documents }
}
}
struct ContentColumn: View {
struct BrandRow: View {
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 7).fill(Sage.brand).frame(width: 26, height: 26)
.overlay(Text("DS").font(.system(size: 10, weight: .heavy)).foregroundStyle(.white))
Text("Document Server").font(.system(size: 13.5, weight: .heavy)).foregroundStyle(Sage.ink)
}
.padding(.vertical, 4)
}
}
/// : ( = ) + /( placeholder).
struct DocumentsSourceSidebar: View {
@Environment(AppModel.self) private var model
var body: some View {
Section("분류") {
SourceRow(label: "전체 문서", color: nil, count: model.stats?.total,
selected: model.documentDomainFilter == nil) {
Task { await model.loadDocuments(domain: nil) }
}
ForEach(model.tree) { node in
SourceRow(label: localizedDomain(node.name), color: Sage.domainColor(node.name),
count: node.count, selected: model.documentDomainFilter == node.path) {
Task { await model.loadDocuments(domain: node.path) }
}
}
}
// IA ( ).
Section("스마트 그룹") {
ForEach(["최근 7일", "검토 대기", "법령 알림"], id: \.self) { t in
Text(t).font(.callout).foregroundStyle(Sage.muted).opacity(0.5)
}
}
Section("태그") {
ForEach(["압력용기", "ASME", "받은편지함"], id: \.self) { t in
Text("#\(t)").font(.callout).foregroundStyle(Sage.muted).opacity(0.5)
}
}
}
}
/// (). brand-soft List ( ).
struct SourceRow: View {
let label: String
let color: Color?
let count: Int?
let selected: Bool
let action: () -> Void
var body: some View {
HStack(spacing: 8) {
if let color { RoundedRectangle(cornerRadius: 3).fill(color).frame(width: 8, height: 8) }
Text(label).font(.callout)
.foregroundStyle(selected ? Sage.brandDark : Sage.ink)
.fontWeight(selected ? .bold : .regular)
.lineLimit(1)
Spacer()
if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) }
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.onTapGesture(perform: action)
.listRowBackground(selected ? Sage.brand.opacity(0.14) : Color.clear)
}
}
// MARK: - Section router
/// detail . inspector/list .
struct SectionDetail: View {
@Environment(AppModel.self) private var model
var body: some View {
Group {
switch model.section {
case .dashboard: DashboardView()
case .documents: DocumentListView()
case .search: SearchView()
case .ask: AskView()
case .memos: MemoListView()
case .digest: DigestView()
case .dashboard: DashboardView() //
case .documents: DocumentsBrowser() // HSplitView 3-pane
case .digest: DigestView() // ( )
case .memos: MemosBoard() // + ( )
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Sage.surface)
.navigationTitle(model.section.title)
}
}
struct DetailColumn: View {
/// v1 + split ( ).
struct MemosBoard: View {
@Environment(AppModel.self) private var model
var body: some View {
Group {
switch model.section {
case .documents:
if let d = model.documentDetail { DocumentDetailView(detail: d) }
else { EmptyState(text: "문서를 선택하세요") }
case .memos:
HSplitView {
MemoListView()
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
Group {
if let m = model.memoDetail { MemoDetailView(memo: m) }
else { EmptyState(text: "메모를 선택하세요") }
default:
EmptyState(text: model.section.title)
}
.frame(minWidth: 360, maxWidth: .infinity)
}
}
}
@@ -149,11 +216,96 @@ struct EmptyState: View {
}
}
// MARK: - Toolbar items
/// NSOpenPanel . .
struct UploadToolbarButton: View {
@Environment(AppModel.self) private var model
var body: some View {
Button {
guard let fileURL = FilePanels.pickFileToUpload() else { return }
Task { await model.uploadPicked(fileURL) }
} label: {
Label("업로드", systemImage: "square.and.arrow.up")
}
.help("문서 업로드")
.disabled(isUploading)
}
private var isUploading: Bool {
if case .uploading = model.uploadState { return true }
return false
}
}
/// + ( ).
struct AccountMenu: View {
@Environment(AppModel.self) private var model
@State private var confirmLogout = false
var body: some View {
Menu {
Button("로그아웃", role: .destructive) { confirmLogout = true }
} label: {
Label(model.currentUser?.username ?? "계정", systemImage: "person.crop.circle")
}
.help("계정")
.confirmationDialog("로그아웃하시겠습니까?", isPresented: $confirmLogout, titleVisibility: .visible) {
Button("로그아웃", role: .destructive) { Task { await model.logout() } }
Button("취소", role: .cancel) {}
}
}
}
/// / . uploading=( ) / done=( )+ / failed=+.
struct UploadStatusBar: View {
@Environment(AppModel.self) private var model
var body: some View {
switch model.uploadState {
case .idle:
EmptyView()
case .uploading(let name):
row(bg: Sage.brand) {
ProgressView().controlSize(.small).tint(.white)
Text("업로드 중 — \(name)").font(.callout).foregroundStyle(.white).lineLimit(1)
Spacer()
}
case .done(let title):
row(bg: Sage.brand) {
Text("업로드 완료 — \(title) (처리 대기 중)").font(.callout).foregroundStyle(.white).lineLimit(1)
Spacer()
closeButton
}
case .failed(let msg):
row(bg: Sage.danger) {
Text("업로드 실패 — \(msg)").font(.callout).foregroundStyle(.white).lineLimit(2)
Spacer()
closeButton
}
}
}
private var closeButton: some View {
Button("닫기") { model.dismissUploadStatus() }
.buttonStyle(.plain)
.foregroundStyle(.white.opacity(0.85))
}
private func row<Content: View>(bg: Color, @ViewBuilder _ content: () -> Content) -> some View {
HStack(spacing: 10) { content() }
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(bg)
}
}
#if DEBUG
#Preview("DS App — full shell") {
@Previewable @State var model = AppModel.preview
RootView()
.environment(model)
.frame(minWidth: 1000, minHeight: 660)
.frame(minWidth: 1100, minHeight: 700)
}
#endif
@@ -2,23 +2,24 @@ import SwiftUI
import Observation
import DSKit
import AIFabric
import UniformTypeIdentifiers
/// The single app-state store driving the 3-pane shell. @MainActor @Observable: mutations are
/// main-isolated; the DSClient returns Sendable models; AIService is an actor.
@MainActor
@Observable
public final class AppModel {
/// = ···. (ask)·(AI chat) v1 macOS (2026-06-15)
/// AIFabric(S2) iPhone/Watch , UI .
public enum Section: String, CaseIterable, Identifiable, Hashable {
case dashboard, documents, search, ask, memos, digest
case dashboard, documents, digest, memos
public var id: String { rawValue }
public var title: String {
switch self {
case .dashboard: return "대시보드"
case .dashboard: return ""
case .documents: return "문서"
case .search: return "검색"
case .ask: return "질문"
case .memos: return "메모"
case .digest: return "뉴스"
case .memos: return "메모"
}
}
}
@@ -27,19 +28,33 @@ public final class AppModel {
/// (ready). Fixture refresh fixture ready.
public enum AuthPhase: Equatable { case checking, loggedOut, ready }
/// / + . done/failed .
public enum UploadState: Equatable, Sendable {
case idle
case uploading(name: String)
case done(title: String)
case failed(String)
}
public var section: Section = .dashboard
public var selectedDocumentID: Int?
public var selectedMemoID: Int?
public var tree: [DomainTreeNode] = []
public var stats: CategoryCounts?
/// ( ). loadInitial count . nil=.
public var reviewPendingCount: Int?
/// ( ). loadInitial me() .
public var currentUser: UserResponse?
public private(set) var uploadState: UploadState = .idle
/// (CaptureCard , saveMemo ).
public var captureText: String = ""
public var documentList: [DocumentResponse] = []
public var documentDetail: DocumentDetailResponse?
public var searchQuery: String = ""
public var searchResponse: SearchResponse?
public var askQuery: String = ""
public var askResult: AIResult?
public var askMeta: DSKit.AskResponse? // qualified: AIFabric also defines an AskResponse
/// ( path, nil = ).
public var documentDomainFilter: String?
/// ( load-all ). .
public private(set) var documentsFullyLoaded = false
public var memoList: [MemoResponse] = []
public var memoDetail: MemoResponse?
public var digest: DigestResponse?
@@ -129,11 +144,16 @@ public final class AppModel {
}
public func loadInitial() async {
await guarded { self.currentUser = try await self.client.me() }
await guarded { self.tree = try await self.client.documentTree() }
await guarded { self.stats = try await self.client.categoryCounts() }
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
await guarded { self.memoList = try await self.client.memos(MemoListQuery()).items }
await guarded { self.digest = try await self.client.digest(date: nil, country: nil) }
await guarded {
var q = DocumentListQuery(); q.reviewStatus = "pending"; q.pageSize = 1
self.reviewPendingCount = try await self.client.documents(q).total
}
}
public func openDocument(_ id: Int) async {
@@ -141,15 +161,60 @@ public final class AppModel {
await guarded { self.documentDetail = try await self.client.document(id: id) }
}
public func runSearch() async {
guard !searchQuery.isEmpty else { return }
await guarded { self.searchResponse = try await self.client.search(q: self.searchQuery, mode: .hybrid, page: 1, debug: false) }
/// ( ). load-all.
public func ensureDocumentsLoaded() async {
if !documentsFullyLoaded { await loadDocuments(domain: documentDomainFilter) }
}
public func runAsk(backend: AIProviderID?) async {
guard !askQuery.isEmpty else { return }
askResult = await ai.corpusAsk(question: askQuery, explicit: backend)
await guarded { self.askMeta = try await self.client.ask(q: self.askQuery, limit: nil, backend: nil, debug: false) }
/// **** load-all ( page_size 100
/// 1582 ). append .
/// / 3-pane .
public func loadDocuments(domain: String?) async {
documentDomainFilter = domain
documentsFullyLoaded = false
documentList = []
let pageSize = 100
var page = 1
do {
while page <= 80 { // ~8000
var q = DocumentListQuery(); q.domain = domain; q.page = page; q.pageSize = pageSize
let resp = try await client.documents(q)
documentList.append(contentsOf: resp.items)
if resp.items.count < pageSize || documentList.count >= resp.total { break }
page += 1
}
documentsFullyLoaded = true
} catch let e as DSError where e.isAuthExpired {
authPhase = .loggedOut
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
} catch {
errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
await syncAccessToken()
if let sel = selectedDocumentID, !documentList.contains(where: { $0.id == sel }) {
selectedDocumentID = nil
documentDetail = nil
}
}
/// . true. / (false).
/// guarded errorText ( ).
@discardableResult
public func saveMemo(_ text: String) async -> Bool {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty else { return false }
var ok = false
await guarded {
let memo = try await self.client.createMemo(MemoCreate(content: t))
self.memoList.insert(memo, at: 0)
ok = true
}
return ok
}
/// captureText , .
public func saveMemo() async {
if await saveMemo(captureText) { captureText = "" }
}
public func openMemo(_ id: Int) async {
@@ -162,6 +227,67 @@ public final class AppModel {
return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken)
}
/// : / (best-effort) loggedOut.
/// stale . .
public func logout() async {
try? await client.logout()
accessToken = ""
currentUser = nil
tree = []
stats = nil
reviewPendingCount = nil
captureText = ""
documentList = []
documentDetail = nil
documentDomainFilter = nil
documentsFullyLoaded = false
memoList = []
memoDetail = nil
digest = nil
selectedDocumentID = nil
selectedMemoID = nil
section = .dashboard // ( LOW: )
errorText = nil
uploadState = .idle
authPhase = .loggedOut
}
/// (NSOpenPanel URL) . IO uploadState .
public func uploadPicked(_ fileURL: URL) async {
let accessed = fileURL.startAccessingSecurityScopedResource()
defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } }
let filename = fileURL.lastPathComponent
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
uploadState = .failed("파일을 읽을 수 없습니다: \((error as NSError).localizedDescription)")
return
}
let mime = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType
await upload(DocumentUpload(filename: filename, data: data, mimeType: mime))
}
/// + . ( = ).
public func upload(_ payload: DocumentUpload) async {
uploadState = .uploading(name: payload.filename)
do {
let doc = try await client.uploadDocument(payload)
uploadState = .done(title: doc.title ?? doc.downloadLabel)
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
} catch let e as DSError where e.isAuthExpired {
authPhase = .loggedOut
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
uploadState = .failed("세션이 만료되었습니다.")
} catch {
uploadState = .failed((error as? LocalizedError)?.errorDescription ?? "\(error)")
}
await syncAccessToken()
}
/// (done/failed ).
public func dismissUploadStatus() { uploadState = .idle }
private func guarded(_ work: () async throws -> Void) async {
do {
try await work()
@@ -23,6 +23,8 @@ public protocol DSClient: Sendable {
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse
func putContent(id: Int, content: String) async throws
func deleteDocument(id: Int) async throws
/// (POST /documents/) Inbox + . 201 DocumentResponse.
func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse
// Search / Ask
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse
@@ -53,6 +53,9 @@ public struct FixtureDSClient: DSClient {
}
public func putContent(id: Int, content: String) async throws {}
public func deleteDocument(id: Int) async throws {}
public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse {
try load("document_detail", as: DocumentDetailResponse.self).base
}
// Search / Ask
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse {
@@ -64,15 +64,26 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
}
private func perform(_ endpoint: DSEndpoint) async throws -> Data {
let request = try makeRequest(endpoint, token: await tokens.current())
try await performWithRetry(requiresBearer: endpoint.requiresBearer) { token in
try self.makeRequest(endpoint, token: token)
}
}
/// 401 - refresh + 1 . `build` ( )URLRequest ,
/// 401 . JSON (perform) .
private func performWithRetry(
requiresBearer: Bool,
_ build: (_ token: String?) throws -> URLRequest
) async throws -> Data {
let request = try build(await tokens.current())
let (data, response) = try await dataOrTransport(request)
guard let http = response as? HTTPURLResponse else {
throw DSError.transport(underlying: "no HTTP response")
}
if http.statusCode == 401, endpoint.requiresBearer {
if http.statusCode == 401, requiresBearer {
// Single-flight refresh + one retry.
let newToken = try await tokens.refreshOnce()
let retry = try makeRequest(endpoint, token: newToken)
let retry = try build(newToken)
let (data2, response2) = try await dataOrTransport(retry)
guard let http2 = response2 as? HTTPURLResponse else {
throw DSError.transport(underlying: "no HTTP response")
@@ -122,6 +133,44 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) }
public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) }
public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse {
let boundary = "DSBoundary-\(UUID().uuidString)"
let body = LiveDSClient.multipartBody(for: upload, boundary: boundary)
// (POST /documents/) base (appendingPathComponent strip).
let raw = base.url.absoluteString + "/documents/"
guard let url = URL(string: raw) else { throw DSError.transport(underlying: "bad URL \(raw)") }
let data = try await performWithRetry(requiresBearer: true) { token in
var request = URLRequest(url: url)
request.httpMethod = "POST"
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body
return request
}
do { return try decoder.decode(DocumentResponse.self, from: data) }
catch { throw DSError.decoding("documents/ upload: \(error)") }
}
/// multipart/form-data . file + form (doc_purpose/library_path).
/// internal( ) UTF-8 (Starlette ).
static func multipartBody(for upload: DocumentUpload, boundary: String) -> Data {
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
body.append(Data("\(value)\r\n".utf8))
}
if let p = upload.docPurpose { appendField("doc_purpose", p) }
if let lp = upload.libraryPath { appendField("library_path", lp) }
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(upload.filename)\"\r\n".utf8))
body.append(Data("Content-Type: \(upload.mimeType ?? "application/octet-stream")\r\n\r\n".utf8))
body.append(upload.data)
body.append(Data("\r\n".utf8))
body.append(Data("--\(boundary)--\r\n".utf8))
return body
}
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) }
public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) }
@@ -24,6 +24,25 @@ public struct MemoListQuery: Sendable {
public init() {}
}
/// (POST /documents/). `file` + form .
/// `data` ( ) .
public struct DocumentUpload: Sendable {
public var filename: String
public var data: Data
public var mimeType: String?
/// "business" | "knowledge" | nil. business @library .
public var docPurpose: String?
public var libraryPath: String?
public init(filename: String, data: Data, mimeType: String? = nil,
docPurpose: String? = nil, libraryPath: String? = nil) {
self.filename = filename
self.data = data
self.mimeType = mimeType
self.docPurpose = docPurpose
self.libraryPath = libraryPath
}
}
public struct DocumentUpdate: Codable, Sendable {
public var title: String?
public var userNote: String?
@@ -0,0 +1,50 @@
import XCTest
@testable import AppFeature
import DSKit
/// + 0 (Fixture).
final class AppModelActionsTests: XCTestCase {
// ready loggedOut + //
@MainActor
func testLogoutResetsStateAndLogsOut() async {
let model = AppModel.preview
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
XCTAssertFalse(model.documentList.isEmpty)
XCTAssertNotNil(model.currentUser, "loadInitial 이 me() 로 사용자 채움")
await model.logout()
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertTrue(model.accessToken.isEmpty)
XCTAssertNil(model.currentUser)
XCTAssertTrue(model.documentList.isEmpty)
XCTAssertNil(model.documentDetail)
XCTAssertTrue(model.tree.isEmpty)
XCTAssertEqual(model.uploadState, .idle)
}
// uploadState=.done +
@MainActor
func testUploadSuccessSetsDoneAndReloads() async {
let model = AppModel.preview
await model.bootstrap()
await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8), mimeType: "application/pdf"))
if case .done = model.uploadState {} else {
XCTFail("기대 .done, 실제 \(model.uploadState)")
}
XCTAssertFalse(model.documentList.isEmpty)
}
// (Equatable )
@MainActor
func testDismissUploadStatusReturnsToIdle() async {
let model = AppModel.preview
await model.bootstrap()
await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8)))
model.dismissUploadStatus()
XCTAssertEqual(model.uploadState, .idle)
}
}
@@ -168,6 +168,7 @@ final class AuthStubClient: DSClient, @unchecked Sendable {
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await inner.patchDocument(id: id, update) }
func putContent(id: Int, content: String) async throws { try await inner.putContent(id: id, content: content) }
func deleteDocument(id: Int) async throws { try await inner.deleteDocument(id: id) }
func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { try await inner.uploadDocument(upload) }
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await inner.search(q: q, mode: mode, page: page, debug: debug) }
func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await inner.ask(q: q, limit: limit, backend: backend, debug: debug) }
func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await inner.memos(query) }
@@ -0,0 +1,42 @@
import XCTest
@testable import DSKit
/// Fixture + multipart (// // ).
final class UploadTests: XCTestCase {
func testFixtureUploadReturnsDocument() async throws {
let doc = try await FixtureDSClient().uploadDocument(
DocumentUpload(filename: "a.pdf", data: Data("x".utf8), mimeType: "application/pdf"))
XCTAssertGreaterThan(doc.id, 0)
}
func testMultipartBodyShape() throws {
let upload = DocumentUpload(
filename: "보고서.pdf",
data: Data("PDFDATA".utf8),
mimeType: "application/pdf",
docPurpose: "knowledge"
)
let boundary = "TESTBOUNDARY"
let body = LiveDSClient.multipartBody(for: upload, boundary: boundary)
let s = try XCTUnwrap(String(data: body, encoding: .utf8))
XCTAssertTrue(s.contains("--TESTBOUNDARY\r\n"), "경계 마커")
XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="file"; filename=".pdf""#),
"file 파트 + 한글 파일명")
XCTAssertTrue(s.contains("Content-Type: application/pdf"), "파일 mime")
XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="doc_purpose""#), "선택 form 필드")
XCTAssertTrue(s.contains("knowledge"))
XCTAssertTrue(s.contains("PDFDATA"), "파일 데이터")
XCTAssertTrue(s.hasSuffix("--TESTBOUNDARY--\r\n"), "종료 경계")
}
func testMultipartOmitsAbsentOptionalFields() throws {
let upload = DocumentUpload(filename: "x.txt", data: Data("a".utf8))
let body = LiveDSClient.multipartBody(for: upload, boundary: "B")
let s = try XCTUnwrap(String(data: body, encoding: .utf8))
XCTAssertFalse(s.contains("doc_purpose"), "미지정 doc_purpose 는 본문에 없어야 함")
XCTAssertFalse(s.contains("library_path"), "미지정 library_path 는 본문에 없어야 함")
XCTAssertTrue(s.contains("Content-Type: application/octet-stream"), "mime 미지정 = octet-stream 폴백")
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, l
| GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` |
| GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` |
| GET | `/documents/stats/category-counts` | — | `{counts: {category: n}, library_pending_suggestions}`**raw dict 반환(Pydantic 모델 없음), 2026-06-07 라이브 재캡처로 정정**(초기 추출이 shape 합성 오류) | `documents_stats.json` |
| POST | `/documents/` (multipart) | 파일 업로드 | `DocumentResponse` (201) | `document_detail.json` |
| POST | `/documents/` (multipart/form-data) | `file`(필수) + `doc_purpose?`(business\|knowledge) `library_path?` `facet_*?` | `DocumentResponse` (201) | `document_detail.json` |
| PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — |
| PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — |
| POST | `/documents/{id}/accept-suggestion` | `{expected_source_updated_at}` | `DocumentResponse` | — |
+4
View File
@@ -0,0 +1,4 @@
DSShell.xcodeproj/
Support/
.build/
*.xcuserstate
@@ -0,0 +1,74 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"images" : [
{
"scale" : "1x",
"filename" : "mac_16.png",
"idiom" : "mac",
"size" : "16x16"
},
{
"idiom" : "mac",
"size" : "16x16",
"scale" : "2x",
"filename" : "mac_32.png"
},
{
"filename" : "mac_32.png",
"size" : "32x32",
"scale" : "1x",
"idiom" : "mac"
},
{
"scale" : "2x",
"idiom" : "mac",
"size" : "32x32",
"filename" : "mac_64.png"
},
{
"idiom" : "mac",
"size" : "128x128",
"filename" : "mac_128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"scale" : "2x",
"filename" : "mac_256.png"
},
{
"filename" : "mac_256.png",
"scale" : "1x",
"idiom" : "mac",
"size" : "256x256"
},
{
"filename" : "mac_512.png",
"scale" : "2x",
"size" : "256x256",
"idiom" : "mac"
},
{
"filename" : "mac_512.png",
"size" : "512x512",
"idiom" : "mac",
"scale" : "1x"
},
{
"filename" : "mac_1024.png",
"size" : "512x512",
"scale" : "2x",
"idiom" : "mac"
},
{
"idiom" : "universal",
"filename" : "ios_1024.png",
"size" : "1024x1024",
"platform" : "ios"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+29
View File
@@ -0,0 +1,29 @@
import SwiftUI
/// DS document.hyungi.net . WKWebsiteDataStore.default()
/// (). ·iOS @main.
@main
struct DSShellApp: App {
private let url = URL(string: "https://document.hyungi.net")!
var body: some Scene {
WindowGroup {
RootWeb(url: url)
}
#if os(macOS)
.windowStyle(.automatic)
#endif
}
}
struct RootWeb: View {
let url: URL
var body: some View {
WebView(url: url)
.ignoresSafeArea()
#if os(macOS)
.frame(minWidth: 900, minHeight: 600)
.background(WindowOnScreenGuard()) //
#endif
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
import WebKit
#if os(macOS)
import AppKit
#else
import UIKit
#endif
/// document.hyungi.net WKWebView (=NSViewRepresentable / iOS=UIViewRepresentable).
/// = . (Content-Disposition: attachment) .
/// (file input) WKWebView .
struct WebView {
let url: URL
func makeCoordinator() -> Coordinator { Coordinator() }
@MainActor
fileprivate func makeWebView(coordinator: Coordinator) -> WKWebView {
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default() // ()
let wv = WKWebView(frame: .zero, configuration: cfg)
wv.navigationDelegate = coordinator
wv.allowsBackForwardNavigationGestures = true
wv.load(URLRequest(url: url))
return wv
}
final class Coordinator: NSObject, WKNavigationDelegate, WKDownloadDelegate {
// , (PDF ).
func webView(_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if let http = navigationResponse.response as? HTTPURLResponse,
let cd = http.value(forHTTPHeaderField: "Content-Disposition"),
cd.lowercased().contains("attachment") {
decisionHandler(.download)
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
download.delegate = self
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
download.delegate = self
}
func download(_ download: WKDownload,
decideDestinationUsing response: URLResponse,
suggestedFilename: String) async -> URL? {
#if os(macOS)
let folder = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
#else
let folder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
#endif
let dir = folder ?? FileManager.default.temporaryDirectory
var dest = dir.appendingPathComponent(suggestedFilename.isEmpty ? "download" : suggestedFilename)
// (name_1.ext )
let base = dest.deletingPathExtension().lastPathComponent
let ext = dest.pathExtension
var n = 1
while FileManager.default.fileExists(atPath: dest.path) {
let name = ext.isEmpty ? "\(base)_\(n)" : "\(base)_\(n).\(ext)"
dest = dir.appendingPathComponent(name); n += 1
}
return dest
}
}
}
#if os(macOS)
extension WebView: NSViewRepresentable {
func makeNSView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) }
func updateNSView(_ nsView: WKWebView, context: Context) {}
}
/// ( ) " " .
struct WindowOnScreenGuard: NSViewRepresentable {
func makeNSView(context: Context) -> NSView { OnScreenView() }
func updateNSView(_ nsView: NSView, context: Context) {}
final class OnScreenView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let win = window else { return }
if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(win.frame) }) { win.center() }
}
}
}
#else
extension WebView: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) }
func updateUIView(_ uiView: WKWebView, context: Context) {}
}
#endif
+88
View File
@@ -0,0 +1,88 @@
# DS 웹 래퍼 — document.hyungi.net 을 WKWebView 로 감싼 네이티브 앱(맥 + iOS).
# 웹 UI 100% 재사용·항상 최신·코드 1벌(2026-06-15 결정). 순수 네이티브는 워치(clients/ds-watch)만.
# project.yml = source of truth, *.xcodeproj/Support = 생성물(gitignore).
name: DSShell
options:
bundleIdPrefix: net.hyungi
deploymentTarget:
macOS: "14.0"
iOS: "17.0"
createIntermediateGroups: true
minimumXcodeGenVersion: "2.40.0"
settings:
base:
SWIFT_VERSION: "6.0"
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
GENERATE_INFOPLIST_FILE: "NO"
targets:
DSShellMac:
type: application
platform: macOS
deploymentTarget: "14.0"
sources:
- path: Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell
PRODUCT_NAME: DS
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: Support/Mac-Info.plist
properties:
CFBundleName: DS
CFBundleDisplayName: DS
CFBundleShortVersionString: "0.1"
CFBundleVersion: "1"
CFBundlePackageType: APPL
LSMinimumSystemVersion: "14.0"
LSApplicationCategoryType: public.app-category.productivity
entitlements:
path: Support/Mac.entitlements
properties:
com.apple.security.app-sandbox: true
com.apple.security.network.client: true
com.apple.security.files.downloads.read-write: true # 원본 다운로드 저장
com.apple.security.files.user-selected.read-write: true # 업로드 파일 선택
DSShelliOS:
type: application
platform: iOS
deploymentTarget: "17.0"
sources:
- path: Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell
PRODUCT_NAME: DS
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: Support/iOS-Info.plist
properties:
CFBundleName: DS
CFBundleDisplayName: DS
CFBundleShortVersionString: "0.1"
CFBundleVersion: "1"
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
schemes:
DSShellMac:
build:
targets: { DSShellMac: all }
run: { config: Debug }
DSShelliOS:
build:
targets: { DSShelliOS: all }
run: { config: Debug }
+5
View File
@@ -0,0 +1,5 @@
# xcodegen 생성물 (project.yml 이 source of truth)
DSWatch.xcodeproj/
Support/
.build/
*.xcuserstate
@@ -0,0 +1,6 @@
{
"images" : [
{ "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024", "filename" : "watch_1024.png" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+28
View File
@@ -0,0 +1,28 @@
import SwiftUI
/// DS (standalone). 4 = (AI)·()· ·.
/// = (/study-cards/due·rate) / = . OLED.
@main
struct DSWatchApp: App {
@State private var model = WatchModel()
var body: some Scene {
WindowGroup {
RootGate()
.environment(model)
.task { await model.bootstrap() }
}
}
}
/// : checking( ) loggedOut() ready().
struct RootGate: View {
@Environment(WatchModel.self) private var model
var body: some View {
switch model.phase {
case .checking: ProgressView()
case .loggedOut: LoginView()
case .ready: RootMenu()
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import WatchKit
/// / ( ).
@MainActor
enum Haptics {
static func success() { WKInterfaceDevice.current().play(.success) }
static func retry() { WKInterfaceDevice.current().play(.retry) }
static func click() { WKInterfaceDevice.current().play(.click) }
}
+262
View File
@@ -0,0 +1,262 @@
import Foundation
/// API . DS TLS(document.hyungi.net) Tailscale .
/// access = / refresh =HTTPCookieStorage(7 ) 1 .
/// Pydantic (study_cards.py CardItem/RateBody) .
enum WatchAPI {
static let baseString = "https://document.hyungi.net/api"
}
/// GET /study-cards/due CardItem ( ).
struct WCard: Decodable, Identifiable, Sendable {
let id: Int
let format: String
let cue: String
let fact: String
let clozeText: String?
let needsReview: Bool
let reviewStage: Int?
enum CodingKeys: String, CodingKey {
case id, format, cue, fact
case clozeText = "cloze_text"
case needsReview = "needs_review"
case reviewStage = "review_stage"
}
}
/// GET /events/today EventResponse ( ).
struct WEvent: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let status: String
let dueAt: String?
let completedAt: String?
enum CodingKeys: String, CodingKey {
case id, title, status
case dueAt = "due_at"
case completedAt = "completed_at"
}
var isDone: Bool { status == "completed" || completedAt != nil }
}
private struct WEventList: Decodable { let items: [WEvent] }
/// GET /briefing/latest / ( ).
struct WPerspective: Decodable, Identifiable, Sendable {
let country: String
let summary: String
var id: String { country }
}
struct WTopic: Decodable, Identifiable, Sendable {
let id: Int
let topicLabel: String
let headline: String
let countryPerspectives: [WPerspective]
enum CodingKeys: String, CodingKey {
case id, headline
case topicLabel = "topic_label"
case countryPerspectives = "country_perspectives"
}
}
struct WBriefing: Decodable, Sendable {
let status: String
let headlineOneliner: String?
let topics: [WTopic]
enum CodingKeys: String, CodingKey {
case status, topics
case headlineOneliner = "headline_oneliner"
}
}
/// SSE unavailable( /).
struct ChatResult: Sendable {
let answer: String
let unavailable: Bool
let reason: String?
}
private struct AccessTokenBody: Decodable { let accessToken: String
enum CodingKeys: String, CodingKey { case accessToken = "access_token" } }
enum WCError: Error, LocalizedError {
case transport(String)
case http(Int, String?)
case decoding(String)
var errorDescription: String? {
switch self {
case .transport(let m): return "네트워크 오류: \(m)"
case .http(let s, let m): return m ?? "서버 오류 (\(s))"
case .decoding(let m): return "응답 해석 실패: \(m)"
}
}
var isUnauthorized: Bool { if case .http(401, _) = self { return true }; return false }
}
actor WatchClient {
private let session: URLSession
private var accessToken: String?
init() {
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = .shared
cfg.httpShouldSetCookies = true
cfg.waitsForConnectivity = true
session = URLSession(configuration: cfg)
}
private func url(_ path: String) -> URL { URL(string: WatchAPI.baseString + "/" + path)! }
private func send(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) {
do {
let (d, r) = try await session.data(for: req)
guard let h = r as? HTTPURLResponse else { throw WCError.transport("no HTTP response") }
return (d, h)
} catch let e as WCError { throw e }
catch { throw WCError.transport("\(error.localizedDescription)") }
}
private static func decodeMessage(_ data: Data) -> String? {
guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
if let s = o["detail"] as? String { return s }
if let d = o["detail"] as? [String: Any] { return d["message"] as? String }
return nil
}
// MARK: auth
func login(username: String, password: String, totp: String?) async throws {
var req = URLRequest(url: url("auth/login"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["username": username, "password": password]
if let totp, !totp.isEmpty { body["totp_code"] = totp }
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, http) = try await send(req)
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
accessToken = try decodeToken(data)
}
@discardableResult
func refresh() async throws -> String {
var req = URLRequest(url: url("auth/refresh"))
req.httpMethod = "POST"
let (data, http) = try await send(req)
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
let t = try decodeToken(data)
accessToken = t
return t
}
func logout() async {
accessToken = nil
var req = URLRequest(url: url("auth/logout")); req.httpMethod = "POST"
_ = try? await send(req)
}
private func decodeToken(_ data: Data) throws -> String {
do { return try JSONDecoder().decode(AccessTokenBody.self, from: data).accessToken }
catch { throw WCError.decoding("token: \(error)") }
}
// MARK: authed request (401 single refresh + retry)
private func authed(_ path: String, method: String = "GET", json: [String: Any]? = nil) async throws -> Data {
func make(_ token: String?) throws -> URLRequest {
var r = URLRequest(url: url(path))
r.httpMethod = method
if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let json { r.httpBody = try JSONSerialization.data(withJSONObject: json); r.setValue("application/json", forHTTPHeaderField: "Content-Type") }
return r
}
let (data, http) = try await send(make(accessToken))
if http.statusCode == 401 {
let newToken = try await refresh()
let (d2, h2) = try await send(make(newToken))
guard (200..<300).contains(h2.statusCode) else { throw WCError.http(h2.statusCode, Self.decodeMessage(d2)) }
return d2
}
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
return data
}
// MARK: study cards
func dueCards() async throws -> [WCard] {
let data = try await authed("study-cards/due")
do { return try JSONDecoder().decode([WCard].self, from: data) }
catch { throw WCError.decoding("due: \(error)") }
}
func rate(cardId: Int, outcome: String) async throws {
_ = try await authed("study-cards/\(cardId)/rate", method: "POST", json: ["outcome": outcome])
}
func flag(cardId: Int) async throws {
_ = try await authed("study-cards/\(cardId)", method: "PATCH", json: ["needs_review": true])
}
// MARK: events ()
func events() async throws -> [WEvent] {
let data = try await authed("events/today")
do { return try JSONDecoder().decode(WEventList.self, from: data).items }
catch { throw WCError.decoding("events: \(error)") }
}
func completeEvent(id: Int) async throws {
_ = try await authed("events/\(id)/complete", method: "POST")
}
// MARK: briefing ( )
func briefing() async throws -> WBriefing {
let data = try await authed("briefing/latest")
do { return try JSONDecoder().decode(WBriefing.self, from: data) }
catch { throw WCError.decoding("briefing: \(error)") }
}
// MARK: eid chat (SSE 26B via DS )
func chat(_ text: String) async throws -> ChatResult {
let payload: [String: Any] = ["mode": "daily", "messages": [["role": "user", "content": text]]]
func make(_ token: String?) throws -> URLRequest {
var r = URLRequest(url: url("eid/chat"))
r.httpMethod = "POST"
r.timeoutInterval = 120
if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.setValue("text/event-stream", forHTTPHeaderField: "Accept")
r.httpBody = try JSONSerialization.data(withJSONObject: payload)
return r
}
var (stream, resp) = try await session.bytes(for: make(accessToken))
if (resp as? HTTPURLResponse)?.statusCode == 401 {
let t = try await refresh()
(stream, resp) = try await session.bytes(for: make(t))
}
guard let http = resp as? HTTPURLResponse else { throw WCError.transport("no HTTP response") }
let ctype = http.value(forHTTPHeaderField: "Content-Type") ?? ""
if ctype.contains("text/event-stream") {
var answer = ""
for try await line in stream.lines {
guard line.hasPrefix("data:") else { continue }
let body = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
if body == "[DONE]" || body.isEmpty { continue }
if let d = body.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
let choices = obj["choices"] as? [[String: Any]],
let delta = choices.first?["delta"] as? [String: Any],
let content = delta["content"] as? String {
answer += content
}
}
return ChatResult(answer: answer, unavailable: answer.isEmpty,
reason: answer.isEmpty ? "빈 응답" : nil)
}
// - = unavailable JSONResponse ( /) .
var raw = Data()
for try await b in stream { raw.append(b) }
return ChatResult(answer: "", unavailable: true, reason: Self.decodeMessage(raw) ?? "이드 연결 불가")
}
}
+46
View File
@@ -0,0 +1,46 @@
import SwiftUI
/// = 4 . .
struct RootMenu: View {
var body: some View {
NavigationStack {
List {
NavigationLink { EidView() } label: {
MenuRow(symbol: "bubble.left.and.bubble.right.fill", title: "이드", sub: "AI 채팅")
}
NavigationLink { StudyView() } label: {
MenuRow(symbol: "rectangle.on.rectangle.angled.fill", title: "공부", sub: "암기 카드")
}
NavigationLink { TodoView() } label: {
MenuRow(symbol: "checklist", title: "할 일", sub: "오늘")
}
NavigationLink { BriefingView() } label: {
MenuRow(symbol: "newspaper.fill", title: "브리핑", sub: "모닝")
}
}
.navigationTitle("DS")
}
.tint(WT.accent)
}
}
struct MenuRow: View {
let symbol: String
let title: String
let sub: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbol)
.font(.system(size: 16))
.foregroundStyle(WT.accent)
.frame(width: 24)
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
Text(sub).font(.system(size: 11)).foregroundStyle(WT.muted)
}
}
.padding(.vertical, 3)
}
}
#Preview { RootMenu() }
+160
View File
@@ -0,0 +1,160 @@
import SwiftUI
// MARK: - (Todo) GET /events/today + POST /complete
struct TodoView: View {
@Environment(WatchModel.self) private var model
@State private var loaded = false
var body: some View {
Group {
if model.eventsLoading && model.events.isEmpty {
ProgressView()
} else if let e = model.eventsError, model.events.isEmpty {
retry("불러오기 실패\n\(e)") { await model.loadEvents() }
} else if model.events.isEmpty {
retry("오늘 할 일이 없어요", color: WT.muted) { await model.loadEvents() }
} else {
List(model.events) { ev in
Button {
if !ev.isDone { Haptics.success() }
Task { await model.completeEvent(ev.id) }
} label: {
HStack(spacing: 10) {
Image(systemName: ev.isDone ? "checkmark.circle.fill" : "circle")
.font(.system(size: 17))
.foregroundStyle(ev.isDone ? WT.accent : WT.muted)
Text(ev.title)
.font(.system(size: 14))
.foregroundStyle(ev.isDone ? WT.muted : WT.ink)
.strikethrough(ev.isDone, color: WT.muted)
Spacer()
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
}
}
}
.navigationTitle("할 일")
.task { if !loaded { loaded = true; await model.loadEvents() } }
}
}
// MARK: - () GET /briefing/latest,
struct BriefingView: View {
@Environment(WatchModel.self) private var model
@State private var loaded = false
var body: some View {
ScrollView {
if model.briefingLoading && model.briefing == nil {
ProgressView().padding(.top, 20)
} else if let e = model.briefingError, model.briefing == nil {
retry("불러오기 실패\n\(e)") { await model.loadBriefing() }
} else if let b = model.briefing, !b.topics.isEmpty {
VStack(alignment: .leading, spacing: 10) {
if let one = b.headlineOneliner, !one.isEmpty {
Text(one).font(.system(size: 15, weight: .semibold)).foregroundStyle(WT.ink)
}
ForEach(b.topics) { t in
VStack(alignment: .leading, spacing: 5) {
Text(t.headline).font(.system(size: 13, weight: .semibold)).foregroundStyle(WT.ink)
ForEach(t.countryPerspectives) { p in
HStack(alignment: .top, spacing: 5) {
Text(p.country.uppercased())
.font(.system(size: 9, weight: .bold)).foregroundStyle(WT.accent)
.frame(minWidth: 22, alignment: .leading)
Text(p.summary).font(.system(size: 11)).foregroundStyle(WT.muted).lineLimit(4)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(WT.card, in: RoundedRectangle(cornerRadius: 12))
}
}
} else {
retry("오늘 브리핑이 아직 없어요", color: WT.muted) { await model.loadBriefing() }
}
}
.navigationTitle("브리핑")
.task { if !loaded { loaded = true; await model.loadBriefing() } }
}
}
// MARK: - (AI ) POST /eid/chat ( 26B via DS )
struct EidView: View {
@Environment(WatchModel.self) private var model
@State private var draft = ""
var body: some View {
ScrollView {
VStack(spacing: 8) {
HStack(spacing: 6) {
TextField("물어보기…", text: $draft)
.textFieldStyle(.plain)
.padding(8)
.background(WT.card, in: RoundedRectangle(cornerRadius: 10))
Button {
let t = draft; draft = ""
Task { await model.sendChat(t) }
} label: {
Image(systemName: "arrow.up.circle.fill").font(.system(size: 22))
}
.buttonStyle(.plain)
.foregroundStyle(WT.accent)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.chatSending)
}
if model.chatSending {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("이드 생각 중…").font(.system(size: 11)).foregroundStyle(WT.muted)
}
}
ForEach(model.chatTurns.reversed()) { turn in
ChatBubble(turn: turn)
}
if model.chatTurns.isEmpty && !model.chatSending {
Text("음성·키보드로 묻고\n맥미니 26B 가 답합니다")
.font(.system(size: 11)).foregroundStyle(WT.muted)
.multilineTextAlignment(.center).padding(.top, 8)
}
}
}
.navigationTitle("이드")
}
}
private struct ChatBubble: View {
let turn: WatchModel.ChatTurn
var body: some View {
let isUser = turn.role == "user"
let isError = turn.role == "error"
HStack {
if isUser { Spacer(minLength: 24) }
Text(turn.text)
.font(.system(size: 12))
.foregroundStyle(isUser ? .black : (isError ? WT.danger : WT.ink))
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
.padding(8)
.background(isUser ? WT.accent : (isError ? WT.danger.opacity(0.15) : WT.card),
in: RoundedRectangle(cornerRadius: 10))
if !isUser { Spacer(minLength: 24) }
}
}
}
// MARK: - /
@MainActor
private func retry(_ text: String, color: Color = WT.danger, _ action: @escaping () async -> Void) -> some View {
VStack(spacing: 10) {
Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center)
Button("다시 불러오기") { Task { await action() } }.tint(WT.accent)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 6).padding(.top, 16)
}
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
/// () ( cue fact) + 2 (/).
/// (B5): 2 ( ), ' ' ( /), OLED.
/// = GET /study-cards/due, = POST /{id}/rate (correct/wrong), = PATCH needs_review.
struct StudyView: View {
@Environment(WatchModel.self) private var model
@State private var index = 0
@State private var revealed = false
@State private var correctCount = 0
@State private var flagged = false
@State private var loaded = false
var body: some View {
Group {
if model.studyLoading && model.cards.isEmpty {
ProgressView()
} else if let err = model.studyError, model.cards.isEmpty {
stateText("불러오기 실패\n\(err)", color: WT.danger, retry: true)
} else if model.cards.isEmpty {
stateText("복습할 카드가 없어요", color: WT.muted, retry: true)
} else if index >= model.cards.count {
ResultView(total: model.cards.count, correct: correctCount) { Task { await reload() } }
} else {
cardScreen(model.cards[index])
}
}
.navigationTitle("공부")
.task { if !loaded { loaded = true; await model.loadDue(); reset() } }
}
private func cardScreen(_ c: WCard) -> some View {
VStack(spacing: 8) {
HStack {
Text("\(index + 1) / \(model.cards.count)").font(.system(size: 11)).foregroundStyle(WT.muted)
Spacer()
Button {
flagged = true
Haptics.click()
Task { await model.flag(cardId: c.id) }
} label: {
Image(systemName: flagged ? "flag.fill" : "flag")
.font(.system(size: 11)).foregroundStyle(flagged ? WT.amber : WT.muted)
}
.buttonStyle(.plain)
}
ScrollView {
VStack(spacing: 10) {
Text(c.cue)
.font(.system(size: 17, weight: .semibold)).foregroundStyle(WT.ink)
.multilineTextAlignment(.center)
if revealed {
Divider().overlay(WT.muted.opacity(0.4))
Text(c.fact)
.font(.system(size: 15)).foregroundStyle(WT.accent)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(12)
.background(WT.card, in: RoundedRectangle(cornerRadius: 14))
}
if revealed {
HStack(spacing: 8) {
rateButton("다시", sub: "내일", color: WT.danger) { advance(c, correct: false) }
rateButton("알아요", sub: nil, color: WT.accent) { advance(c, correct: true) }
}
} else {
Button { withAnimation(.easeOut(duration: 0.15)) { revealed = true } } label: {
Text("답 보기").frame(maxWidth: .infinity)
}
.tint(WT.accent)
}
}
.padding(.horizontal, 4)
}
private func rateButton(_ title: String, sub: String?, color: Color, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 1) {
Text(title).font(.system(size: 14, weight: .semibold))
if let sub { Text(sub).font(.system(size: 9)).opacity(0.8) }
}
.frame(maxWidth: .infinity).padding(.vertical, 2)
}
.tint(color)
}
private func stateText(_ text: String, color: Color, retry: Bool) -> some View {
VStack(spacing: 10) {
Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center)
if retry { Button("다시 불러오기") { Task { await reload() } }.tint(WT.accent) }
}
.padding(.horizontal, 6)
}
private func advance(_ c: WCard, correct: Bool) {
if correct { correctCount += 1 }
Haptics.success() // (/ )
Task { await model.rate(cardId: c.id, outcome: correct ? "correct" : "wrong") }
flagged = false
revealed = false
index += 1
}
private func reload() async { await model.loadDue(); reset() }
private func reset() { index = 0; revealed = false; correctCount = 0; flagged = false }
}
/// tally( streak X).
struct ResultView: View {
let total: Int
let correct: Int
let onRestart: () -> Void
var body: some View {
ScrollView {
VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill").font(.system(size: 30)).foregroundStyle(WT.accent)
Text("오늘 복습 완료").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
Text("\(correct) / \(total) 알아요").font(.system(size: 13)).foregroundStyle(WT.muted)
Text("애매하거나 몰랐던 카드는 내일 다시 만나요")
.font(.system(size: 11)).foregroundStyle(WT.muted).multilineTextAlignment(.center)
Button("다시 불러오기", action: onRestart).tint(WT.accent).padding(.top, 4)
}
.frame(maxWidth: .infinity).padding(.vertical, 6)
}
.navigationTitle("결과")
}
}
+162
View File
@@ -0,0 +1,162 @@
import SwiftUI
import Observation
/// . refresh . .
@MainActor
@Observable
final class WatchModel {
enum Phase: Equatable { case checking, loggedOut, ready }
var phase: Phase = .checking
var loginError: String?
// (study)
var cards: [WCard] = []
var studyLoading = false
var studyError: String?
// (events)
var events: [WEvent] = []
var eventsLoading = false
var eventsError: String?
//
var briefing: WBriefing?
var briefingLoading = false
var briefingError: String?
// (chat)
struct ChatTurn: Identifiable, Sendable { let id: Int; let role: String; let text: String }
var chatTurns: [ChatTurn] = []
var chatSending = false
private var chatSeq = 0
private let client = WatchClient()
func bootstrap() async {
do { _ = try await client.refresh(); phase = .ready }
catch { phase = .loggedOut } // / =
}
func login(username: String, password: String, totp: String?) async {
loginError = nil
let code = totp?.trimmingCharacters(in: .whitespacesAndNewlines)
do {
try await client.login(username: username, password: password,
totp: (code?.isEmpty ?? true) ? nil : code)
phase = .ready
} catch {
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
}
func logout() async {
await client.logout()
cards = []; studyError = nil
phase = .loggedOut
}
func loadDue() async {
studyLoading = true; studyError = nil
do { cards = try await client.dueCards() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
studyLoading = false
}
/// (correct/wrong). ( ) .
func rate(cardId: Int, outcome: String) async {
do { try await client.rate(cardId: cardId, outcome: outcome) }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
func flag(cardId: Int) async {
do { try await client.flag(cardId: cardId) }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
// MARK: (events)
func loadEvents() async {
eventsLoading = true; eventsError = nil
do { events = try await client.events() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
eventsLoading = false
}
func completeEvent(_ id: Int) async {
do { try await client.completeEvent(id: id); await loadEvents() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
// MARK:
func loadBriefing() async {
briefingLoading = true; briefingError = nil
do { briefing = try await client.briefing() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { briefingError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
briefingLoading = false
}
// MARK: (chat)
func sendChat(_ text: String) async {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty, !chatSending else { return }
chatSeq += 1; chatTurns.append(.init(id: chatSeq, role: "user", text: t))
chatSending = true
do {
let result = try await client.chat(t)
chatSeq += 1
if result.unavailable {
chatTurns.append(.init(id: chatSeq, role: "error", text: result.reason ?? "이드 연결 불가"))
} else {
chatTurns.append(.init(id: chatSeq, role: "assistant", text: result.answer))
}
} catch let e as WCError where e.isUnauthorized {
phase = .loggedOut
} catch {
chatSeq += 1
chatTurns.append(.init(id: chatSeq, role: "error",
text: (error as? LocalizedError)?.errorDescription ?? "\(error)"))
}
chatSending = false
}
}
/// 1 (refresh 7 1). TOTP 6 .
struct LoginView: View {
@Environment(WatchModel.self) private var model
@State private var username = ""
@State private var password = ""
@State private var totp = ""
@State private var busy = false
var body: some View {
ScrollView {
VStack(spacing: 8) {
Text("DS 로그인").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
TextField("아이디", text: $username)
.textContentType(.username)
SecureField("비밀번호", text: $password)
TextField("OTP 6자리", text: $totp)
if let err = model.loginError {
Text(err).font(.system(size: 11)).foregroundStyle(WT.danger).multilineTextAlignment(.center)
}
Button {
busy = true
Task { await model.login(username: username, password: password, totp: totp); busy = false }
} label: {
if busy { ProgressView() } else { Text("로그인").frame(maxWidth: .infinity) }
}
.tint(WT.accent)
.disabled(busy || username.isEmpty || password.isEmpty)
}
.padding(.horizontal, 4)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import SwiftUI
/// OLED ( watch-app: --wgreen #37d67a). = OLED ·.
enum WT {
static let bg = Color.black
static let card = Color(white: 0.12)
static let accent = Color(red: 0x37 / 255, green: 0xd6 / 255, blue: 0x7a / 255) // #37d67a
static let ink = Color.white
static let muted = Color(white: 0.62)
static let amber = Color(red: 0xf2 / 255, green: 0xb6 / 255, blue: 0x3c / 255)
static let danger = Color(red: 0xe5 / 255, green: 0x6a / 255, blue: 0x5a / 255)
}
+55
View File
@@ -0,0 +1,55 @@
# DS Apple Watch 앱 (단일 타깃 standalone watchOS, WKApplication). 맥/아이폰은 웹 래퍼로 가고
# 순수 네이티브는 워치 전용(2026-06-15 사용자 결정). 시뮬레이터 빌드·스크린샷으로 검증, 실기기
# 설치는 사용자 Xcode 서명. project.yml = source of truth, *.xcodeproj/Support 는 생성물(gitignore).
name: DSWatch
options:
bundleIdPrefix: net.hyungi
deploymentTarget:
watchOS: "11.0"
createIntermediateGroups: true
minimumXcodeGenVersion: "2.40.0"
settings:
base:
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
WATCHOS_DEPLOYMENT_TARGET: "11.0"
CODE_SIGN_STYLE: Automatic
# 실기기 설치 시 Xcode 에서 Signing → 본인 Apple ID 팀 선택하면 자동 서명.
# (헤드리스 시뮬 빌드는 xcodebuild 에 CODE_SIGNING_ALLOWED=NO 를 CLI 로 전달)
targets:
DSWatch:
type: application
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dswatch
PRODUCT_NAME: DS
GENERATE_INFOPLIST_FILE: "NO"
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
TARGETED_DEVICE_FAMILY: "4" # Apple Watch
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: Support/Info.plist
properties:
CFBundleDisplayName: DS
CFBundleName: DS
CFBundleVersion: "1"
CFBundleShortVersionString: "0.1"
WKApplication: true # 단일 타깃 standalone 워치 앱 (컴패니언 불요)
WKWatchOnly: true # 컴패니언 iOS 앱 없는 watch-only (설치 필수 키)
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
schemes:
DSWatch:
build:
targets:
DSWatch: all
run:
config: Debug
+40 -12
View File
@@ -6,25 +6,40 @@ ai:
models:
# ─── 단일 generation 호스트 routing (2026-05-14 GPU LLM 제거) ───
# GPU Ollama gemma4:e4b-it-q8_0 제거. Mac mini 26B-A4B 가 triage + primary + classifier 모두 흡수.
# fallback 은 Claude Sonnet 4 API (Mac mini 다운 시 자동 trigger, premium 과 budget 공유).
# plan: ~/.claude/plans/rosy-launching-otter.md §C/§D/§E
# 2026-06-11 B안: 맥미니 모델 = Gemma 26B-A4B → Qwen3.6-27B-6bit 풀교체 (사용자 결정).
# dense 27B 라 디코드 ~13 tok/s 급 (a4b ~42 대비 감속) → timeout 상향 (triage 30→120, primary 180→300).
# fallback 은 Claude Sonnet 4 API (CLAUDE_API_KEY 미주입 = 비활성).
# plan: ~/.claude/plans/rosy-launching-otter.md §C/§D/§E + project_macmini_model_decision
# triage: 상시 분류·요약·근거 선별. Mac mini 26B (primary 와 동일 endpoint, 짧은 max_tokens).
# triage: 상시 분류·요약·근거 선별. Mac mini Qwen 27B (primary 와 동일 endpoint, 짧은 max_tokens).
triage:
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
model: "mlx-community/gemma-4-26b-a4b-it-8bit"
model: "mlx-community/Qwen3.6-27B-6bit"
max_tokens: 4096
timeout: 30
timeout: 480 # 프리필 실측 ~112 tok/s — 120K자 장문 커버 (2026-06-11)
context_char_limit: 120000
temperature: 0.0
# primary: 에스컬레이션 전용. 26B MLX (맥미니 Semaphore(1) 보호 대상).
# primary: 에스컬레이션 전용. Qwen 27B MLX (맥미니 Semaphore(1) 보호 대상).
primary:
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
model: "mlx-community/gemma-4-26b-a4b-it-8bit"
model: "mlx-community/Qwen3.6-27B-6bit"
max_tokens: 8192
timeout: 180
timeout: 900 # 프리필 실측 ~112 tok/s — 260K자 상한 장문 커버 (2026-06-11)
context_char_limit: 260000
temperature: 0.3
top_p: 0.9
# deep: 야간 night-drain 전용 — 맥북 M5 Max Qwen3.6-27B-6bit (llm-router :8890 경유,
# model=qwen-macbook alias). 2026-06-11 재도입 (사용자: 자기 전 night-drain 으로 백로그 분담).
# 맥북 불가(503/연결/절단) = StageDeferred 보류 — 맥미니/cloud 강등 없음, attempts 미소모.
# consumer 의 deep_summary 도 슬롯 존재 시 맥북 경유 (잠들어 있으면 30분 백오프 보류 = 무해).
# 슬롯 제거 시 deep_summary 는 primary(맥미니) 경로 복귀.
deep:
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
model: "qwen-macbook"
max_tokens: 8192
timeout: 900
context_char_limit: 260000
temperature: 0.3
top_p: 0.9
@@ -58,9 +73,9 @@ ai:
# classifier_service 가 hasattr 체크로 optional 이므로 이 섹션 제거 시 classifier gate 는 자동 skip (score-only).
classifier:
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
model: "mlx-community/gemma-4-26b-a4b-it-8bit"
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 마진 (Mac mini 26B concurrent load). classifier_service.LLM_TIMEOUT_MS=30000 와 align
timeout: 30 # 2026-05-17: 15s 도 동시 부하 시 elapsed 14.4s 직전이라 tight — 30s 로 2x 마진. classifier_service.LLM_TIMEOUT_MS=30000 와 align (초과 = score-only skip, graceful)
# 제거: vision (미사용)
# ─── deep_summary enqueue 폭발 억제 (B-1 R2) ───
@@ -84,7 +99,7 @@ search:
macbook_url: "http://100.118.112.84:8810" # MacBook M5 Max Tailscale interface bind
macbook_model: "mlx-community/Qwen3.6-27B-8bit"
timeout_connect_s: 1 # MacBook sleep/wake 빠른 감지 (자동 fallback 부재 → 빠른 503)
timeout_read_s: 30 # synthesis_service.LLM_TIMEOUT_MS=30000 와 align
timeout_read_s: 120 # 2026-06-11 Qwen 27B(디코드 ~11.7 tok/s) — synthesis_service.LLM_TIMEOUT_MS=120000 와 align
# PR-DocSrv-Ask-ToolCalling-ReAct-1: /api/search/ask/react ReAct loop (qwen-macbook only)
react:
enabled: true
@@ -176,3 +191,16 @@ schedule:
daily_digest: "20:00"
file_watcher_interval_minutes: 5
queue_consumer_interval_minutes: 10
# 생성 LLM 홀드 게이트 (2026-06-11 신설): held_stages 에 든 이름의 컨슈머/워커는 claim 자체를
# 하지 않는다 (attempts 미소모, pending 적체). 유효 키 8 = classify/summarize/deep_summary(큐) +
# digest/briefing(cron) + study_explanation/study_session_analysis/study_memo_card(컨슈머).
# 그 외 문자열은 무동작(오타 주의). 적용/해제 = 리스트 수정 후 fastapi 재기동.
# 이력: 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_concurrency: 2
@@ -0,0 +1,566 @@
<script lang="ts">
// 처리 머신 보드 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, 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,
formatRate,
} from '$lib/utils/queueDisplay';
import type {
FailedItem,
FailedListResponse,
MachineCurrentItem,
MachineOverview,
QueueOverview,
QueueStageRow,
RetryResponse,
SkipResponse,
} from '$lib/types/queue';
let { overview }: { overview: QueueOverview } = $props();
// ─── 노드 통계 합성 ───
interface NodeStats {
def: FlowNodeDef;
/** 다중 stage 노드(청크·임베딩)는 같은 문서가 양쪽 큐에 있어 max — 합산 = 이중계산 */
pending: number;
processing: number;
failed: number; // 실패는 행 단위 사실이라 합산
done1h: number;
created1h: number;
doneToday: number;
oldestAgeSec: number | null;
etaMinutes: number | null;
inflowDominant: boolean;
perStage: QueueStageRow[];
}
const stageBy = $derived(new Map(overview.stages.map((s) => [s.stage, s])));
function nodeStats(def: FlowNodeDef): NodeStats {
const rows = def.stages
.map((s) => stageBy.get(s))
.filter((r): r is QueueStageRow => r != null);
const pending = rows.reduce((m, r) => Math.max(m, r.pending), 0);
const done1h = rows.reduce((m, r) => Math.max(m, r.done_1h), 0);
const created1h = rows.reduce((m, r) => Math.max(m, r.created_1h), 0);
const oldest = rows.reduce<number | null>(
(m, r) => (r.oldest_pending_age_sec == null ? m : Math.max(m ?? 0, r.oldest_pending_age_sec)),
null,
);
return {
def,
pending,
processing: rows.reduce((s, r) => s + r.processing, 0),
failed: rows.reduce((s, r) => s + r.failed, 0),
done1h,
created1h,
doneToday: rows.reduce((m, r) => Math.max(m, r.done_today), 0),
oldestAgeSec: oldest,
etaMinutes: pending > 0 && done1h > 0 ? Math.round((pending / done1h) * 60) : null,
inflowDominant: pending > 0 && created1h > done1h,
perStage: rows,
};
}
const mainNodes = $derived(FLOW_NODES.map(nodeStats));
const auxAll = $derived(AUX_NODES.map(nodeStats));
const auxActive = $derived(
auxAll.filter((n) => n.pending + n.processing + n.failed + n.doneToday > 0),
);
const auxIdle = $derived(
auxAll.filter((n) => n.pending + n.processing + n.failed + n.doneToday === 0),
);
const totalFailed = $derived(overview.totals.failed);
// ─── 선택 상태 (노드 상세 / 실패 드로어 — 동시에 하나만) ───
let selected = $state<string | null>(null);
let failOpen = $state(false);
function toggleNode(key: string) {
selected = selected === key ? null : key;
if (selected) failOpen = false;
}
const selectedNode = $derived(
[...mainNodes, ...auxAll].find((n) => n.def.key === selected) ?? null,
);
function nodeCurrent(def: FlowNodeDef): MachineCurrentItem[] {
return overview.machines.flatMap((m) => m.current.filter((c) => def.stages.includes(c.stage)));
}
// ─── 실패 드로어 ───
let failItems = $state<FailedItem[]>([]);
let failLoading = $state(false);
let busy = $state(false);
let expanded = $state<Record<string, boolean>>({});
async function openFailures() {
failOpen = true;
selected = null;
await loadFailures();
}
async function loadFailures() {
failLoading = true;
try {
const r = await api<FailedListResponse>('/queue/failed');
failItems = r.items;
} catch {
addToast('error', '실패 목록을 불러오지 못했습니다');
} finally {
failLoading = false;
}
}
interface FailGroup {
key: string;
stage: string;
pattern: string;
items: FailedItem[];
}
// 그룹핑 = stage + 에러 메시지 prefix(36자) — 같은 원인(ReadTimeout 등) 묶음
const failGroups = $derived.by(() => {
const map = new Map<string, FailGroup>();
for (const it of failItems) {
const pattern = (it.error_message ?? '(메시지 없음)').slice(0, 36);
const key = `${it.stage}::${pattern}`;
const g = map.get(key);
if (g) g.items.push(it);
else map.set(key, { key, stage: it.stage, pattern, items: [it] });
}
return [...map.values()].sort(
(a, b) => a.stage.localeCompare(b.stage) || b.items.length - a.items.length,
);
});
async function retryIds(ids: number[]) {
if (busy || ids.length === 0) return;
busy = true;
try {
const r = await api<RetryResponse>('/queue/retry', {
method: 'POST',
body: JSON.stringify({ ids }),
});
addToast(
'success',
`재시도 ${r.retried}건 큐 재진입${r.not_retried > 0 ? ` (${r.not_retried} 제외 이미 활성/처리됨)` : ''}`,
);
await afterAction();
} catch {
addToast('error', '재시도 요청 실패');
} finally {
busy = false;
}
}
async function skipIds(ids: number[]) {
if (busy || ids.length === 0) return;
busy = true;
try {
const r = await api<SkipResponse>('/queue/skip', {
method: 'POST',
body: JSON.stringify({ ids }),
});
addToast('success', `건너뛰기 ${r.skipped}건 처리 (해당 단계 제외)`);
await afterAction();
} catch {
addToast('error', '건너뛰기 요청 실패');
} finally {
busy = false;
}
}
async function afterAction() {
await Promise.all([loadFailures(), refreshQueueOverview()]);
}
// ─── 머신 레인 (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 ?? []);
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 = 300;
const h = 64;
const step = w / Math.max(1, t.length - 1);
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">
<!-- 헤더: 타이틀 + 신선도 + 실패 합계 -->
<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">
{#if totalFailed > 0}
<button
class="text-[11px] font-semibold text-error hover:underline cursor-pointer"
onclick={openFailures}
>실패 {totalFailed}건 처리</button>
{/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>
<!-- 지배 백로그 스트립 (요약) + 정직 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(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>
{#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>
<!-- 번다운 / 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>
<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">
{#each auxActive as n, i (n.def.key)}
{i > 0 ? ' · ' : '보조: '}{n.def.label}({n.def.engine}) 대기 {n.pending.toLocaleString()} · {formatRate(n.done1h)}/h{n.failed > 0 ? ` · 실패 ${n.failed}` : ''}
{/each}
{#if auxIdle.length > 0}
{auxActive.length > 0 ? ' — ' : ''}한가: {auxIdle.map((n) => n.def.label).join(' · ')}
{/if}
— 뉴스 등 일부 소스는 분류/추출을 건너뜀 (흐름 그림은 대표 경로)
</p>
<!-- 상세 패널 (노드 클릭) -->
{#if selectedNode}
<div class="border rounded-card mt-3 overflow-hidden bg-surface detail-frame">
<div class="flex items-center gap-2.5 px-4 py-2.5 text-xs font-bold detail-head">
{selectedNode.def.label}{selectedNode.def.engine}
<span class="text-[10px] font-mono font-medium text-dim bg-surface border border-default rounded px-1.5">{selectedNode.def.sub} · {MACHINE_META[selectedNode.def.machine].label}</span>
<button class="ml-auto text-[11px] text-dim font-normal cursor-pointer hover:text-text" onclick={() => (selected = null)}>닫기</button>
</div>
<div class="px-4 pb-3.5">
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5 my-2.5">
<div class="bg-bg border border-default rounded-card px-3 py-2">
<div class="text-[9px] text-faint uppercase tracking-wide">대기</div>
<div class="text-lg font-extrabold tabular-nums text-text">{selectedNode.pending.toLocaleString()}</div>
</div>
<div class="bg-bg border border-default rounded-card px-3 py-2">
<div class="text-[9px] text-faint uppercase tracking-wide">처리율 (1h)</div>
<div class="text-lg font-extrabold tabular-nums text-text">{formatRate(selectedNode.done1h)}<span class="text-[11px] text-dim font-semibold">/h</span></div>
</div>
<div class="bg-bg border border-default rounded-card px-3 py-2">
<div class="text-[9px] text-faint uppercase tracking-wide">오늘 완료</div>
<div class="text-lg font-extrabold tabular-nums text-text">{selectedNode.doneToday.toLocaleString()}</div>
</div>
<div class="bg-bg border border-default rounded-card px-3 py-2">
<div class="text-[9px] text-faint uppercase tracking-wide">소진 예상</div>
<div class="text-lg font-extrabold tabular-nums {selectedNode.inflowDominant ? 'text-warning' : 'text-accent'}">
{#if selectedNode.inflowDominant}유입 우세{:else if selectedNode.etaMinutes != null}{etaShort(selectedNode.etaMinutes)}{:else if selectedNode.pending === 0}한가{:else}{/if}
</div>
</div>
</div>
{#if selectedNode.perStage.length > 1}
{#each selectedNode.perStage as row (row.stage)}
<div class="flex items-center gap-2.5 py-1.5 border-t border-default text-xs">
<span class="font-semibold text-text min-w-[72px]">{flowStageLabel(row.stage)}</span>
<span class="ml-auto text-dim tabular-nums">
대기 <strong class="text-text">{row.pending.toLocaleString()}</strong>
· {formatRate(row.done_1h)}/h · 오늘 {row.done_today.toLocaleString()}
{#if row.failed > 0}· <span class="text-error font-semibold">실패 {row.failed}</span>{/if}
</span>
</div>
{/each}
{/if}
<div class="text-[11px] text-dim border-t border-dashed border-default mt-2 pt-2 tabular-nums">
{#if selectedNode.oldestAgeSec != null && selectedNode.oldestAgeSec > 600}
가장 오래 기다린 항목 {formatAgeSec(selectedNode.oldestAgeSec)}
{/if}
{#each nodeCurrent(selectedNode.def) as c, i (c.document_id + c.stage)}
{i === 0 && !(selectedNode.oldestAgeSec != null && selectedNode.oldestAgeSec > 600) ? '' : ' · '}지금: {c.title} ({flowStageLabel(c.stage)})
{/each}
{#if selectedNode.failed > 0}
· <button class="text-error font-semibold cursor-pointer hover:underline" onclick={openFailures}>실패 {selectedNode.failed} 처리</button>
{/if}
</div>
</div>
</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">
<div class="flex items-center gap-2.5 px-4 py-2.5 bg-error/5 text-xs font-bold text-text">
실패 처리
<span class="text-[10px] font-semibold text-error">영구 실패 {failItems.length}건 — 자동 재시도 3회 소진, 수동 조치 대기</span>
<button class="ml-auto text-[11px] text-dim font-normal cursor-pointer hover:text-text" onclick={() => (failOpen = false)}>닫기</button>
</div>
{#if failLoading}
<p class="text-xs text-dim text-center py-4">불러오는 중…</p>
{:else if failItems.length === 0}
<p class="text-xs text-dim text-center py-4">영구 실패 항목 없음</p>
{:else}
{#each failGroups as g (g.key)}
<div class="px-4 py-2.5 border-t border-default">
<div class="flex items-center gap-2 flex-wrap text-xs font-bold text-text mb-1">
{flowStageLabel(g.stage)} {g.items.length}
<span class="text-[10px] font-mono font-medium text-error bg-error/10 rounded px-1.5 py-px">{g.pattern}{g.items[0]?.error_message && g.items[0].error_message.length > 36 ? '…' : ''}</span>
</div>
{#each expanded[g.key] ? g.items : g.items.slice(0, 4) as it (it.id)}
<div class="flex items-center gap-2.5 py-1 border-t border-dashed border-default/60 text-xs">
<span class="flex-1 min-w-0 truncate text-text" title={it.title}>{it.title}</span>
<span class="text-[10px] font-mono text-faint shrink-0 tabular-nums">시도 {it.attempts}/{it.max_attempts}</span>
<span class="text-[10px] font-mono text-error shrink-0 max-w-[260px] truncate" title={it.error_message ?? ''}>{it.error_message ?? ''}</span>
<button class="text-[10px] font-bold border border-accent text-accent rounded px-2 py-0.5 shrink-0 cursor-pointer hover:bg-accent/10 disabled:opacity-40" disabled={busy} onclick={() => retryIds([it.id])}>재시도</button>
<button class="text-[10px] font-bold border border-default text-faint rounded px-2 py-0.5 shrink-0 cursor-pointer hover:bg-surface-hover disabled:opacity-40" disabled={busy} onclick={() => skipIds([it.id])}>건너뛰기</button>
</div>
{/each}
{#if g.items.length > 4 && !expanded[g.key]}
<button class="text-[10px] text-dim cursor-pointer hover:text-text mt-1" onclick={() => (expanded = { ...expanded, [g.key]: true })}> {g.items.length - 4} 펼치기</button>
{/if}
{#if g.items.length > 1}
<div class="flex gap-2 mt-1.5">
<button class="text-[10px] font-bold border border-accent text-accent rounded px-2.5 py-0.5 cursor-pointer hover:bg-accent/10 disabled:opacity-40" disabled={busy} onclick={() => retryIds(g.items.map((x) => x.id))}>그룹 전체 재시도 ({g.items.length})</button>
<button class="text-[10px] font-bold border border-default text-faint rounded px-2.5 py-0.5 cursor-pointer hover:bg-surface-hover disabled:opacity-40" disabled={busy} onclick={() => skipIds(g.items.map((x) => x.id))}>그룹 전체 건너뛰기</button>
</div>
{/if}
</div>
{/each}
<p class="text-[10px] text-faint px-4 py-2 border-t border-default">
재시도 = 시도 횟수 리셋 후 큐 재진입 (자동 재시도 3회 새로 부여) · 건너뛰기 = 이 단계 완료 처리(후속 단계 연쇄 없음, 감사 마킹) · 같은 오류가 반복되는 항목(빈 텍스트 등)은 건너뛰기 권장
</p>
{/if}
</div>
{/if}
</div>
<style>
/* 머신 색 — 디자인 토큰 외 3색 (gpu 청/macmini 보라/macbook 황) — 이 컴포넌트 한정 */
.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; }
</style>
@@ -43,14 +43,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(' ')}
>
@@ -0,0 +1,31 @@
<!--
EidEvidenceCard — 이드 채팅 deep(검색) 답변의 근거 카드 (ds-eid-ask-absorb P1).
ReactResult.sources = {id, doc_id, title, score} (citation 번호 n 없음 — /ask 의 Citation 과
다름) → 순서 기반 번호([1],[2]...). 1단계 카드 = 제목·출처·점수 (스니펫은 react_loop
_result_payload items_src 에 없음 — 2단계 후보). 접이식 <details> 로 채팅 흐름 보존.
디자인 토큰만 (CLAUDE.md lint:tokens).
-->
<script lang="ts">
type EidSource = { id?: number; doc_id?: number; title?: string; score?: number };
let { sources, partial = false }: { sources: EidSource[]; partial?: boolean } = $props();
</script>
{#if sources.length}
<details class="mt-2 rounded-lg border border-default bg-surface text-xs max-w-[85%] sm:max-w-[75%]">
<summary class="cursor-pointer px-3 py-2 text-dim hover:text-text select-none font-semibold">
근거 {sources.length}{partial ? ' · 부분 답변 (확정 근거 부족)' : ''}
</summary>
<ul class="px-3 pb-2.5 flex flex-col gap-1.5">
{#each sources as src, i (src.id ?? i)}
<li class="flex items-start gap-2">
<span class="text-accent font-bold shrink-0">[{i + 1}]</span>
<span class="flex-1 min-w-0 text-text break-words">{src.title || `문서 ${src.doc_id ?? '?'}`}</span>
{#if typeof src.score === 'number'}
<span class="text-faint shrink-0 tabular-nums">{src.score.toFixed(2)}</span>
{/if}
</li>
{/each}
</ul>
</details>
{/if}

Some files were not shown because too many files have changed in this diff Show More