[feat] agent: Req_Chat 슬림화 — 협상 컨텍스트를 DB 조회로 전환 + CRUD 계층 도입

- Req_Chat 을 session_id/user_input/client_step 3필드로 축소 — rq_type·목표가·앵커·품목가·
  매출액·유통코드·파트너 유형·수용률 필드 전부 제거
- NegotiationContextLoader 신설: 세션 시작 시 공유 DB 1회 조회로 컨텍스트 확정
  · rq_type = sessions.qt_type ({1,3}→재협상 / {2,4}→재견적)
  · anchor = sessions.anchoring_price(박제) — NULL 이면 무할인 폴백 anchor=target (v1.2 정책 승계)
  · 매출액 = suppliers.total_revenue(KTC 미러), 유통코드 = quotations.supplier_type 매핑
  · 파트너 유형 = 상품별 distinct supplier 수 → PartnerType enum(0=NONE/1=SINGLE/2=MULTIPLE)
- 가격 수용률은 세션 내 동적 계산: max(0, (첫 제시가−현재가)/첫 제시가)
- DB 쿼리를 backend crud 패턴으로 분리: INegoContextCRUD(ABC)+NegoContextCRUD,
  IChatSessionRepository 인터페이스 추가 (테스트 더블 주입 가능)
- 와일드카드 1% 수락 시 합의가=offer_1pct 반영 + Res_Chat.settled_price 신설 —
  backend 요약/입찰가가 이를 최우선 사용 (19,800원 수락이 20,000원으로 기록되던 버그 수정)
- backend: agent 전송 바디 3필드로 축소, 앵커/파트너 조회 메서드 제거,
  test_anchoring_chat 을 새 구조로 재작업(박제 소비/폴백 검증은 agent 테스트로 이관)
- 데모 페이지(/demo·negotiation_demo.html) 제거 — 컨텍스트 주입 경로 폐지로 무의미
- 테스트: agent 83/83, backend 57/57 (컨텍스트 로더 실데이터 왕복 4종 + CRUD 더블 검증 포함)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-07-07 09:44:21 +09:00
parent 1ac42ddc9c
commit ed175c5b65
18 changed files with 641 additions and 267 deletions

View File

View File

