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/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/retrieval_service.py b/app/services/search/retrieval_service.py index 448e25c..c14a2eb 100644 --- a/app/services/search/retrieval_service.py +++ b/app/services/search/retrieval_service.py @@ -139,6 +139,23 @@ def _axis_sql(alias: str, af: "AxisFilter | None", params: dict) -> str: 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). @@ -308,7 +325,8 @@ async def search_text( await session.execute(text("SELECT set_limit(0.15)")) _params: dict[str, Any] = {"q": query, "limit": limit} - _where = _axis_sql("d", axis, _params) # 미지정 시 '' (byte 불변) + # license(항상) + axis(조건부). license 가 항상 ' AND ...' 이라 WHERE 는 늘 존재. + _where = _license_sql("d") + _axis_sql("d", axis, _params) result = await session.execute( text(f""" @@ -368,7 +386,7 @@ async def search_text( d.material_type, d.jurisdiction, d.published_date FROM documents d JOIN candidates c ON d.id = c.id - {("WHERE" + _where[4:]) if _where else ""} + WHERE{_where[4:]} ORDER BY score DESC LIMIT :limit """), @@ -514,6 +532,7 @@ async def _search_vector_docs( 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, @@ -522,13 +541,14 @@ async def _search_vector_docs( 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}{axis_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, @@ -538,7 +558,7 @@ async def _search_vector_docs( 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{axis_clause} + WHERE d.deleted_at IS NULL{axis_clause}{license_clause} ORDER BY c.embedding <=> cast(:embedding AS vector) LIMIT :limit """ @@ -589,6 +609,10 @@ async def _search_vector_chunks( 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, @@ -612,7 +636,7 @@ async def _search_vector_chunks( 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/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/file_watcher.py b/app/workers/file_watcher.py index c45fd2a..f1987cc 100644 --- a/app/workers/file_watcher.py +++ b/app/workers/file_watcher.py @@ -58,11 +58,21 @@ 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"), +# 안전 자료실 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": "기술 매뉴얼"}), } @@ -249,9 +259,9 @@ 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) + # 안전 자료실 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("*"): @@ -287,10 +297,14 @@ async def watch_inbox(): source_channel="drive_sync", category=category, needs_conversion=needs_conversion, - # 안전 자료실 A-2 — watch 타깃 매핑 (KGS=law/KR 등, 비대상=NULL) + # 안전 자료실 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() @@ -306,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/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)