[feat] agent: 타결선을 목표가 → 타결 상한가로 — 목표가 초과 낙찰 허용(IMK 0803 ②)

목표가를 1원이라도 넘으면 결렬되던 탓에, 기존 단가보다 인하됐는데도 결렬되는 케이스가 있었다
(EST-202607-973E: 기존 17,500 / 목표 16,980 / 최종 17,300). 견적 생성 시 세션에 박제해 두던
done_ceiling_price(목표가×(1+타결상한율), 세팅 기본 +5%)를 협상 엔진이 실제로 읽게 배선했다.

- tactics: settle_ceiling() 신설 — 타결선 판정을 한 곳으로. 박제가 없는 옛 세션·데모는 목표가 폴백
- 카드 제안가 유효조건의 상한도 목표가 → 타결 상한가 (받아줄 수 있는 금액까지는 부를 수 있어야 함)
- _render 가드레일이 목표가 초과 성공을 결렬로 되돌리고 있어 같이 상한 기준으로 교정 —
  타결 판정만 고치면 이 가드에서 다시 뒤집혀, 배선했는데도 결렬로 떨어졌다
- crud/loader/세션 컨텍스트에 done_ceiling_price 적재

검증(목표가 956,580 · 상한 1,004,410): 950,000·1,000,000·1,004,410 타결 / 1,004,500·1,010,000 결렬.
agent 테스트 178건 통과.
This commit is contained in:
Mina Choi 2026-08-05 14:10:38 +09:00
parent 6f5d69b5cb
commit c56cf8e3af
6 changed files with 43 additions and 16 deletions

View File

@ -62,6 +62,16 @@ class CardSpec:
HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스텀)의 폴백 — 기존 동작(설득만) 유지 HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스텀)의 폴백 — 기존 동작(설득만) 유지
def settle_ceiling(context: Dict[str, Any]) -> float:
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)). 목표가를 조금
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값이다(IMK: 기존 17,500 / 목표 16,980 /
최종 17,300 이 결렬되던 케이스). 박제가 없는 옛 세션·데모는 목표가로 폴백 — 종전 동작 유지.
"""
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
def parse_offer_variable(script: Optional[str]) -> Optional[str]: def parse_offer_variable(script: Optional[str]) -> Optional[str]:
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드). """스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
@ -109,8 +119,9 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다. """카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
변수 공통 유효조건 (전부 만족해야 발동): 변수 공통 유효조건 (전부 만족해야 발동):
· 값 ≤ 목표가 — 구매자는 목표가를 넘겨 부르지 않는다. 넘으면 클램프가 아니라 **미발동** · 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라
(목표가로 깎아 부르면 "중간에서 만나자"면서 목표가를 부르는 모순이 된다) **미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다).
상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가.
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다 · 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
· 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이 · 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이
좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다 좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
@ -132,8 +143,8 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
value = calc(target, anchor, price, prev_customer) value = calc(target, anchor, price, prev_customer)
if not value or value <= 0: if not value or value <= 0:
return None # 재료 부족(앵커 미박제·직전 제안 없음) return None # 재료 부족(앵커 미박제·직전 제안 없음)
if value > target: if value > settle_ceiling(context):
return None # 목표가 초과 — 이 변수는 지금 못 쓴다 return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일 offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
if offer >= price: if offer >= price:
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음 return None # 제시가가 이미 그 값 이하 → 부를 이유 없음

View File

@ -23,6 +23,7 @@ _SESSIONS = table(
"sessions", "sessions",
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"), column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
column("qt_type"), column("target_price"), column("anchoring_price"), column("qt_type"), column("target_price"), column("anchoring_price"),
column("done_ceiling_price"), # 타결 상한가 — 견적 생성 시 박제(목표가×(1+타결상한율))
column("qt_setting_id"), column("qt_setting_id"),
column("deleted"), column("deleted"),
schema="negotiation", schema="negotiation",
@ -96,7 +97,7 @@ _SUPPLIER_ITEMS = table(
class INegoContextCRUD(ABC): class INegoContextCRUD(ABC):
@abstractmethod @abstractmethod
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: 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.""" """세션 행 (qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id). 없으면 None."""
pass pass
@abstractmethod @abstractmethod
@ -163,6 +164,7 @@ class NegoContextCRUD(INegoContextCRUD):
try: try:
query = ( query = (
select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price, select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price,
_SESSIONS.c.done_ceiling_price,
_SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id) _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 .where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712
.limit(1) .limit(1)

View File

@ -10,7 +10,9 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from negotiation.cards.domain.tactics import available, compute_offer, is_played, mark_played, playable, spec_from_context from negotiation.cards.domain.tactics import (
available, compute_offer, is_played, mark_played, playable, settle_ceiling, spec_from_context,
)
from negotiation.chat.service.script_repository import ScriptRepository from negotiation.chat.service.script_repository import ScriptRepository
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds) MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
@ -237,11 +239,13 @@ class ChatEngine:
or none_playable or none_playable
) )
if exhausted: if exhausted:
target = ctx.get("target_price", 0) # 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
# 상한 이내면 타결한다(IMK: 기존 단가보다 인하됐는데 결렬되던 케이스).
ceiling = settle_ceiling(ctx)
if not ctx.get("closing_played"): if not ctx.get("closing_played"):
ctx["force_closing"] = True ctx["force_closing"] = True
return "가격협상" return "가격협상"
return "협상완료" if (target > 0 and price <= target) else c.get("next") return "협상완료" if (ceiling > 0 and price <= ceiling) else c.get("next")
ok = False ok = False
elif cond == "default": elif cond == "default":
ok = True ok = True
@ -384,13 +388,14 @@ class ChatEngine:
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView: def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
if not step_key or step_key not in self.scripts: if not step_key or step_key not in self.scripts:
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}") return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
# 가드레일(최후 방어선): 구매자 대리는 목표가 초과로 절대 타결하지 않는다. # 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
# 카운터 클램프·종결 규칙이 정상이면 도달하지 않지만, 스크립트 편집 실수 등으로 # 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
# 성공 스텝에 초과가로 진입하면 결렬로 강제 전환한다. (재협상 흐름 한정) # 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로(IMK: 기존 단가보다 인하됐는데
# 결렬되던 케이스) 여기서 뒤집으면 안 된다. 상한까지 넘은 경우만 결렬로 강제 전환한다.
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상": if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
ctx = session.context ctx = session.context
target = ctx.get("target_price") or 0 ceiling = settle_ceiling(ctx)
if target > 0 and ctx.get("input_price", 0) > target: if ceiling > 0 and ctx.get("input_price", 0) > ceiling:
step_key = "협상실패" step_key = "협상실패"
node = self.scripts[step_key] node = self.scripts[step_key]
session.step = step_key session.step = step_key