@ -0,0 +1,135 @@
"""협상 컨텍스트 CRUD — backend 소유 스키마 read-only 조회 (backend crud 패턴 준용).
backend/crud/chat_crud.py(IChatCRUD/ChatCRUD) 같은 형식: ABC 인터페이스 + 구현 클래스,
cdb(AsyncSession) 주입, (ErrorType, data) 반환. 트랜잭션/세션 경계는 호출부
(NegotiationContextLoader) DB_SESSION_MNG.execute_lambda 관리한다.
스키마 소유권: negotiation/partner/quotation 스키마는 backend 소유(01-schema.sql)
여기서는 read-only 로만 접근한다. ORM 모델 중복 정의를 피하려고 sqlalchemy 경량
table()/column() 구성을 쓴다(agent DB 매니저는 text() 미지원 select 구성만 가능).
"""
from abc import ABC, abstractmethod
from typing import Optional, Tuple
from sqlalchemy import column, distinct, func, select, table
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import ErrorType
from common.logger import LOG
_SESSIONS = table(
"sessions",
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
column("qt_type"), column("target_price"), column("anchoring_price"),
column("deleted"),
schema="negotiation",
)
_ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner")
_SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table("quotations", column("qt_id"), column("supplier_type"), column("deleted"), schema="quotation")
class INegoContextCRUD(ABC):
@abstractmethod
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
"""세션 행 (qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id). 없으면 None."""
pass
@abstractmethod
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
"""품목 기준가(items.price). 없으면 0."""
pass
@abstractmethod
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
"""협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0."""
pass
@abstractmethod
async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]:
"""견적의 협력사 유형(quotations.supplier_type: 0=none/1=유통/2=제조/3=총판). 미지정 시 None."""
pass
@abstractmethod
async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
"""상품에 연결된 협력사 수(협상 세션 이력 기준 distinct supplier)."""
pass
class NegoContextCRUD(INegoContextCRUD):
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
try:
query = (
select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price,
_SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id)
.where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_session_row failed.", raise_error=False)
if err_type != ErrorType.SUCCESS:
return err_type, None
return ErrorType.SUCCESS, (rows[0] if rows else None)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
try:
query = (
select(_ITEMS.c.price)
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_price failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
return err_type, 0
return ErrorType.SUCCESS, int(rows[0])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
try:
query = (
select(_SUPPLIERS.c.total_revenue)
.where(_SUPPLIERS.c.supplier_id == supplier_id, _SUPPLIERS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_supplier_total_revenue failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
return err_type, 0.0
return ErrorType.SUCCESS, float(rows[0])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0.0
async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]:
try:
query = (
select(_QUOTATIONS.c.supplier_type)
.where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_quotation_supplier_type failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows or rows[0] is None:
return err_type, None
return ErrorType.SUCCESS, int(rows[0])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
try:
query = (
select(func.count(distinct(_SESSIONS.c.supplier_id)))
.where(_SESSIONS.c.item_id == item_id, _SESSIONS.c.deleted == False) # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_item_suppliers failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows:
return err_type, 0
return ErrorType.SUCCESS, int(rows[0] or 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0

View File

@ -89,9 +89,16 @@ class ChatEngine:
if price is None:
return self._error(session, "가격을 숫자로 입력해 주세요.")
session.context["input_price"] = price
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
session.context.setdefault("first_offer_price", price)
session.context["round"] = session.context.get("round", 0) + 1
nxt = self._default_next(node)
elif mode in _CHOICE_MODES:
# 와일드카드 1% 인하 제안을 수락("예")하면 합의가를 제안가(offer_1pct)로 확정한다.
# (멘트에만 쓰이던 offer_1pct 가 input_price 에 반영되지 않아, 요약/입찰가가
# 직전 제시가로 잡히던 버그 수정 — 수락 시 실제 합의가는 인하가다.)
if session.step == "wild_card_1pct" and user_input == "" and session.context.get("offer_1pct"):
session.context["input_price"] = float(session.context["offer_1pct"])
nxt = self._choice_next(node, user_input, session)
else:
nxt = self._default_next(node)

View File

@ -2,9 +2,11 @@
인메모리 대신 learning.chat_sessions 진행 상태를 저장 서버 재시작/멀티워커 안전.
company_id 스코프. ChatSession(dataclass) row 직렬화.
인터페이스(IChatSessionRepository) + 구현 형식 backend crud 패턴 준용(테스트 더블 주입 가능).
"""
import uuid
from abc import ABC, abstractmethod
from typing import Optional
from sqlalchemy import select
@ -18,7 +20,19 @@ from common.utils.gtime import GTime
from negotiation.chat.service.chat_engine import ChatSession
class ChatSessionRepository:
class IChatSessionRepository(ABC):
@abstractmethod
async def get(self, session_id: Optional[str]) -> Optional[ChatSession]:
"""세션 조회(자사 company_id 스코프). 없으면 None."""
pass
@abstractmethod
async def save(self, session: ChatSession) -> ErrorType:
"""진행 상태 upsert(재시작/멀티워커 안전)."""
pass
class ChatSessionRepository(IChatSessionRepository):
def __init__(self, company_id: str):
self.company_id = company_id

View File

@ -0,0 +1,102 @@
"""NegotiationContextLoader — 협상 시작 컨텍스트를 DB 에서 1회 조회 (Req_Chat 슬림화).
backend 요청마다 실어 보내던 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/
파트너 유형) 세션 시작 agent 직접 조회한다. session_id backend 공유하는
negotiation.sessions.session_id. 행이 없으면(데모/테스트 직접 호출) None 반환하고
호출부(ChatService) 기본값으로 폴백한다.
DB 쿼리는 INegoContextCRUD(negotiation/chat/infra/repository/nego_context_crud.py) 위임
backend crud 패턴 준용(인터페이스 + 함수 호출). 여기는 판정 로직(rq_type·앵커 폴백·
코드 매핑) 세션 경계(execute_lambda) 담당한다.
가격 수용률은 여기서 다루지 않는다 세션 라운드별 제시가로 동적 계산(ChatService).
"""
import uuid
from dataclasses import dataclass
from typing import Optional
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType, ErrorType
from common.logger import LOG
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
from negotiation.qtable.domain.model.snapshot import PartnerType
# 1:1 견적유형 → 재협상 스크립트. QuotationType: 1=renego, 3=new_nego (2=requote, 4=new_quote 는 1:N 재견적).
_ONE_TO_ONE_QT_TYPES = (1, 3)
# 유통 코드: quotations.supplier_type(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판)
# → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값).
_SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"}
@dataclass(frozen=True)
class NegotiationDbContext:
"""세션 시작 시 DB 에서 확정되는 협상 컨텍스트 (라운드 진행 중 불변)."""
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
partner_type: PartnerType # 상품에 연결된 협력사 수(distinct supplier) → NONE/SINGLE/MULTIPLE
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
distribution_code: Optional[str] # 유통 코드(A/B/C) — quotations.supplier_type 매핑. 미지정 시 None
class NegotiationContextLoader:
def __init__(self, crud: Optional[INegoContextCRUD] = None):
self.crud: INegoContextCRUD = crud or NegoContextCRUD()
async def load(self, session_id: Optional[str]) -> Optional[NegotiationDbContext]:
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백)."""
if not session_id:
return None
try:
sid = uuid.UUID(session_id)
except ValueError:
return None # 데모/테스트의 비-UUID 세션 키
async def _load(s) -> Optional[NegotiationDbContext]:
err, row = await self.crud.get_session_row(s, sid)
if err != ErrorType.SUCCESS or row is None:
return None
qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row
target = int(target_price or 0)
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
# 앵커를 재계산하지 않으며, 해당 세션은 앵커링 집계에서 자동 제외된다.
anchor = int(anchoring_price or 0)
if anchor <= 0:
LOG.w(f"[NegotiationContextLoader] 앵커가 박제 없음 session_id={session_id} — 무할인 폴백(anchor=target)")
anchor = target
# 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값.
_, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id)
# 유통 코드: 견적의 협력사 유형(supplier_type) 매핑. 미지정 시 None → 호출부 기본값.
_, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id)
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
_, item_price = await self.crud.get_item_price(s, item_id)
# 파트너사 유형: 상품에 연결된 협력사 수(협상 세션 이력 기준 distinct supplier). 실패 시 SINGLE.
err, supplier_count = await self.crud.count_item_suppliers(s, item_id)
if err != ErrorType.SUCCESS:
supplier_count = 1
return NegotiationDbContext(
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
target_price=target,
anchor_price=anchor,
item_price=item_price,
partner_type=PartnerType.from_count(supplier_count),
revenue_amount=revenue_amount,
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
)
try:
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _load)
except Exception as ex: # DB 불가 등 — 컨텍스트 없이 기본값으로 진행(협상 자체는 가능해야 함)
LOG.e_no_callstack(f"[NegotiationContextLoader] 컨텍스트 조회 실패 session_id={session_id}: {ex}")
return None

View File

@ -7,7 +7,7 @@
"""
from dataclasses import dataclass, asdict
from enum import Enum
from enum import Enum, IntEnum
from typing import Any, Dict, Optional
@ -19,6 +19,29 @@ class NegotiationOutcome(str, Enum):
FAILURE = "failure"
class PartnerType(IntEnum):
"""파트너사 유형 — 상품 하나를 취급하는 협력사의 경쟁 구조.
상품별 협력사 DB 조회(NegotiationContextLoader) 세션 시작 확정한다:
없음=NONE(0), 하나=SINGLE(1), 여러 =MULTIPLE(2).
값이 협력사 수와 호환되도록 설계됨(0/1/2) snapshot.partner_count 그대로 흘러
state 버킷(_partner_bucket) W 가중치 계산에 쓰인다.
"""
NONE = 0
SINGLE = 1
MULTIPLE = 2
@classmethod
def from_count(cls, count: int) -> "PartnerType":
"""협력사 수 → 유형. 0=NONE, 1=SINGLE, 2 이상=MULTIPLE."""
if count <= 0:
return cls.NONE
if count == 1:
return cls.SINGLE
return cls.MULTIPLE
@dataclass
class NegotiationSnapshot:
# --- 이산 상태 산출 입력 ---

View File

@ -1,11 +1,9 @@
import os
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
@ -22,7 +20,7 @@ API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화, P5)
# startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화)
from bootstrap.lifespan import ensure_base_seeded
await ensure_base_seeded()
yield
@ -58,16 +56,6 @@ async def log_time(request: Request, call_next):
async def healthz():
return API_SERVER_START_TIME
# 간단 테스트 프론트 (tests/negotiation_demo.html). 같은 출처로 서빙 → CORS 불필요.
_DEMO_HTML = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "negotiation_demo.html")
@app.get(path="/demo", include_in_schema=False)
async def demo():
return FileResponse(_DEMO_HTML)
# 각 도메인 라우터 등록. 새 기능 추가 시 router.v1.<domain>.<file> import 후 include.
# (chat / card / learning / qtable 라우터는 P7 에서 Chat_server 14개 API 이식하며 추가)
app.include_router(router.v1.health.health.router)

View File

@ -8,25 +8,22 @@ from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol
class Req_Chat(Req_WebPacketProtocol):
"""대화 한 턴. session_id 없으면 새 협상 시작(아래 컨텍스트로). tenant 는 헤더로만."""
"""대화 한 턴. session_id 없으면 새 협상 시작. tenant 는 헤더로만.
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성")
rq_type: str = Field("재협상", description="재협상 | 재견적")
협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형) 요청에 싣지
않는다 세션 시작 agent DB 에서 1 조회해 확정한다(NegotiationContextLoader):
negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price),
partner.suppliers(total_revenue), quotation.quotations(supplier_type), 상품별 협력사 .
행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
가격 수용률은 세션 라운드별 제시가로 동적 계산: max(0, ( 제시가현재가)/ 제시가).
"""
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성. 운영 경로는 negotiation.sessions.session_id 를 그대로 사용")
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"
partner_count: int = 1
acceptance_ratio: float = 0.05
# 갑(KT/iMK)이 직접 입력. anchor < target. anchor 기본 제안값 = target*(1-0.01).
target_price: int = 10000 # KT 목표 매입가
anchor_price: int = 9900 # KT 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
# 기존 공급가(품목 기준가). 가격협상_확인 멘트의 인하율(discount_rate) 산출용. 0 이면 인하율 미표시.
item_price: int = 0
class Res_Chat(Res_WebPacketProtocol):
@ -52,6 +49,9 @@ class Res_Chat(Res_WebPacketProtocol):
updated_q: Optional[float] = None
visit_count: Optional[int] = None
reward_total: Optional[float] = None
# 성공 확정 이후 턴(협상완료 요약·협상종료)에 내려주는 합의가. 와일드카드 1% 인하 수락 등
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어, backend 요약/입찰가는 이 값을 최우선 사용한다.
settled_price: Optional[int] = None
class Res_ChatSession(Res_WebPacketProtocol):

View File

@ -14,16 +14,25 @@ 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.negotiation_context_loader import NegotiationContextLoader
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.model.snapshot import NegotiationOutcome, NegotiationSnapshot, PartnerType
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
# 직접 호출(데모/테스트) 폴백 기본 컨텍스트 — 운영 경로는 NegotiationContextLoader 가
# DB(negotiation.sessions·partner.items·partner.suppliers·quotation.quotations)에서 조회한다.
_DEFAULT_RQ_TYPE = "재협상"
_DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가
_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — quotations.supplier_type 미지정 시 폴백
class ChatService:
async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat:
@ -33,7 +42,11 @@ class ChatService:
# 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))
# 새 세션 컨텍스트: 요청 페이로드 대신 DB(negotiation.sessions 등)에서 1회 조회.
# 행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
db_ctx = None if session else await NegotiationContextLoader().load(req.session_id)
rq_type = session.rq_type if session else (db_ctx.rq_type if db_ctx else _DEFAULT_RQ_TYPE)
chat_engine = ChatEngine(repo, rq_type=rq_type)
# ① step desync 감지: backend 가 본 직전 봇 step(client_step)이 agent 세션 step 과 다르면 경고.
# agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴),
@ -54,14 +67,22 @@ class ChatService:
# 새 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,
rq_type=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,
# 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader).
# 미기재/미지정이면 기본값 폴백.
"revenue_amount": db_ctx.revenue_amount if db_ctx and db_ctx.revenue_amount > 0 else _DEFAULT_REVENUE_AMOUNT,
"distribution_code": db_ctx.distribution_code if db_ctx and db_ctx.distribution_code else _DEFAULT_DISTRIBUTION_CODE,
# 파트너 유형(PartnerType 값 0/1/2) — 상품별 협력사 수 DB 조회로 세션 시작 시 1회 확정.
# snapshot.partner_count 로 그대로 사용(값 호환).
# 가격 수용률은 컨텍스트에 두지 않는다 — _snapshot 이 라운드별 제시가로 동적 계산.
"partner_count": int(db_ctx.partner_type) if db_ctx else int(PartnerType.SINGLE),
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE,
"target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
"round": 0,
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
"item_price": req.item_price,
"item_price": db_ctx.item_price if db_ctx else 0,
},
)
view = chat_engine.start(session)
@ -78,6 +99,10 @@ class ChatService:
res.chat_end = view.chat_end
res.outcome = view.outcome
res.desynced = desynced
# 합의가: 성공 확정 이후 턴(협상완료 요약 → 협상종료)에 내려준다. 와일드카드 수락처럼
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어 backend 요약/입찰가는 이 값을 최우선으로 쓴다.
if session.context.get("final_outcome") == "success" and session.context.get("input_price"):
res.settled_price = int(session.context["input_price"])
# 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
if view.error is None and engine.action_space_size > 0:
@ -112,11 +137,24 @@ class ChatService:
return res
# ---- 학습 ----------------------------------------------------------
@staticmethod
def _acceptance_ratio(context: dict) -> float:
"""가격 수용률 동적 계산 — 협력사 첫 제시가 대비 현재 제시가의 양보율 (설계서 공식).
acceptance = max(0, ( 제시가 현재 제시가) / 제시가).
제시 라운드(양보 없음)· 제시가 미기록(과거 세션 호환)이면 0(low 버킷).
"""
first = context.get("first_offer_price") or 0
current = context.get("input_price") or 0
if first <= 0 or current <= 0:
return 0.0
return max(0.0, (first - current) / first)
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"],
partner_count=c["partner_count"], acceptance_ratio=self._acceptance_ratio(c),
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,
)

View File

@ -1,142 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Negosium Agent — 협상 채팅 (테스트)</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; background: #eef1f5; color: #1c2530; }
header { background: #1f2d3d; color: #fff; padding: 12px 16px; }
header h1 { margin: 0; font-size: 16px; }
header p { margin: 4px 0 0; font-size: 12px; color: #9fb0c3; }
.wrap { max-width: 760px; margin: 0 auto; padding: 12px; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; background: #fff; padding: 10px; border-radius: 8px; margin-bottom: 10px; }
.controls label { font-size: 12px; color: #51607a; display: flex; flex-direction: column; gap: 2px; }
.controls input, .controls select { padding: 6px 8px; border: 1px solid #cdd6e3; border-radius: 6px; font-size: 13px; }
.controls input[type=number] { width: 110px; }
button { cursor: pointer; border: 0; border-radius: 6px; padding: 8px 12px; font-size: 13px; font-weight: 600; }
.btn-reset { background: #5b6b82; color: #fff; }
.chat { background: #fff; border-radius: 8px; height: 56vh; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.row { display: flex; }
.row.agent { justify-content: flex-start; }
.row.user { justify-content: flex-end; }
.row.sys { justify-content: center; }
.bubble { max-width: 80%; padding: 9px 12px; border-radius: 12px; font-size: 13px; line-height: 1.55; white-space: pre-wrap; }
.agent .bubble { background: #eef1f6; color: #28323f; border-bottom-left-radius: 3px; }
.user .bubble { background: #2f6fed; color: #fff; border-bottom-right-radius: 3px; }
.sys .bubble { background: #fff5d6; color: #6b5500; font-size: 12px; border: 1px solid #f0e2a8; }
.card { margin-top: 7px; font-size: 12px; background: #0f1b2d; color: #9fe0c0; border-radius: 6px; padding: 6px 8px; }
.controls-area { margin-top: 10px; background: #fff; padding: 10px; border-radius: 8px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 52px; }
.opt { background: #2f6fed; color: #fff; }
.opt.alt { background: #5b6b82; }
.price-in { padding: 8px 10px; border: 1px solid #cdd6e3; border-radius: 6px; font-size: 14px; width: 160px; }
.btn-send { background: #1f9d57; color: #fff; }
.note { font-size: 11px; color: #6b7890; margin-top: 8px; line-height: 1.5; }
.pill { display:inline-block; background:#0f1b2d; color:#9fe0c0; border-radius:4px; padding:1px 6px; font-size:11px; margin-left:6px;}
</style>
</head>
<body>
<header>
<h1>Negosium Agent — 협상 채팅 <span class="pill" id="srv">/v1/chat</span></h1>
<p>KT 구매자 관점: 협력사(판매자)가 제시가를 입력 → <b>앵커가 이하면 우선협상(타결)</b>, 초과면 카드로 인하 협상(카드 소진까지). 낮게 매입할수록 KT 이득.</p>
</header>
<div class="wrap">
<div class="controls">
<label>테넌트
<select id="tenant" onchange="reset()">
<option value="ktcommerce">ktcommerce (데모상사 A)</option>
<option value="imarketkorea">imarketkorea (데모상사 B)</option>
</select>
</label>
<label>유형
<select id="rq" onchange="reset()"><option value="재협상">재협상</option><option value="재견적">재견적</option></select>
</label>
<label>목표가(KT 매입)<input type="number" id="target" value="10000" oninput="suggestAnchor()" /></label>
<label>앵커가(KT 앵커링, ≤목표)<input type="number" id="anchor" value="9900" /></label>
<button class="btn-reset" onclick="reset()">새 협상 시작</button>
</div>
<div class="chat" id="chat"></div>
<div class="controls-area" id="controls"></div>
<div class="note" id="note"></div>
</div>
<script>
let sid = null, ended = false;
const $ = id => document.getElementById(id);
// 갑(KT)이 앵커가를 직접 입력. 목표가 변경 시 기본 제안값(target*0.99)을 채워주되 편집 가능.
function suggestAnchor() {
const t = Number($('target').value);
if (t > 0) $('anchor').value = Math.round(t * 0.99);
}
function bubble(side, html) {
const row = document.createElement('div'); row.className = 'row ' + side;
const b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = html;
row.appendChild(b); $('chat').appendChild(row); $('chat').scrollTop = 1e9;
}
const sys = t => bubble('sys', t);
async function reset() {
sid = null; ended = false; $('chat').innerHTML = ''; $('controls').innerHTML = '';
sys(`새 협상 · ${$('tenant').value} · ${$('rq').value} · 앵커 ${$('anchor').value} → 목표 ${$('target').value}`);
await send(null);
}
async function send(userInput) {
if (ended) return;
const body = { session_id: sid, rq_type: $('rq').value, user_input: userInput,
anchor_price: Number($('anchor').value), target_price: Number($('target').value) };
let res;
try {
const r = await fetch('/v1/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': $('tenant').value },
body: JSON.stringify(body) });
res = await r.json();
} catch (e) { sys('서버 호출 실패: ' + e); return; }
sid = res.session_id;
if (res.result && !res.result.success) { sys('처리 불가: ' + res.result.desc + ' — ' + (res.msg||'')); return; }
let html = (res.script || '').replace(/\n/g, '<br>');
if (res.card_id) {
html += `<div class="card">🃏 제시 카드 <b>${res.card_id}</b> · 학습 Q→${(res.updated_q||0).toFixed(3)} · visit ${res.visit_count||0} · reward ${(res.reward_total!=null?res.reward_total.toFixed(3):'-')}</div>`;
}
if (res.outcome) {
html += `<div class="card">📌 협상 ${res.outcome==='success'?'타결':'결렬'} · 종료보상 ${(res.reward_total!=null?res.reward_total.toFixed(3):'-')}</div>`;
}
bubble('agent', html);
renderControls(res);
if (res.chat_end) { ended = true; $('controls').innerHTML = '<span style="color:#6b7890;font-size:12px">대화가 종료되었습니다. "새 협상 시작"을 누르세요.</span>'; }
}
function renderControls(res) {
const c = $('controls'); c.innerHTML = '';
if (res.chat_end) return;
const mode = res.input_mode;
if (mode === 'price') {
const inp = document.createElement('input'); inp.type = 'number'; inp.className = 'price-in'; inp.placeholder = '제시 가격 입력'; inp.value = '';
const btn = document.createElement('button'); btn.className = 'btn-send'; btn.textContent = '제시';
const go = () => { const v = inp.value.trim(); if (!v) return; userSay(v + '원 제시'); send(v); };
btn.onclick = go; inp.onkeydown = e => { if (e.key === 'Enter') go(); };
c.appendChild(inp); c.appendChild(btn); inp.focus();
} else if (Array.isArray(res.input_options) && res.input_options.length) {
res.input_options.forEach((opt, i) => {
const btn = document.createElement('button'); btn.className = 'opt' + (i > 0 ? ' alt' : '');
btn.textContent = opt; btn.onclick = () => { userSay(opt); send(opt); };
c.appendChild(btn);
});
} else {
const btn = document.createElement('button'); btn.className = 'opt'; btn.textContent = '계속';
btn.onclick = () => send(null); c.appendChild(btn);
}
}
function userSay(t){ bubble('user', t); }
reset();
</script>
</body>
</html>

View File

@ -33,8 +33,8 @@ async def test_4_1_honors_backend_session_id(db_engine):
svc = ChatService()
# 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X)
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상",
target_price=10000, anchor_price=9900))
# 컨텍스트는 DB 조회(행 없음 → 기본값 폴백: target=10000, anchor=9900)
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID))
assert r.session_id == BACKEND_SESSION_ID
assert r.step == "서비스안내"
@ -62,8 +62,7 @@ async def test_4_4_company_id_chat_end_to_end(db_engine):
reset_sessions()
eng = await _reg().get_engine(COMPANY_ID) # 자동 온보딩 테넌트
svc = ChatService()
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상",
target_price=10000, anchor_price=9900))
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID))
assert r.session_id == BACKEND_SESSION_ID and r.step == "서비스안내"
# 카드선택 턴까지 진행 → company_id 스코프로 학습 기록
for ui in ["확인", "", "확인", "11000", ""]:

View File

@ -0,0 +1,202 @@
"""NegotiationContextLoader 검증 — Req_Chat 슬림화 후 세션 시작 컨텍스트 DB 조회.
backend 소유 스키마(negotiation.sessions / quotation.quotations / partner.items / partner.suppliers)
실데이터를 넣고, agent session_id 만으로 rq_type·목표가·앵커링가·품목가·매출액(total_revenue)·
유통코드·파트너 유형을 확정하는지 검증한다. 삽입 행은 테스트 종료 삭제.
"""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import column, delete, insert, table
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, ErrorType
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
import os
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
# 삽입용 테이블 구성(backend 소유 스키마 — 테스트 데이터 셋업 전용).
_T_SESSIONS = table(
"sessions",
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
column("qt_number"), column("qt_round"), column("qt_type"), column("target_price"),
column("anchoring_price"), column("status"), column("end_time"),
schema="negotiation",
)
_T_QUOTATIONS = table(
"quotations",
column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"),
column("name"), column("number"), column("type"), column("status"),
column("start_time"), column("end_time"), column("supplier_type"),
schema="quotation",
)
_T_ITEMS = table(
"items",
column("item_id"), column("company_id"), column("user_id"), column("name"), column("price"),
schema="partner",
)
_T_SUPPLIERS = table(
"suppliers",
column("supplier_id"), column("company_id"), column("user_id"), column("name"), column("total_revenue"),
schema="partner",
)
@pytest.mark.asyncio
async def test_context_loaded_from_db(db_engine):
"""세션 시작 시 협상 컨텍스트가 요청이 아니라 DB 에서 확정된다."""
reset_sessions()
sid, sid2 = uuid.uuid4(), uuid.uuid4()
qid, iid = uuid.uuid4(), uuid.uuid4()
sup1, sup2 = uuid.uuid4(), uuid.uuid4()
now = datetime.now(timezone.utc)
def _ins_item(s):
return DB_SESSION_MNG.add(s, insert(_T_ITEMS).values(
item_id=iid, company_id=uuid.uuid4(), user_id=uuid.uuid4(),
name="로더 테스트 상품", price=5000,
))
def _ins_supplier(s):
return DB_SESSION_MNG.add(s, insert(_T_SUPPLIERS).values(
supplier_id=sup1, company_id=uuid.uuid4(), user_id=uuid.uuid4(),
name="로더 테스트 협력사", total_revenue=55_000_000, # 매출액(KTC total_revenue 미러)
))
def _ins_quote(s):
return DB_SESSION_MNG.add(s, insert(_T_QUOTATIONS).values(
qt_id=qid, user_id=uuid.uuid4(), qt_setting_id=uuid.uuid4(), version_id=uuid.uuid4(),
name="로더 테스트", number="QT-LOADER-TEST", type=3, status=2,
start_time=now, end_time=now + timedelta(days=1),
supplier_type=2, # manufacture(제조) → 유통 코드 "A"
))
def _ins_sess(s, session_id, supplier_id):
return DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
session_id=session_id, quotation_id=qid, item_id=iid, supplier_id=supplier_id,
qt_number="QT-LOADER-TEST", qt_round=1,
qt_type=3, # 신규협상(1:1) → 재협상 스크립트
target_price=20000, anchoring_price=19000, # 생성 시 박제된 앵커
status=2, end_time=now + timedelta(days=1),
))
err = await DB_SESSION_MNG.execute_lambda_run(
[DBType.MAIN.value],
[_ins_item, _ins_supplier, _ins_quote,
lambda s: _ins_sess(s, sid, sup1),
lambda s: _ins_sess(s, sid2, sup2)], # 같은 상품에 공급사 2곳 → MULTIPLE
)
assert err == ErrorType.SUCCESS
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("ktcommerce")
r = await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
assert r.session_id == str(sid) and r.step == "서비스안내"
saved = await ChatSessionRepository(eng.company_id).get(str(sid))
assert saved is not None
assert saved.rq_type == "재협상" # qt_type=3(신규협상 1:1)
c = saved.context
assert c["target_price"] == 20000
assert c["anchor_price"] == 19000 # sessions.anchoring_price(박제)
assert c["item_price"] == 5000
assert c["revenue_amount"] == 55_000_000.0 # 매출액 = suppliers.total_revenue
assert c["distribution_code"] == "A" # supplier_type=2(제조) → A
assert c["partner_count"] == 2 # 공급사 2곳 → MULTIPLE
finally:
await DB_SESSION_MNG.execute_lambda_run(
[DBType.MAIN.value],
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.quotation_id == qid)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_ITEMS).where(_T_ITEMS.c.item_id == iid)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIERS).where(_T_SUPPLIERS.c.supplier_id == sup1))],
)
@pytest.mark.asyncio
async def test_null_anchoring_falls_back_to_target(db_engine):
"""박제 앵커(anchoring_price)가 NULL 이면 무할인 폴백 anchor=target (앵커링 v1.2 정책 승계)."""
reset_sessions()
sid, qid, iid = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
now = datetime.now(timezone.utc)
def _ins_sess(s):
return DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=uuid.uuid4(),
qt_number="QT-LOADER-NULL", qt_round=1, qt_type=1,
target_price=30000, anchoring_price=None, # 박제 없음(데이터 이상 경로)
status=2, end_time=now + timedelta(days=1),
))
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_ins_sess])
assert err == ErrorType.SUCCESS
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("ktcommerce")
await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
saved = await ChatSessionRepository(eng.company_id).get(str(sid))
assert saved is not None
assert saved.context["target_price"] == 30000
assert saved.context["anchor_price"] == 30000 # 무할인 폴백: anchor = target
finally:
await DB_SESSION_MNG.execute_lambda_run(
[DBType.MAIN.value],
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid))],
)
@pytest.mark.asyncio
async def test_loader_with_crud_double(db_engine):
"""INegoContextCRUD 인터페이스에 더블을 주입해 쿼리 없이 판정 로직만 검증 (backend crud 패턴)."""
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD
from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader
from negotiation.qtable.domain.model.snapshot import PartnerType
class _FakeCRUD(INegoContextCRUD):
async def get_session_row(self, cdb, session_id):
# (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
async def get_item_price(self, cdb, item_id):
return ErrorType.SUCCESS, 7000
async def get_supplier_total_revenue(self, cdb, supplier_id):
return ErrorType.SUCCESS, 12_000_000.0
async def get_quotation_supplier_type(self, cdb, quotation_id):
return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B"
async def count_item_suppliers(self, cdb, item_id):
return ErrorType.SUCCESS, 0 # 연결 협력사 없음 → NONE
ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
assert ctx is not None
assert ctx.rq_type == "재견적" # qt_type=2(1:N)
assert ctx.target_price == 50000
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.item_price == 7000
assert ctx.revenue_amount == 12_000_000.0
assert ctx.distribution_code == "B" # supplier_type=3(총판) → B
assert ctx.partner_type is PartnerType.NONE
@pytest.mark.asyncio
async def test_context_falls_back_without_db_row(db_engine):
"""DB 에 세션 행이 없으면(데모/직접 호출) 기본 컨텍스트로 폴백한다."""
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("ktcommerce")
r = await ChatService().chat(eng, Req_Chat())
saved = await ChatSessionRepository(eng.company_id).get(r.session_id)
assert saved is not None
c = saved.context
assert c["target_price"] == 10000 and c["anchor_price"] == 9900
assert c["partner_count"] == 1 and c["distribution_code"] == "A"

View File

@ -31,7 +31,7 @@ async def _run(svc, eng, turns):
"""turns: user_input 리스트(첫 None=시작). 반환: 응답 리스트."""
sid, out = None, []
for ui in turns:
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui, rq_type="재협상"))
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
sid = r.session_id
out.append(r)
if r.chat_end:
@ -76,6 +76,48 @@ async def test_full_conversation_reaches_completion(db_engine):
assert cnt >= 1
@pytest.mark.asyncio
async def test_wildcard_1pct_accept_settles_at_offer_price(db_engine):
"""와일드카드 1% 인하 제안을 ''로 수락하면 합의가는 인하 제안가(offer_1pct)여야 한다.
회귀: 수락해도 input_price 직전 제시가로 남아 요약/입찰가가 원래 가격(: 20000)으로
잡히던 버그 settled_price 인하가(: 19800) 내려와야 한다.
"""
reset_sessions()
eng = await _reg().get_engine("ktcommerce")
svc = ChatService()
# anchor=9900(기본). 10000 은 anchor*1.02(10098) 이내 → wild_card_1pct 발동, offer_1pct=9900.
out = await _run(svc, eng, [None, "확인", "", "확인", "10000", "", "",
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."])
steps = [r.step for r in out]
assert "wild_card_1pct" in steps
# 수락 후 협상완료(요약 턴)·협상종료(종료 턴) 모두 합의가 = 인하 제안가 9900
done = [r for r in out if r.step in ("협상완료", "협상종료")]
assert done and all(r.settled_price == 9900 for r in done)
def test_partner_type_enum_mapping():
"""PartnerType 계약값(0=NONE/1=SINGLE/2=MULTIPLE)과 수→유형 매핑."""
from negotiation.qtable.domain.model.snapshot import PartnerType
assert PartnerType.NONE == 0 and PartnerType.SINGLE == 1 and PartnerType.MULTIPLE == 2
assert PartnerType.from_count(0) is PartnerType.NONE
assert PartnerType.from_count(1) is PartnerType.SINGLE
assert PartnerType.from_count(2) is PartnerType.MULTIPLE
assert PartnerType.from_count(7) is PartnerType.MULTIPLE
def test_acceptance_ratio_dynamic_calc():
"""가격 수용률 동적 계산 — 첫 제시가 대비 양보율. 첫 제시/미기록=0, 인상 시 0 클립."""
calc = ChatService._acceptance_ratio
assert calc({}) == 0.0 # 가격 입력 전
assert calc({"first_offer_price": 11000, "input_price": 11000}) == 0.0 # 첫 제시(양보 0)
assert calc({"first_offer_price": 11000, "input_price": 10200}) == pytest.approx(800 / 11000)
assert calc({"first_offer_price": 10000, "input_price": 12000}) == 0.0 # 인상(비정상) → 0
assert calc({"input_price": 9800}) == 0.0 # 과거 세션 호환(첫 제시가 미기록)
@pytest.mark.asyncio
async def test_priority_completes_without_wildcard(db_engine):
reset_sessions()
@ -95,7 +137,7 @@ async def test_tenant_brand_isolation(db_engine):
reset_sessions()
svc = ChatService()
reg = _reg()
ik = await svc.chat(await reg.get_engine("imarketkorea"), Req_Chat(session_id=None, rq_type="재협상"))
ik = await svc.chat(await reg.get_engine("imarketkorea"), Req_Chat(session_id=None))
assert "데모상사 B" in ik.script

View File

@ -27,19 +27,19 @@ async def test_session_persists_and_resumes_across_instances(db_engine):
reg = _eng()
eng = await reg.get_engine("ktcommerce")
# 인스턴스 1: 협상 시작 + 몇 턴 진행
# 인스턴스 1: 협상 시작 + 몇 턴 진행 (컨텍스트는 DB 조회 — 행이 없으므로 기본값 폴백)
svc1 = ChatService()
r = await svc1.chat(eng, Req_Chat(rq_type="재협상", target_price=10000, anchor_price=8000))
r = await svc1.chat(eng, Req_Chat())
sid = r.session_id
for ui in ["확인", "", "확인", "11000"]:
r = await svc1.chat(eng, Req_Chat(session_id=sid, user_input=ui))
step_before = r.step
assert step_before == "가격협상_확인" # 11000 제시 후 확인 단계
# DB 에 저장됐는지 확인
# DB 에 저장됐는지 확인 (기본 컨텍스트: anchor=9900)
saved = await ChatSessionRepository(eng.company_id).get(sid)
assert saved is not None and saved.step == step_before
assert saved.context["anchor_price"] == 8000
assert saved.context["anchor_price"] == 9900
# 인스턴스 2 (재시작/다른 워커 모사): 같은 session_id 로 이어가기
svc2 = ChatService()
@ -53,7 +53,7 @@ async def test_session_company_scoped(db_engine):
reg = _eng()
eng = await reg.get_engine("ktcommerce")
svc = ChatService()
r = await svc.chat(eng, Req_Chat(rq_type="재협상"))
r = await svc.chat(eng, Req_Chat())
sid = r.session_id
# 자사(ktcommerce)로는 조회됨

View File

@ -32,26 +32,24 @@ class AgentTurn:
indicator_value: Optional[float] = None
# agent 가 직접 내려주는 표현 폼(summaryRSP/CM·rejectRSP/CM·indicator). 없으면 backend 가 step+qt_type 으로 폴백.
bot_chat_type: Optional[str] = None
# 성공 확정 이후 턴에 agent 가 내려주는 합의가. 와일드카드 1% 인하 수락처럼 유저가 직접
# 입력하지 않은 가격으로 타결될 수 있어, 요약 표시가·입찰가 확정 시 이 값을 최우선 사용한다.
settled_price: Optional[int] = None
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리.
@dataclass
class AgentChatContext:
"""새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다."""
"""agent 호출 컨텍스트. 협상 컨텍스트(목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는
이상 전송하지 않는다 agent session_id DB(negotiation.sessions )에서 직접
조회·계산한다(Req_Chat 슬림화). rq_type/target_price backend 자체 로직(표현 폴백·테스트
더블)용으로만 유지하며 전송되지 않는다.
"""
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.anchoring_price.
item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어
# 기본값으로 보낸다 → agent 가 실제값을 받으려면 backend 스키마에 컬럼 추가 필요(HANDOFF.md ④).
revenue_amount: float = 20_000_000 # 매출액(원) — DB 소스 없음(기본값)
distribution_code: str = "A" # 유통 코드(agent config code_map 키) — DB 소스 없음(기본값)
partner_count: int = 1 # 공급사 수 — 견적당 세션 수로 산출
acceptance_ratio: float = 0.05 # 가격 수용률 0~1 — DB 소스 없음(기본값)
rq_type: str = "재협상" # 재협상(1:1) | 재견적(1:N) — backend 로컬 용도
target_price: int = 0 # 갑 목표 매입가(원) — backend 로컬 용도
# 핸드오프 #1: backend 가 보는 현재 step(직전 봇 step). agent 가 자기 세션 step 과 대조해 desync 감지에 쓸 수 있다.
client_step: Optional[str] = None
extra: dict = field(default_factory=dict)
@ -72,16 +70,9 @@ class HttpAgentClient(IAgentClient):
body = {
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
"rq_type": ctx.rq_type,
# 협상 컨텍스트(rq_type/목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는 보내지
# 않는다 — agent 가 session_id 로 DB 에서 직접 조회·계산한다(NegotiationContextLoader).
"user_input": user_input,
"target_price": ctx.target_price,
"anchor_price": ctx.anchor_price,
"item_price": ctx.item_price,
# 핸드오프 #4: RL state 입력 (agent Req_Chat 이 받는 필드). 현재 기본값.
"revenue_amount": ctx.revenue_amount,
"distribution_code": ctx.distribution_code,
"partner_count": ctx.partner_count,
"acceptance_ratio": ctx.acceptance_ratio,
# 핸드오프 #1: backend 가 보는 직전 step. agent 가 desync 감지에 사용(미구현 시 무시됨).
"client_step": ctx.client_step,
}
@ -111,6 +102,7 @@ class HttpAgentClient(IAgentClient):
card_id=data.get("card_id"),
indicator_value=data.get("indicator_value"),
bot_chat_type=data.get("bot_chat_type"),
settled_price=data.get("settled_price"),
ok=True,
)

View File

@ -361,7 +361,9 @@ class ChatService:
summary = None
if bot_chat_type in ("summaryRSP", "summaryCM"):
final_price = price if price is not None else (sess.bid_price or sess.target_price)
summary = await self._build_summary(sess, quote, item, final_price, last_price)
# 합의가는 agent 가 내려준 settled_price 최우선 (와일드카드 1% 인하 수락 등
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
@ -374,10 +376,11 @@ class ChatService:
if turn.chat_end:
if turn.outcome == "success":
new_status = SessionStatus.DONE.value
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정.
bid = price if price is not None else (last_price if last_price else sess.target_price)
# 입찰가 = agent 합의가(settled_price, 와일드카드 수락 등) → 이번 턴 가격(보통 None)
# → 마지막 제시가 → 목표가 순으로 확정.
bid = turn.settled_price or (price if price is not None else (last_price if last_price else sess.target_price))
# ⑤ 협상된 제시가가 하나도 없어 목표가로 폴백하면, 합의가가 실제 협상과 다를 수 있어 경고.
if price is None and not last_price:
if not turn.settled_price and price is None and not last_price:
LOG.w(f"[chat] 합의가 폴백→목표가 session_id={sess.session_id} bid={bid} "
f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='price' 누락 의심)")
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
@ -417,52 +420,18 @@ class ChatService:
tenant_id = str(item.company_id)
else:
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
# 1:1 견적유형(1=renego, 3=new_nego) → 재협상, 1:N(2=requote, 4=new_quote) → 재견적.
# agent 도 sessions.qt_type 으로 동일 판별(NegotiationContextLoader) — backend 로컬 용도.
rq_type = "재협상" if sess.qt_type in (1, 3) else "재견적"
target_price = int(sess.target_price or 0)
# 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess)
# revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어
# AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요).
# 기존 공급가(품목 기준가) — agent 가격협상_확인 인하율(discount_rate) 산출 입력.
item_price = int(item.price) if item is not None and item.price else 0
# 협상 컨텍스트(앵커가/품목가/매출액/유통코드/파트너 유형/수용률)는 더 이상 계산·전송하지 않는다 —
# agent 가 session_id 로 DB(negotiation.sessions·partner.items/suppliers·quotations)에서 직접 조회한다.
# (앵커가 박제·무할인 폴백 정책 — schedules/anchoring/docs/개발용.md §9.2 — 은 agent loader 가 승계.)
return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type,
target_price=target_price, anchor_price=anchor, partner_count=partner_count,
item_price=item_price, client_step=client_step,
target_price=target_price, client_step=client_step,
)
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
"""세션에 박제된 앵커가(anchoring_price — negodata 가 생성 시 기록)를 그대로 사용.
박제값 사용이 정상 경로다: 협상 진행 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다
("제안 당시 값" 판정의 전제 schedules/anchoring/docs/개발용.md §9.2). backend 앵커를 계산하지 않는다.
박제가 없으면(데이터 이상 사실상 발생하지 않음) 무할인 폴백 anchor=target + WARN.
이때 박제하지 않으므로 해당 세션은 앵커링 집계에서 자동 제외(EXCLUDED)된다 학습 무오염.
"""
if not target_price:
return 0
if sess.anchoring_price is not None:
return int(sess.anchoring_price)
LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
return target_price
async def _count_partners(self, sess) -> int:
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""
def _q(s):
stmt = (
select(func.count())
.select_from(sessions)
.where(sessions.quotation_id == sess.quotation_id, sessions.deleted == False) # noqa: E712
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(sessions.DBType(), DBWRType.DB_READ.value, _q)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
return 1
return int(rows[0])
async def _last_user_price(self, sess) -> Optional[int]:
"""세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None."""
def _q(s):

View File

@ -32,15 +32,17 @@ class _AnchorAgent(IAgentClient):
"""결정론적 더블: 서비스안내(오프닝) → 가격 입력 요청 → 합의 종료.
앵커보다 높은 가격이면 같은 step 반복(마지막 제시가 덮어쓰기 검증용).
수신한 ctx.anchor_price 기록해 backend 앵커 해석을 관찰한다.
(script 앵커가 보이는 테스트 관찰 편의일 실제 agent 비노출.)
앵커 해석(박제값 소비/무할인 폴백) agent DB 에서 직접 수행하도록 이관됐다
(NegotiationContextLoader agent tests/test_context_loader.py 검증). 여기 더블은
자체 앵커 상수로 수락 여부만 판정하고, backend 보내는 ctx.target_price 관찰한다.
"""
def __init__(self):
self.seen_anchors: list[int] = []
def __init__(self, anchor: int = ANCHOR):
self.anchor = anchor
self.seen_targets: list[int] = []
async def chat(self, session_id, user_input, ctx) -> AgentTurn:
self.seen_anchors.append(ctx.anchor_price)
self.seen_targets.append(ctx.target_price)
sid = session_id or "fake-session"
if user_input is None: # 오프닝(턴0)
return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내",
@ -48,10 +50,10 @@ class _AnchorAgent(IAgentClient):
input_options=["네, 시작할게요"])
if ctx.client_step == "서비스안내":
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
script=f"저희가 제안드리는 첫 목표 가격은 {ctx.anchor_price}원입니다. "
script=f"저희가 제안드리는 첫 목표 가격은 {self.anchor}원입니다. "
f"제안하실 가격을 입력해 주세요.", input_mode="price")
price = _parse_price(user_input)
if price is not None and price <= ctx.anchor_price:
if price is not None and price <= self.anchor:
return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
script=f"{price:,}원으로 합의되었습니다.", chat_end=True, outcome="success")
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
@ -159,7 +161,7 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch
assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
row = await _anchor_columns(db_engine, sid)
assert row.last_offer_price is None
assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달
assert _fake_agent.seen_targets[-1] == TARGET # backend 로컬 컨텍스트(target) 전달 확인
r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
assert r.json()["message"]["step"] == "기존가격제시"
@ -175,7 +177,8 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch
assert (row.anchoring_price, row.anchoring_value) == (ANCHOR, 10) # 박제 불변
# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ──
# ── 폴백 경로: 박제 NULL 세션도 backend 채팅 경로가 정상 동작 + 미박제 유지 ──
# (무할인 폴백 anchor=target 자체는 agent NegotiationContextLoader 가 수행/검증 — agent 테스트 소관)
async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed, _fake_agent):
sid = str(anchor_seed["sids"]["N"])
token = await _login_token(client)
@ -183,11 +186,13 @@ async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed
await _messages(client, token, sid)
r = await _send(client, token, sid, "네, 시작할게요")
assert r.json()["message"]["step"] == "기존가격제시"
assert _fake_agent.seen_anchors[-1] == TARGET # 무할인 폴백: anchor = target
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료)
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(더블 앵커 이하 → 종료)
assert r.json()["session_status"] == 3
row = await _anchor_columns(db_engine, sid)
assert (row.anchoring_price, row.anchoring_value) == (None, None) # 미박제 유지(집계 제외 조건)
assert row.bid_price == 97_000
row = await _anchor_columns(db_engine, sid)
assert row.anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
assert row.anchoring_value is None
assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작