diff --git a/app/api/documents.py b/app/api/documents.py index 1259fc6..d2b847b 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -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: @@ -1244,11 +1259,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: diff --git a/app/api/queue_overview.py b/app/api/queue_overview.py index c98e130..383cc71 100644 --- a/app/api/queue_overview.py +++ b/app/api/queue_overview.py @@ -59,6 +59,20 @@ class SummarizeEta(BaseModel): eta_minutes: int | None +class MachineDone(BaseModel): + """머신 1대의 summarize 완료 실적 (분담 표시용).""" + done_1h: int + done_today: int + + +class SummarizeByMachine(BaseModel): + """summarize 풀의 머신별 완료 실적 분담 — 보드 레인의 '맥미니 vs 맥북' + 오프로드 가시화용. rows_to_summarize_split 이 이미 계산하던 값의 노출 + (ds-board-merged A-1, 신규 수집 SQL 0).""" + macmini: MachineDone + macbook: MachineDone + + class TrendBucket(BaseModel): """summarize 24h 추이 버킷 — hour 는 KST "HH:00" 라벨.""" hour: str @@ -93,6 +107,7 @@ class QueueOverviewResponse(BaseModel): machines: list[MachineCard] stages: list[StageRow] summarize_eta: SummarizeEta + summarize_by_machine: SummarizeByMachine trend_24h: list[TrendBucket] totals: Totals diff --git a/app/api/search.py b/app/api/search.py index 431f0c3..2cd43ed 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -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, ) diff --git a/app/main.py b/app/main.py index b2b4339..6f6aa8d 100644 --- a/app/main.py +++ b/app/main.py @@ -53,8 +53,8 @@ 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.fulltext_worker import reconcile_unresolved as fulltext_reconcile_run from workers.kosha_collector import run as kosha_collector_run @@ -120,7 +120,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") diff --git a/app/models/document.py b/app/models/document.py index ec031b2..f5b0abf 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -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 diff --git a/app/models/legal_act.py b/app/models/legal_act.py new file mode 100644 index 0000000..a320a0e --- /dev/null +++ b/app/models/legal_act.py @@ -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 + ) diff --git a/app/models/news_source.py b/app/models/news_source.py index 9518d88..b01254c 100644 --- a/app/models/news_source.py +++ b/app/models/news_source.py @@ -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) diff --git a/app/services/briefing/loader.py b/app/services/briefing/loader.py index 42a1e85..a84e6d3 100644 --- a/app/services/briefing/loader.py +++ b/app/services/briefing/loader.py @@ -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")} """) diff --git a/app/services/digest/loader.py b/app/services/digest/loader.py index ff15b8c..3bead5c 100644 --- a/app/services/digest/loader.py +++ b/app/services/digest/loader.py @@ -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")} """) diff --git a/app/services/queue_overview.py b/app/services/queue_overview.py index c796600..682660f 100644 --- a/app/services/queue_overview.py +++ b/app/services/queue_overview.py @@ -213,6 +213,16 @@ def build_summarize_eta(stage_stats: dict[str, dict]) -> dict: } +def build_summarize_by_machine(summarize_split: dict[str, dict]) -> dict: + """summarize 머신별 완료 실적 분담 (macmini vs macbook) — 보드 레인의 + 오프로드 가시화용. rows_to_summarize_split 이 이미 만든 값을 응답 형태로 + 투영(done_1h/done_today 만, done_15m 은 내부 state 판정 전용이라 제외).""" + def m(key: str) -> dict: + s = summarize_split.get(key, {}) + return {"done_1h": int(s.get("done_1h", 0)), "done_today": int(s.get("done_today", 0))} + return {"macmini": m("macmini"), "macbook": m("macbook")} + + def build_trend( inflow_buckets: dict[str, int], done_buckets: dict[str, int], @@ -292,6 +302,7 @@ def compose_overview( ), "stages": build_stages(stage_stats), "summarize_eta": build_summarize_eta(stage_stats), + "summarize_by_machine": build_summarize_by_machine(summarize_split), "trend_24h": build_trend(inflow_buckets, done_buckets, now_kst), "totals": build_totals(stage_stats), } diff --git a/app/services/search/fusion_service.py b/app/services/search/fusion_service.py index 5c857bf..c5a426a 100644 --- a/app/services/search/fusion_service.py +++ b/app/services/search/fusion_service.py @@ -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] diff --git a/app/services/search/license_filter.py b/app/services/search/license_filter.py new file mode 100644 index 0000000..3c49148 --- /dev/null +++ b/app/services/search/license_filter.py @@ -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 부재면 미제외 — redistribute≠restricted 가 핵심). +""" + + +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" diff --git a/app/services/search/result_decorate.py b/app/services/search/result_decorate.py new file mode 100644 index 0000000..bbb07fb --- /dev/null +++ b/app/services/search/result_decorate.py @@ -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 diff --git a/app/services/search/retrieval_service.py b/app/services/search/retrieval_service.py index 56a338b..c14a2eb 100644 --- a/app/services/search/retrieval_service.py +++ b/app/services/search/retrieval_service.py @@ -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 @@ -98,6 +99,63 @@ QWEN3_QUERY_INSTRUCT = ( "\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). @@ -235,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 분해). @@ -266,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 @@ -320,13 +382,15 @@ async def search_text( WHEN similarity(coalesce(d.ai_summary, ''), :q) >= 0.3 THEN 'summary' WHEN similarity(coalesce(d.extracted_text, ''), :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] @@ -341,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). @@ -415,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"]: @@ -424,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()) @@ -439,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_).embedding 직접 검색. @@ -463,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 """ @@ -500,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_).embedding window partition. @@ -525,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 ), @@ -543,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 """ diff --git a/app/services/search/search_pipeline.py b/app/services/search/search_pipeline.py index 0d7d9b9..996737f 100644 --- a/app/services/search/search_pipeline.py +++ b/app/services/search/search_pipeline.py @@ -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] diff --git a/app/services/study/explanation_rag.py b/app/services/study/explanation_rag.py index a690d8c..dc088b1 100644 --- a/app/services/study/explanation_rag.py +++ b/app/services/study/explanation_rag.py @@ -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() diff --git a/app/workers/api_standards_collector.py b/app/workers/api_standards_collector.py index ec1061e..c272cd7 100644 --- a/app/workers/api_standards_collector.py +++ b/app/workers/api_standards_collector.py @@ -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, diff --git a/app/workers/chunk_worker.py b/app/workers/chunk_worker.py index bd5fd94..8eead6e 100644 --- a/app/workers/chunk_worker.py +++ b/app/workers/chunk_worker.py @@ -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 삭제 (재처리) diff --git a/app/workers/classify_worker.py b/app/workers/classify_worker.py index b5c8908..5f8470c 100644 --- a/app/workers/classify_worker.py +++ b/app/workers/classify_worker.py @@ -62,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" @@ -492,6 +501,24 @@ async def process( 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))) diff --git a/app/workers/csb_collector.py b/app/workers/csb_collector.py index 59d4f65..14b3298 100644 --- a/app/workers/csb_collector.py +++ b/app/workers/csb_collector.py @@ -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, diff --git a/app/workers/file_watcher.py b/app/workers/file_watcher.py index 64e0116..f1987cc 100644 --- a/app/workers/file_watcher.py +++ b/app/workers/file_watcher.py @@ -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) diff --git a/app/workers/kosha_collector.py b/app/workers/kosha_collector.py index dab61fb..0ece796 100644 --- a/app/workers/kosha_collector.py +++ b/app/workers/kosha_collector.py @@ -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)) diff --git a/app/workers/law_monitor.py b/app/workers/law_monitor.py index a520429..a3a304b 100644 --- a/app/workers/law_monitor.py +++ b/app/workers/law_monitor.py @@ -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) diff --git a/app/workers/news_collector.py b/app/workers/news_collector.py index 1b3f8c4..ea6945a 100644 --- a/app/workers/news_collector.py +++ b/app/workers/news_collector.py @@ -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) diff --git a/app/workers/statute_adapters/__init__.py b/app/workers/statute_adapters/__init__.py new file mode 100644 index 0000000..d6e1527 --- /dev/null +++ b/app/workers/statute_adapters/__init__.py @@ -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) diff --git a/app/workers/statute_adapters/kr.py b/app/workers/statute_adapters/kr.py new file mode 100644 index 0000000..43229da --- /dev/null +++ b/app/workers/statute_adapters/kr.py @@ -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 diff --git a/app/workers/statute_collector.py b/app/workers/statute_collector.py new file mode 100644 index 0000000..7f402a0 --- /dev/null +++ b/app/workers/statute_collector.py @@ -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)) diff --git a/frontend/src/lib/components/ProcessingFlowBoard.svelte b/frontend/src/lib/components/ProcessingFlowBoard.svelte index f66bf77..16752dc 100644 --- a/frontend/src/lib/components/ProcessingFlowBoard.svelte +++ b/frontend/src/lib/components/ProcessingFlowBoard.svelte @@ -1,16 +1,18 @@
- +
처리 머신
@@ -219,80 +297,107 @@ onclick={openFailures} >실패 {totalFailed}건 처리 {/if} - {#if spark} -
- - - - - 요약 24h 유입/소화 -
- {/if} + + + {freshLabel}{#if stale} · 갱신 지연{/if} +
- -
- {#each machineStrip as m (m.key)} -
- - {m.meta?.label ?? m.label} - {m.meta?.model} - {formatRate(m.done_1h)}/h - {#if m.key === 'macbook' && m.deferred_pending > 0} - 보류 {m.deferred_pending} - {/if} + +
+ 지배 백로그 + 요약 + 대기 {eta.pending.toLocaleString()} · 순소화 {formatRate(eta.done_rate_1h)}/h · 유입 {formatRate(eta.inflow_rate_1h)}/h + + 정직 ETA + {honestEtaLabel} + +
+ + +
+ {#each lanes as lane (lane.key)} +
+
+ + {lane.meta.label} + {lane.meta.model} + {formatRate(lane.card?.done_1h ?? 0)}/h + {#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0} + 보류 {lane.card?.deferred_pending} + {/if} + {#if lane.card?.state === 'deferred'} + 잠듦 — 요약은 맥미니로 복귀 + {/if} +
+
+ {#each lane.nodes as n (n.def.key)} + {@const idle = n.pending + n.processing + n.doneToday + n.failed === 0} + + {/each} + {#if lane.key === 'macbook' && offloadActive} + + {/if} +
{/each}
- -
- {#each mainNodes as n, i (n.def.key)} - {#if i > 0} - - {/if} -
toggleNode(n.def.key)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNode(n.def.key); } }} - title="{n.def.label} — 클릭하면 상세" - > - {#if n.failed > 0} - - {/if} - - {MACHINE_META[n.def.machine].label} · {n.def.engine} - -
- {n.def.label} - {#if n.processing > 0} - - {/if} - {#if n.inflowDominant} - 유입 우세 - {/if} -
-
- {n.pending.toLocaleString()} -
-
- {formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()} - {#if n.etaMinutes != null && !n.inflowDominant && n.pending > 0} - · {etaShort(n.etaMinutes)} - {/if} -
+ + {#if burn} +
+
+ 요약 백로그 24시간 + 유입(회색) vs 소화(녹색) + {#if offloadActive}맥북 합류 {burn.markHour} — 소화 급증{/if}
- {/each} -
+ + + + + {#if offloadActive} + + {/if} + +
+ {#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)} + {n.def.label} 대기 {n.pending.toLocaleString()}{#if netEtaLabel(n)} · {netEtaLabel(n)}{/if} + {/each} +
+
+ {/if}

@@ -413,6 +518,9 @@ .mtag-gpu { background: #e7eef6; color: #3b6ea5; } .mtag-macmini { background: #efe9f7; color: #8a5fbf; } .mtag-macbook { background: #f7eedd; color: #b07a10; } + /* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */ + .mtag-macmini-bar { background: #8a5fbf; } + .mtag-macbook-bar { background: #b07a10; } .node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; } .detail-frame { border-color: #3b6ea5; } .detail-head { background: #e7eef6; } diff --git a/frontend/src/lib/stores/queueOverview.ts b/frontend/src/lib/stores/queueOverview.ts index d46d61f..6d2faa5 100644 --- a/frontend/src/lib/stores/queueOverview.ts +++ b/frontend/src/lib/stores/queueOverview.ts @@ -17,6 +17,11 @@ let pollHandle: ReturnType | null = null; let subscriberCount = 0; let inFlight: Promise | null = null; +// 마지막 성공 갱신 시각(epoch ms) — 보드 신선도 '갱신 N초 전' + stale 경고용 +// (ds-board-merged B-4). 실패(null 수렴) 시엔 갱신 안 함 → age 가 늘어 stale 로 드러남. +const updatedAt = writable(null); +export const queueUpdatedAt = { subscribe: updatedAt.subscribe }; + const internal = writable(null, (_set) => { subscriberCount += 1; if (subscriberCount === 1 && browser) { @@ -54,7 +59,9 @@ export async function refreshQueueOverview(): Promise { if (inFlight) return inFlight; inFlight = (async () => { try { - internal.set(await fetchOverview()); + const ov = await fetchOverview(); + internal.set(ov); + if (ov) updatedAt.set(Date.now()); // 성공 시에만 신선도 갱신 (실패=stale 유지) } finally { inFlight = null; } diff --git a/frontend/src/lib/types/queue.ts b/frontend/src/lib/types/queue.ts index 740251d..0915bbf 100644 --- a/frontend/src/lib/types/queue.ts +++ b/frontend/src/lib/types/queue.ts @@ -43,13 +43,19 @@ export interface SummarizeEta { eta_minutes: number | null; } -/** 시간당 유입 vs 소화 (이번 트랙 미렌더 — 후속 추세 위젯 슬롯) */ +/** 시간당 유입 vs 소화 (요약 24h 추이) */ export interface TrendPoint { hour: string; inflow: number; done: number; } +/** summarize 머신별 완료 실적 분담 (오프로드 가시화 — ds-board-merged A-1) */ +export interface SummarizeByMachine { + macmini: { done_1h: number; done_today: number }; + macbook: { done_1h: number; done_today: number }; +} + export interface QueueTotals { pending: number; processing: number; @@ -72,6 +78,7 @@ export interface QueueStageRow { export interface QueueOverview { machines: MachineOverview[]; summarize_eta: SummarizeEta; + summarize_by_machine: SummarizeByMachine; trend_24h: TrendPoint[]; stages: QueueStageRow[]; totals: QueueTotals; diff --git a/migrations/340_documents_material_type.sql b/migrations/340_documents_material_type.sql new file mode 100644 index 0000000..9340936 --- /dev/null +++ b/migrations/340_documents_material_type.sql @@ -0,0 +1,6 @@ +-- 340_documents_material_type.sql +-- 안전 자료실 분류 축 A-1 (1/12) — 자료유형 컬럼. +-- plan: safety-library-1 (PKM plans/2026-06-12-safety-library-plan.html) +-- TEXT+CHECK 방식 (PG enum 아님 — 152 의 enum ADD VALUE 동일-런 사용 불가 함정 회피). +-- 값 부여 = 수집기 ingest 시점 deterministic (classify_worker 아님 — classify-skip 경로 다수). +ALTER TABLE documents ADD COLUMN IF NOT EXISTS material_type TEXT; diff --git a/migrations/341_documents_material_type_check.sql b/migrations/341_documents_material_type_check.sql new file mode 100644 index 0000000..4f1e60c --- /dev/null +++ b/migrations/341_documents_material_type_check.sql @@ -0,0 +1,6 @@ +-- 341_documents_material_type_check.sql +-- 안전 자료실 분류 축 A-1 (2/12) — material_type 값 공간 named CHECK. +-- plan: safety-library-1 0-1 확정 7값. 값 추가 시 = 본 제약 DROP + 재ADD 2파일 (named 라 가능). +-- NULL 은 CHECK 통과 (비안전/일반 문서는 NULL 유지 — 전수 분류 시도 금지). +ALTER TABLE documents ADD CONSTRAINT chk_documents_material_type + CHECK (material_type IN ('law', 'paper', 'book', 'incident', 'manual', 'standard', 'guide')); diff --git a/migrations/342_documents_jurisdiction.sql b/migrations/342_documents_jurisdiction.sql new file mode 100644 index 0000000..78947a2 --- /dev/null +++ b/migrations/342_documents_jurisdiction.sql @@ -0,0 +1,5 @@ +-- 342_documents_jurisdiction.sql +-- 안전 자료실 분류 축 A-1 (3/12) — 관할(나라) 컬럼. 법령 1급 시민 축. +-- plan: safety-library-1 0-1. 'GB' 표기 (news_sources.country 실측 어휘와 통일, UI 라벨만 UK). +-- paper 는 NULL 허용 (국제 학술지 — 관할 개념 부적합). INT = ISO 류 국제기구 자료 유보. +ALTER TABLE documents ADD COLUMN IF NOT EXISTS jurisdiction TEXT; diff --git a/migrations/343_documents_jurisdiction_check.sql b/migrations/343_documents_jurisdiction_check.sql new file mode 100644 index 0000000..29045f2 --- /dev/null +++ b/migrations/343_documents_jurisdiction_check.sql @@ -0,0 +1,4 @@ +-- 343_documents_jurisdiction_check.sql +-- 안전 자료실 분류 축 A-1 (4/12) — jurisdiction 값 공간 named CHECK. +ALTER TABLE documents ADD CONSTRAINT chk_documents_jurisdiction + CHECK (jurisdiction IN ('KR', 'US', 'EU', 'JP', 'GB', 'INT')); diff --git a/migrations/344_documents_law_jurisdiction_check.sql b/migrations/344_documents_law_jurisdiction_check.sql new file mode 100644 index 0000000..34aa79a --- /dev/null +++ b/migrations/344_documents_law_jurisdiction_check.sql @@ -0,0 +1,7 @@ +-- 344_documents_law_jurisdiction_check.sql +-- 안전 자료실 분류 축 A-1 (5/12) — 나라 혼선 금지를 구조로 강제. +-- 법령(material_type='law')인데 jurisdiction NULL 인 행은 적재 자체가 거부된다. +-- 업로드 승인 경로는 proposed_jurisdiction 필수 입력 (KR 기본값 오염 금지 — plan A-2). +-- material_type 이 NULL 이면 식 전체가 NULL = CHECK 통과 (비법령 무영향). +ALTER TABLE documents ADD CONSTRAINT chk_documents_law_jurisdiction + CHECK (material_type <> 'law' OR jurisdiction IS NOT NULL); diff --git a/migrations/345_documents_published_date.sql b/migrations/345_documents_published_date.sql new file mode 100644 index 0000000..c25e8d7 --- /dev/null +++ b/migrations/345_documents_published_date.sql @@ -0,0 +1,5 @@ +-- 345_documents_published_date.sql +-- 안전 자료실 분류 축 A-1 (6/12) — 유형별 대표 날짜 (패싯 연도·freshness 단일 날짜 축). +-- 법령 = COALESCE(effective_date, promulgation_date) — plan 0-1 R2-M2 확정. +-- 논문 = 발행일 / 재해 = 발생일 / 뉴스·크롤 = extract_meta.published_at backfill (A-3). +ALTER TABLE documents ADD COLUMN IF NOT EXISTS published_date DATE; diff --git a/migrations/346_legal_acts_table.sql b/migrations/346_legal_acts_table.sql new file mode 100644 index 0000000..9b549a5 --- /dev/null +++ b/migrations/346_legal_acts_table.sql @@ -0,0 +1,22 @@ +-- 346_legal_acts_table.sql +-- 안전 자료실 A-1 (7/12) — 법령 레지스트리 = 워치리스트 (news_sources 패턴의 법령판). +-- plan: safety-library-1 0-2. statute_watchlist 별도 테이블 안 만듦 (R2 blocker — 이중 정의 해소, watermark 흡수). +-- KOSHA GUIDE / KGS Code 는 비대상 (guide=비법령, KGS=watch-폴더 단독 트랙 R3-M5). +-- 시드 = B-1 PR① (레거시 law_monitor 26개 superset, watch=true 전부 — R3-B1). +-- repeal_detected_at: 어댑터(코어)는 폐지 감지 마킹만, 전이는 일일 잡 단일 지점 (R3-M3). +CREATE TABLE IF NOT EXISTS legal_acts ( + family_id TEXT PRIMARY KEY, + jurisdiction TEXT NOT NULL CHECK (jurisdiction IN ('KR', 'US', 'EU', 'JP', 'GB', 'INT')), + law_level TEXT NOT NULL CHECK (law_level IN ('statute', 'decree', 'rule', 'admin_rule', 'code')), + title TEXT NOT NULL, + title_ko TEXT, + parent_family_id TEXT REFERENCES legal_acts(family_id), + native_id TEXT NOT NULL, + source_api TEXT NOT NULL, + watch BOOLEAN NOT NULL DEFAULT TRUE, + poll_cycle TEXT NOT NULL DEFAULT 'daily' CHECK (poll_cycle IN ('daily', 'weekly', 'monthly', 'quarterly')), + watermark TEXT, + repeal_detected_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/347_legal_meta_table.sql b/migrations/347_legal_meta_table.sql new file mode 100644 index 0000000..4e89318 --- /dev/null +++ b/migrations/347_legal_meta_table.sql @@ -0,0 +1,20 @@ +-- 347_legal_meta_table.sql +-- 안전 자료실 A-1 (8/12) — 법령 문서 1건(=1버전 또는 1부속문서)당 1행. documents 1:0..1 위성, 최소형. +-- plan: safety-library-1 0-2. supersedes 체인 컬럼은 미포함 (개정 이벤트 10건 관찰 후 승격). +-- version_key: KR primary = MST / annex = 'MST|별표N' 합성 (같은 MST 별표 다건 UNIQUE 충돌 회피) +-- / interpretation = 소스 native id. dedup 키도 이 합성형 그대로 (R3-M4 silent skip 차단). +-- version_status 운영 계약 (B-1 PR② 일일 잡이 유일한 전이 지점, R2-B2·R3-M3): +-- 전 버전 pending 적재 → 잡이 KST 기준 시행일 도래분 current 승격 + 직전 current 를 superseded +-- + 구버전 청크 in_corpus=false 를 한 트랜잭션 처리. repeal 도 잡 경유. +-- 입법예고 등 신호류 문서는 legal_meta 없음 (legal_meta 존재 = 법령 본문). +CREATE TABLE IF NOT EXISTS legal_meta ( + document_id BIGINT PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + family_id TEXT NOT NULL REFERENCES legal_acts(family_id), + law_doc_kind TEXT NOT NULL DEFAULT 'primary' CHECK (law_doc_kind IN ('primary', 'annex', 'interpretation')), + version_key TEXT NOT NULL, + promulgation_date DATE, + effective_date DATE, + version_status TEXT NOT NULL DEFAULT 'pending' CHECK (version_status IN ('pending', 'current', 'superseded', 'repealed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_legal_meta_version UNIQUE (family_id, law_doc_kind, version_key) +); diff --git a/migrations/348_documents_material_type_idx.sql b/migrations/348_documents_material_type_idx.sql new file mode 100644 index 0000000..0d1cbf0 --- /dev/null +++ b/migrations/348_documents_material_type_idx.sql @@ -0,0 +1,4 @@ +-- 348_documents_material_type_idx.sql +-- 안전 자료실 A-1 (9/12) — material_type partial index (128~131 facet 인덱스 선례). +CREATE INDEX IF NOT EXISTS idx_documents_material_type + ON documents (material_type) WHERE material_type IS NOT NULL; diff --git a/migrations/349_documents_jurisdiction_idx.sql b/migrations/349_documents_jurisdiction_idx.sql new file mode 100644 index 0000000..09e2c9c --- /dev/null +++ b/migrations/349_documents_jurisdiction_idx.sql @@ -0,0 +1,4 @@ +-- 349_documents_jurisdiction_idx.sql +-- 안전 자료실 A-1 (10/12) — jurisdiction partial index. +CREATE INDEX IF NOT EXISTS idx_documents_jurisdiction + ON documents (jurisdiction) WHERE jurisdiction IS NOT NULL; diff --git a/migrations/350_legal_meta_family_idx.sql b/migrations/350_legal_meta_family_idx.sql new file mode 100644 index 0000000..216b70b --- /dev/null +++ b/migrations/350_legal_meta_family_idx.sql @@ -0,0 +1,6 @@ +-- 350_legal_meta_family_idx.sql +-- 안전 자료실 A-1 (11/12) — point-in-time 조회 축. +-- 술어 = COALESCE(effective_date, promulgation_date) (KGS 류 시행일 미상 row 침묵 탈락 방지) +-- 이나 인덱스는 effective_date 단순형으로 시작 — COALESCE expression index 는 실측 후. +CREATE INDEX IF NOT EXISTS idx_legal_meta_family + ON legal_meta (family_id, effective_date DESC); diff --git a/migrations/351_documents_paper_doi_uq.sql b/migrations/351_documents_paper_doi_uq.sql new file mode 100644 index 0000000..93ee88f --- /dev/null +++ b/migrations/351_documents_paper_doi_uq.sql @@ -0,0 +1,9 @@ +-- 351_documents_paper_doi_uq.sql +-- 안전 자료실 A-1 (12/12) — 논문 DOI dedup 구조 강제 (partial UNIQUE). +-- doi 보유 계약 (R3 — R2-B1): paper.doi 는 서지 Document 단일 보유. +-- OA 전문 PDF / 구매분 file Document 는 paper.doi 를 갖지 않고 paper.parent_doi 링크로 연결 +-- → 인덱스 식이 NULL 이라 다중 행 허용, 2-Document 구조와 무충돌. +-- DOI 정규화(소문자·prefix 제거)는 단일 함수 경유 — 저장=조회 동일 함수 원칙 (B-3). +CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_paper_doi + ON documents (lower(extract_meta #>> '{paper,doi}')) + WHERE material_type = 'paper' AND extract_meta #>> '{paper,doi}' IS NOT NULL; diff --git a/migrations/352_news_sources_material_type.sql b/migrations/352_news_sources_material_type.sql new file mode 100644 index 0000000..423a637 --- /dev/null +++ b/migrations/352_news_sources_material_type.sql @@ -0,0 +1,8 @@ +-- 352_news_sources_material_type.sql +-- 안전 자료실 A-2 (1/4) — 소스 레지스트리에 자료유형 기본값. +-- plan: safety-library-1 A-2. 수집기 ingest 시점 deterministic 부여의 단일 진실 = +-- 레지스트리 행 (country 와 동일 패턴 — 코드 하드코딩/이름 매칭 회피). +-- NULL = 자료유형 비대상 (뉴스/철학 등). paper 소스는 country 가 있어도 +-- documents.jurisdiction 은 NULL (국제 학술지 — 코드 레벨 규칙). +ALTER TABLE news_sources ADD COLUMN IF NOT EXISTS material_type TEXT + CHECK (material_type IN ('law', 'paper', 'book', 'incident', 'manual', 'standard', 'guide')); diff --git a/migrations/353_news_sources_license_scheme.sql b/migrations/353_news_sources_license_scheme.sql new file mode 100644 index 0000000..0b59d30 --- /dev/null +++ b/migrations/353_news_sources_license_scheme.sql @@ -0,0 +1,6 @@ +-- 353_news_sources_license_scheme.sql +-- 안전 자료실 A-2 (2/4) — 소스별 라이선스 scheme (0-3 license 메타 deterministic 주입). +-- kogl(공공누리류) / ogl(UK) / public_domain(미 연방) / proprietary / unknown. +-- 미확정 소스는 보수적으로 unknown/proprietary + redistribute=false 에서 시작 +-- (갱신은 근거 확보 시 완화 방향 — 보수적=빡빡 원칙). +ALTER TABLE news_sources ADD COLUMN IF NOT EXISTS license_scheme TEXT; diff --git a/migrations/354_news_sources_license_redistribute.sql b/migrations/354_news_sources_license_redistribute.sql new file mode 100644 index 0000000..2955de4 --- /dev/null +++ b/migrations/354_news_sources_license_redistribute.sql @@ -0,0 +1,4 @@ +-- 354_news_sources_license_redistribute.sql +-- 안전 자료실 A-2 (3/4) — 재배포 가능 여부. P3 다이제스트/발행류의 구조 게이트 입력 +-- (redistribute=false 소스 제외 — 사람 기억 의존 차단, 0-3). +ALTER TABLE news_sources ADD COLUMN IF NOT EXISTS license_redistribute BOOLEAN; diff --git a/migrations/355_news_sources_material_seed.sql b/migrations/355_news_sources_material_seed.sql new file mode 100644 index 0000000..e8b3a05 --- /dev/null +++ b/migrations/355_news_sources_material_seed.sql @@ -0,0 +1,45 @@ +-- 355_news_sources_material_seed.sql +-- 안전 자료실 A-2 (4/4) — 기존 안전/공학 소스 12행 material_type + license 시드. +-- 매핑 근거 = plan safety-library-1 0-1 경계 확정 (2026-06-12 prod 레지스트리 실측 대조): +-- law=입법예고(신호) / incident=HSE·KOSHA사례·CSB·CCPS / guide=KOSHA GUIDE·TWI +-- / standard=NB·API 공지 / paper=JPVT·arXiv (jurisdiction 은 코드에서 NULL 강제). +-- 뉴스/철학 소스는 NULL 유지 (자료유형 비대상). 이름 키 = 시드 마이그레이션이 부여한 고정값. +UPDATE news_sources SET + material_type = CASE name + WHEN '고용노동부 입법행정예고' THEN 'law' + WHEN 'UK HSE Press' THEN 'incident' + WHEN 'KOSHA 재해사례' THEN 'incident' + WHEN 'US CSB 사고조사보고서' THEN 'incident' + WHEN 'CCPS Process Safety Beacon' THEN 'incident' + WHEN 'KOSHA GUIDE' THEN 'guide' + WHEN 'TWI Job Knowledge' THEN 'guide' + WHEN 'National Board 기술 아티클' THEN 'standard' + WHEN 'API 표준 공지' THEN 'standard' + WHEN 'ASME J. Pressure Vessel Technology' THEN 'paper' + WHEN 'arXiv physics.app-ph' THEN 'paper' + WHEN 'arXiv cond-mat.mtrl-sci' THEN 'paper' + END, + license_scheme = CASE name + WHEN '고용노동부 입법행정예고' THEN 'kogl' + WHEN 'KOSHA 재해사례' THEN 'kogl' + WHEN 'KOSHA GUIDE' THEN 'kogl' + WHEN 'UK HSE Press' THEN 'ogl' + WHEN 'US CSB 사고조사보고서' THEN 'public_domain' + WHEN 'TWI Job Knowledge' THEN 'proprietary' + WHEN 'National Board 기술 아티클' THEN 'proprietary' + WHEN 'API 표준 공지' THEN 'proprietary' + WHEN 'CCPS Process Safety Beacon' THEN 'proprietary' + WHEN 'ASME J. Pressure Vessel Technology' THEN 'proprietary' + WHEN 'arXiv physics.app-ph' THEN 'unknown' + WHEN 'arXiv cond-mat.mtrl-sci' THEN 'unknown' + END, + license_redistribute = CASE name + WHEN 'UK HSE Press' THEN TRUE + WHEN 'US CSB 사고조사보고서' THEN TRUE + ELSE FALSE + END +WHERE name IN ('고용노동부 입법행정예고', 'UK HSE Press', 'KOSHA 재해사례', + 'US CSB 사고조사보고서', 'CCPS Process Safety Beacon', 'KOSHA GUIDE', + 'TWI Job Knowledge', 'National Board 기술 아티클', 'API 표준 공지', + 'ASME J. Pressure Vessel Technology', 'arXiv physics.app-ph', + 'arXiv cond-mat.mtrl-sci'); diff --git a/migrations/356_seed_legal_acts_kr.sql b/migrations/356_seed_legal_acts_kr.sql new file mode 100644 index 0000000..2fbe30d --- /dev/null +++ b/migrations/356_seed_legal_acts_kr.sql @@ -0,0 +1,41 @@ +-- 356_seed_legal_acts_kr.sql +-- 안전 자료실 B-1 PR① — legal_acts KR 시드 26행 (레거시 law_monitor 26개 superset). +-- plan: safety-library-1 B-1. watch=true 26개 전부 (R3-B1 ① — '우선순위'는 정렬일 뿐 제외 아님). +-- 법령ID/공포/시행 = 2026-06-13 lawSearch 라이브 실측 (tests/fixtures/statute_kr/seed_26laws.tsv). +-- ★ '유해ㆍ위험작업...' = 정식명에 가운뎃점(U+318D) — law_monitor 하드코딩(점 없음)은 exact match +-- 불일치로 이 법령을 영구 미매칭하던 잠복 누락이었음 (R8-m1 의 watchlist 판 실증). +-- parent 계열: 법률 → 시행령/시행규칙/위임 부령. VALUES 순서 = 부모 선행 (FK). +INSERT INTO legal_acts (family_id, jurisdiction, law_level, title, parent_family_id, native_id, source_api, watch, poll_cycle) +SELECT v.family_id, v.jurisdiction, v.law_level, v.title, v.parent_family_id, v.native_id, v.source_api, v.watch, v.poll_cycle +FROM (VALUES + -- 법률 (statute, 14) + ('kr-law:001766', 'KR', 'statute', '산업안전보건법', NULL, '001766', 'law.go.kr', TRUE, 'daily'), + ('kr-law:013993', 'KR', 'statute', '중대재해 처벌 등에 관한 법률', NULL, '013993', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001807', 'KR', 'statute', '건설기술 진흥법', NULL, '001807', 'law.go.kr', TRUE, 'daily'), + ('kr-law:000237', 'KR', 'statute', '시설물의 안전 및 유지관리에 관한 특별법', NULL, '000237', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009502', 'KR', 'statute', '위험물안전관리법', NULL, '009502', 'law.go.kr', TRUE, 'daily'), + ('kr-law:000162', 'KR', 'statute', '화학물질관리법', NULL, '000162', 'law.go.kr', TRUE, 'daily'), + ('kr-law:011857', 'KR', 'statute', '화학물질의 등록 및 평가 등에 관한 법률', NULL, '011857', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009503', 'KR', 'statute', '소방시설 설치 및 관리에 관한 법률', NULL, '009503', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001854', 'KR', 'statute', '전기사업법', NULL, '001854', 'law.go.kr', TRUE, 'daily'), + ('kr-law:013718', 'KR', 'statute', '전기안전관리법', NULL, '013718', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001850', 'KR', 'statute', '고압가스 안전관리법', NULL, '001850', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001849', 'KR', 'statute', '액화석유가스의 안전관리 및 사업법', NULL, '001849', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001872', 'KR', 'statute', '근로기준법', NULL, '001872', 'law.go.kr', TRUE, 'daily'), + ('kr-law:002016', 'KR', 'statute', '환경영향평가법', NULL, '002016', 'law.go.kr', TRUE, 'daily'), + -- 대통령령 (decree, 7) + ('kr-law:003786', 'KR', 'decree', '산업안전보건법 시행령', 'kr-law:001766', '003786', 'law.go.kr', TRUE, 'daily'), + ('kr-law:014159', 'KR', 'decree', '중대재해 처벌 등에 관한 법률 시행령', 'kr-law:013993', '014159', 'law.go.kr', TRUE, 'daily'), + ('kr-law:002111', 'KR', 'decree', '건설기술 진흥법 시행령', 'kr-law:001807', '002111', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009707', 'KR', 'decree', '위험물안전관리법 시행령', 'kr-law:009502', '009707', 'law.go.kr', TRUE, 'daily'), + ('kr-law:004390', 'KR', 'decree', '화학물질관리법 시행령', 'kr-law:000162', '004390', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009694', 'KR', 'decree', '소방시설 설치 및 관리에 관한 법률 시행령', 'kr-law:009503', '009694', 'law.go.kr', TRUE, 'daily'), + ('kr-law:002246', 'KR', 'decree', '고압가스 안전관리법 시행령', 'kr-law:001850', '002246', 'law.go.kr', TRUE, 'daily'), + -- 부령 (rule, 5) + ('kr-law:007364', 'KR', 'rule', '산업안전보건법 시행규칙', 'kr-law:001766', '007364', 'law.go.kr', TRUE, 'daily'), + ('kr-law:007363', 'KR', 'rule', '산업안전보건기준에 관한 규칙', 'kr-law:001766', '007363', 'law.go.kr', TRUE, 'daily'), + ('kr-law:007844', 'KR', 'rule', '유해ㆍ위험작업의 취업 제한에 관한 규칙', 'kr-law:001766', '007844', 'law.go.kr', TRUE, 'daily'), + ('kr-law:006175', 'KR', 'rule', '건설기술 진흥법 시행규칙', 'kr-law:001807', '006175', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009732', 'KR', 'rule', '위험물안전관리법 시행규칙', 'kr-law:009502', '009732', 'law.go.kr', TRUE, 'daily') +) AS v(family_id, jurisdiction, law_level, title, parent_family_id, native_id, source_api, watch, poll_cycle) +WHERE NOT EXISTS (SELECT 1 FROM legal_acts la WHERE la.family_id = v.family_id); diff --git a/scripts/backfill_material_axis.py b/scripts/backfill_material_axis.py new file mode 100644 index 0000000..4f5c8ce --- /dev/null +++ b/scripts/backfill_material_axis.py @@ -0,0 +1,221 @@ +"""안전 자료실 A-3 백필 — 기존 코퍼스에 material_type/jurisdiction/published_date/license 소급. + +plan: safety-library-1 A-3 (PKM plans/2026-06-12-safety-library-plan.html) +선례: backfill_category.py (one-off 멱등 스크립트 — migration 아님, 152 단일 트랜잭션 제약 회피) + +술어 (2026-06-13 prod 실측 교정 — R2 blocker 반영): + 1. extract_meta.source_id JOIN news_sources → 레지스트리 material_type/country 전파 + (KOSHA 사례 본문·CSB 페이지·HSE·MOEL·JPVT·arXiv·NB·TWI·API 공지 전부 커버. + paper 는 jurisdiction NULL 강제 — plan 0-1. KOSHA 본문의 kosha.kind='case' 가정은 + 실측 부정됨: kind 는 첨부/GUIDE 에만 존재 → source_id JOIN 이 정본 술어) + 2. kosha.kind='case_attachment' → incident/KR + 3. kosha.kind='guide' → guide/KR (+ ofancYmd 'YYYY-MM-DD' 실측) + 4. csb.kind='report_pdf' → incident/US (source_id 없음 — JOIN 비대상) + 5. source_channel='law_monitor' → law/KR (243건. legal_meta 생략 — MST 미보존, + 버전 체인은 B-1 가동 시점부터. published_date = title 의 '(YYYYMMDD)' 공포일 추출 — + extract_meta 빈값 실측, R3-m1 의 'NULL 허용' 보다 1줄 정규식이 저렴해 채움) + 6. file_path LIKE '%KGS_Code%' → law/KR (frontmatter 키 = 'code' 실측 117/118, + 'kgs_code' 0건. 경로 술어가 더 단순·전수. license 는 B-4 소관 — 미주입) + +불변식: + - 전 UPDATE 에 material_type IS NULL 가드 (멱등 — 재실행 안전, A-2 신규 유입분 무접촉) + - material_type + jurisdiction 동일 statement (law CHECK chk_documents_law_jurisdiction 충족) + - published_date / license 는 각자 필드 부재 가드 (이미 값 있으면 무접촉) + - 업로드 Industrial_Safety 문서 = 대상 아님 (LLM 제안+승인 경로만 — 자동 전이 금지) + - 코퍼스(청크/임베딩) 무접촉 — 검색 지표 무변동이 정상 + +실행: + docker compose exec -T fastapi python /app/scripts/backfill_material_axis.py --dry-run + docker compose exec -T fastapi python /app/scripts/backfill_material_axis.py --apply +""" + +import argparse +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app")) + +# text() 미사용 — exec_driver_sql (정규식 콜론 함정) +from sqlalchemy.ext.asyncio import create_async_engine + +# ─── 술어별 (라벨, 카운트 SQL, 적용 SQL) ─────────────────────────────────────── + +_KOSHA_LICENSE = ("kogl", "false", "한국산업안전보건공단(KOSHA)") +_CSB_LICENSE = ("public_domain", "true", "U.S. Chemical Safety Board") +_LAW_LICENSE = ("public_domain", "true", "국가법령정보센터") + + +def _license_obj(scheme: str, redistribute: str, attribution: str) -> str: + return ( + f"jsonb_build_object('license', jsonb_build_object(" + f"'scheme', '{scheme}', 'redistribute', {redistribute}::boolean, " + f"'attribution', '{attribution}'))" + ) + + +STEPS: list[tuple[str, str]] = [ + # 1) 레지스트리 전파 (source_id JOIN) + ("1. src_join material/jurisdiction", """ + UPDATE documents d SET + material_type = ns.material_type, + jurisdiction = CASE WHEN ns.material_type = 'paper' THEN NULL ELSE ns.country END + FROM news_sources ns + WHERE d.material_type IS NULL AND d.deleted_at IS NULL + AND d.extract_meta->>'source_id' ~ '^[0-9]+$' + AND ns.id = (d.extract_meta->>'source_id')::int + AND ns.material_type IS NOT NULL + """), + # 2) KOSHA 첨부 + ("2. kosha 첨부 incident/KR", """ + UPDATE documents SET material_type = 'incident', jurisdiction = 'KR' + WHERE material_type IS NULL AND deleted_at IS NULL + AND extract_meta#>>'{kosha,kind}' = 'case_attachment' + """), + # 3) KOSHA GUIDE + ("3. kosha GUIDE guide/KR", """ + UPDATE documents SET material_type = 'guide', jurisdiction = 'KR' + WHERE material_type IS NULL AND deleted_at IS NULL + AND extract_meta#>>'{kosha,kind}' = 'guide' + """), + # 4) CSB 보고서 PDF + ("4. csb PDF incident/US", """ + UPDATE documents SET material_type = 'incident', jurisdiction = 'US' + WHERE material_type IS NULL AND deleted_at IS NULL + AND extract_meta#>>'{csb,kind}' = 'report_pdf' + """), + # 5) 레거시 law_monitor + ("5. law_monitor law/KR", """ + UPDATE documents SET material_type = 'law', jurisdiction = 'KR' + WHERE material_type IS NULL AND deleted_at IS NULL + AND source_channel = 'law_monitor' + """), + # 6) KGS Code watch 폴더 + ("6. KGS law/KR", """ + UPDATE documents SET material_type = 'law', jurisdiction = 'KR' + WHERE material_type IS NULL AND deleted_at IS NULL + AND file_path LIKE '%KGS_Code%' + """), + # 7) published_date — crawl/news 공통 (extract_meta.published_at ISO) + ("7. published_date (published_at)", """ + UPDATE documents SET published_date = (extract_meta->>'published_at')::date + WHERE published_date IS NULL AND deleted_at IS NULL + AND extract_meta->>'published_at' ~ '^\\d{4}-\\d{2}-\\d{2}' + """), + # 8) published_date — KOSHA GUIDE 공표일자 ('YYYY-MM-DD' 실측) + ("8. published_date (GUIDE ofancYmd)", """ + UPDATE documents SET published_date = (extract_meta#>>'{kosha,ofancYmd}')::date + WHERE published_date IS NULL AND deleted_at IS NULL + AND extract_meta#>>'{kosha,ofancYmd}' ~ '^\\d{4}-\\d{2}-\\d{2}$' + """), + # 9) published_date — 레거시 law title 공포일 '(YYYYMMDD)' + ("9. published_date (law title 공포일)", """ + UPDATE documents + SET published_date = to_date(substring(title from '\\((20\\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01]))\\)'), 'YYYYMMDD') + WHERE published_date IS NULL AND deleted_at IS NULL + AND source_channel = 'law_monitor' + AND title ~ '\\((20\\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01]))\\)' + """), + # 10) license — 레지스트리 전파 (scheme 있는 소스만) + ("10. license (src_join)", """ + UPDATE documents d SET + extract_meta = COALESCE(d.extract_meta, '{}'::jsonb) + || jsonb_build_object('license', jsonb_build_object( + 'scheme', ns.license_scheme, + 'redistribute', COALESCE(ns.license_redistribute, false), + 'attribution', ns.name)) + FROM news_sources ns + WHERE d.deleted_at IS NULL AND NOT (COALESCE(d.extract_meta, '{}'::jsonb) ? 'license') + AND d.extract_meta->>'source_id' ~ '^[0-9]+$' + AND ns.id = (d.extract_meta->>'source_id')::int + AND ns.license_scheme IS NOT NULL + """), + # 11) license — KOSHA 첨부/GUIDE (source_id 없음) + ("11. license (kosha kinds)", f""" + UPDATE documents SET + extract_meta = COALESCE(extract_meta, '{{}}'::jsonb) || {_license_obj(*_KOSHA_LICENSE)} + WHERE deleted_at IS NULL AND NOT (COALESCE(extract_meta, '{{}}'::jsonb) ? 'license') + AND extract_meta#>>'{{kosha,kind}}' IN ('case_attachment', 'guide') + """), + # 12) license — CSB PDF + ("12. license (csb PDF)", f""" + UPDATE documents SET + extract_meta = COALESCE(extract_meta, '{{}}'::jsonb) || {_license_obj(*_CSB_LICENSE)} + WHERE deleted_at IS NULL AND NOT (COALESCE(extract_meta, '{{}}'::jsonb) ? 'license') + AND extract_meta#>>'{{csb,kind}}' = 'report_pdf' + """), + # 13) license — 레거시 법령 (저작권법 제7조 비보호) + ("13. license (law_monitor)", f""" + UPDATE documents SET + extract_meta = COALESCE(extract_meta, '{{}}'::jsonb) || {_license_obj(*_LAW_LICENSE)} + WHERE deleted_at IS NULL AND NOT (COALESCE(extract_meta, '{{}}'::jsonb) ? 'license') + AND source_channel = 'law_monitor' + """), +] + +VERIFY_SQL = [ + ("축 전수표 (material_type x jurisdiction)", """ + SELECT material_type, jurisdiction, count(*) AS docs, + count(published_date) AS with_date, + count(*) FILTER (WHERE extract_meta ? 'license') AS with_license + FROM documents WHERE material_type IS NOT NULL AND deleted_at IS NULL + GROUP BY 1, 2 ORDER BY 1, 2 + """), + ("law & jurisdiction NULL (0 이어야 함 — hard)", """ + SELECT count(*) FROM documents + WHERE material_type = 'law' AND jurisdiction IS NULL AND deleted_at IS NULL + """), + ("잔여 미분류 안전 후보 (kosha/csb 메타 보유인데 NULL — 0 이어야 함)", """ + SELECT count(*) FROM documents + WHERE material_type IS NULL AND deleted_at IS NULL + AND (extract_meta ? 'kosha' OR extract_meta ? 'csb') + """), +] + + +async def main() -> None: + parser = argparse.ArgumentParser() + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true", + help="전 UPDATE 를 트랜잭션 안에서 실행해 정확한 rowcount + 검증표를 보여주고 ROLLBACK (변경 0)") + mode.add_argument("--apply", action="store_true", help="백필 실행 (단일 트랜잭션 커밋)") + args = parser.parse_args() + + db_url = os.getenv( + "DATABASE_URL", "postgresql+asyncpg://pkm:pkm@localhost:5432/pkm" + ) + engine = create_async_engine(db_url) + tag = "apply" if args.apply else "dry-run" + + async with engine.connect() as conn: + trans = await conn.begin() + try: + for label, sql in STEPS: + # text() 는 정규식의 '(?:' 콜론을 bind param 으로 오인 (migration 러너와 + # 동일 함정) → driver 직결 실행 + result = await conn.exec_driver_sql(sql) + print(f"[{tag}] {label}: {result.rowcount}행") + + print("\n─── 검증 (트랜잭션 내 미리보기) ───") + for label, sql in VERIFY_SQL: + result = await conn.exec_driver_sql(sql) + rows = result.fetchall() + print(f"\n{label}:") + for row in rows: + print(" ", tuple(row)) + + if args.apply: + await trans.commit() + print("\n[apply] 커밋 완료") + else: + await trans.rollback() + print("\n[dry-run] 전체 롤백 — 변경 0") + except Exception: + await trans.rollback() + raise + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/verify_statute_chain.py b/scripts/verify_statute_chain.py new file mode 100644 index 0000000..64711db --- /dev/null +++ b/scripts/verify_statute_chain.py @@ -0,0 +1,138 @@ +"""B-1 PR③ — 법령 버전 체인 검증 3술어 (plan safety-library-1). + +read-only 진단 — E-1 관찰의 법령 게이트 도구로도 재사용 (반복 실행 안전). + +검증 3술어 (R7-M1, B-1 단일 정본): + ① 존재성 — watch family 각각 primary 시리즈 current 정확 1건(0건도 위반) + + annex 시리즈당 current ≤ 1 + ② 노출 유일성 — primary current 보유 family당 primary 노출(체인+레거시 매핑 합산) 정확 1건 + (모집단 = primary current 보유 family 한정 — R8-M2) + ③ 고아 그물 — law_monitor in_corpus=true 레거시 중: + (a) current 보유 family 에 매핑되는데 안 flip 된 것(flip 누락) = 0 + (b) 어느 watch family 에도 매핑 안 되는 것(제명 개정 등 매핑 구멍) = 0 + repealed family·primary current 미보유 family 의 레거시 보존은 위반 아님 + +repealed family 는 ①② 기대값 0 으로 면제. + +실행: + docker compose exec -T fastapi python /app/scripts/verify_statute_chain.py +종료코드: 0 = 전건 PASS, 1 = 위반 (CI/관찰 게이트 용) +""" + +import asyncio +import os +import sys + +# 컨테이너: /app/scripts → /app (workers/core/models 패키지 루트). 로컬: repo/scripts → repo/app +_here = os.path.dirname(os.path.abspath(__file__)) +for _cand in (os.path.join(_here, ".."), os.path.join(_here, "..", "app")): + if os.path.isdir(os.path.join(_cand, "workers")): + sys.path.insert(0, os.path.abspath(_cand)) + break + +from collections import defaultdict + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import create_async_engine + +from workers.statute_collector import legacy_law_name, normalize_law_name, series_suffix + + +async def main() -> int: + db_url = os.getenv("DATABASE_URL", "postgresql+asyncpg://pkm:pkm@localhost:5432/pkm") + engine = create_async_engine(db_url) + violations: list[str] = [] + + async with engine.connect() as conn: + # ── 로드 ── + acts = (await conn.execute(text( + "SELECT family_id, title, repeal_detected_at IS NOT NULL AS repealed " + "FROM legal_acts WHERE watch"))).all() + metas = (await conn.execute(text( + "SELECT family_id, law_doc_kind, version_key, version_status FROM legal_meta"))).all() + + act_title = {a.family_id: a.title for a in acts} + repealed = {a.family_id for a in acts if a.repealed} + active = [a for a in acts if not a.repealed] + + # family → primary current 수 / annex 시리즈별 current 수 + prim_current = defaultdict(int) + annex_series_current = defaultdict(int) + for m in metas: + if m.version_status != "current": + continue + if m.law_doc_kind == "primary": + prim_current[m.family_id] += 1 + else: + annex_series_current[(m.family_id, series_suffix(m.version_key))] += 1 + + # ── ① 존재성 ── + for a in active: + n = prim_current[a.family_id] + if n != 1: + violations.append(f"① {a.family_id} ({a.title}): primary current {n}건 (정확 1 기대)") + for (fid, suf), n in annex_series_current.items(): + if fid not in repealed and n > 1: + violations.append(f"① {fid} annex 시리즈 {suf}: current {n}건 (≤1 기대)") + + # ── ③ 고아 그물 (정규화 동등 매핑) ── + # watch family 정규화명 → family_id (current 보유 여부 동반) + norm_to_fid = {} + for a in active: + norm_to_fid[normalize_law_name(a.title)] = a.family_id + + legacy = (await conn.execute(text( + "SELECT d.id, d.title, " + " EXISTS(SELECT 1 FROM document_chunks c WHERE c.doc_id=d.id AND c.in_corpus) AS exposed " + "FROM documents d WHERE d.source_channel='law_monitor' AND d.deleted_at IS NULL"))).all() + + orphan_flip_miss = 0 + orphan_unmapped = 0 + unmapped_names = set() + for row in legacy: + if not row.exposed: + continue # in_corpus=false = 정상 (스윕됨 or 청크 없음) + name = legacy_law_name(row.title or "") + norm = normalize_law_name(name) if name else None + fid = norm_to_fid.get(norm) if norm else None + if fid is None: + orphan_unmapped += 1 + if name: + unmapped_names.add(name) + elif prim_current.get(fid, 0) >= 1: + # current 보유 family 인데 레거시가 노출 중 = flip 누락 + orphan_flip_miss += 1 + if orphan_flip_miss: + violations.append(f"③(a) flip 누락: current 보유 family 의 노출 레거시 {orphan_flip_miss}건") + if orphan_unmapped: + violations.append( + f"③(b) 무매핑 노출 레거시 {orphan_unmapped}건 — 매핑 구멍(매핑 보강 신호): " + + ", ".join(sorted(unmapped_names))[:200]) + + # ── ② 노출 유일성 (primary current 보유 family 한정) ── + # 노출 primary = 체인 primary current(=1) + 레거시 매핑 노출분. + # ③(a)=0 이면 레거시 노출분 0 → 체인 1건만 = 정확 1. 별도 위반 추출은 ③(a)에 포함됨. + # (annex 노출 비동기 일반화는 may — Phase 1 미적용) + + # ── 상태 요약 출력 ── + print("=== 법령 체인 검증 (B-1 PR③ 3술어) ===") + print(f"watch family: {len(acts)} (active {len(active)}, repealed {len(repealed)})") + print(f"primary current 보유 family: {sum(1 for a in active if prim_current[a.family_id]==1)}/{len(active)}") + print(f"annex current 시리즈: {len(annex_series_current)}") + exposed_legacy = sum(1 for r in legacy if r.exposed) + print(f"레거시 law_monitor: {len(legacy)}건 (in_corpus 노출 {exposed_legacy}건)") + print() + + await engine.dispose() + + if violations: + print(f"[FAIL] 위반 {len(violations)}건:") + for v in violations: + print(" -", v) + return 1 + print("[PASS] 3술어 전건 통과 (존재성·노출 유일성·고아 그물)") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/fixtures/kosha_fatal_response.json b/tests/fixtures/kosha_fatal_response.json new file mode 100644 index 0000000..3422528 --- /dev/null +++ b/tests/fixtures/kosha_fatal_response.json @@ -0,0 +1 @@ +{"header": {"resultCode": "00", "resultMsg": "NORMAL_CODE"}, "body": {"pageNo": 1, "totalCount": 2845, "numOfRows": 3, "items": {"item": [{"contents": "


