517 lines
17 KiB
Python
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",
|
|
]
|