개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue