From 5da94213ec8b88ca996db7962ba1f4fae6ecbb0c Mon Sep 17 00:00:00 2001 From: hyungi Date: Fri, 12 Jun 2026 21:25:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(safety):=20=EB=B6=84=EB=A5=98=20=EC=B6=95?= =?UTF-8?q?=20A-1=20=E2=80=94=20material=5Ftype/jurisdiction/published=5Fd?= =?UTF-8?q?ate=20+=20legal=5Facts/legal=5Fmeta=20(mig=20340~351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 안전 자료실 plan safety-library-1 A-1 (r3 계약 반영): - documents 3컬럼 (TEXT+CHECK, nullable additive) + law→jurisdiction NOT NULL 구조 강제 - legal_acts 단일 레지스트리(워치리스트 겸, watermark·repeal_detected_at 포함) - legal_meta 최소형 (version_key 합성형 UNIQUE, 전 버전 pending 적재 계약) - partial 인덱스 2 + family 인덱스 + paper DOI partial UNIQUE (doi=서지 단일 보유 계약) - ephemeral PG16 스모크: 12파일 적용 + CHECK/UNIQUE 계약 6종 검증 PASS Co-Authored-By: Claude Fable 5 --- app/models/document.py | 14 +++- app/models/legal_act.py | 73 +++++++++++++++++++ migrations/340_documents_material_type.sql | 6 ++ .../341_documents_material_type_check.sql | 6 ++ migrations/342_documents_jurisdiction.sql | 5 ++ .../343_documents_jurisdiction_check.sql | 4 + .../344_documents_law_jurisdiction_check.sql | 7 ++ migrations/345_documents_published_date.sql | 5 ++ migrations/346_legal_acts_table.sql | 22 ++++++ migrations/347_legal_meta_table.sql | 20 +++++ .../348_documents_material_type_idx.sql | 4 + migrations/349_documents_jurisdiction_idx.sql | 4 + migrations/350_legal_meta_family_idx.sql | 6 ++ migrations/351_documents_paper_doi_uq.sql | 9 +++ 14 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 app/models/legal_act.py create mode 100644 migrations/340_documents_material_type.sql create mode 100644 migrations/341_documents_material_type_check.sql create mode 100644 migrations/342_documents_jurisdiction.sql create mode 100644 migrations/343_documents_jurisdiction_check.sql create mode 100644 migrations/344_documents_law_jurisdiction_check.sql create mode 100644 migrations/345_documents_published_date.sql create mode 100644 migrations/346_legal_acts_table.sql create mode 100644 migrations/347_legal_meta_table.sql create mode 100644 migrations/348_documents_material_type_idx.sql create mode 100644 migrations/349_documents_jurisdiction_idx.sql create mode 100644 migrations/350_legal_meta_family_idx.sql create mode 100644 migrations/351_documents_paper_doi_uq.sql 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/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;