View File

@ -39,6 +39,7 @@ class NegotiationDbContext:
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별 rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
target_price: int # 목표 매입가(원) — sessions.target_price target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백) anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
done_ceiling_price: int # 타결 상한가 — sessions.done_ceiling_price(생성 시 박제). 없으면 target
item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0 item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값) item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용 labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
@ -73,8 +74,10 @@ class NegotiationContextLoader:
err, row = await self.crud.get_session_row(s, sid) err, row = await self.crud.get_session_row(s, sid)
if err != ErrorType.SUCCESS or row is None: if err != ErrorType.SUCCESS or row is None:
return None return None
qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id = row
target = int(target_price or 0) target = int(target_price or 0)
# 타결 상한가: 견적 생성 시 박제(목표가×(1+타결상한율)). 옛 세션은 NULL → 목표가로 폴백.
ceiling = int(done_ceiling_price or 0) or target
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. # 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상 # 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
@ -134,6 +137,7 @@ class NegotiationContextLoader:
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적", rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
target_price=target, target_price=target,
anchor_price=anchor, anchor_price=anchor,
done_ceiling_price=ceiling,
item_price=item_price, item_price=item_price,
item_price_label=item_price_label, item_price_label=item_price_label,
labels=labels, labels=labels,

View File

@ -110,6 +110,9 @@ class ChatService:
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader). # 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE, "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, "target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
# 타결 상한가(sessions.done_ceiling_price 박제) — 타결 판정선이자 카드 제안가 상한.
# 목표가를 조금 넘어도 이 이하면 타결한다. 미박제/데모는 목표가와 같다.
"done_ceiling_price": db_ctx.done_ceiling_price if db_ctx else _DEFAULT_TARGET_PRICE,
# 협력사명/상품명 — 카드 스크립트 {partner_name}·{product_name} 치환용(loader). 없으면 폴백. # 협력사명/상품명 — 카드 스크립트 {partner_name}·{product_name} 치환용(loader). 없으면 폴백.
"partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME), "partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME),
"product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME), "product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME),

View File

@ -186,8 +186,9 @@ async def test_loader_with_crud_double(db_engine):
class _FakeCRUD(INegoContextCRUD): class _FakeCRUD(INegoContextCRUD):
async def get_session_row(self, cdb, session_id): async def get_session_row(self, cdb, session_id):
# (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제 # (qt_type, target, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id)
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) # — 재견적(2)·앵커 미박제·타결상한 52,500(목표가 +5%)
return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
async def get_item_baseline(self, cdb, item_id): async def get_item_baseline(self, cdb, item_id):
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전 # 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전
@ -230,6 +231,7 @@ async def test_loader_with_crud_double(db_engine):
assert ctx.rq_type == "재견적" # qt_type=2(1:N) assert ctx.rq_type == "재견적" # qt_type=2(1:N)
assert ctx.target_price == 50000 assert ctx.target_price == 50000
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%)
assert ctx.item_price == 7000 assert ctx.item_price == 7000
assert ctx.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지 assert ctx.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지 assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지