2026. 6. 9. (화), 12:22경부산 사상구 소재 아파트에서


재해자가 2명이 실외기 설치 작업 중


베란다 난간이 파손되며 바닥으로 떨어짐


(사망 2명)

※ 위 내용은 신고 및 현재 파악된 내용으로 조사결과에 따라 변경될 수 있습니다.


", "keyword": "[6/9, 부산 사상구] 실외기 설치 작업 중 베란다 난간이 파손되어 떨어짐", "arno": "20260611111536KIZXJ8"}, {"contents": "



2026. 6. 9. (화), 17:26경서울 관악구 철도 공사 현장에서


재해자가 수직형 케이블 거치대 설치 준비 작업 중


개구부로 떨어짐(사망 1명)


※ 위 내용은 신고 및 현재 파악된 내용으로 조사결과에 따라 변경될 수 있습니다.


", "keyword": "[6/9, 서울 관악구] 수직형 케이블 거치대 설치 준비 중 개구부로 떨어짐", "arno": "20260611111355OZSS9T"}, {"contents": "



2026. 5. 14. (목), 16:51경전남 광양시 소재 화학물질 제조사업장에서


재해자가 정제설비 내부에서 플랜지 해체 작업 중


고온 응축수가 쏟아져 화상을 입음(사망 1명)※ 위 내용은 신고 및 현재 파악된 내용으로 조사결과에 따라 변경될 수 있습니다.

