From 3feddd012b8db51fa51e0c56e30b01a813d22b3b Mon Sep 17 00:00:00 2001 From: hyungi Date: Sat, 13 Jun 2026 06:23:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(safety):=20A-2=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20ingest=20=EC=8B=9C=EC=A0=90=20=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=EC=B6=95=20=EB=B6=80=EC=97=AC=20=E2=80=94=20=EB=A0=88=EC=A7=80?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=AC=20=EC=A0=84=ED=8C=8C=20+=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EA=B0=80=EB=93=9C=20(mig=20352~355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan safety-library-1 A-2 (classify-skip 경로 전수 커버): - news_sources 에 material_type/license_scheme/license_redistribute + 안전·공학 12행 시드 - news_collector: 레지스트리 → documents 전파 (_material_axis — paper 는 jurisdiction NULL 강제) - kosha(사례·첨부=incident, GUIDE=guide)/csb(incident·US)/api_std(standard·US)/law_monitor(law·KR) /file_watcher(KGS=law·KR 타깃 매핑) deterministic 부여 + extract_meta.license 주입 - published_date: 소스별 가용 날짜 (GUIDE 공표일·CSB lastmod·API 공지일·법령 공포일·뉴스 발행일) - classify_worker: document_type→material_type 결정적 매핑 제안 (자동 전이 금지) - accept-suggestion: material 제안 적용 + law=jurisdiction 필수(기본값 없음) + 청크 미러 1문 동기화 - chunk_worker: 비뉴스 문서 country=jurisdiction 미러 (R3-m3: 검색측 country 소비자 0 실측) Co-Authored-By: Claude Fable 5 --- app/api/documents.py | 52 +++++++++++++++++-- app/models/news_source.py | 9 ++++ app/workers/api_standards_collector.py | 6 +++ app/workers/chunk_worker.py | 4 ++ app/workers/classify_worker.py | 27 ++++++++++ app/workers/csb_collector.py | 13 ++++- app/workers/file_watcher.py | 15 ++++++ app/workers/kosha_collector.py | 34 ++++++++++-- app/workers/law_monitor.py | 19 ++++++- app/workers/news_collector.py | 43 ++++++++++++++- migrations/352_news_sources_material_type.sql | 8 +++ .../353_news_sources_license_scheme.sql | 6 +++ .../354_news_sources_license_redistribute.sql | 4 ++ migrations/355_news_sources_material_seed.sql | 45 ++++++++++++++++ 14 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 migrations/352_news_sources_material_type.sql create mode 100644 migrations/353_news_sources_license_scheme.sql create mode 100644 migrations/354_news_sources_license_redistribute.sql create mode 100644 migrations/355_news_sources_material_seed.sql diff --git a/app/api/documents.py b/app/api/documents.py index 1259fc6..397eba5 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): @@ -1244,11 +1250,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/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/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..c45fd2a 100644 --- a/app/workers/file_watcher.py +++ b/app/workers/file_watcher.py @@ -58,6 +58,13 @@ SCAN_TARGETS: list[tuple[str, str | None]] = [ ("Videos", "video"), ] +# 안전 자료실 A-2 — additional watch 타깃별 자료유형 (폴더 = deterministic 축). +# 키 = 타깃 경로의 마지막 성분. KGS Code = 법정 위임 상세기준 = law/KR (plan 0-1 확정). +# B-4 에서 Books(book)/Manuals(manual)/Papers_Purchased(paper) + license 주입 표로 확장. +_TARGET_MATERIAL: dict[str, tuple[str, str | None]] = { + "KGS_Code": ("law", "KR"), +} + def should_skip(path: Path) -> bool: if path.name in SKIP_NAMES or path.name.startswith("._"): @@ -242,6 +249,11 @@ async def watch_inbox(): if not scan_root.exists(): continue + # 안전 자료실 A-2 — 타깃 폴더 기반 자료유형 (없으면 (None, None)) + target_mt, target_jur = _TARGET_MATERIAL.get( + Path(sub).name, (None, None) + ) + for file_path in scan_root.rglob("*"): if not file_path.is_file() or should_skip(file_path): continue @@ -275,6 +287,9 @@ async def watch_inbox(): source_channel="drive_sync", category=category, needs_conversion=needs_conversion, + # 안전 자료실 A-2 — watch 타깃 매핑 (KGS=law/KR 등, 비대상=NULL) + material_type=target_mt, + jurisdiction=target_jur, ) session.add(doc) await session.flush() diff --git a/app/workers/kosha_collector.py b/app/workers/kosha_collector.py index dab61fb..611d707 100644 --- a/app/workers/kosha_collector.py +++ b/app/workers/kosha_collector.py @@ -23,7 +23,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 @@ -60,6 +60,21 @@ _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", "") @@ -155,7 +170,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 +232,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) @@ -307,8 +330,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() 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/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');