"""관리자 프로토콜 등록·활성화·퇴역 수명주기. 초안 원문은 ``kb.protocol_registration`` 에만 머문다. 활성화 트랜잭션이 ``kb.source`` 등록과 기존 RAG 인덱싱을 모두 마친 뒤에만 status를 active로 바꾼다. 검색 쪽은 레지스트리 행이 있는 source를 active 상태에서만 허용한다. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime import hashlib import re from typing import Any, Literal from uuid import uuid4 from . import rag ProtocolStatus = Literal["draft", "active", "retired"] ProtocolLicense = Literal["A", "B", "C", "D"] class ProtocolRegistryError(Exception): """프로토콜 레지스트리의 도메인 오류.""" class ProtocolNotFound(ProtocolRegistryError): """요청한 프로토콜이 존재하지 않음.""" class ProtocolTransitionConflict(ProtocolRegistryError): """현재 상태에서 요청한 전환을 수행할 수 없음.""" class ProtocolPolicyViolation(ProtocolRegistryError): """라이선스·콘텐츠 정책 위반.""" class ProtocolStoreUnavailable(ProtocolRegistryError): """DB 스키마 또는 저장소를 사용할 수 없음.""" @dataclass(frozen=True, slots=True) class ProtocolRecord: protocol_id: str source_id: str title: str source: str version: int license: ProtocolLicense external_llm_ok: bool content: str content_hash: str status: ProtocolStatus registered_by: str registered_at: datetime activated_at: datetime | None retired_at: datetime | None PROTOCOL_SCHEMA_SQL = """ CREATE SCHEMA IF NOT EXISTS kb; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace WHERE n.nspname = 'kb' AND t.relname = 'source' AND c.conname = 'ck_kb_source_external_license' ) THEN ALTER TABLE kb.source ADD CONSTRAINT ck_kb_source_external_license CHECK (license_class IN ('A','B') OR external_llm_ok = FALSE) NOT VALID; END IF; END $$; CREATE TABLE IF NOT EXISTS kb.protocol_registration ( protocol_id UUID PRIMARY KEY, source_id TEXT NOT NULL UNIQUE, title TEXT NOT NULL CHECK (btrim(title) <> ''), source_ref TEXT NOT NULL CHECK (btrim(source_ref) <> ''), version INT NOT NULL CHECK (version > 0), license_class CHAR(1) NOT NULL CHECK (license_class IN ('A','B','C','D')), external_llm_ok BOOLEAN NOT NULL DEFAULT FALSE, content TEXT NOT NULL CHECK (btrim(content) <> ''), content_hash CHAR(64) NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'), status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','active','retired')), registered_by UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, registered_at TIMESTAMPTZ NOT NULL DEFAULT now(), activated_at TIMESTAMPTZ, retired_at TIMESTAMPTZ, CONSTRAINT ck_protocol_external_license CHECK (license_class IN ('A','B') OR external_llm_ok = FALSE), CONSTRAINT ck_protocol_lifecycle_timestamps CHECK ( (status = 'draft' AND activated_at IS NULL AND retired_at IS NULL) OR (status = 'active' AND activated_at IS NOT NULL AND retired_at IS NULL) OR (status = 'retired' AND activated_at IS NOT NULL AND retired_at IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS idx_protocol_registration_status ON kb.protocol_registration(status, registered_at DESC); ALTER TABLE kb.chunk ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_kb_chunk_admin_write ON kb.chunk; CREATE POLICY p_kb_chunk_admin_write ON kb.chunk FOR ALL USING (app.current_role_name() = 'admin') WITH CHECK (app.current_role_name() = 'admin'); """ PROTOCOL_READINESS_SQL = """ SELECT to_regclass('kb.protocol_registration') IS NOT NULL AS protocol_table, EXISTS ( SELECT 1 FROM pg_constraint c WHERE c.conrelid = to_regclass('kb.source') AND c.conname = 'ck_kb_source_external_license' AND c.convalidated ) AS source_license_constraint, EXISTS ( SELECT 1 FROM pg_constraint c WHERE c.conrelid = to_regclass('kb.protocol_registration') AND c.conname = 'ck_protocol_external_license' AND c.convalidated ) AS protocol_license_constraint, EXISTS ( SELECT 1 FROM pg_constraint c WHERE c.conrelid = to_regclass('kb.protocol_registration') AND c.conname = 'ck_protocol_lifecycle_timestamps' AND c.convalidated ) AS protocol_lifecycle_constraint, EXISTS ( SELECT 1 FROM pg_policies p WHERE p.schemaname = 'kb' AND p.tablename = 'chunk' AND p.policyname = 'p_kb_chunk_admin_write' AND p.cmd = 'ALL' ) AS protocol_chunk_write_policy, to_regclass('kb.idx_protocol_registration_status') IS NOT NULL AS protocol_status_index """ _PROTOCOL_READINESS_FIELDS = ( "protocol_table", "source_license_constraint", "protocol_license_constraint", "protocol_lifecycle_constraint", "protocol_chunk_write_policy", "protocol_status_index", ) _SELECT_COLUMNS = """ protocol_id::text AS protocol_id, source_id, title, source_ref, version, license_class, external_llm_ok, content, content_hash, status, registered_by::text AS registered_by, registered_at, activated_at, retired_at """ def canonical_content(content: str) -> str: """플랫폼별 줄바꿈 차이를 제거한 해시·저장 공통 원문.""" return content.replace("\r\n", "\n").replace("\r", "\n").strip() def content_hash(content: str) -> str: """정규화된 전체 원문의 SHA-256.""" return hashlib.sha256(canonical_content(content).encode("utf-8")).hexdigest() def validate_license_policy(license_class: str, external_llm_ok: bool) -> None: """C/D 저작물은 외부 LLM 허용으로 등록할 수 없다.""" if license_class not in {"A", "B", "C", "D"}: raise ProtocolPolicyViolation("라이선스는 A, B, C, D 중 하나여야 합니다.") if license_class in {"C", "D"} and external_llm_ok: raise ProtocolPolicyViolation( "라이선스 C/D 프로토콜은 외부 LLM 사용을 허용할 수 없습니다." ) def _split_long_block(block: str, limit: int) -> list[str]: chunks: list[str] = [] remainder = block.strip() while len(remainder) > limit: cut = max(remainder.rfind("\n", 0, limit + 1), remainder.rfind(" ", 0, limit + 1)) if cut < limit // 2: cut = limit chunks.append(remainder[:cut].strip()) remainder = remainder[cut:].strip() if remainder: chunks.append(remainder) return chunks def build_index_chunks(record: ProtocolRecord, *, limit: int = 1800) -> list[dict[str, Any]]: """원문을 결정론적 문단 청크로 바꾸되 라이선스 메타데이터를 보존한다.""" blocks = [item.strip() for item in re.split(r"\n{2,}", record.content) if item.strip()] chunk_texts: list[str] = [] pending = "" for block in blocks: candidate = f"{pending}\n\n{block}".strip() if pending else block if len(candidate) <= limit: pending = candidate continue if pending: chunk_texts.append(pending) pending = "" pieces = _split_long_block(block, limit) chunk_texts.extend(pieces[:-1]) pending = pieces[-1] if pieces else "" if pending: chunk_texts.append(pending) if not chunk_texts and record.content: chunk_texts = _split_long_block(record.content, limit) context = f"{record.title} · 버전 {record.version} · 출처 {record.source}" return [ { "seq": seq, "heading_path": record.title, "chunk_text": text, "context_prefix": context, "kb_kind": "theory", # 등록 프로토콜 원문은 평가/슈퍼비전 경로에서만 회수한다. # 내담자·상담사 생성 루프에는 요약이라도 흘리지 않는다. "visible_to": ["evaluator"], "sensitivity": 2, "meta": { "protocol_id": record.protocol_id, "protocol_status": "active", "source_title": record.title, "source_ref": record.source, "source_version": record.version, "license_class": record.license, "external_llm_ok": record.external_llm_ok, }, "token_count": max(1, len(text) // 4), } for seq, text in enumerate(chunk_texts) ] def _record(row: Any) -> ProtocolRecord: try: return ProtocolRecord( protocol_id=str(row["protocol_id"]), source_id=str(row["source_id"]), title=str(row["title"]), source=str(row["source_ref"]), version=int(row["version"]), license=str(row["license_class"]), # type: ignore[arg-type] external_llm_ok=bool(row["external_llm_ok"]), content=str(row["content"]), content_hash=str(row["content_hash"]), status=str(row["status"]), # type: ignore[arg-type] registered_by=str(row["registered_by"]), registered_at=row["registered_at"], activated_at=row["activated_at"], retired_at=row["retired_at"], ) except (KeyError, TypeError, ValueError) as exc: raise ProtocolStoreUnavailable("프로토콜 저장 행의 계약이 올바르지 않습니다.") from exc async def ensure_protocol_tables() -> None: """앱 역할로 owner migration 17의 완전 적용 여부만 확인한다.""" from ..db import acquire try: async with acquire(role="admin") as conn: row = await conn.fetchrow(PROTOCOL_READINESS_SQL) except Exception as exc: raise ProtocolStoreUnavailable( f"프로토콜 스키마 준비 상태를 확인하지 못했습니다: {exc}" ) from exc missing = [ field for field in _PROTOCOL_READINESS_FIELDS if row is None or row[field] is not True ] if missing: raise ProtocolStoreUnavailable( "프로토콜 스키마가 불완전합니다. owner 권한으로 " "infra/db/init/17_improvement_workbook_contracts.sql을 적용해야 합니다: " + ", ".join(missing) ) async def create_protocol( conn: Any, *, title: str, source: str, version: int, license_class: ProtocolLicense, external_llm_ok: bool, content: str, registered_by: str, ) -> ProtocolRecord: validate_license_policy(license_class, external_llm_ok) normalized = canonical_content(content) if not title.strip() or not source.strip() or not normalized: raise ProtocolPolicyViolation("제목, 출처, 내용은 비워 둘 수 없습니다.") protocol_id = str(uuid4()) source_id = f"protocol:{protocol_id}" try: row = await conn.fetchrow( f""" INSERT INTO kb.protocol_registration (protocol_id, source_id, title, source_ref, version, license_class, external_llm_ok, content, content_hash, status, registered_by) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, 'draft', $10::uuid) RETURNING {_SELECT_COLUMNS} """, protocol_id, source_id, title.strip(), source.strip(), version, license_class, external_llm_ok, normalized, content_hash(normalized), registered_by, ) except ProtocolRegistryError: raise except Exception as exc: raise ProtocolStoreUnavailable(f"프로토콜 초안을 저장하지 못했습니다: {exc}") from exc if row is None: raise ProtocolStoreUnavailable("프로토콜 초안 저장 결과가 비어 있습니다.") return _record(row) async def list_protocols( conn: Any, *, status_filter: ProtocolStatus | None = None, search: str | None = None, ) -> list[ProtocolRecord]: search_text = (search or "").strip() try: rows = await conn.fetch( f""" SELECT {_SELECT_COLUMNS} FROM kb.protocol_registration WHERE ($1::text IS NULL OR status = $1) AND ( $2 = '' OR title ILIKE '%' || $2 || '%' OR source_ref ILIKE '%' || $2 || '%' ) ORDER BY registered_at DESC, protocol_id DESC LIMIT 200 """, status_filter, search_text, ) except Exception as exc: raise ProtocolStoreUnavailable(f"프로토콜 목록을 불러오지 못했습니다: {exc}") from exc return [_record(row) for row in rows] async def _locked_protocol(conn: Any, protocol_id: str) -> ProtocolRecord: try: row = await conn.fetchrow( f""" SELECT {_SELECT_COLUMNS} FROM kb.protocol_registration WHERE protocol_id = $1::uuid FOR UPDATE """, protocol_id, ) except Exception as exc: raise ProtocolStoreUnavailable(f"프로토콜 상태를 확인하지 못했습니다: {exc}") from exc if row is None: raise ProtocolNotFound("프로토콜을 찾을 수 없습니다.") return _record(row) async def activate_protocol( conn: Any, *, protocol_id: str, ) -> tuple[ProtocolRecord, rag.IndexResult]: """draft를 색인한 뒤 active로 전환한다. 호출자는 DB 트랜잭션을 소유해야 한다.""" current = await _locked_protocol(conn, protocol_id) if current.status != "draft": raise ProtocolTransitionConflict("초안 상태의 프로토콜만 활성화할 수 있습니다.") validate_license_policy(current.license, current.external_llm_ok) try: await conn.execute( """ INSERT INTO kb.source (source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok) VALUES ($1, $2, 'theory', $3, $4, $4, $5) ON CONFLICT (source_id) DO UPDATE SET title = EXCLUDED.title, kb_kind = EXCLUDED.kb_kind, license_class = EXCLUDED.license_class, origin_path = EXCLUDED.origin_path, citation = EXCLUDED.citation, external_llm_ok = EXCLUDED.external_llm_ok """, current.source_id, current.title, current.license, current.source, current.external_llm_ok, ) index_result = await rag.index_document( conn, rag.IndexRequest( source_id=current.source_id, doc_uri=current.source, version=current.version, content_hash=current.content_hash, chunks=build_index_chunks(current), ), ) row = await conn.fetchrow( f""" UPDATE kb.protocol_registration SET status = 'active', activated_at = now(), retired_at = NULL WHERE protocol_id = $1::uuid AND status = 'draft' RETURNING {_SELECT_COLUMNS} """, protocol_id, ) except (rag.IndexPolicyViolation, rag.NotConfigured) as exc: raise ProtocolStoreUnavailable(f"프로토콜 색인을 완료하지 못했습니다: {exc}") from exc except ProtocolRegistryError: raise except Exception as exc: raise ProtocolStoreUnavailable(f"프로토콜을 활성화하지 못했습니다: {exc}") from exc if row is None: raise ProtocolTransitionConflict("프로토콜 상태가 바뀌어 활성화를 완료하지 못했습니다.") return _record(row), index_result async def retire_protocol(conn: Any, *, protocol_id: str) -> ProtocolRecord: """active 프로토콜의 문서를 먼저 비활성화하고 retired로 전환한다.""" current = await _locked_protocol(conn, protocol_id) if current.status != "active": raise ProtocolTransitionConflict("활성 상태의 프로토콜만 퇴역할 수 있습니다.") try: await conn.execute( "UPDATE kb.document SET is_active = FALSE WHERE source_id = $1 AND is_active", current.source_id, ) row = await conn.fetchrow( f""" UPDATE kb.protocol_registration SET status = 'retired', retired_at = now() WHERE protocol_id = $1::uuid AND status = 'active' RETURNING {_SELECT_COLUMNS} """, protocol_id, ) except Exception as exc: raise ProtocolStoreUnavailable(f"프로토콜을 퇴역하지 못했습니다: {exc}") from exc if row is None: raise ProtocolTransitionConflict("프로토콜 상태가 바뀌어 퇴역을 완료하지 못했습니다.") return _record(row) __all__ = [ "PROTOCOL_READINESS_SQL", "PROTOCOL_SCHEMA_SQL", "ProtocolLicense", "ProtocolNotFound", "ProtocolPolicyViolation", "ProtocolRecord", "ProtocolRegistryError", "ProtocolStatus", "ProtocolStoreUnavailable", "ProtocolTransitionConflict", "activate_protocol", "build_index_chunks", "canonical_content", "content_hash", "create_protocol", "ensure_protocol_tables", "list_protocols", "retire_protocol", "validate_license_policy", ]