", "keyword": "[5/14, 전남 광양시] 플랜지 해체 작업 중 고온 응축수가 쏟아져 화상", "arno": "202606111110595AR9QY"}]}}} diff --git a/tests/fixtures/statute_kr/lawjosub_probe.xml b/tests/fixtures/statute_kr/lawjosub_probe.xml new file mode 100644 index 0000000..811a59f --- /dev/null +++ b/tests/fixtures/statute_kr/lawjosub_probe.xml @@ -0,0 +1,46 @@ + +<법령 법령키="0017662026021921374"> +<기본정보> +<법령ID>001766 +<공포일자>20260219 +<공포번호>21374 +<언어>한글 +<법종구분 법종구분코드="A0002">법률 +<법령명_한글> +<법령명_한자> +<제명변경여부>N +<한글법령여부>Y +<편장절관>40040000 +<소관부처 소관부처코드="1492000">고용노동부 +<전화번호>044-202-8810, 8813, 8815, 8997 +<시행일자>20260601 +<제개정구분>일부개정 +<조문별시행일자>20260601 +<조문시행일자문자열>20260801:제10조의2, 제23조, 제175조제4항제1호의2 +<별표편집여부>N +<공포법령여부>Y +<시행일기준편집여부>Y + +<조문> +<조문단위 조문키="0001000"> +<조문번호>1 +<조문여부>전문 +<조문시행일자>20260601 +<조문이동이전> +<조문이동이후> +<조문변경여부>N + +<조문단위 조문키="0001001"> +<조문번호>1 +<조문여부>조문 +<조문제목> +<조문시행일자>20260601 +<조문이동이전> +<조문이동이후> +<조문변경여부>N +<조문내용> +]]> + + + + diff --git a/tests/fixtures/statute_kr/lawsearch_rule.xml b/tests/fixtures/statute_kr/lawsearch_rule.xml new file mode 100644 index 0000000..a88c62c --- /dev/null +++ b/tests/fixtures/statute_kr/lawsearch_rule.xml @@ -0,0 +1,2 @@ +law<키워드>산업안전보건기준에 관한 규칙
lawNm
11100success<법령일련번호>273603<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>007363<공포일자>20250901<공포번호>00450<제개정구분명>일부개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>고용노동부령<공동부령정보><시행일자>20260302<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=273603&type=HTML&mobileYn=&efYd=20260302
+ diff --git a/tests/fixtures/statute_kr/lawsearch_sanab.xml b/tests/fixtures/statute_kr/lawsearch_sanab.xml new file mode 100644 index 0000000..cdc76b7 --- /dev/null +++ b/tests/fixtures/statute_kr/lawsearch_sanab.xml @@ -0,0 +1,2 @@ +law<키워드>산업안전보건법
lawNm
31300success<법령일련번호>283449<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>001766<공포일자>20260219<공포번호>21374<제개정구분명>일부개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>법률<공동부령정보><시행일자>20260601<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=283449&type=HTML&mobileYn=&efYd=20260601<법령일련번호>284771<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>003786<공포일자>20260324<공포번호>36220<제개정구분명>타법개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>대통령령<공동부령정보><시행일자>20260324<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=284771&type=HTML&mobileYn=&efYd=20260324<법령일련번호>286657<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>007364<공포일자>20260529<공포번호>00470<제개정구분명>일부개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>고용노동부령<공동부령정보><시행일자>20260601<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=286657&type=HTML&mobileYn=&efYd=20260601
+ diff --git a/tests/fixtures/statute_kr/lawservice_rule.xml.gz b/tests/fixtures/statute_kr/lawservice_rule.xml.gz new file mode 100644 index 0000000..92595bc Binary files /dev/null and b/tests/fixtures/statute_kr/lawservice_rule.xml.gz differ diff --git a/tests/fixtures/statute_kr/lawservice_sanab.xml.gz b/tests/fixtures/statute_kr/lawservice_sanab.xml.gz new file mode 100644 index 0000000..154916c Binary files /dev/null and b/tests/fixtures/statute_kr/lawservice_sanab.xml.gz differ diff --git a/tests/fixtures/statute_kr/seed_26laws.tsv b/tests/fixtures/statute_kr/seed_26laws.tsv new file mode 100644 index 0000000..8dbfe3f --- /dev/null +++ b/tests/fixtures/statute_kr/seed_26laws.tsv @@ -0,0 +1,26 @@ +산업안전보건법 001766 283449 20260219 20260601 법률 +산업안전보건법 시행령 003786 284771 20260324 20260324 대통령령 +산업안전보건법 시행규칙 007364 286657 20260529 20260601 고용노동부령 +산업안전보건기준에 관한 규칙 007363 273603 20250901 20260302 고용노동부령 +유해위험작업의 취업 제한에 관한 규칙 MISS +중대재해 처벌 등에 관한 법률 013993 228817 20210126 20220127 법률 +중대재해 처벌 등에 관한 법률 시행령 014159 277417 20251001 20251001 대통령령 +건설기술 진흥법 001807 276921 20251001 20251001 법률 +건설기술 진흥법 시행령 002111 286847 20260609 20260609 대통령령 +건설기술 진흥법 시행규칙 006175 286885 20260611 20260611 국토교통부령 +시설물의 안전 및 유지관리에 관한 특별법 000237 266683 20241203 20251204 법률 +위험물안전관리법 009502 259933 20240206 20250807 법률 +위험물안전관리법 시행령 009707 273077 20250805 20250807 대통령령 +위험물안전관리법 시행규칙 009732 262765 20240520 20250521 행정안전부령 +화학물질관리법 000162 276815 20251001 20251001 법률 +화학물질관리법 시행령 004390 280507 20251223 20251223 대통령령 +화학물질의 등록 및 평가 등에 관한 법률 011857 279805 20251111 20260512 법률 +소방시설 설치 및 관리에 관한 법률 009503 236977 20211130 20241201 법률 +소방시설 설치 및 관리에 관한 법률 시행령 009694 284781 20260324 20260324 대통령령 +전기사업법 001854 283981 20260310 20260310 법률 +전기안전관리법 013718 268805 20250131 20260201 법률 +고압가스 안전관리법 001850 283919 20260310 20260310 법률 +고압가스 안전관리법 시행령 002246 286839 20260609 20260609 대통령령 +액화석유가스의 안전관리 및 사업법 001849 276549 20251001 20251128 법률 +근로기준법 001872 265959 20241022 20251023 법률 +환경영향평가법 002016 276833 20251001 20251023 법률 \ No newline at end of file diff --git a/tests/test_b4_license_watch.py b/tests/test_b4_license_watch.py new file mode 100644 index 0000000..a3bd4d6 --- /dev/null +++ b/tests/test_b4_license_watch.py @@ -0,0 +1,80 @@ +"""B-4 — licensed_restricted 차단 술어 + watch 타깃 (material/jurisdiction/license) 매핑 순수 테스트. + +차단 술어(_license_sql)는 retrieval 3-leg + digest 가 공유하는 단일 술어. 실제 제외 동작은 +GPU 라이브(합성 restricted doc 검색 제외)로 검증 — 여기선 술어 형태 + 매핑 표 계약만. +[[feedback_external_api_fixture_first]] / [[feedback_structural_integrity_over_path_discipline]] +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "app")) + +from services.search.license_filter import ( # noqa: E402 + restricted_exclude_orm, + restricted_exclude_sql, +) +from services.search.retrieval_service import _license_sql # noqa: E402 +from workers.file_watcher import _TARGET_AXIS # noqa: E402 + + +def test_shared_predicate_single_source(): + # retrieval/digest/briefing 가 같은 술어 정의를 공유 — drift 방지(단일 source 계약) + assert _license_sql("d") == " AND " + restricted_exclude_sql("d") + assert _license_sql("") == " AND " + restricted_exclude_sql("") + assert restricted_exclude_sql("d").startswith("COALESCE(d.extract_meta") + + +def test_restricted_exclude_orm_constructs(): + # study 풀이(explanation_rag)용 ORM 표현 — 컴파일 SQL 이 raw 술어와 동일 구조인지 + from sqlalchemy.dialects import postgresql + + clause = restricted_exclude_orm() + sql = str(clause.compile(dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True})) + assert "extract_meta" in sql + assert "'license'" in sql and "'restricted'" in sql # JSONB 경로 키 + assert "'false'" in sql and "'true'" in sql # COALESCE 기본 + 비교값 + + +def test_license_sql_shape_with_alias(): + sql = _license_sql("d") + assert sql.startswith(" AND ") # 항상 ' AND ...' (WHERE 합성용) + assert "COALESCE(d.extract_meta -> 'license' ->> 'restricted', 'false')" in sql + assert "<> 'true'" in sql # restricted=true 만 제외 + + +def test_license_sql_shape_no_alias(): + # alias='' = 단일 FROM documents (컬럼 직접 참조) + sql = _license_sql("") + assert "COALESCE(extract_meta -> 'license' ->> 'restricted', 'false')" in sql + assert ".extract_meta" not in sql # 점 없는 컬럼 직접 + + +def test_axis_books_papers_are_restricted(): + for folder, mt in (("Books", "book"), ("Papers_Purchased", "paper")): + material, jur, lic = _TARGET_AXIS[folder] + assert material == mt + assert jur is None # 책/논문 = 관할 없음(A-2 paper NULL 강제와 정합) + assert lic["scheme"] == "proprietary" + assert lic["restricted"] is True # RAG/digest 차단 대상 + assert lic["redistribute"] is False + + +def test_axis_manuals_proprietary_but_not_restricted(): + material, jur, lic = _TARGET_AXIS["Manuals"] + assert material == "manual" + assert lic["scheme"] == "proprietary" + assert lic["restricted"] is False # 사용자 결정 2026-06-13 (검색·RAG 활용) + + +def test_axis_kgs_law_kr_public_not_restricted(): + material, jur, lic = _TARGET_AXIS["KGS_Code"] + assert (material, jur) == ("law", "KR") + assert lic["scheme"] == "kogl" + assert lic["restricted"] is False # 법정 위임 공공 → 차단 아님 + + +def test_axis_non_target_folder_yields_none(): + # Inbox/Recordings 등 비대상 = (None, None, None) → material/license 미주입 + assert _TARGET_AXIS.get("Inbox", (None, None, None)) == (None, None, None) diff --git a/tests/test_c1_decorate.py b/tests/test_c1_decorate.py new file mode 100644 index 0000000..905040b --- /dev/null +++ b/tests/test_c1_decorate.py @@ -0,0 +1,57 @@ +"""C-1 후속 — facets 집계 + version_status decorate 순수 로직 테스트. + +version_status 의 실제 legal_meta 조회는 GPU 라이브(법령 검색)로 검증 — 여기선 facets 분포 +계약 + decorate 의 law 무결과 skip 경로(DB 미접촉)만. +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "app")) + +from services.search.result_decorate import ( # noqa: E402 + compute_facets, + decorate_version_status, +) + + +class _R: + """SearchResult 흉내 — 분류 축 속성만.""" + + def __init__(self, material_type=None, jurisdiction=None, version_status=None, id=1): + self.material_type = material_type + self.jurisdiction = jurisdiction + self.version_status = version_status + self.id = id + + +def test_compute_facets_distribution(): + results = [ + _R("law", "KR", "current"), + _R("law", "KR", "superseded"), + _R("incident", "KR", None), + _R("paper", None, None), + ] + f = compute_facets(results) + assert f["material_type"] == {"law": 2, "incident": 1, "paper": 1} + assert f["jurisdiction"] == {"KR": 3} # paper jurisdiction None 제외 + assert f["version_status"] == {"current": 1, "superseded": 1} # None 제외 + + +def test_compute_facets_empty_and_all_none(): + assert compute_facets([]) == {} + assert compute_facets([_R(), _R()]) == {} # 모든 축 None → 빈 축 미포함 + + +def test_compute_facets_excludes_empty_axes(): + f = compute_facets([_R(jurisdiction="US"), _R(jurisdiction="EU")]) + assert f == {"jurisdiction": {"US": 1, "EU": 1}} + assert "material_type" not in f + + +def test_decorate_version_status_skips_without_law(): + # law 결과 없으면 legal_meta 조회 skip → session 미사용(None 으로도 무오류) + results = [_R("incident", "KR"), _R("paper")] + asyncio.run(decorate_version_status(None, results)) + assert all(r.version_status is None for r in results) diff --git a/tests/test_kosha_fatal.py b/tests/test_kosha_fatal.py new file mode 100644 index 0000000..e5bbb41 --- /dev/null +++ b/tests/test_kosha_fatal.py @@ -0,0 +1,67 @@ +"""B-2 KOSHA 사망사고 속보(callApiId=1040) — 순수 파서 fixture 테스트 (plan safety-library-1). + +fixture = 2026-06-13 data.go.kr 라이브 박제 (serviceKey 응답 본문 미포함 확인, +tests/fixtures/kosha_fatal_response.json). _fatal_fields/_items 는 순수 함수라 DB/httpx +호출 없이 검증 — [[feedback_external_api_fixture_first]]. +""" + +import json +from datetime import date +from pathlib import Path + +from workers.kosha_collector import _fatal_fields, _items + +FIXTURE = Path(__file__).parent / "fixtures" / "kosha_fatal_response.json" + + +def _payload() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def test_items_envelope_parse(): + """body.items.item 봉투 파싱 — 재해사례와 동일 envelope.""" + items = _items(_payload()) + assert len(items) == 3 + assert all({"arno", "keyword", "contents"} <= set(it) for it in items) + + +def test_fatal_fields_basic_mapping(): + item = _items(_payload())[0] + f = _fatal_fields(item) + assert f is not None + assert f["arno"] == "20260611111536KIZXJ8" + assert f["title"].startswith("[6/9, 부산 사상구]") + # HTML 태그 + 이미지 서버 URL 노이즈 완전 제거 (검색/임베딩 본문 정화) + assert "<" not in f["text"] + assert "portal.kosha.or.kr" not in f["text"] + assert "data-filename" not in f["text"] + # 본문 텍스트는 보존 + assert "(사망 2명)" in f["text"] + assert "베란다 난간" in f["text"] + # published_date = arno 접두 8자리(KST 등록일), reg_dt = 14자리 등록시각 원문 + assert f["published_date"] == date(2026, 6, 11) + assert f["reg_dt"] == "20260611111536" + + +def test_fatal_fields_all_three_items_well_formed(): + for item in _items(_payload()): + f = _fatal_fields(item) + assert f is not None + assert f["published_date"] == date(2026, 6, 11) # 3건 모두 06-11 등록 + assert f["reg_dt"] is not None + assert f["text"] and "<" not in f["text"] + + +def test_fatal_fields_skips_missing_required(): + assert _fatal_fields({"arno": "20260611111536XX", "contents": "x"}) is None # keyword 부재 + assert _fatal_fields({"keyword": "제목만", "contents": "x"}) is None # arno 부재 + assert _fatal_fields({"arno": " ", "keyword": " ", "contents": "x"}) is None # 공백뿐 + + +def test_fatal_fields_malformed_arno_date_is_fail_quiet(): + # arno 접두가 8자리 날짜로 안 풀리면 published_date/reg_dt = None (보조 축이라 fail-quiet) + f = _fatal_fields({"arno": "ABC123", "keyword": "제목", "contents": "

본문

"}) + assert f is not None + assert f["published_date"] is None + assert f["reg_dt"] is None + assert f["text"] == "본문" diff --git a/tests/test_queue_overview.py b/tests/test_queue_overview.py index c24664e..a1f230a 100644 --- a/tests/test_queue_overview.py +++ b/tests/test_queue_overview.py @@ -103,6 +103,32 @@ def test_summarize_pool_split_attribution(): assert macbook["pending"] == 0 # 풀 pending 은 macmini 만 +def test_summarize_by_machine_projection(): + """build_summarize_by_machine = split 의 done_1h/done_today 를 머신별로 투영 + (done_15m 은 제외 — 내부 state 판정 전용).""" + from services.queue_overview import build_summarize_by_machine + split = _split( + macbook={"done_1h": 226, "done_today": 312, "done_15m": 60}, + macmini={"done_1h": 37, "done_today": 94, "done_15m": 9}, + ) + sbm = build_summarize_by_machine(split) + assert sbm == { + "macmini": {"done_1h": 37, "done_today": 94}, + "macbook": {"done_1h": 226, "done_today": 312}, + } + assert "done_15m" not in sbm["macbook"] + + +def test_compose_overview_includes_summarize_by_machine(): + """compose_overview 응답 계약에 summarize_by_machine 포함 (FE 레인 분담 재료).""" + now_kst = datetime(2026, 6, 13, 13, 0, tzinfo=KST) + stats = {"summarize": _stage(pending=1317, done_1h=264)} + split = _split(macbook={"done_1h": 226, "done_today": 312}, macmini={"done_1h": 37, "done_today": 94}) + ov = compose_overview(stats, split, {}, {}, [], deep_enabled=True, now_kst=now_kst) + assert ov["summarize_by_machine"]["macbook"]["done_1h"] == 226 + assert ov["summarize_by_machine"]["macmini"]["done_today"] == 94 + + def test_deep_disabled_deep_summary_counts_to_macmini(): stats = {"deep_summary": _stage(pending=2, processing=1, done_1h=3, done_today=4)} machines = build_machines(stats, _split(), [], deep_enabled=False) diff --git a/tests/test_statute_kr_adapter.py b/tests/test_statute_kr_adapter.py new file mode 100644 index 0000000..715c9e4 --- /dev/null +++ b/tests/test_statute_kr_adapter.py @@ -0,0 +1,89 @@ +"""B-1 PR① — KR 어댑터 순수 파서 fixture 테스트 (plan safety-library-1). + +fixture = 2026-06-13 law.go.kr 라이브 박제 (OC 새니타이즈, tests/fixtures/statute_kr/). +파서는 순수 함수라 httpx/DB 불요 — 컨테이너 밖 로컬 실행. +""" + +import gzip +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "app")) + +from workers.statute_adapters import ChangeEvent # noqa: E402 +from workers.statute_adapters.kr import detect_change, parse_search_hit # noqa: E402 + +FIX = Path(__file__).parent / "fixtures" / "statute_kr" + + +def _read(name: str) -> str: + p = FIX / name + if name.endswith(".gz"): + return gzip.decompress(p.read_bytes()).decode("utf-8") + return p.read_text(encoding="utf-8") + + +def test_parse_search_hit_exact_match(): + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + assert hit is not None + assert hit["law_id"] == "001766" + assert hit["mst"] == "283449" + assert hit["promulgation_date"] == "20260219" + assert hit["effective_date"] == "20260601" + assert hit["status_code"] == "현행" + + +def test_parse_search_hit_rejects_partial_name(): + # totalCnt 3 인 응답에서 '산업안전보건법 시행령' 등 부분 일치는 비매칭이어야 함 + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건") + assert hit is None + + +def test_detect_change_same_watermark_is_silent(): + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + assert detect_change(hit, "kr-law:001766", "산업안전보건법", watermark="283449") is None + + +def test_detect_change_new_mst_is_amend(): + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + ev = detect_change(hit, "kr-law:001766", "산업안전보건법", watermark="283448") + assert isinstance(ev, ChangeEvent) + assert ev.kind == "amend" + assert ev.new_version_key == "283449" + assert ev.effective_date == "20260601" + + +def test_detect_change_empty_watermark_is_amend(): + # 첫 폴링(워터마크 부재) = 변경으로 감지 — PR② 부트스트랩 전 관찰 모드의 기대 동작 + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + ev = detect_change(hit, "kr-law:001766", "산업안전보건법", watermark=None) + assert ev is not None and ev.kind == "amend" + + +def test_detect_change_repeal_keyword(): + hit = {"mst": "9", "revision_type": "폐지", "promulgation_date": None, + "effective_date": None, "law_id": "x", "status_code": None} + ev = detect_change(hit, "kr-law:x", "x", watermark="1") + assert ev is not None and ev.kind == "repeal" + + +def test_lawservice_snapshot_semantics_rule(): + """R7-M3 판정 박제: 전문 1콜 XML = 조문+별표 전체 스냅샷 (PR② payload 계약의 전제).""" + root = ET.fromstring(_read("lawservice_rule.xml.gz")) + articles = root.findall(".//조문단위") + annexes = root.findall(".//별표단위") + assert len(articles) >= 800, "산안기준규칙 조문 853 기대 — 전문 1콜 판정 근거" + assert len(annexes) == 23, "별표 23 전부 본문 XML 포함 = 스냅샷 의미론" + # R7-M3 ②: 별표 식별 = 구조화 필드 (suffix 문자열 파싱 불요) + first = annexes[0] + assert first.findtext("별표번호") is not None + assert first.findtext("별표가지번호") is not None + + +def test_lawservice_sanab_basic_info(): + root = ET.fromstring(_read("lawservice_sanab.xml.gz")) + assert root.findtext(".//법령ID") == "001766" + assert len(root.findall(".//조문단위")) >= 200 + # 별표 없는 법령 = 별표단위 0 (스냅샷 의미론의 반대쪽 케이스) + assert len(root.findall(".//별표단위")) == 0 diff --git a/tests/test_statute_lifecycle_units.py b/tests/test_statute_lifecycle_units.py new file mode 100644 index 0000000..afe2778 --- /dev/null +++ b/tests/test_statute_lifecycle_units.py @@ -0,0 +1,87 @@ +"""B-1 PR② — 매핑·시리즈·payload 순수 단위 테스트 (plan safety-library-1). + +법령명 매핑 단위 테스트 = R8-B1 동반 계약 (검증(PR③) 전에 스윕이 도는 만큼 +매핑은 코드 레벨 선고정). 실 title 표본 = 2026-06-13 prod documents 실측 형태. +""" + +import gzip +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "app")) + +from workers.statute_adapters.kr import parse_service_payloads # noqa: E402 +from workers.statute_collector import ( # noqa: E402 + legacy_law_name, + normalize_law_name, + series_suffix, +) + +FIX = Path(__file__).parent / "fixtures" / "statute_kr" + + +# ─── 법령명 매핑 (실 title 표본) ─── + +def test_legacy_law_name_extraction(): + # prod 실측 형태: '법령명 (YYYYMMDD) 섹션' + assert legacy_law_name("건설기술 진흥법 시행규칙 (20260611) 제6장_보칙") == "건설기술 진흥법 시행규칙" + assert legacy_law_name("산업안전보건법 (20260219) 전문") == "산업안전보건법" + assert legacy_law_name("패턴 불일치 제목") is None + + +def test_mapping_equality_not_prefix(): + """prefix 비교 금지 — '산업안전보건법' family 가 시행령 레거시를 오폭하면 안 됨.""" + name = legacy_law_name("산업안전보건법 시행령 (20260324) 제1장") + assert name == "산업안전보건법 시행령" + assert normalize_law_name(name) != normalize_law_name("산업안전보건법") + assert normalize_law_name(name) == normalize_law_name("산업안전보건법 시행령") + + +def test_mapping_absorbs_middle_dot_and_space(): + """가운뎃점·공백 변형 흡수 — 유해ㆍ위험작업(정식) vs 유해위험작업(law_monitor 표기).""" + assert (normalize_law_name("유해ㆍ위험작업의 취업 제한에 관한 규칙") + == normalize_law_name("유해위험작업의 취업 제한에 관한 규칙")) + assert (normalize_law_name("산업안전보건기준에 관한 규칙") + == normalize_law_name("산업안전보건기준에관한규칙")) + + +# ─── 버전 시리즈 식별자 (R7-B1 a) ─── + +def test_series_suffix(): + assert series_suffix("283449") is None # primary + assert series_suffix("273603|별표0001-00") == "별표0001-00" # annex (구분 차원 포함) + assert series_suffix("273603|서식0003-00") == "서식0003-00" + + +# ─── fetch_version payload (fixture — R4-M4 리스트 계약) ─── + +def _read_gz(name: str) -> str: + return gzip.decompress((FIX / name).read_bytes()).decode("utf-8") + + +def test_parse_service_payloads_rule_with_annexes(): + payloads = parse_service_payloads( + _read_gz("lawservice_rule.xml.gz"), "산업안전보건기준에 관한 규칙", "273603") + assert payloads[0].law_doc_kind == "primary" + assert payloads[0].version_key == "273603" + assert len(payloads[0].content) > 100_000 # 853조 본문 + annexes = [p for p in payloads if p.law_doc_kind == "annex"] + # 별표단위 23 중 삭제 tombstone 3 skip(별표10 '삭제 <2023.11.14>'·서식1·2 '삭제 <2012.3.5>') + # — KR 별표/서식 삭제 = absence 아닌 명시 tombstone (R7-M3 absence 추론 불요의 fixture 증거) + assert len(annexes) == 20 + keys = [p.version_key for p in annexes] + assert len(keys) == len(set(keys)), "annex version_key 유일성 (uq_legal_meta_version 전제)" + assert all(k.startswith("273603|") for k in keys) + # 구분 차원 — 별표1 vs 서식N 공존 (fixture 실측: (번호,가지)만으로는 4건 충돌) + assert any("별표" in k for k in keys) and any("서식" in k for k in keys) + + +def test_parse_service_payloads_sanab_no_annex(): + payloads = parse_service_payloads( + _read_gz("lawservice_sanab.xml.gz"), "산업안전보건법", "283449") + assert len(payloads) == 1 # 별표 없는 법령 = primary 단독 + p = payloads[0] + assert p.promulgation_date == "20260219" + assert p.effective_date == "20260601" + assert "제2조(정의)" in p.content # 조문내용 보존 + assert p.content.startswith("# 산업안전보건법")