개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
141
apps/api/app/services/feedback_policy.py
Normal file
141
apps/api/app/services/feedback_policy.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue