"""ChatService — 대화형 /chat 오케스트레이션 (P7 슬라이스). ChatEngine(동기 step 전이) + UCB Q-Table(가격협상 카드선택·학습) + DB(experience_logs) 결합. 세션 상태는 인메모리 스토어(PoC; 단일 워커). 종료 outcome 은 마지막 (state,action)에 종료보상을 역전파. """ import uuid from typing import Optional from common.enums import DBType, ErrorType from common.database.db_session_manager import DB_SESSION_MNG from common.logger import LOG from config.server_configs import agent_config from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView from negotiation.chat.service.chat_session_repository import ChatSessionRepository from negotiation.chat.service.script_repository import ScriptRepository from negotiation.policies.base import EpisodeState, PolicyContext, Transition from negotiation.policy.model_store import QTablePolicyStore from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot from negotiation.qtable.domain.service.reward_calculator import RewardCalculator from negotiation.qtable.domain.service.state_calculator import state_index from negotiation.qtable.infra.repository.learning_repository import LearningRepository from router.v1.chat.protocol import Req_Chat, Res_Chat from tenancy.registry import TenantEngine class ChatService: async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat: res = Res_Chat() repo = ScriptRepository(engine.config, agent_config.tenants_dir) sess_repo = ChatSessionRepository(engine.company_id) # 1) 세션 확보 / 시작 (DB 영속 — 재시작/멀티워커 안전, P8-A) session = await sess_repo.get(req.session_id) if req.session_id else None chat_engine = ChatEngine(repo, rq_type=(session.rq_type if session else req.rq_type)) if session is None: session = ChatSession( session_id=str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id, rq_type=req.rq_type, action_space_size=engine.action_space_size, context={ "revenue_amount": req.revenue_amount, "distribution_code": req.distribution_code, "partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio, # 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)). "anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0, }, ) view = chat_engine.start(session) else: view = chat_engine.advance(session, req.user_input) # 2) 학습 결합 (가격협상 카드선택 / 종료 보상) if view.error is None and engine.action_space_size > 0: if view.needs_card_selection: await self._select_and_learn(engine, session, res) elif view.outcome is not None: await self._terminal_learn(engine, session, view.outcome, res) # 3) 응답 res.session_id = session.session_id res.step = view.step res.client_step = view.client_step res.script = view.script res.input_mode = view.input_mode res.input_options = view.input_options res.chat_end = view.chat_end res.outcome = view.outcome if view.error: res.result.SetResult(ErrorType.NEGO_INVALID_STEP) res.msg = view.error await sess_repo.save(session) # 진행 상태 영속화 (재시작/멀티워커 안전) return res # ---- 학습 ---------------------------------------------------------- def _snapshot(self, session: ChatSession, outcome: NegotiationOutcome) -> NegotiationSnapshot: c = session.context return NegotiationSnapshot( revenue_amount=c["revenue_amount"], distribution_code=c["distribution_code"], partner_count=c["partner_count"], acceptance_ratio=c["acceptance_ratio"], input_price=c.get("input_price", c["anchor_price"]), anchor_price=c["anchor_price"], target_price=c["target_price"], round_number=c.get("round", 0), outcome=outcome, ) async def _select_and_learn(self, engine: TenantEngine, session: ChatSession, res: Res_Chat): snap = self._snapshot(session, NegotiationOutcome.ONGOING) try: idx = state_index(snap, engine.config.state) except ValueError as ex: LOG.e_no_callstack(f"[ChatService] state error: {ex}") return policy, version_id, repo = await QTablePolicyStore.load(engine) ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size, episode=EpisodeState(used_action_ids=set(session.used_action_ids))) decision = policy.select(ctx) session.used_action_ids.add(decision.action_id) card_id = engine.mapper.get_card_id(decision.action_id) reward = RewardCalculator(engine.config.reward).calculate(snap) policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False)) await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id) session.context["last_state"] = idx session.context["last_action"] = decision.action_id await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False) res.card_id = card_id res.policy = policy.name res.q_value = decision.q_value res.updated_q = float(policy.qtable.q[idx, decision.action_id]) res.visit_count = int(policy.qtable.visits[idx, decision.action_id]) res.reward_total = reward.total async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat): oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE snap = self._snapshot(session, oc) reward = RewardCalculator(engine.config.reward).calculate(snap) res.reward_total = reward.total last_state = session.context.get("last_state") last_action = session.context.get("last_action") if last_state is None or last_action is None: return # 카드선택 없이 종료된 경우(예: 담당자확인 단계 이탈) policy, version_id, repo = await QTablePolicyStore.load(engine) policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True)) await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action) await self._log(repo, session, last_state, last_action, engine.mapper.get_card_id(last_action), snap, reward, None, done=True) res.updated_q = float(policy.qtable.q[last_state, last_action]) async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done): data = { "session_id": session.session_id, "state_index": state_index, "action_id": action_id, "card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity, "turn": snap.round_number, "reward": reward.total, "done": done, "settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None, } try: await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)]) except Exception as ex: LOG.e_no_callstack(f"[ChatService] log failed: {ex}") def reset_sessions(): """세션은 DB(learning.chat_sessions)에 영속화된다(P8-A). 테스트는 db_engine 픽스처가 TRUNCATE 하므로 no-op.""" pass