vignette/apps/api/app/services/protocol_registry.py

517 lines
17 KiB
Python

"""관리자 프로토콜 등록·활성화·퇴역 수명주기.
초안 원문은 ``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",
]