[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 # 가격 흔적 기록은 정상 동작