diff --git a/agent/negotiation/cards/domain/tactics.py b/agent/negotiation/cards/domain/tactics.py index cabb29a..add1c62 100644 --- a/agent/negotiation/cards/domain/tactics.py +++ b/agent/negotiation/cards/domain/tactics.py @@ -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 # 제시가가 이미 그 값 이하 → 부를 이유 없음 diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index d9a9b83..ad5826c 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -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) diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 12ec178..1c2af24 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -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 diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py index 8f140e6..d25e5fa 100644 --- a/agent/negotiation/chat/service/negotiation_context_loader.py +++ b/agent/negotiation/chat/service/negotiation_context_loader.py @@ -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, diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index b792686..06d3c3f 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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), diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py index cdb729d..f42c603 100644 --- a/agent/tests/test_context_loader.py +++ b/agent/tests/test_context_loader.py @@ -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": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지