개선관리 요구사항과 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

@ -15,6 +15,7 @@ from . import session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role
from .engine_client import EngineError, GenerateResponse
from .routes import eval as eval_routes
from .routes import sessions
from .routes import voice as voice_routes
from .services import (
@ -129,6 +130,293 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.session_id, sess.session_id)
schedule_session_evaluation.assert_not_called()
async def test_failed_end_evaluation_keeps_old_review_and_next_session_writable(
self,
) -> None:
principal = _principal()
card = persona_service.P1
case_id = "00000000-0000-0000-0000-00000000ca5e"
old_session = _session(principal)
old_session.case_id = case_id
old_session.session_no = 1
old_session.turns.extend(
[
TurnRecord(
turn_seq=1,
speaker="learner",
stage=old_session.state.stage.value,
text="지금 가장 버거운 마음이 어떤 건가요?",
text_masked="지금 가장 버거운 마음이 어떤 건가요?",
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=old_session.state.stage.value,
text="아무것도 하고 싶지 않아요.",
text_masked="아무것도 하고 싶지 않아요.",
),
]
)
durable_sessions = {old_session.session_id: old_session}
end_order: list[str] = []
async def persist_end(sess: InProcSession, _carry: memory.CarryOver) -> None:
end_order.append("persisted")
sess.ended = True
sess.ended_at = sess.created_at + 120
durable_sessions[sess.session_id] = sess
def schedule_evaluation(sess: InProcSession) -> None:
self.assertTrue(durable_sessions[sess.session_id].ended)
end_order.append("evaluation_scheduled")
with (
patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=old_session),
),
patch.object(sessions, "_end_persisted_session", persist_end),
patch.object(
sessions.rupture_runtime,
"schedule_session_scan",
),
patch.object(
sessions,
"_schedule_session_evaluation",
schedule_evaluation,
),
):
ended = await sessions.end_session(old_session.session_id, principal)
self.assertEqual(ended.session_id, old_session.session_id)
self.assertEqual(ended.session_no, 1)
self.assertEqual(end_order, ["persisted", "evaluation_scheduled"])
self.assertTrue(durable_sessions[old_session.session_id].ended)
self.assertIsNotNone(durable_sessions[old_session.session_id].ended_at)
durable_evaluations: dict[str, dict[str, object]] = {}
async def save_evaluation(
write: session_persistence.SessionEvaluationWrite,
) -> bool:
durable_evaluations[write.session_id] = write.cache_record()
return True
with (
patch.object(
sessions.evaluator,
"evaluate_session",
AsyncMock(side_effect=asyncio.TimeoutError),
),
patch.object(
sessions.session_persistence,
"save_session_evaluation",
save_evaluation,
),
patch.object(
sessions,
"_enqueue_session_review_ready_notification",
AsyncMock(return_value=None),
),
):
await sessions._generate_and_save_session_evaluation(old_session)
old_evaluation = durable_evaluations[old_session.session_id]
self.assertEqual(old_evaluation["status"], "error")
self.assertIn("session evaluation timeout", str(old_evaluation["error"]))
self.assertTrue(durable_sessions[old_session.session_id].ended)
catalog_persona = SimpleNamespace(
card=card,
persona_id="00000000-0000-0000-0000-0000000000a1",
version=3,
degraded=False,
)
case_context = sessions.session_persistence.CaseContext(
case_id=case_id,
last_session_no=1,
)
next_session_id = "00000000-0000-0000-0000-000000000702"
async def create_next_session(**kwargs: object) -> InProcSession:
self.assertEqual(kwargs["case_id"], case_id)
self.assertEqual(kwargs["session_no"], 2)
created = InProcSession(
session_id=next_session_id,
case_id=case_id,
learner_id=principal.user_id,
persona_code=card.code,
theory_mode=str(kwargs["theory_mode"]),
persona=card,
state=kwargs["state"],
session_no=int(kwargs["session_no"]),
prev_rapport_credit=float(kwargs["carry_rapport"]),
)
durable_sessions[created.session_id] = created
return created
def close_background(coro: object) -> None:
close = getattr(coro, "close", None)
if callable(close):
close()
with (
patch.object(
sessions,
"get_catalog_persona",
AsyncMock(return_value=catalog_persona),
),
patch.object(
sessions.session_persistence,
"get_case_context",
AsyncMock(return_value=case_context),
),
patch.object(
sessions,
"_build_seed_recall",
AsyncMock(return_value=memory.RecallContext()),
),
patch.object(
sessions.session_persistence,
"create_session",
create_next_session,
),
patch.object(sessions.asyncio, "create_task", close_background),
):
started = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
principal,
)
self.assertNotEqual(started.session_id, old_session.session_id)
self.assertEqual(started.session_id, next_session_id)
self.assertEqual(started.case_id, case_id)
self.assertEqual(started.session_no, 2)
next_session = durable_sessions[next_session_id]
persisted_turns: list[TurnRecord] = []
async def persist_turn(**kwargs: object) -> bool:
turn = kwargs["turn"]
assert isinstance(turn, TurnRecord)
persisted_turns.append(turn)
return True
async def successful_turn(
ctx: orchestrator.TurnContext,
_engine: object,
**_kwargs: object,
) -> orchestrator.TurnResult:
assert ctx.state_after is not None
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply="조금 더 이야기해볼게요.",
safety_flagged=False,
state_after=ctx.state_after,
)
with (
patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=next_session),
),
patch.object(
sessions.orchestrator,
"run_turn_generate",
successful_turn,
),
patch.object(
sessions.session_persistence,
"append_turn",
persist_turn,
),
patch.object(
sessions.session_persistence,
"update_state",
AsyncMock(return_value=True),
),
patch.object(
sessions.rupture_runtime,
"schedule_session_scan",
),
):
first_turn = await sessions.submit_turn(
next_session_id,
sessions.TurnRequest(text="지난 이야기부터 이어가도 괜찮을까요?"),
principal,
)
self.assertEqual(first_turn.client_reply, "조금 더 이야기해볼게요.")
self.assertEqual([turn.speaker for turn in persisted_turns], ["counselor", "client"])
self.assertEqual([turn.speaker for turn in next_session.turns], ["counselor", "client"])
with (
patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=old_session),
),
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(old_evaluation, True)),
),
patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(None, True)),
),
):
old_review = await sessions.get_session_review(
old_session.session_id,
principal,
)
self.assertFalse(old_review.reviewReady)
self.assertTrue(old_review.degraded)
self.assertEqual(old_review.supervisorState, "평가 실패")
self.assertIn("AI 평가 재시도가 필요합니다", old_review.summary)
teacher = Principal(
user_id="00000000-0000-0000-0000-000000000202",
role=Role.TEACHER,
cohort_ids=[],
email="teacher@hs.ac.kr",
display_name="Teacher",
)
retry_save = AsyncMock(return_value=True)
with (
patch.object(
eval_routes,
"_load_session_or_404",
AsyncMock(return_value=old_session),
),
patch.object(
eval_routes.evaluator,
"evaluate_session",
AsyncMock(side_effect=EngineError("controlled evaluator 500")),
),
patch.object(
eval_routes.session_persistence,
"save_session_evaluation",
retry_save,
),
):
with self.assertRaises(HTTPException) as retry_error:
await eval_routes.reevaluate_session(
old_session.session_id,
eval_routes.ReevaluateRequest(scope="session_end"),
teacher,
)
self.assertEqual(retry_error.exception.status_code, 503)
retry_save.assert_awaited_once()
retry_write = retry_save.await_args.args[0]
self.assertEqual(retry_write.status, "error")
async def test_append_turn_writes_provider_events_to_db(self) -> None:
class FakeConn:
def __init__(self) -> None:
@ -257,12 +545,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
persona_version=1,
case_id="00000000-0000-0000-0000-000000000202",
goal_stages=goals,
learner_feedback_enabled=False,
)
self.assertIsNotNone(created)
self.assertIsInstance(conn.session_insert_args[11], list)
self.assertEqual(conn.session_insert_args[11], goals)
self.assertNotIsInstance(conn.session_insert_args[11], str)
self.assertFalse(conn.session_insert_args[12])
assert created is not None
self.assertFalse(created.learner_feedback_enabled)
async def test_generate_turn_engine_failure_does_not_append_learner_turn(
self,
@ -887,7 +1179,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(response.next_utterance)
self.assertTrue(response.sources)
self.assertEqual(
response.sources[0].source_id, "workbook_0615_case_conceptualization"
response.sources[0].source_id, "official_counseling_guideline_seed"
)
self.assertNotIn(
"workbook_0615_case_conceptualization",
{source.source_id for source in response.sources},
)
history = await sessions.list_live_coach_history(sess.session_id, principal)
self.assertEqual(history.source, "runtime")
@ -992,6 +1288,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
"citation": "허가된 공식 지침 요약",
},
source_id="official_suicide_risk_guidelines",
license_class="B",
external_llm_ok=True,
)
],
policy_name="evaluator:k=supervisor_pattern:s<=2",
@ -1029,6 +1327,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(source.source_type, "official_guideline_summary")
self.assertEqual(source.version, "2026-06-15")
self.assertEqual(source.citation, "허가된 공식 지침 요약")
self.assertEqual(source.license_class, "B")
self.assertTrue(source.external_llm_ok)
query = retrieve_grounding.await_args.kwargs["query"]
self.assertNotIn("박민수", query)
self.assertIn("[NAME]에게", query)
@ -1146,6 +1446,98 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(official.citation)
engine.generate.assert_called_once()
async def test_live_coach_prompt_uses_client_role_token_not_persona_name(
self,
) -> None:
item = live_coach.LiveCoachInput(
session_id="masked-coach-session",
turn_seq=1,
stage="rapport",
effective_openness=0.25,
theory_mode="humanistic",
persona_code="P1",
persona_name="서연",
learner_text="오늘 어떤 마음으로 오셨어요?",
client_reply="조금 긴장돼요.",
prior_coach=[{"title": "서연에게 감정을 반영하세요", "focus": "emotion"}],
)
prompt = "\n".join(
message.content for message in live_coach._messages(item, grounding=[])
)
self.assertIn("[내담자] [CLIENT] (P1)", prompt)
self.assertIn("[CLIENT]에게 감정을 반영하세요", prompt)
self.assertNotIn("서연", prompt)
async def test_live_coach_structured_output_masks_client_name_before_storage(
self,
) -> None:
principal = _principal()
sess = _session(principal)
engine_response = GenerateResponse(
text="",
model="fake-live-coach-model",
provider="fake-provider",
tokens_in=12,
tokens_out=9,
cost_usd=0.01,
structured={
"tone": "pos",
"focus": "emotion",
"title": "서연의 감정을 반영하세요",
"message": "서연이 말한 긴장을 한 번 더 따라가세요.",
"next_utterance": "서연님, 그 긴장이 언제 가장 커지나요?",
"rationale": "서연의 표현을 그대로 짚으면 초점이 선명해집니다.",
"safety_note": "서연의 안전 신호도 확인하세요.",
},
)
with (
patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
),
patch.object(
sessions.engine_client,
"generate",
AsyncMock(return_value=engine_response),
),
patch.object(
session_persistence,
"record_llm_call_audit",
AsyncMock(return_value=True),
),
):
response = await sessions.live_coach_turn(
sess.session_id,
sessions.LiveCoachRequest(
learner_text="그 마음이 컸겠네요.",
client_reply="네, 조금 긴장돼요.",
turn_seq=1,
),
principal,
)
rendered = " ".join(
value
for value in (
response.title,
response.message,
response.next_utterance,
response.rationale,
response.safety_note,
)
if value
)
self.assertNotIn("서연", rendered)
self.assertIn("[CLIENT]", rendered)
history = await sessions.list_live_coach_history(sess.session_id, principal)
persisted = history.events[0].suggestion.model_dump_json()
self.assertNotIn("서연", persisted)
self.assertIn("[CLIENT]", persisted)
async def test_start_session_uses_stable_case_context_and_seed_recall(self) -> None:
principal = _principal()
card = persona_service.P1
@ -1448,6 +1840,38 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(events[1].data["tokens_out"], 7)
self.assertEqual(events[1].data["cost_usd"], 0.034567)
async def test_run_turn_stream_rejects_clean_eof_without_gateway_done(self) -> None:
class FakeStreamEngine:
engine_mode = "claude_cli"
default_model = None
async def stream(self, req):
yield "event: token"
yield 'data: {"text":"완료 전 부분 응답"}'
async def stream_packets(self, req):
async for packet in _decoded_stream_packets(self, req):
yield packet
principal = _principal()
sess = _session(principal)
ctx = orchestrator.prepare_turn(
session_id=sess.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text="완료 이벤트 없는 스트림 테스트",
memory=orchestrator.TurnMemory(recent_turns=[]),
)
events = [
event
async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type]
]
self.assertEqual([event.event for event in events], ["error"])
self.assertEqual(events[0].data["detail"], "client_stream_incomplete")
async def test_run_turn_stream_treats_gateway_error_event_as_error(self) -> None:
class FakeStreamEngine:
engine_mode = "claude_cli"