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

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

View file

@ -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,

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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})

View file

@ -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)

View file

@ -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,

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

View file

@ -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,

View file

@ -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(

View file

@ -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,