개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
517
apps/api/app/services/protocol_registry.py
Normal file
517
apps/api/app/services/protocol_registry.py
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
"""관리자 프로토콜 등록·활성화·퇴역 수명주기.
|
||||
|
||||
초안 원문은 ``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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue