[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:
parent
6f5d69b5cb
commit
c56cf8e3af
@ -62,6 +62,16 @@ class 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]:
|
||||
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||||
|
||||
@ -109,8 +119,9 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||||
|
||||
변수 공통 유효조건 (전부 만족해야 발동):
|
||||
· 값 ≤ 목표가 — 구매자는 목표가를 넘겨 부르지 않는다. 넘으면 클램프가 아니라 **미발동**
|
||||
(목표가로 깎아 부르면 "중간에서 만나자"면서 목표가를 부르는 모순이 된다)
|
||||
· 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라
|
||||
**미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다).
|
||||
상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가.
|
||||
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||||
· 값 ≥ 당사 직전 제안 — 역행 금지(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)
|
||||
if not value or value <= 0:
|
||||
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||
if value > target:
|
||||
return None # 목표가 초과 — 이 변수는 지금 못 쓴다
|
||||
if value > settle_ceiling(context):
|
||||
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||
offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
|
||||
if offer >= price:
|
||||
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
|
||||
|
||||
@ -23,6 +23,7 @@ _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("done_ceiling_price"), # 타결 상한가 — 견적 생성 시 박제(목표가×(1+타결상한율))
|
||||
column("qt_setting_id"),
|
||||
column("deleted"),
|
||||
schema="negotiation",
|
||||
@ -96,7 +97,7 @@ _SUPPLIER_ITEMS = table(
|
||||
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."""
|
||||
"""세션 행 (qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id). 없으면 None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -163,6 +164,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
try:
|
||||
query = (
|
||||
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)
|
||||
.where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
|
||||
@ -10,7 +10,9 @@ import re
|
||||
from dataclasses import dataclass, field
|
||||
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
|
||||
|
||||
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
||||
@ -237,11 +239,13 @@ class ChatEngine:
|
||||
or none_playable
|
||||
)
|
||||
if exhausted:
|
||||
target = ctx.get("target_price", 0)
|
||||
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
||||
# 상한 이내면 타결한다(IMK: 기존 단가보다 인하됐는데 결렬되던 케이스).
|
||||
ceiling = settle_ceiling(ctx)
|
||||
if not ctx.get("closing_played"):
|
||||
ctx["force_closing"] = True
|
||||
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
|
||||
elif cond == "default":
|
||||
ok = True
|
||||
@ -384,13 +388,14 @@ class ChatEngine:
|
||||
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
|
||||
if not step_key or step_key not in self.scripts:
|
||||
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
||||
# 가드레일(최후 방어선): 구매자 대리는 목표가 초과로 절대 타결하지 않는다.
|
||||
# 카운터 클램프·종결 규칙이 정상이면 도달하지 않지만, 스크립트 편집 실수 등으로
|
||||
# 성공 스텝에 초과가로 진입하면 결렬로 강제 전환한다. (재협상 흐름 한정)
|
||||
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
|
||||
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
|
||||
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로(IMK: 기존 단가보다 인하됐는데
|
||||
# 결렬되던 케이스) 여기서 뒤집으면 안 된다. 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
||||
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
|
||||
ctx = session.context
|
||||
target = ctx.get("target_price") or 0
|
||||
if target > 0 and ctx.get("input_price", 0) > target:
|
||||
ceiling = settle_ceiling(ctx)
|
||||
if ceiling > 0 and ctx.get("input_price", 0) > ceiling:
|
||||
step_key = "협상실패"
|
||||
node = self.scripts[step_key]
|
||||
session.step = step_key
|
||||
|
||||
@ -39,6 +39,7 @@ class NegotiationDbContext:
|
||||
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
|
||||
target_price: int # 목표 매입가(원) — sessions.target_price
|
||||
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
|
||||
done_ceiling_price: int # 타결 상한가 — sessions.done_ceiling_price(생성 시 박제). 없으면 target
|
||||
item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
|
||||
item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
|
||||
labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
|
||||
@ -73,8 +74,10 @@ class NegotiationContextLoader:
|
||||
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
|
||||
qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id = row
|
||||
target = int(target_price or 0)
|
||||
# 타결 상한가: 견적 생성 시 박제(목표가×(1+타결상한율)). 옛 세션은 NULL → 목표가로 폴백.
|
||||
ceiling = int(done_ceiling_price or 0) or target
|
||||
|
||||
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
||||
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
|
||||
@ -134,6 +137,7 @@ class NegotiationContextLoader:
|
||||
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
|
||||
target_price=target,
|
||||
anchor_price=anchor,
|
||||
done_ceiling_price=ceiling,
|
||||
item_price=item_price,
|
||||
item_price_label=item_price_label,
|
||||
labels=labels,
|
||||
|
||||
@ -110,6 +110,9 @@ class ChatService:
|
||||
# 목표가/앵커링가: 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,
|
||||
# 타결 상한가(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": (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),
|
||||
|
||||
@ -186,8 +186,9 @@ async def test_loader_with_crud_double(db_engine):
|
||||
|
||||
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())
|
||||
# (qt_type, target, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id)
|
||||
# — 재견적(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):
|
||||
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전
|
||||
@ -230,6 +231,7 @@ async def test_loader_with_crud_double(db_engine):
|
||||
assert ctx.rq_type == "재견적" # qt_type=2(1:N)
|
||||
assert ctx.target_price == 50000
|
||||
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
|
||||
assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%)
|
||||
assert ctx.item_price == 7000
|
||||
assert ctx.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지
|
||||
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
|
||||
|
||||
Loading…
Reference in New Issue
Block a user