- 동적 가중치 W: 라운드 감쇠 → 상태 5차원 가중합 clip(Σwᵢ·Sᵢ, 0.2, 0.8) (식 12~13, 기존 w1~w5 연결) - 종료보상에 (1−W) 적용: R = W×R_price + (1−W)×R_end − λ×round (식 8) - R_price 3단계: P<anchor 시 1+β·(anchor−P)/anchor 초과달성 보너스 추가 (식 9~11, beta 의미 재정의) - price zone 경계는 명세(T)와 달리 anchor 유지(우선협상 규칙이 실제 의사결정 경계) — 사유 docstring 명시 - state_calculator/config 의 낡은 반대 컨벤션(anchor≥target) 주석 정정 - RewardCalculator(RewardConfig, StateConfig) 시그니처 변경 + 호출부 5곳 갱신, 테스트 기대값 정정 (76/76 PASS) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
197 lines
11 KiB
Python
197 lines
11 KiB
Python
"""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.indicator import compute_indicator
|
|
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, Res_ChatSession
|
|
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))
|
|
|
|
# ① step desync 감지: backend 가 본 직전 봇 step(client_step)이 agent 세션 step 과 다르면 경고.
|
|
# agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴),
|
|
# 추적/리싱크 트리거를 위해 로깅 + 응답 desynced 플래그로 알린다.
|
|
desynced = False
|
|
if session is not None and req.client_step:
|
|
agent_step = session.step
|
|
agent_client_step = chat_engine.step_map.get(agent_step, agent_step)
|
|
if req.client_step not in (agent_step, agent_client_step):
|
|
desynced = True
|
|
LOG.w(
|
|
f"[ChatService] step desync: backend client_step={req.client_step!r} != "
|
|
f"agent step={agent_step!r}(client={agent_client_step!r}) session={session.session_id}"
|
|
)
|
|
|
|
if session is None:
|
|
# session_id honoring: backend 가 보낸 session_id(= negotiation.sessions.session_id)를
|
|
# 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성.
|
|
session = ChatSession(
|
|
session_id=req.session_id or 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,
|
|
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
|
|
"item_price": req.item_price,
|
|
},
|
|
)
|
|
view = chat_engine.start(session)
|
|
else:
|
|
view = chat_engine.advance(session, req.user_input)
|
|
|
|
# 2) 응답 기본 채움 (학습 블록이 가격협상 턴에서 script/indicator 를 덮어쓸 수 있어 먼저 채운다)
|
|
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
|
|
res.desynced = desynced
|
|
|
|
# 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
|
|
if view.error is None and engine.action_space_size > 0:
|
|
if view.needs_card_selection:
|
|
await self._select_and_learn(engine, chat_engine, repo, session, res)
|
|
elif view.outcome is not None:
|
|
await self._terminal_learn(engine, session, view.outcome, res)
|
|
|
|
if view.error:
|
|
res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
|
|
res.msg = view.error
|
|
|
|
await sess_repo.save(session) # 진행 상태 영속화 (재시작/멀티워커 안전)
|
|
return res
|
|
|
|
# ---- 세션 상태 조회 (① desync 리싱크용) ----------------------------
|
|
async def get_session_state(self, engine: TenantEngine, session_id: str) -> "Res_ChatSession":
|
|
"""backend 가 타임아웃/재진입 시 agent 의 현재 step 을 읽어 정합을 맞춘다."""
|
|
res = Res_ChatSession()
|
|
repo = ScriptRepository(engine.config, agent_config.tenants_dir)
|
|
session = await ChatSessionRepository(engine.company_id).get(session_id)
|
|
if session is None:
|
|
res.result.SetResult(ErrorType.NEGO_SESSION_NOT_FOUND)
|
|
return res
|
|
step_map = repo.client_step_mapping()
|
|
res.session_id = session.session_id
|
|
res.step = session.step
|
|
res.client_step = step_map.get(session.step, session.step)
|
|
res.rq_type = session.rq_type
|
|
res.ended = session.ended
|
|
res.found = True
|
|
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, chat_engine: ChatEngine,
|
|
scripts: ScriptRepository, 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, engine.config.state).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
|
|
|
|
# 가격협상 턴 연출(선행 chat_server 의 dynamic step type=indicator 재현):
|
|
# ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉.
|
|
# backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다.
|
|
card_script = scripts.card_script(decision.action_id, chat_engine.vars_for(session))
|
|
if card_script:
|
|
res.script = card_script
|
|
c = session.context
|
|
ind = compute_indicator(c.get("anchor_price", 0), c.get("input_price", 0), c.get("target_price", 0))
|
|
if ind is not None:
|
|
res.indicator_value = float(ind[0])
|
|
res.indicator_range = ind[1]
|
|
res.bot_chat_type = "indicator"
|
|
|
|
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, engine.config.state).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
|