개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -71,6 +71,14 @@ def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
return default
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 학습자 피드백 정책 판정 전용 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
@ -1544,10 +1552,17 @@ async def read_calibration_transfer(
histories = list(
await conn.fetch(
"""
SELECT history_id, session_id, competency_id, practice_block_id,
scenario_variant_id, phrase_family_id, created_at
FROM app.calibration_prediction_history
WHERE learner_id = $1 ORDER BY created_at, history_id
SELECT history.history_id, history.session_id,
history.competency_id, history.practice_block_id,
history.scenario_variant_id, history.phrase_family_id,
history.created_at,
source_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.calibration_prediction_history history
JOIN app.sessions source_session
ON source_session.id = history.session_id
WHERE history.learner_id = $1
ORDER BY history.created_at, history.history_id
""",
target_learner_id,
)
@ -1615,10 +1630,13 @@ async def read_calibration_transfer(
a.source_observation_ids, a.assessment_payload,
a.model_run_id, a.instrument_id, a.instrument_version,
a.evidence_turn_ids, a.created_at,
p.prescription_id, p.prescription_payload
p.prescription_id, p.prescription_payload,
source_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.calibration_assessment_snapshot a
JOIN app.calibration_metacognitive_prescription p
ON p.assessment_snapshot_id = a.assessment_snapshot_id
JOIN app.sessions source_session ON source_session.id = a.session_id
WHERE a.learner_id = $1
ORDER BY a.competency_id, a.snapshot_no
""",
@ -1628,12 +1646,18 @@ async def read_calibration_transfer(
suites = list(
await conn.fetch(
"""
SELECT transfer_suite_record_id, submission_id, suite_key,
session_id, training_phrase_family_ids, model_run_id,
instrument_id, instrument_version, data_classification,
clinical_claim_allowed, created_at
FROM app.calibration_transfer_suite
WHERE learner_id = $1 ORDER BY created_at, transfer_suite_record_id
SELECT suite.transfer_suite_record_id, suite.submission_id,
suite.suite_key, suite.session_id,
suite.training_phrase_family_ids, suite.model_run_id,
suite.instrument_id, suite.instrument_version,
suite.data_classification, suite.clinical_claim_allowed,
suite.created_at,
source_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.calibration_transfer_suite suite
JOIN app.sessions source_session ON source_session.id = suite.session_id
WHERE suite.learner_id = $1
ORDER BY suite.created_at, suite.transfer_suite_record_id
""",
target_learner_id,
)
@ -1715,14 +1739,29 @@ async def read_calibration_transfer(
actual_event_rows = list(
await conn.fetch(
"""
SELECT *
FROM app.calibration_transfer_execution_event
WHERE learner_id = $1
ORDER BY competency_id, created_at, execution_event_id
SELECT execution.*,
practice_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.calibration_transfer_execution_event execution
JOIN app.sessions practice_session
ON practice_session.id = execution.practice_session_id
WHERE execution.learner_id = $1
ORDER BY execution.competency_id, execution.created_at,
execution.execution_event_id
""",
target_learner_id,
)
)
learner_feedback_snapshot_enabled = all(
bool(
_value(
item,
"source_learner_feedback_enabled",
True,
)
)
for item in (*histories, *assessments, *suites, *actual_event_rows)
)
revisions_by_history: dict[UUID, list[dict[str, Any]]] = {}
for row in revisions:
@ -1737,7 +1776,7 @@ async def read_calibration_transfer(
}
prediction_histories: list[dict[str, Any]] = []
for row in histories:
item = dict(row)
item = _public_row(row)
history_id = UUID(str(_value(row, "history_id")))
item["revisions"] = revisions_by_history.get(history_id, [])
item["lock"] = locks_by_history.get(history_id)
@ -1760,7 +1799,7 @@ async def read_calibration_transfer(
).append(dict(row))
suite_payloads: list[dict[str, Any]] = []
for row in suites:
item = dict(row)
item = _public_row(row)
suite_id = UUID(str(_value(row, "transfer_suite_record_id")))
item["trials"] = trials_by_suite.get(suite_id, [])
item["assessments"] = assessments_by_suite.get(suite_id, [])
@ -1774,8 +1813,9 @@ async def read_calibration_transfer(
"learner_id": target_learner_id,
"requested_view": requested_view,
"clinical_claim_allowed": False,
"_learner_feedback_snapshot_enabled": learner_feedback_snapshot_enabled,
"prediction_histories": prediction_histories,
"calibration_assessments": [dict(item) for item in assessments],
"calibration_assessments": [_public_row(item) for item in assessments],
"transfer_suites": suite_payloads,
"teacher_reviews": [dict(item) for item in reviews],
"actual_executions": [

View file

@ -56,6 +56,10 @@ class DeliberatePracticeConflictError(RuntimeError):
pass
class DeliberatePracticeFeedbackDisabledError(PermissionError):
"""처방/연습 원천 회기의 학습자 피드백 스냅샷이 비활성이다."""
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
@ -63,6 +67,14 @@ def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
return default
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 정책 판정 전용 내부 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
@ -202,11 +214,15 @@ async def _latest_snapshot(
) -> Mapping[str, Any] | None:
return await conn.fetchrow(
"""
SELECT snapshot_id, session_id, snapshot_no, content_hash, graph_payload,
evidence_turn_ids, created_at
FROM app.competency_graph_snapshot
WHERE learner_id = $1
ORDER BY snapshot_no DESC
SELECT snapshot.snapshot_id, snapshot.session_id, snapshot.snapshot_no,
snapshot.content_hash, snapshot.graph_payload,
snapshot.evidence_turn_ids, snapshot.created_at,
source_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.competency_graph_snapshot snapshot
JOIN app.sessions source_session ON source_session.id = snapshot.session_id
WHERE snapshot.learner_id = $1
ORDER BY snapshot.snapshot_no DESC
LIMIT 1
""",
learner_id,
@ -595,6 +611,37 @@ async def append_learner_attempt_submission(
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
f"practice-attempt:{submission_id}",
)
prescription_row = await conn.fetchrow(
"""
SELECT prescription.prescription_record_id,
prescription.session_id,
prescription.prescription_payload,
prescription.created_at,
source_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.practice_prescription prescription
JOIN app.sessions source_session
ON source_session.id = prescription.session_id
WHERE prescription.learner_id = $1
AND prescription.prescription_key = $2
""",
learner_id,
prescription_id,
)
if prescription_row is None:
raise DeliberatePracticeNotFoundError(
"practice prescription not found or not visible"
)
if not bool(
_value(
prescription_row,
"source_learner_feedback_enabled",
True,
)
):
raise DeliberatePracticeFeedbackDisabledError(
"learner feedback was disabled for the prescription source session"
)
existing = await _existing_submission(
conn,
table="app.practice_episode_submission",
@ -608,19 +655,6 @@ async def append_learner_attempt_submission(
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
f"practice-graph:{learner_id}",
)
prescription_row = await conn.fetchrow(
"""
SELECT prescription_record_id, session_id, prescription_payload, created_at
FROM app.practice_prescription
WHERE learner_id = $1 AND prescription_key = $2
""",
learner_id,
prescription_id,
)
if prescription_row is None:
raise DeliberatePracticeNotFoundError(
"practice prescription not found or not visible"
)
source_session_id = UUID(str(_value(prescription_row, "session_id")))
session_id = practice_session_id or source_session_id
practice_session = await _visible_session(conn, session_id)
@ -1218,10 +1252,13 @@ async def read_deliberate_practice(
p.activity_mode, p.scenario_variant_id, p.scenario_novelty,
p.difficulty_level, p.prescription_payload, p.created_at,
c.card_key, c.coach_claim, c.evidence_turn_ids, c.source_refs,
c.uncertainty, c.counterevidence
c.uncertainty, c.counterevidence,
source_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.practice_prescription p
JOIN app.practice_coaching_card c
ON c.coaching_card_record_id = p.coaching_card_record_id
JOIN app.sessions source_session ON source_session.id = p.session_id
WHERE p.learner_id = $1
ORDER BY p.created_at, p.prescription_record_id
""",
@ -1231,12 +1268,19 @@ async def read_deliberate_practice(
episodes = list(
await conn.fetch(
"""
SELECT episode_submission_id, episode_key, session_id,
progress, mastery_allowed, mastery_blockers, uncertainty,
evidence_turn_ids, counterevidence, assessment_payload, created_at
FROM app.practice_episode_submission
WHERE learner_id = $1
ORDER BY created_at, episode_submission_id
SELECT episode.episode_submission_id, episode.episode_key,
episode.session_id, episode.progress,
episode.mastery_allowed, episode.mastery_blockers,
episode.uncertainty, episode.evidence_turn_ids,
episode.counterevidence, episode.assessment_payload,
episode.created_at,
source_session.learner_feedback_enabled
AS source_learner_feedback_enabled
FROM app.practice_episode_submission episode
JOIN app.sessions source_session
ON source_session.id = episode.session_id
WHERE episode.learner_id = $1
ORDER BY episode.created_at, episode.episode_submission_id
""",
target_learner_id,
)
@ -1285,6 +1329,19 @@ async def read_deliberate_practice(
else []
)
snapshot = await _latest_snapshot(conn, target_learner_id)
if principal.role == Role.LEARNER and any(
not bool(
_value(
item,
"source_learner_feedback_enabled",
True,
)
)
for item in (*prescriptions, *episodes, *([snapshot] if snapshot else []))
):
raise DeliberatePracticeFeedbackDisabledError(
"learner feedback was disabled for a practice source session"
)
decision = (
await conn.fetchrow(
"""
@ -1313,7 +1370,7 @@ async def read_deliberate_practice(
).append(payload)
episode_payloads: list[dict[str, Any]] = []
for row in episodes:
payload = dict(row)
payload = _public_row(row)
payload["attempts"] = attempts_by_episode.get(
UUID(str(_value(row, "episode_submission_id"))), []
)
@ -1321,7 +1378,7 @@ async def read_deliberate_practice(
return {
"learner_id": target_learner_id,
"clinical_claim_allowed": False,
"prescriptions": [dict(item) for item in prescriptions],
"prescriptions": [_public_row(item) for item in prescriptions],
"episodes": episode_payloads,
"competency_graph": (
_value(snapshot, "graph_payload") if snapshot is not None else None
@ -1335,6 +1392,7 @@ async def read_deliberate_practice(
__all__ = [
"DeliberatePracticeConflictError",
"DeliberatePracticeFeedbackDisabledError",
"DeliberatePracticeNotFoundError",
"DeliberatePracticeStateError",
"append_learner_attempt_submission",

View file

@ -520,8 +520,11 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
"""fast-loop 평가 프롬프트(L0 역할 + 후보 라벨 + 이번 턴 맥락)."""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
client_reply_masked = guardrail.mask_synthetic_generated_pii(
client_reply
client_reply_masked = guardrail.mask_role_identities(
client_reply,
counselor_identity=ctx.counselor_identity,
client_identity=ctx.client_identity,
synthetic_generated=True,
).text_masked
recent = (
"\n".join(

View file

@ -0,0 +1,141 @@
"""계정별 학습자 AI 피드백 노출 정책의 단일 경계."""
from __future__ import annotations
from typing import Protocol
from fastapi import HTTPException, status
LEARNER_FEEDBACK_DISABLED_DETAIL = "learner_feedback_disabled"
class FeedbackPolicySession(Protocol):
learner_feedback_enabled: bool
class FeedbackPolicyPrincipal(Protocol):
role: object
learner_feedback_enabled: bool
def _role_value(role: object) -> str:
return str(getattr(role, "value", role))
def learner_feedback_enabled(session: FeedbackPolicySession) -> bool:
"""구 회기 객체는 호환성을 위해 피드백 허용으로 취급한다."""
return bool(getattr(session, "learner_feedback_enabled", True))
def effective_learner_feedback_enabled(
session: FeedbackPolicySession,
principal: FeedbackPolicyPrincipal,
) -> bool:
"""학습자는 현재 계정과 회기 스냅샷이 모두 켜져야 피드백이 활성이다."""
snapshot_enabled = learner_feedback_enabled(session)
if _role_value(principal.role) != "learner":
return snapshot_enabled
return snapshot_enabled and bool(
getattr(principal, "learner_feedback_enabled", True)
)
def can_expose_principal_learner_feedback(
session: FeedbackPolicySession,
principal: FeedbackPolicyPrincipal,
) -> bool:
"""교수자·관리자는 유지하고 학습자에게 effective AND 정책을 적용한다."""
return _role_value(principal.role) != "learner" or (
effective_learner_feedback_enabled(session, principal)
)
def can_expose_learner_feedback(
session: FeedbackPolicySession,
*,
viewer_role: str,
) -> bool:
"""관리자·교수자 검토는 유지하고 학습자에게만 스냅샷 정책을 적용한다."""
return _role_value(viewer_role) != "learner" or learner_feedback_enabled(session)
def require_learner_feedback(
session: FeedbackPolicySession,
*,
viewer_role: object,
) -> None:
"""학습자에게만 회기 스냅샷 정책을 적용하고 파생 출력을 fail-closed 한다."""
if can_expose_learner_feedback(
session,
viewer_role=_role_value(viewer_role),
):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LEARNER_FEEDBACK_DISABLED_DETAIL,
)
def require_principal_learner_feedback(principal: FeedbackPolicyPrincipal) -> None:
"""회기 ID가 없는 학습자 전용 파생 원장은 계정 정책으로 차단한다."""
if _role_value(principal.role) != "learner" or bool(
getattr(principal, "learner_feedback_enabled", True)
):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LEARNER_FEEDBACK_DISABLED_DETAIL,
)
async def can_expose_session_learner_feedback(
session_id: object,
principal: FeedbackPolicyPrincipal,
) -> bool:
"""현재 계정과 생성 당시 회기 스냅샷을 모두 확인한다.
교수자·관리자 감독 화면은 그대로 유지한다. 학습자는 관리자가 현재 피드백을
경우 즉시 차단하고, 다시 뒤에도 비활성 상태로 생성된 회기에서는 파생
출력을 되살리지 않는다.
"""
if _role_value(principal.role) != "learner":
return True
if not bool(getattr(principal, "learner_feedback_enabled", True)):
return False
# 순환 import를 피하면서 세션 조회 경계와 동일한 RLS/런타임 폴백을 사용한다.
from .. import session_persistence
from ..store import store
session_key = str(session_id)
session = await session_persistence.load_session(
session_key,
principal, # type: ignore[arg-type]
allow_ended=True,
)
if session is None:
session = store.get(session_key)
if session is None:
# 소유권·존재 오류는 각 도메인 저장소가 원래 계약대로 처리한다.
return True
return can_expose_principal_learner_feedback(session, principal)
async def require_session_learner_feedback(
session_id: object,
principal: FeedbackPolicyPrincipal,
) -> None:
if await can_expose_session_learner_feedback(session_id, principal):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LEARNER_FEEDBACK_DISABLED_DETAIL,
)

View file

@ -363,6 +363,89 @@ def mask_synthetic_generated_pii(text: str) -> MaskResult:
)
_IDENTITY_SEGMENT_RE = re.compile(r"\s*[·|,/]\s*", re.UNICODE)
_IDENTITY_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
_IDENTITY_KO_PARTICLE_LOOKAHEAD = (
r"(?:은|는|이|가|을|를|와|과|의|도|에게|께|랑|하고|님|씨)"
)
def _known_identity_variants(identity: str | None) -> list[str]:
"""Return conservative display-name variants that are safe to role-tokenize."""
raw = str(identity or "").strip()
if not raw or "@" in raw or _IDENTITY_UUID_RE.fullmatch(raw):
return []
first_segment = _IDENTITY_SEGMENT_RE.split(raw, maxsplit=1)[0].strip()
first_segment = re.sub(r"\s*\(가명\)\s*$", "", first_segment).strip()
variants: list[str] = []
for candidate in (raw, first_segment):
if candidate in variants:
continue
letters = re.sub(r"[^A-Za-z가-힣]", "", candidate)
if len(letters) < 2:
continue
variants.append(candidate)
return sorted(variants, key=len, reverse=True)
def mask_role_identities(
text: str,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> MaskResult:
"""Mask known session identities with role tokens, then apply the normal PII gate.
Only identities already owned by the authenticated session are role-tokenized.
Unknown third-party names keep the generic ``[NAME]`` token so the UI cannot
incorrectly present every person as the client.
"""
role_values = (
("ROLE_COUNSELOR", "[COUNSELOR]", counselor_identity),
("ROLE_CLIENT", "[CLIENT]", client_identity),
)
redacted = text
role_entities: list[str] = []
claimed_variants: set[str] = set()
for entity, placeholder, identity in role_values:
for variant in _known_identity_variants(identity):
normalized = variant.casefold()
if normalized in claimed_variants:
continue
pattern = (
rf"(?<![A-Za-z가-힣]){re.escape(variant)}"
rf"(?=$|[^A-Za-z가-힣]|{_IDENTITY_KO_PARTICLE_LOOKAHEAD})"
)
replaced, count = re.subn(
pattern,
placeholder,
redacted,
flags=re.IGNORECASE,
)
if count:
redacted = replaced
role_entities.append(entity)
claimed_variants.add(normalized)
masked = (
mask_synthetic_generated_pii(redacted)
if synthetic_generated
else mask_pii(redacted)
)
return MaskResult(
text_masked=masked.text_masked,
entities=sorted(set(masked.entities + role_entities)),
used_presidio=masked.used_presidio,
used_ko_recognizer=masked.used_ko_recognizer,
)
# ════════════════════════════════════════════════════════════════════════════
# 2. 위기 분류 (입력 — 실제위기 vs 페르소나 연기 구분, R8)
# ════════════════════════════════════════════════════════════════════════════
@ -438,6 +521,8 @@ _MEANS_TERMS = [
"커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지",
]
_DISPLAY_PLACEHOLDER_LABELS = {
"COUNSELOR": "상담자",
"CLIENT": "내담자",
"NAME": "그 이름",
"ORG": "그 기관",
"PHONE": "연락처",
@ -449,7 +534,7 @@ _DISPLAY_PLACEHOLDER_LABELS = {
"ADDR": "그 주소",
}
_DISPLAY_PLACEHOLDER_RE = re.compile(
r"\[(?P<label>NAME|ORG|PHONE|EMAIL|RRN|NUMID|DATE|MONEY|ADDR)\]"
r"\[(?P<label>COUNSELOR|CLIENT|NAME|ORG|PHONE|EMAIL|RRN|NUMID|DATE|MONEY|ADDR)\]"
r"(?P<particle>[은는이가을를와과])?"
)
_DISPLAY_PLACEHOLDER_STREAM_TAIL = 16
@ -625,6 +710,7 @@ __all__ = [
"PiiEntitySpan",
"set_ko_pii_recognizer",
"mask_pii",
"mask_role_identities",
"mask_synthetic_generated_pii",
"CrisisKind",
"CrisisResult",

View file

@ -153,6 +153,8 @@ class LiveCoachGrounding(BaseModel):
source_type: Optional[str] = None
version: Optional[str] = None
citation: Optional[str] = None
license_class: Literal["A", "B", "C", "D"] | None = None
external_llm_ok: bool = False
summary: str
@ -388,7 +390,8 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
fallback: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
for source_index, payload in enumerate(_local_source_payloads()):
source = payload.get("source") or {}
if source.get("external_llm_ok") is False:
license_class = str(source.get("license_class") or "").strip().upper()
if source.get("external_llm_ok") is not True or license_class not in {"A", "B"}:
continue
for chunk_index, chunk in enumerate(payload.get("chunks") or []):
if not isinstance(chunk, dict):
@ -404,6 +407,9 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
scored.sort(key=lambda pair: (pair[0], pair[1]), reverse=True)
out: list[LiveCoachGrounding] = []
for _, _, source, chunk in scored[:limit]:
source_license_class = str(source.get("license_class") or "").strip().upper()
if source_license_class not in {"A", "B"}:
continue
out.append(
LiveCoachGrounding(
source_id=str(chunk.get("source_id") or source.get("source_id") or "live_coaching_source"),
@ -413,12 +419,26 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
source_type=str(chunk.get("source_type") or source.get("source_type") or "") or None,
version=str(chunk.get("version") or source.get("version") or "") or None,
citation=str(chunk.get("citation") or source.get("citation") or "") or None,
license_class=source_license_class,
external_llm_ok=source.get("external_llm_ok") is True,
summary=str(chunk.get("summary") or ""),
)
)
return out
def _external_safe_grounding(
grounding: list[LiveCoachGrounding],
) -> list[LiveCoachGrounding]:
"""외부 엔진 요청에 명시적으로 허용된 A/B 근거만 전달한다."""
return [
item
for item in grounding
if item.external_llm_ok is True and item.license_class in {"A", "B"}
]
def _source_refs(grounding: list[LiveCoachGrounding]) -> list[LiveCoachSource]:
seen: set[tuple[str, str | None]] = set()
refs: list[LiveCoachSource] = []
@ -478,7 +498,30 @@ def _clip(value: Any, limit: int) -> Optional[str]:
return text if len(text) <= limit else text[: limit - 1].rstrip() + ""
def _coerce_payload(payload: dict[str, Any], *, sources: list[LiveCoachSource], latency_ms: int) -> LiveCoachSuggestion:
def _mask_generated_text(
value: Any,
*,
client_identity: str | None,
limit: int,
) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
masked = guardrail.mask_role_identities(
text,
client_identity=client_identity,
synthetic_generated=True,
).text_masked
return _clip(masked, limit)
def _coerce_payload(
payload: dict[str, Any],
*,
sources: list[LiveCoachSource],
latency_ms: int,
client_identity: str | None = None,
) -> LiveCoachSuggestion:
tone = str(payload.get("tone") or "neutral")
if tone not in ("pos", "warn", "neutral"):
tone = "neutral"
@ -500,11 +543,25 @@ def _coerce_payload(payload: dict[str, Any], *, sources: list[LiveCoachSource],
status="ready",
tone=tone, # type: ignore[arg-type]
focus=focus, # type: ignore[arg-type]
title=_clip(payload.get("title"), 32) or "다음 발화 조정",
message=_clip(payload.get("message"), 120) or "지금은 내담자 말을 더 구체적으로 따라가는 편이 낫다.",
next_utterance=_clip(payload.get("next_utterance"), 140),
rationale=_clip(payload.get("rationale"), 180),
safety_note=_clip(payload.get("safety_note"), 120),
title=_mask_generated_text(
payload.get("title"), client_identity=client_identity, limit=32
)
or "다음 발화 조정",
message=_mask_generated_text(
payload.get("message"), client_identity=client_identity, limit=120
)
or "지금은 내담자 말을 더 구체적으로 따라가는 편이 낫다.",
next_utterance=_mask_generated_text(
payload.get("next_utterance"),
client_identity=client_identity,
limit=140,
),
rationale=_mask_generated_text(
payload.get("rationale"), client_identity=client_identity, limit=180
),
safety_note=_mask_generated_text(
payload.get("safety_note"), client_identity=client_identity, limit=120
),
sources=sources,
latency_ms=latency_ms,
)
@ -672,7 +729,8 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
goals = ", ".join(item.goal_stages) if item.goal_stages else "(미지정)"
prior = (
"\n".join(
f"- {entry.get('title', '')} (focus: {entry.get('focus', '')})"
f"- {_mask_generated_text(entry.get('title'), client_identity=item.persona_name, limit=80) or ''} "
f"(focus: {entry.get('focus', '')})"
for entry in item.prior_coach[-2:]
if entry.get("title")
)
@ -685,7 +743,7 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
)
user = (
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
f"[내담자] {item.persona_name} ({item.persona_code})\n"
f"[내담자] [CLIENT] ({item.persona_code})\n"
f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n"
f"[이번 회기 목표 단계] {goals} — 코칭은 목표 단계 작업에 정렬하고, 목표를 이미 이뤘다면 심화를 제안한다.\n"
f"[직전 코칭]\n{prior}\n(같은 조언을 반복하지 말고 다음 단계를 제시한다)\n\n"
@ -726,7 +784,7 @@ async def generate_live_coaching(
) -> LiveCoachSuggestion:
"""턴 직후 라이브 코칭을 생성한다. 실패해도 규칙 기반 제안으로 반환한다."""
local_grounding = _local_reference_grounding(item)
all_grounding = [*local_grounding, *(grounding or [])]
all_grounding = _external_safe_grounding([*local_grounding, *(grounding or [])])
crisis = guardrail.classify_crisis(item.learner_text)
if crisis.escalate:
return _fallback_suggestion(item, grounding=all_grounding, status="ready")
@ -769,7 +827,12 @@ async def generate_live_coaching(
grounding=all_grounding,
reason="구조화 출력을 반환하지 않았다",
)
return _coerce_payload(payload, sources=_source_refs(all_grounding), latency_ms=latency_ms)
return _coerce_payload(
payload,
sources=_source_refs(all_grounding),
latency_ms=latency_ms,
client_identity=item.persona_name,
)
except EngineError as exc:
return _fallback_suggestion(item, grounding=all_grounding, reason=str(exc))
except Exception as exc:

View file

@ -79,6 +79,8 @@ class TurnContext:
state_before: SessionState
learner_text_raw: str
learner_text_masked: str = ""
counselor_identity: Optional[str] = None
client_identity: Optional[str] = None
crisis: Optional[guardrail.CrisisResult] = None
state_after: Optional[SessionState] = None
messages: list[EngineMessage] = field(default_factory=list)
@ -133,6 +135,7 @@ def prepare_turn(
card: PersonaCard,
state: SessionState,
learner_text: str,
learner_identity: Optional[str] = None,
memory: Optional[TurnMemory] = None,
theory_mode: Optional[str] = None,
eval_rapport_signal: Optional[float] = None,
@ -146,23 +149,42 @@ def prepare_turn(
state_machine 경량 휴리스틱으로 라포 신호를 추정한다.
"""
turn_memory = memory or TurnMemory()
client_identity = card.display_name
ctx = TurnContext(
session_id=session_id,
case_id=case_id,
persona=card,
state_before=state,
learner_text_raw=learner_text,
counselor_identity=learner_identity,
client_identity=client_identity,
memory=TurnMemory(
recall_summary=_mask_optional_text(turn_memory.recall_summary),
pinned_facts=_mask_text_list(turn_memory.pinned_facts),
recent_turns=_mask_recent_turns(turn_memory.recent_turns),
recall_summary=_mask_optional_text(
turn_memory.recall_summary,
counselor_identity=learner_identity,
client_identity=client_identity,
),
pinned_facts=_mask_text_list(
turn_memory.pinned_facts,
counselor_identity=learner_identity,
client_identity=client_identity,
),
recent_turns=_mask_recent_turns(
turn_memory.recent_turns,
counselor_identity=learner_identity,
client_identity=client_identity,
),
kb_behavior_cues=list(turn_memory.kb_behavior_cues or []),
),
theory_mode=theory_mode,
)
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
mask = guardrail.mask_pii(learner_text)
mask = guardrail.mask_role_identities(
learner_text,
counselor_identity=learner_identity,
client_identity=client_identity,
)
ctx.learner_text_masked = mask.text_masked
ctx.crisis = guardrail.classify_crisis(learner_text, speaker_is_persona_context=True)
@ -210,21 +232,51 @@ def prepare_turn(
return ctx
def _mask_optional_text(text: Optional[str]) -> Optional[str]:
def _mask_optional_text(
text: Optional[str],
*,
counselor_identity: Optional[str] = None,
client_identity: Optional[str] = None,
) -> Optional[str]:
if text is None:
return None
return guardrail.mask_pii(text).text_masked
return guardrail.mask_role_identities(
text,
counselor_identity=counselor_identity,
client_identity=client_identity,
).text_masked
def _mask_text_list(values: Optional[list[str]]) -> list[str]:
return [guardrail.mask_pii(value).text_masked for value in (values or [])]
def _mask_text_list(
values: Optional[list[str]],
*,
counselor_identity: Optional[str] = None,
client_identity: Optional[str] = None,
) -> list[str]:
return [
guardrail.mask_role_identities(
value,
counselor_identity=counselor_identity,
client_identity=client_identity,
).text_masked
for value in (values or [])
]
def _mask_recent_turns(turns: Optional[list[dict[str, str]]]) -> list[dict[str, str]]:
def _mask_recent_turns(
turns: Optional[list[dict[str, str]]],
*,
counselor_identity: Optional[str] = None,
client_identity: Optional[str] = None,
) -> list[dict[str, str]]:
masked: list[dict[str, str]] = []
for turn in turns or []:
item = dict(turn)
item["text"] = guardrail.mask_pii(str(item.get("text", ""))).text_masked
item["text"] = guardrail.mask_role_identities(
str(item.get("text", "")),
counselor_identity=counselor_identity,
client_identity=client_identity,
).text_masked
masked.append(item)
return masked
@ -427,6 +479,7 @@ async def run_turn_stream(
flagged = False
output_error: str | None = None
stream_meta: dict[str, Any] = {}
gateway_done = False
previous_client_reply = _latest_client_reply(ctx.memory.recent_turns)
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
@ -471,6 +524,7 @@ async def run_turn_stream(
payload = packet.payload
if isinstance(payload, StreamDoneEvent):
stream_meta = payload.model_dump()
gateway_done = True
break
payload = packet.payload
@ -479,6 +533,12 @@ async def run_turn_stream(
text_piece = payload.text
accumulated += text_piece
if not gateway_done:
# 토큰 일부 또는 빈 본문 뒤 연결이 끊겨도 성공 done을 합성하지 않는다.
# 라우트는 이 error 이벤트를 전달하고 durable turn을 저장하지 않는다.
yield StreamEvent("error", {"detail": "client_stream_incomplete"})
return
scenario_leakage = rupture_scenario_director.contains_internal_scenario_leakage(
accumulated
)

View file

@ -24,6 +24,7 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Optional
from ..engine_client import EngineMessage
from .guardrail import clamp_ideation
if TYPE_CHECKING:
from .state_machine import OpennessParams
@ -65,7 +66,11 @@ class PersonaCard:
return float(self.resistance.get("decay_floor", 0.05))
def ideation_baseline(self) -> int:
return int(self.affect_baseline.get("suicide_ideation_stage", 1))
# DB에 들어온 과거/외부 카드가 0 또는 안전 상한 밖 값을 포함해도
# session_state(1~5) 원장 쓰기와 R5 출력 상한(<=3)을 깨지 않게 한다.
return clamp_ideation(
int(self.affect_baseline.get("suicide_ideation_stage", 1)),
)
def openness_params(self) -> "OpennessParams":
"""init_state 입력용 openness 파라미터 묶음(base_resistance/unlock_rate/decay_floor/

View 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",
]

View file

@ -137,6 +137,8 @@ class RetrievedChunk:
label_id: Optional[int] = None # 평가 정책에서만
meta: dict[str, Any] = field(default_factory=dict)
source_id: Optional[str] = None
license_class: Optional[str] = None
external_llm_ok: bool = False
dense_score: float = 0.0
sparse_score: float = 0.0
@ -297,8 +299,12 @@ WITH params AS (
dense AS (
SELECT c.chunk_id,
1 - (c.embedding <=> p.q_dense) AS s_dense
FROM kb.chunk c, params p
FROM kb.chunk c
JOIN kb.document d ON d.doc_id = c.doc_id AND d.is_active
LEFT JOIN kb.protocol_registration pr ON pr.source_id = c.source_id
CROSS JOIN params p
WHERE c.embedding IS NOT NULL
AND (pr.protocol_id IS NULL OR pr.status = 'active')
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
AND $4 = ANY(c.visible_to)
@ -309,8 +315,12 @@ dense AS (
sparse AS (
SELECT c.chunk_id,
ts_rank_cd(to_tsvector('simple', c.chunk_text), p.q_ts) AS s_sparse
FROM kb.chunk c, params p
FROM kb.chunk c
JOIN kb.document d ON d.doc_id = c.doc_id AND d.is_active
LEFT JOIN kb.protocol_registration pr ON pr.source_id = c.source_id
CROSS JOIN params p
WHERE p.q_ts IS NOT NULL
AND (pr.protocol_id IS NULL OR pr.status = 'active')
AND to_tsvector('simple', c.chunk_text) @@ p.q_ts
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
@ -329,9 +339,11 @@ fused AS (
)
SELECT c.chunk_id, c.kb_kind, c.heading_path, c.chunk_text, c.context_prefix,
c.label_id, c.meta, c.source_id,
src.license_class, src.external_llm_ok,
f.s_dense, f.s_sparse, f.fused_score
FROM fused f
JOIN kb.chunk c USING (chunk_id)
JOIN kb.source src ON src.source_id = c.source_id
ORDER BY f.fused_score DESC
LIMIT $9
"""
@ -537,6 +549,12 @@ async def search_kb(
# asyncpg는 jsonb를 str(JSON text)로 반환 → 파싱. 코덱 등록 시 dict 그대로도 수용.
_meta_raw = r["meta"]
meta = json.loads(_meta_raw) if isinstance(_meta_raw, str) else dict(_meta_raw or {})
# 청크 JSON 메타는 관리자 입력/과거 색인값일 수 있다. 외부 전송 정책은
# 언제나 현재 kb.source 레코드가 권위 원본이며, 누락은 허용하지 않는다.
license_class = str(r["license_class"] or "").strip().upper() or None
external_llm_ok = r["external_llm_ok"] is True
meta["license_class"] = license_class
meta["external_llm_ok"] = external_llm_ok
body = r["chunk_text"] if policy.expose_body else None
cue = None
if not policy.expose_body:
@ -554,6 +572,8 @@ async def search_kb(
label_id=r["label_id"] if policy.include_label else None,
meta=meta if policy.include_label else {},
source_id=r["source_id"],
license_class=license_class,
external_llm_ok=external_llm_ok,
dense_score=float(r["s_dense"]),
sparse_score=float(r["s_sparse"]),
)
@ -845,6 +865,48 @@ def _validate_index_chunks(req: IndexRequest) -> None:
)
async def validate_index_source(conn: "asyncpg.Connection", source_id: str) -> None:
"""DB 등록 출처의 라이선스와 프로토콜 상태를 인덱싱 전에 강제한다.
호출자가 license/external_llm_ok 값을 임의로 보내는 우회는 허용하지 않는다.
``kb.source`` 없거나 등록 프로토콜이 active가 아니면 fail closed한다.
"""
try:
row = await conn.fetchrow(
"""
SELECT s.license_class, s.external_llm_ok, pr.status AS protocol_status
FROM kb.source s
LEFT JOIN kb.protocol_registration pr ON pr.source_id = s.source_id
WHERE s.source_id = $1
""",
source_id,
)
except Exception as exc:
raise NotConfigured(f"kb source policy lookup failed: {exc}") from exc
if row is None:
raise IndexPolicyViolation(
f"source must be registered in kb.source before indexing (source_id={source_id})"
)
license_class = str(row["license_class"] or "").upper()
external_llm_ok = bool(row["external_llm_ok"])
if license_class not in {"A", "B", "C", "D"}:
raise IndexPolicyViolation(f"source license is invalid (source_id={source_id})")
if license_class in {"C", "D"} and external_llm_ok:
raise IndexPolicyViolation(
"license C/D sources cannot allow external LLM use "
f"(source_id={source_id})"
)
protocol_status = row["protocol_status"]
if protocol_status is not None and str(protocol_status) != "active":
raise IndexPolicyViolation(
"registered protocols can be indexed only after activation "
f"(source_id={source_id}, status={protocol_status})"
)
async def index_document(
conn: "asyncpg.Connection",
req: IndexRequest,
@ -858,7 +920,7 @@ async def index_document(
4. 임베딩 모델 미가용 : embedding NULL 적재(텍스트만, BM25 동작) + degraded=True.
무거운 작업(임베딩) 본래는 백그라운드 워커/배치. 라우트는 BackgroundTasks 위임 권장.
DSM verbatim 저작권(license C/D): source.external_llm_ok=false 가드는 source 등록 시점 책임.
라이선스와 등록 상태 검증은 호출부가 ``validate_index_source`` DB 값을 확인해야 한다.
Raises: NotConfigured DB(kb 스키마/vector) 미가용.
"""
@ -997,5 +1059,6 @@ __all__ = [
"log_retrieval",
"IndexRequest",
"IndexResult",
"validate_index_source",
"index_document",
]