[feat] 세션 step desync 동기화

This commit is contained in:
hbyang 2026-06-23 09:09:07 +09:00
parent 5d9eedf2f6
commit fd2e5c1cc2
4 changed files with 69 additions and 7 deletions

View File

@ -3,7 +3,7 @@
from fastapi import APIRouter, Depends
from router.deps import get_tenant_engine
from router.v1.chat.protocol import Req_Chat, Res_Chat
from router.v1.chat.protocol import Req_Chat, Res_Chat, Res_ChatSession
from services.chat_service import ChatService
from tenancy.registry import TenantEngine
@ -22,3 +22,17 @@ async def chat(
service: ChatService = Depends(),
):
return await service.chat(engine, req)
@router.get(
path="/sessions/{session_id}",
response_model=Res_ChatSession,
summary="세션 상태 조회 (desync 리싱크)",
description="agent 세션의 현재 step/ended 를 반환한다. backend 가 타임아웃·재진입 시 정합을 맞출 때 사용. X-Tenant-ID 헤더 필수.",
)
async def get_session(
session_id: str,
engine: TenantEngine = Depends(get_tenant_engine),
service: ChatService = Depends(),
):
return await service.get_session_state(engine, session_id)

View File

@ -13,6 +13,10 @@ class Req_Chat(Req_WebPacketProtocol):
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성")
rq_type: str = Field("재협상", description="재협상 | 재견적")
user_input: Optional[str] = Field(None, description="버튼 선택 텍스트 또는 가격(price 모드)")
# ① desync 감지: backend 가 보는 직전 봇 step(내부 step 또는 표시 step). 없으면 검사 생략.
# agent 는 자기 세션 step 을 정답으로 보고 진행하되, 불일치 시 경고 로깅하고 응답에 desynced 를 실어
# backend/front 가 agent 응답의 step/client_step 으로 리싱크하게 한다.
client_step: Optional[str] = Field(None, description="backend 가 본 직전 봇 step (desync 감지용)")
# 새 세션 시작 시 협상 컨텍스트 (옵션, 기본값 제공)
revenue_amount: float = 20_000_000
distribution_code: str = "A"
@ -32,6 +36,8 @@ class Res_Chat(Res_WebPacketProtocol):
input_options: Optional[List[str]] = None
chat_end: bool = False
outcome: Optional[str] = None
# ① step desync 신호: 요청 client_step 이 agent 세션 step 과 달랐음을 알린다(agent step 이 정답).
desynced: bool = False
# 가격협상 턴에서 선택된 협상 카드 + 학습 메타
card_id: Optional[str] = None
policy: Optional[str] = None
@ -39,3 +45,14 @@ class Res_Chat(Res_WebPacketProtocol):
updated_q: Optional[float] = None
visit_count: Optional[int] = None
reward_total: Optional[float] = None
class Res_ChatSession(Res_WebPacketProtocol):
"""① 세션 상태 조회 응답. backend 가 타임아웃/재진입 시 정합을 맞출 때 사용."""
session_id: Optional[str] = None
step: Optional[str] = None
client_step: Optional[str] = None
rq_type: Optional[str] = None
ended: bool = False
found: bool = False

View File

@ -20,7 +20,7 @@ from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, Negotia
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 router.v1.chat.protocol import Req_Chat, Res_Chat, Res_ChatSession
from tenancy.registry import TenantEngine
@ -34,6 +34,20 @@ class ChatService:
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 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성.
@ -67,6 +81,7 @@ class ChatService:
res.input_options = view.input_options
res.chat_end = view.chat_end
res.outcome = view.outcome
res.desynced = desynced
if view.error:
res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
res.msg = view.error
@ -74,6 +89,24 @@ class ChatService:
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

View File

@ -8,8 +8,8 @@
\connect negosium_db
-- 회사 1개 (고정 UUID). status 1=active. code/industry 는 코드값이라 비워둠(nullable).
INSERT INTO company.companies (company_id, name, business_number, representative_name, email, contact_number, website_url, status)
SELECT '00000000-0000-0000-0000-000000000001', '아이마켓코리아', '220-88-21724', '홍길동',
INSERT INTO company.companies (name, business_number, representative_name, email, contact_number, website_url, status)
SELECT '아이마켓코리아', '220-88-21724', '홍길동',
'admin@imarketkorea.com', '02-3708-5000', 'https://www.imarketkorea.com', 1
WHERE NOT EXISTS (
SELECT 1 FROM company.companies WHERE company_id = '00000000-0000-0000-0000-000000000001'
@ -17,9 +17,7 @@ WHERE NOT EXISTS (
-- admin 유저. password 는 'admin123' 의 bcrypt 해시(백엔드 GetHashedPW 와 동일 알고리즘, checkpw 로 검증됨).
-- role 2=manager (UserRole.MANAGER; ADMIN 코드는 enum 에 없어 최상위인 MANAGER 사용). status 1=active.
INSERT INTO company.users (user_id, company_id, id, password, name, email, contact_number, last_accessed_at, status, role)
SELECT '00000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000001',
INSERT INTO company.users (id, password, name, email, contact_number, last_accessed_at, status, role)
'admin',
'$2b$12$KY4T0kXQ2npvvt71iWZG0.JZHlMNt9angIkE/7.lBC4vta4dHgrj2',
'관리자', 'admin@imarketkorea.com', '02-3708-5000', now(), 1, 2