diff --git a/agent/router/v1/chat/chat.py b/agent/router/v1/chat/chat.py index 02f2fad..fd07b34 100644 --- a/agent/router/v1/chat/chat.py +++ b/agent/router/v1/chat/chat.py @@ -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) diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py index 56f02fa..953af37 100644 --- a/agent/router/v1/chat/protocol.py +++ b/agent/router/v1/chat/protocol.py @@ -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 diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 6b2584d..3c10e9d 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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 diff --git a/postgres-init/03-seed-negodata.sql b/postgres-init/03-seed-negodata.sql index 936a912..012622a 100644 --- a/postgres-init/03-seed-negodata.sql +++ b/postgres-init/03-seed-negodata.sql @@ -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