개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -344,6 +344,7 @@ class AdminUserResponse(BaseModel):
|
|||
display_name: str
|
||||
role: RoleName
|
||||
admin_access: bool
|
||||
learner_feedback_enabled: bool
|
||||
super_admin: bool = False
|
||||
account_status: AccountStatus
|
||||
cohort_ids: list[str]
|
||||
|
|
@ -364,6 +365,7 @@ class AdminUserPatch(BaseModel):
|
|||
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
role: RoleName | None = None
|
||||
admin_access: bool | None = None
|
||||
learner_feedback_enabled: bool | None = None
|
||||
account_status: AccountStatus | None = None
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
cohort_ids: list[str] | None = None
|
||||
|
|
@ -1182,11 +1184,18 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
|
|||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
email: str = Field(..., min_length=3, max_length=254)
|
||||
email: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=254,
|
||||
pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$",
|
||||
)
|
||||
display_name: str = Field(..., min_length=1, max_length=80)
|
||||
role: RoleName = "learner"
|
||||
admin_access: bool = False
|
||||
account_status: AccountStatus = "approved"
|
||||
learner_feedback_enabled: bool = True
|
||||
# 외부 연구참여자는 exact-email 사전등록 뒤 별도 승인을 거치게 한다.
|
||||
account_status: Literal["pending"] = "pending"
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
cohort_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
|
@ -1574,6 +1583,7 @@ async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
|
|||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
admin_access=effective_admin_access,
|
||||
learner_feedback_enabled=user.learner_feedback_enabled,
|
||||
super_admin=is_super_admin_email(user.email),
|
||||
account_status=user.account_status,
|
||||
cohort_ids=user.cohort_ids,
|
||||
|
|
@ -2171,6 +2181,7 @@ async def create_user(
|
|||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
admin_access=body.admin_access,
|
||||
learner_feedback_enabled=body.learner_feedback_enabled,
|
||||
account_status=body.account_status,
|
||||
affiliation=body.affiliation,
|
||||
cohort_ids=body.cohort_ids,
|
||||
|
|
@ -2207,6 +2218,7 @@ async def patch_user(
|
|||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
admin_access=body.admin_access,
|
||||
learner_feedback_enabled=body.learner_feedback_enabled,
|
||||
account_status=body.account_status,
|
||||
affiliation=body.affiliation,
|
||||
cohort_ids=body.cohort_ids,
|
||||
|
|
|
|||
|
|
@ -221,6 +221,25 @@ def validate_google_identity_domain(
|
|||
return normalized_email
|
||||
|
||||
|
||||
def validate_google_identity(
|
||||
*,
|
||||
email: str | None,
|
||||
email_verified: bool,
|
||||
) -> str:
|
||||
"""Accept every Google account whose email claim is present and verified.
|
||||
|
||||
Google has already validated the account before issuing the ID token. The
|
||||
application deliberately does not impose an email-domain or pre-registration
|
||||
gate on top of that provider identity.
|
||||
"""
|
||||
normalized_email = _normalize_email(email)
|
||||
if not normalized_email or not _email_domain(normalized_email):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email claim is required")
|
||||
if not email_verified:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email is not verified")
|
||||
return normalized_email
|
||||
|
||||
|
||||
async def validate_login_identity_email(
|
||||
*,
|
||||
email: str | None,
|
||||
|
|
@ -774,7 +793,10 @@ async def auth_config(request: Request) -> AuthConfigResponse:
|
|||
google_oauth_configured=google_ready,
|
||||
saml_configured=saml_ready,
|
||||
providers=_auth_provider_statuses(),
|
||||
allowed_email_domains=sorted(allowed_email_domains()),
|
||||
# Google OIDC accepts every provider-verified email. Keep the legacy
|
||||
# setting for dev-login/SAML policy, but do not advertise it as a Google
|
||||
# restriction to the browser.
|
||||
allowed_email_domains=[],
|
||||
redirect_uri=settings.oauth_redirect_uri,
|
||||
dev_login_enabled=_dev_login_available(request),
|
||||
)
|
||||
|
|
@ -937,19 +959,18 @@ async def callback(
|
|||
return _oauth_callback_error("issuer_mismatch", request)
|
||||
|
||||
try:
|
||||
email, managed_user = await validate_login_identity_email(
|
||||
email = validate_google_identity(
|
||||
email=claims.get("email"),
|
||||
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
|
||||
hosted_domain=claims.get("hd"),
|
||||
)
|
||||
except HTTPException:
|
||||
_log_oauth_callback_failure(
|
||||
request,
|
||||
"domain_not_allowed",
|
||||
"id_token_invalid",
|
||||
email_domain=_email_domain(str(claims.get("email") or "")),
|
||||
hosted_domain=_normalize_domain(str(claims.get("hd") or "")),
|
||||
)
|
||||
return _oauth_callback_error("domain_not_allowed", request)
|
||||
return _oauth_callback_error("id_token_invalid", request)
|
||||
managed_user = await get_managed_user_by_email(email)
|
||||
role = _role_for_managed_user(managed_user, _role_for_email(email))
|
||||
display_name = str(claims.get("name") or email)
|
||||
cohort_ids = _cohort_ids_for_managed_user(
|
||||
|
|
@ -967,6 +988,7 @@ async def callback(
|
|||
role=role.value,
|
||||
cohort_ids=cohort_ids,
|
||||
external_id=external_id,
|
||||
account_status="approved",
|
||||
)
|
||||
except InactiveUserError:
|
||||
_log_oauth_callback_failure(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ from ..contracts.calibration_transfer import (
|
|||
)
|
||||
from ..config import Settings, get_settings
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import calibration_transfer_store, session_learning_producer
|
||||
from ..services import (
|
||||
calibration_transfer_store,
|
||||
feedback_policy,
|
||||
session_learning_producer,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(tags=["calibration-transfer"])
|
||||
|
|
@ -337,8 +341,8 @@ class ActualTransferExecutionRequest(BaseModel):
|
|||
|
||||
|
||||
class ActualTransferExecutionResponse(BaseModel):
|
||||
execution: ActualTransferExecution
|
||||
assessment: ActualTransferAssessment
|
||||
execution: ActualTransferExecution | None = None
|
||||
assessment: ActualTransferAssessment | None = None
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
|
|
@ -532,6 +536,22 @@ class CalibrationTransferReadModelResponse(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
def _learner_input_only_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자기예측 원문만 남기고 내부·교수자·AI 파생 판정을 제거한다."""
|
||||
|
||||
redacted = dict(payload)
|
||||
redacted["prediction_histories"] = [
|
||||
{**dict(history), "external_observation": None}
|
||||
for history in payload.get("prediction_histories", [])
|
||||
]
|
||||
redacted["calibration_assessments"] = []
|
||||
redacted["transfer_suites"] = []
|
||||
redacted["teacher_reviews"] = []
|
||||
redacted["actual_executions"] = []
|
||||
redacted["actual_transfer_assessments"] = []
|
||||
return redacted
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(
|
||||
exc, calibration_transfer_store.CalibrationTransferNotFoundError
|
||||
|
|
@ -673,12 +693,20 @@ async def create_actual_transfer_execution(
|
|||
body: ActualTransferExecutionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> ActualTransferExecutionResponse:
|
||||
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
|
||||
body.practice_session_id,
|
||||
principal,
|
||||
)
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_actual_transfer_execution(
|
||||
principal=principal, **body.model_dump()
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
if not expose_feedback:
|
||||
return ActualTransferExecutionResponse(
|
||||
idempotent_replay=bool(payload.get("idempotent_replay", False))
|
||||
)
|
||||
return ActualTransferExecutionResponse.model_validate(payload)
|
||||
|
||||
|
||||
|
|
@ -706,6 +734,7 @@ async def create_teacher_review(
|
|||
)
|
||||
async def get_my_calibration_transfer(
|
||||
principal: LearnerPrincipal,
|
||||
session_id: UUID | None = None,
|
||||
) -> CalibrationTransferReadModelResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.read_calibration_transfer(
|
||||
|
|
@ -713,6 +742,23 @@ async def get_my_calibration_transfer(
|
|||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
payload = dict(payload)
|
||||
snapshot_enabled = bool(
|
||||
payload.pop("_learner_feedback_snapshot_enabled", True)
|
||||
)
|
||||
expose_feedback = bool(
|
||||
principal.learner_feedback_enabled and snapshot_enabled
|
||||
)
|
||||
if session_id is not None:
|
||||
expose_feedback = bool(
|
||||
expose_feedback
|
||||
and await feedback_policy.can_expose_session_learner_feedback(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
)
|
||||
if not expose_feedback:
|
||||
payload = _learner_input_only_payload(payload)
|
||||
return CalibrationTransferReadModelResponse.model_validate(payload)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from ..contracts.deliberate_practice import (
|
|||
PracticePrescription,
|
||||
)
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import deliberate_practice_store
|
||||
from ..services import deliberate_practice_store, feedback_policy
|
||||
|
||||
|
||||
router = APIRouter(tags=["deliberate-practice"])
|
||||
|
|
@ -248,6 +248,14 @@ class DeliberatePracticeReadModelResponse(BaseModel):
|
|||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(
|
||||
exc,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
):
|
||||
return HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
if isinstance(exc, deliberate_practice_store.DeliberatePracticeNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, deliberate_practice_store.DeliberatePracticeConflictError):
|
||||
|
|
@ -295,6 +303,7 @@ async def create_practice_attempt(
|
|||
body: PracticeAttemptSubmissionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PracticeAttemptSubmissionResponse:
|
||||
feedback_policy.require_principal_learner_feedback(principal)
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_learner_attempt_submission(
|
||||
principal=principal,
|
||||
|
|
@ -305,6 +314,7 @@ async def create_practice_attempt(
|
|||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
|
@ -321,6 +331,10 @@ async def observe_completed_practice_session(
|
|||
practice_session_id: UUID,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PracticeAttemptSubmissionResponse:
|
||||
await feedback_policy.require_session_learner_feedback(
|
||||
practice_session_id,
|
||||
principal,
|
||||
)
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_runtime_practice_session(
|
||||
principal=principal,
|
||||
|
|
@ -330,6 +344,7 @@ async def observe_completed_practice_session(
|
|||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
|
@ -368,6 +383,7 @@ async def correct_practice_attempt(
|
|||
async def get_my_deliberate_practice(
|
||||
principal: LearnerPrincipal,
|
||||
) -> DeliberatePracticeReadModelResponse:
|
||||
feedback_policy.require_principal_learner_feedback(principal)
|
||||
try:
|
||||
payload = await deliberate_practice_store.read_deliberate_practice(
|
||||
principal=principal
|
||||
|
|
@ -375,6 +391,7 @@ async def get_my_deliberate_practice(
|
|||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
|
|
|||
|
|
@ -123,7 +123,13 @@ async def reevaluate_session(
|
|||
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
|
||||
"""
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
enriched = enriched_masked_turns(sess.masked_turns())
|
||||
counselor_identity = getattr(sess, "learner_label", None)
|
||||
client_identity = getattr(sess.persona, "display_name", None)
|
||||
enriched = enriched_masked_turns(
|
||||
sess.masked_turns(),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
|
||||
# 누적 기법 코드 — DB 미가용이라 fast 결과가 없으면 빈 분포(deep LLM 정성 평가는 그대로 유효).
|
||||
technique_codes: list[str] = []
|
||||
|
|
@ -147,6 +153,8 @@ async def reevaluate_session(
|
|||
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
|
||||
stage=sess.state.stage.value,
|
||||
error=detail,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -161,6 +169,8 @@ async def reevaluate_session(
|
|||
session_id=session_id,
|
||||
learner_id=sess.learner_id,
|
||||
result=result,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
|
|||
|
|
@ -316,7 +316,8 @@ async def index_document(
|
|||
"""문서 인덱싱(관리자, RBAC ADMIN 강제). content_hash 증분 + 청크 임베딩 적재.
|
||||
|
||||
⚠️ 임베딩은 무거운 작업 → 본래 BackgroundTasks/배치 워커 위임 권장(202 Accepted).
|
||||
DSM verbatim 저작권(license C/D)은 source 등록 시점 external_llm_ok 가드 책임.
|
||||
source_id는 사전 등록된 kb.source만 허용하고, 라이선스와 프로토콜 active 상태는
|
||||
요청값이 아닌 DB 행으로 검증한다.
|
||||
모델 미가용 시 embedding NULL 폴백(BM25 만, degraded=True) — 크래시 X.
|
||||
"""
|
||||
req = rag.IndexRequest(
|
||||
|
|
@ -329,6 +330,7 @@ async def index_document(
|
|||
try:
|
||||
# 관리자 인덱싱은 RLS 미적용(쓰기 — kb 스키마 직접). role 주입 없이 acquire.
|
||||
async with acquire() as conn:
|
||||
await rag.validate_index_source(conn, body.source_id)
|
||||
result = await rag.index_document(conn, req)
|
||||
except rag.IndexPolicyViolation as e:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from ..contracts.measurement import (
|
|||
SourceKind,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import alliance_measurement
|
||||
from ..services import alliance_measurement, feedback_policy
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["measurements"])
|
||||
|
|
@ -168,6 +168,10 @@ async def get_alliance_pulses(
|
|||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> AlliancePulseListResponse:
|
||||
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
try:
|
||||
items = await alliance_measurement.list_alliance_pulses(
|
||||
principal=principal,
|
||||
|
|
@ -175,6 +179,8 @@ async def get_alliance_pulses(
|
|||
)
|
||||
except alliance_measurement.AlliancePulseNotFoundError as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
if not expose_feedback:
|
||||
items = [{**dict(item), "measurements": []} for item in items]
|
||||
return AlliancePulseListResponse.model_validate({"items": items})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from ..contracts.multimodal_alliance import (
|
|||
ModalityAxisMeasurement,
|
||||
)
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import multimodal_alliance_store
|
||||
from ..services import feedback_policy, multimodal_alliance_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["multimodal-alliance"])
|
||||
|
|
@ -521,7 +521,14 @@ async def sweep_multimodal_retention(
|
|||
async def get_multimodal_session_metadata(
|
||||
session_id: UUID,
|
||||
principal: HumanPrincipal,
|
||||
include_derived: bool = True,
|
||||
) -> MultimodalSessionMetadataResponse:
|
||||
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
if include_derived and not expose_feedback:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await multimodal_alliance_store.read_session_metadata(
|
||||
principal=principal,
|
||||
|
|
@ -529,6 +536,15 @@ async def get_multimodal_session_metadata(
|
|||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
if not include_derived:
|
||||
payload = {
|
||||
**payload,
|
||||
"timelines": [],
|
||||
"word_timestamps": [],
|
||||
"voice_events": [],
|
||||
"measurements": [],
|
||||
"fusion_decisions": [],
|
||||
}
|
||||
return MultimodalSessionMetadataResponse.model_validate(payload)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from ..contracts.outcome_trajectory import (
|
|||
SyntheticExpectedDistribution,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import outcome_trajectory_store
|
||||
from ..services import feedback_policy, outcome_trajectory_store
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["outcome-trajectories"])
|
||||
|
|
@ -190,6 +190,7 @@ async def get_outcome_trajectory(
|
|||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
|
|
@ -214,6 +215,7 @@ async def recompute_outcome_trajectory(
|
|||
body: OutcomeTrajectoryRecomputeRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
|
|
|
|||
220
apps/api/app/routes/protocols.py
Normal file
220
apps/api/app/routes/protocols.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""관리자 전용 상담 프로토콜 등록·활성화·퇴역 API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from ..db import acquire
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..services import protocol_registry
|
||||
|
||||
router = APIRouter(prefix="/admin/protocols", tags=["admin-protocols"])
|
||||
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
|
||||
|
||||
|
||||
class AdminProtocolCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=240)
|
||||
source: str = Field(..., min_length=1, max_length=1000)
|
||||
version: int = Field(default=1, ge=1, le=1_000_000)
|
||||
license: Literal["A", "B", "C", "D"]
|
||||
external_llm_ok: bool = False
|
||||
content: str = Field(..., min_length=1, max_length=500_000)
|
||||
|
||||
@field_validator("title", "source", "content")
|
||||
@classmethod
|
||||
def reject_blank_text(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("빈 값은 등록할 수 없습니다.")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def enforce_license_boundary(self) -> "AdminProtocolCreate":
|
||||
if self.license in {"C", "D"} and self.external_llm_ok:
|
||||
raise ValueError("라이선스 C/D는 외부 LLM 사용을 허용할 수 없습니다.")
|
||||
return self
|
||||
|
||||
|
||||
class AdminProtocolResponse(BaseModel):
|
||||
protocol_id: str
|
||||
source_id: str
|
||||
title: str
|
||||
source: str
|
||||
version: int
|
||||
license: Literal["A", "B", "C", "D"]
|
||||
external_llm_ok: bool
|
||||
content: str
|
||||
content_hash: str
|
||||
status: Literal["draft", "active", "retired"]
|
||||
registered_by: str
|
||||
registered_at: datetime
|
||||
activated_at: datetime | None = None
|
||||
retired_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminProtocolListResponse(BaseModel):
|
||||
protocols: list[AdminProtocolResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AdminProtocolActivationResponse(BaseModel):
|
||||
protocol: AdminProtocolResponse
|
||||
chunks_indexed: int
|
||||
skipped_unchanged: bool
|
||||
embedded: bool
|
||||
degraded: bool
|
||||
|
||||
|
||||
def _response(record: protocol_registry.ProtocolRecord) -> AdminProtocolResponse:
|
||||
return AdminProtocolResponse(
|
||||
protocol_id=record.protocol_id,
|
||||
source_id=record.source_id,
|
||||
title=record.title,
|
||||
source=record.source,
|
||||
version=record.version,
|
||||
license=record.license,
|
||||
external_llm_ok=record.external_llm_ok,
|
||||
content=record.content,
|
||||
content_hash=record.content_hash,
|
||||
status=record.status,
|
||||
registered_by=record.registered_by,
|
||||
registered_at=record.registered_at,
|
||||
activated_at=record.activated_at,
|
||||
retired_at=record.retired_at,
|
||||
)
|
||||
|
||||
|
||||
def _raise_http(error: protocol_registry.ProtocolRegistryError) -> None:
|
||||
if isinstance(error, protocol_registry.ProtocolNotFound):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
||||
if isinstance(error, protocol_registry.ProtocolTransitionConflict):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
if isinstance(error, protocol_registry.ProtocolPolicyViolation):
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error)) from error
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get("", response_model=AdminProtocolListResponse)
|
||||
async def list_admin_protocols(
|
||||
principal: AdminPrincipal,
|
||||
status_filter: Annotated[
|
||||
Literal["draft", "active", "retired"] | None,
|
||||
Query(alias="status"),
|
||||
] = None,
|
||||
search: Annotated[str | None, Query(max_length=240)] = None,
|
||||
) -> AdminProtocolListResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
records = await protocol_registry.list_protocols(
|
||||
conn,
|
||||
status_filter=status_filter,
|
||||
search=search,
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return AdminProtocolListResponse(
|
||||
protocols=[_response(record) for record in records],
|
||||
total=len(records),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AdminProtocolResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_admin_protocol(
|
||||
body: AdminProtocolCreate,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminProtocolResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
record = await protocol_registry.create_protocol(
|
||||
conn,
|
||||
title=body.title,
|
||||
source=body.source,
|
||||
version=body.version,
|
||||
license_class=body.license,
|
||||
external_llm_ok=body.external_llm_ok,
|
||||
content=body.content,
|
||||
registered_by=principal.user_id,
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return _response(record)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{protocol_id}/activate",
|
||||
response_model=AdminProtocolActivationResponse,
|
||||
)
|
||||
async def activate_admin_protocol(
|
||||
protocol_id: UUID,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminProtocolActivationResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
async with conn.transaction():
|
||||
record, indexed = await protocol_registry.activate_protocol(
|
||||
conn,
|
||||
protocol_id=str(protocol_id),
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return AdminProtocolActivationResponse(
|
||||
protocol=_response(record),
|
||||
chunks_indexed=indexed.chunks_indexed,
|
||||
skipped_unchanged=indexed.skipped_unchanged,
|
||||
embedded=indexed.embedded,
|
||||
degraded=indexed.degraded,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{protocol_id}/retire", response_model=AdminProtocolResponse)
|
||||
async def retire_admin_protocol(
|
||||
protocol_id: UUID,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminProtocolResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
async with conn.transaction():
|
||||
record = await protocol_registry.retire_protocol(
|
||||
conn,
|
||||
protocol_id=str(protocol_id),
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return _response(record)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdminProtocolActivationResponse",
|
||||
"AdminProtocolCreate",
|
||||
"AdminProtocolListResponse",
|
||||
"AdminProtocolResponse",
|
||||
"router",
|
||||
]
|
||||
|
|
@ -15,7 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
|||
from ..contracts.rupture_repair import RuptureLifecycleState, RuptureType
|
||||
from ..config import Settings, get_settings
|
||||
from ..deps import AIView, CurrentPrincipal, db_for_ai_view
|
||||
from ..services import rupture_repair_store
|
||||
from ..services import feedback_policy, rupture_repair_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["rupture-repairs"])
|
||||
|
|
@ -358,6 +358,7 @@ async def get_rupture_repairs(
|
|||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> RuptureRepairReadModelResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await rupture_repair_store.read_rupture_repairs(
|
||||
principal=principal,
|
||||
|
|
@ -435,6 +436,7 @@ async def create_human_rupture_correction(
|
|||
body: HumanRuptureCorrectionRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> HumanRuptureCorrectionResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
observation_id = await rupture_repair_store.append_human_correction(
|
||||
principal=principal,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from ..runtime_policy import require_runtime_fallback_allowed
|
|||
from ..session_evaluation_input import enriched_masked_turns
|
||||
from ..services import (
|
||||
evaluator,
|
||||
feedback_policy,
|
||||
guardrail,
|
||||
live_coach,
|
||||
memory,
|
||||
|
|
@ -67,6 +68,7 @@ from ..session_read_model import (
|
|||
dashboard_growth as _dashboard_growth,
|
||||
dashboard_overview as _dashboard_overview,
|
||||
dashboard_persona_progress as _dashboard_persona_progress,
|
||||
dashboard_training_exposure as _dashboard_training_exposure,
|
||||
iso as _iso,
|
||||
learner_summary as _learner_summary,
|
||||
learner_visible_turns as _learner_visible_turns,
|
||||
|
|
@ -112,6 +114,8 @@ class SessionStartRequest(BaseModel):
|
|||
class SessionStartResponse(BaseModel):
|
||||
session_id: str
|
||||
case_id: str
|
||||
persona_id: str
|
||||
persona_version: int
|
||||
session_no: int
|
||||
stage: StageLabel
|
||||
effective_openness: float
|
||||
|
|
@ -122,6 +126,7 @@ class SessionStartResponse(BaseModel):
|
|||
# 시간 기반 회기 종료 계약(회의 P1): 프론트 타이머·10분 전 알람의 기준값.
|
||||
duration_limit_seconds: int = 0
|
||||
warning_before_end_seconds: int = 0
|
||||
learner_feedback_enabled: bool = True
|
||||
|
||||
|
||||
class TurnRequest(BaseModel):
|
||||
|
|
@ -308,6 +313,8 @@ async def _retrieve_live_coach_grounding(
|
|||
source_type=source_type or None,
|
||||
version=source_version or None,
|
||||
citation=citation or None,
|
||||
license_class=chunk.license_class,
|
||||
external_llm_ok=chunk.external_llm_ok,
|
||||
summary=body[:500],
|
||||
)
|
||||
)
|
||||
|
|
@ -525,6 +532,7 @@ async def _prepare_turn_context(
|
|||
card=sess.persona,
|
||||
state=sess.state,
|
||||
learner_text=learner_text,
|
||||
learner_identity=sess.learner_label,
|
||||
memory=orchestrator.TurnMemory(
|
||||
recall_summary=recall.recall_summary,
|
||||
pinned_facts=recall.pinned_facts,
|
||||
|
|
@ -908,7 +916,11 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
return
|
||||
|
||||
timeout_seconds = _session_evaluation_timeout_seconds()
|
||||
enriched = enriched_masked_turns(sess.masked_turns())
|
||||
enriched = enriched_masked_turns(
|
||||
sess.masked_turns(),
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
|
|
@ -928,6 +940,8 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
result=result,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -960,6 +974,8 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
scope="session_end",
|
||||
stage=_stage_label(sess.state.stage),
|
||||
error=message,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -978,6 +994,8 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
scope="session_end",
|
||||
stage=_stage_label(sess.state.stage),
|
||||
error=exc,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -1124,6 +1142,8 @@ async def _load_learner_sessions(
|
|||
|
||||
|
||||
async def _review_ready(sess: InProcSession, principal: Principal) -> bool:
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
return False
|
||||
turns = _learner_visible_turns(sess)
|
||||
if not sess.ended or not turns:
|
||||
return False
|
||||
|
|
@ -1173,6 +1193,9 @@ async def _session_archive_response(
|
|||
session=_learner_summary(
|
||||
sess,
|
||||
review_ready=await _review_ready(sess, principal),
|
||||
learner_feedback_enabled=(
|
||||
feedback_policy.effective_learner_feedback_enabled(sess, principal)
|
||||
),
|
||||
archived=archived,
|
||||
archived_at=archived_at,
|
||||
),
|
||||
|
|
@ -1194,6 +1217,12 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR
|
|||
_learner_summary(
|
||||
sess,
|
||||
review_ready=await _review_ready(sess, principal),
|
||||
learner_feedback_enabled=(
|
||||
feedback_policy.effective_learner_feedback_enabled(
|
||||
sess,
|
||||
principal,
|
||||
)
|
||||
),
|
||||
archived=archive_record is not None,
|
||||
archived_at=_iso(float(archived_at))
|
||||
if isinstance(archived_at, (int, float))
|
||||
|
|
@ -1213,10 +1242,22 @@ async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResp
|
|||
principal = _ensure_learner(principal)
|
||||
sessions, durable = await _load_learner_sessions(
|
||||
principal,
|
||||
include_turn_evaluation=True,
|
||||
include_turn_evaluation=principal.learner_feedback_enabled,
|
||||
)
|
||||
review_ready = await _review_ready_map(sessions, principal)
|
||||
archives = await _archive_map(sessions, principal)
|
||||
learner_feedback_enabled = {
|
||||
sess.session_id: feedback_policy.effective_learner_feedback_enabled(
|
||||
sess,
|
||||
principal,
|
||||
)
|
||||
for sess in sessions
|
||||
}
|
||||
feedback_sessions = [
|
||||
sess
|
||||
for sess in sessions
|
||||
if learner_feedback_enabled.get(sess.session_id, True)
|
||||
]
|
||||
visible_review_ready = {
|
||||
session_id: ready
|
||||
for session_id, ready in review_ready.items()
|
||||
|
|
@ -1229,10 +1270,15 @@ async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResp
|
|||
visible_review_ready=visible_review_ready,
|
||||
archived_sessions=len(archives),
|
||||
),
|
||||
growth=_dashboard_growth(sessions),
|
||||
persona_progress=_dashboard_persona_progress(sessions, visible_review_ready),
|
||||
growth=_dashboard_growth(feedback_sessions),
|
||||
persona_progress=_dashboard_persona_progress(
|
||||
sessions,
|
||||
visible_review_ready,
|
||||
learner_feedback_enabled,
|
||||
),
|
||||
training_exposure=_dashboard_training_exposure(sessions),
|
||||
achievements=_dashboard_achievements(sessions, visible_review_ready),
|
||||
recent_feedback=_dashboard_feedback(sessions),
|
||||
recent_feedback=_dashboard_feedback(feedback_sessions),
|
||||
message=(
|
||||
"실제 연습 기록을 기준으로 개인 학습 흐름을 표시합니다."
|
||||
if sessions
|
||||
|
|
@ -1253,7 +1299,13 @@ async def get_session_detail(
|
|||
principal,
|
||||
allow_ended=True,
|
||||
)
|
||||
return _session_detail(sess, review_ready=await _review_ready(sess, principal))
|
||||
return _session_detail(
|
||||
sess,
|
||||
review_ready=await _review_ready(sess, principal),
|
||||
learner_feedback_enabled=(
|
||||
feedback_policy.effective_learner_feedback_enabled(sess, principal)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/archive", response_model=SessionArchiveResponse)
|
||||
|
|
@ -1355,18 +1407,26 @@ async def start_session(
|
|||
|
||||
carry_rapport = st.rapport_credit
|
||||
goal_stages = [str(stage) for stage in body.goal_stages]
|
||||
sess = await session_persistence.create_session(
|
||||
learner_id=principal.user_id,
|
||||
card=card,
|
||||
theory_mode=body.theory_mode,
|
||||
state=st,
|
||||
session_no=session_no,
|
||||
carry_rapport=carry_rapport,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
case_id=case_context.case_id if case_context else None,
|
||||
goal_stages=goal_stages,
|
||||
)
|
||||
learner_feedback_enabled = principal.learner_feedback_enabled
|
||||
try:
|
||||
sess = await session_persistence.create_session(
|
||||
learner_id=principal.user_id,
|
||||
card=card,
|
||||
theory_mode=body.theory_mode,
|
||||
state=st,
|
||||
session_no=session_no,
|
||||
carry_rapport=carry_rapport,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
case_id=case_context.case_id if case_context else None,
|
||||
goal_stages=goal_stages,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
)
|
||||
except session_persistence.SessionCreationPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="session_persistence_unavailable",
|
||||
) from exc
|
||||
degraded = catalog_persona.degraded or sess is None
|
||||
if sess is None:
|
||||
require_runtime_fallback_allowed("session creation")
|
||||
|
|
@ -1375,13 +1435,21 @@ async def start_session(
|
|||
persona=card,
|
||||
theory_mode=body.theory_mode,
|
||||
state=st,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
session_no=session_no,
|
||||
carry_rapport=carry_rapport,
|
||||
goal_stages=goal_stages,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
)
|
||||
else:
|
||||
store.put(sess)
|
||||
|
||||
# DB 재조회 전의 첫 턴과 runtime fallback에서도 인증된 학습자 표시명을
|
||||
# 역할 기반 비식별화에 사용할 수 있게 in-process 세션에만 보존한다.
|
||||
sess.learner_label = principal.display_name or principal.user_id
|
||||
store.put(sess)
|
||||
|
||||
# 즉시 빈/carry 회상으로 응답을 막지 않는다. RAG 회상·KB 단서(임베더 로드 수 초)는
|
||||
# 백그라운드 warm으로 캐시 — 회기 시작/턴 응답이 임베더 로드에 블로킹되지 않게(성능 회귀 방지).
|
||||
_RECALL_CACHE[sess.session_id] = recall
|
||||
|
|
@ -1390,6 +1458,8 @@ async def start_session(
|
|||
return SessionStartResponse(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
session_no=sess.session_no,
|
||||
stage=_stage_label(st.stage),
|
||||
effective_openness=round(st.effective_openness, 4),
|
||||
|
|
@ -1399,6 +1469,7 @@ async def start_session(
|
|||
goal_stages=body.goal_stages,
|
||||
duration_limit_seconds=settings.session_duration_minutes * 60,
|
||||
warning_before_end_seconds=settings.session_warning_minutes * 60,
|
||||
learner_feedback_enabled=sess.learner_feedback_enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1413,18 +1484,31 @@ async def get_session_review(
|
|||
principal,
|
||||
include_turn_evaluation=True,
|
||||
)
|
||||
(
|
||||
evaluation_record,
|
||||
evaluation_durable,
|
||||
) = await session_persistence.load_session_evaluation(
|
||||
session_id,
|
||||
review_principal,
|
||||
expose_learner_feedback = (
|
||||
feedback_policy.can_expose_principal_learner_feedback(
|
||||
sess,
|
||||
review_principal,
|
||||
)
|
||||
)
|
||||
if expose_learner_feedback:
|
||||
(
|
||||
evaluation_record,
|
||||
evaluation_durable,
|
||||
) = await session_persistence.load_session_evaluation(
|
||||
session_id,
|
||||
review_principal,
|
||||
)
|
||||
else:
|
||||
evaluation_record, evaluation_durable = None, True
|
||||
saved_worksheet_payload, _ = await session_persistence.load_case_worksheet(
|
||||
session_id,
|
||||
review_principal,
|
||||
)
|
||||
include_teacher_review = review_principal.role in {Role.TEACHER, Role.ADMIN}
|
||||
learner_feedback_enabled = feedback_policy.effective_learner_feedback_enabled(
|
||||
sess,
|
||||
review_principal,
|
||||
)
|
||||
teacher_review_record = None
|
||||
if include_teacher_review:
|
||||
teacher_review_record, _ = await session_persistence.load_session_review_status(
|
||||
|
|
@ -1440,6 +1524,8 @@ async def get_session_review(
|
|||
saved_worksheet_payload=saved_worksheet_payload,
|
||||
include_teacher_review=include_teacher_review,
|
||||
teacher_review_record=teacher_review_record,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
expose_learner_feedback=expose_learner_feedback,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1462,6 +1548,11 @@ async def create_session_share(
|
|||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, detail="session must be ended before sharing"
|
||||
)
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
|
||||
review = await get_session_review(session_id, principal)
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
|
@ -1606,7 +1697,12 @@ async def list_live_coach_history(
|
|||
) -> LiveCoachHistoryResponse:
|
||||
"""현재 회기에서 학습자에게 실제로 전달된 라이브 코칭 이력을 반환한다."""
|
||||
principal = _ensure_learner(principal)
|
||||
await _load_session_or_404(session_id, principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
events, durable = await session_persistence.list_live_coach_events(
|
||||
session_id, principal
|
||||
)
|
||||
|
|
@ -1641,6 +1737,11 @@ async def live_coach_turn(
|
|||
"""방금 완료된 턴에 대한 비차단 라이브 코칭을 반환한다."""
|
||||
principal = _ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
quota, _ = await session_persistence.get_live_coach_quota(session_id, principal)
|
||||
if int(quota.get("remaining", 0)) <= 0:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -1458,6 +1458,7 @@ async def _prepare_voice_turn_context(
|
|||
card=sess.persona,
|
||||
state=sess.state,
|
||||
learner_text=learner_text,
|
||||
learner_identity=sess.learner_label,
|
||||
memory=orchestrator.TurnMemory(
|
||||
recall_summary=recall.recall_summary,
|
||||
pinned_facts=recall.pinned_facts,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue