[fix] agent: 협상 투찰가 표시≠타결가 버그 + 카드 사용 횟수 미강제 수정

1) 표시가≠투찰가 (배포 서버 협상 결과 오류)
- 카운터 제시 중 멘트의 절충/중간 변수(middle_price·target_mid_price)가 vars_for 재계산으로
  compute_counter 의 target 클램프·prev_customer 갱신과 어긋나, 화면엔 1,740,000 인데 실제로는
  1,700,000 으로 타결되던 문제 → vars_for 에서 세 변수(counter/middle/target_mid)를 pending 으로 고정
- WC-03 최후통첩 멘트가 제시 금액을 안 보여줘 수락 시 화면에 없던 target 으로 타결되던 문제 →
  멘트에 {target_price} 명시(seed init-data.sql). 운영 DB 는 별도 UPDATE 필요

2) 카드 사용 횟수 3회 초과
- quotation_settings.card_count(협상카드 사용 횟수 상한, 기본 3)가 어디서도 강제되지 않던 죽은 설정 →
  에이전트가 sessions.qt_setting_id 로 card_count 를 조회해 협상카드 재생 수를 min(선택수, card_count)로 캡
  (session.action_space_size 는 소진 판정 전용 — Q-table 은 카탈로그 전체로 별도 고정)

- 회귀 테스트: 표시가==타결가(test_card_tactics·test_p7_chat), card_count 로드(test_context_loader). 전체 153 pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-07-24 11:41:33 +09:00
parent 2541a391b7
commit 3eec80d989
8 changed files with 102 additions and 12 deletions

View File

@ -23,9 +23,16 @@ _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("qt_setting_id"),
column("deleted"),
schema="negotiation",
)
# 견적 설정 — card_count(협상 내 협상카드 사용 횟수 상한) 조회용.
_QUOTATION_SETTINGS = table(
"quotation_settings",
column("qt_setting_id"), column("card_count"), column("deleted"),
schema="quotation",
)
_ITEMS = table("items", column("item_id"), column("name"), column("price"),
column("internet_lowest_price"), column("deleted"), schema="partner")
_SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
@ -73,6 +80,12 @@ class INegoContextCRUD(ABC):
"""품목 기준가(items.price). 없으면 0."""
pass
@abstractmethod
async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
"""협상카드 사용 횟수 상한(quotation_settings.card_count) — 세션의 qt_setting_id 로 조인.
설정이 없으면 None(호출부가 상한 미적용). 이 값이 협상 중 실제로 플레이 가능한 협상카드 수를 캡한다."""
pass
@abstractmethod
async def get_item_lowest_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
"""상품 인터넷 최저가(items.internet_lowest_price — LPS 수집 대표값). 미수집이면 0.
@ -148,6 +161,29 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
try:
query = (
select(_QUOTATION_SETTINGS.c.card_count)
.select_from(
_SESSIONS.join(
_QUOTATION_SETTINGS,
_QUOTATION_SETTINGS.c.qt_setting_id == _SESSIONS.c.qt_setting_id,
)
)
.where(_SESSIONS.c.session_id == session_id,
_SESSIONS.c.deleted == False, # noqa: E712
_QUOTATION_SETTINGS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_card_count 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 get_item_lowest_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
try:
query = (

View File

@ -266,7 +266,15 @@ class ChatEngine:
if prev_customer and "input_price" in ctx:
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
if ctx.get("pending_counter_price"):
out["counter_price"] = int(ctx["pending_counter_price"])
# 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다.
# 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_counter 의
# target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
pending_i = int(ctx["pending_counter_price"])
out["counter_price"] = pending_i
out["middle_price"] = pending_i
out["target_mid_price"] = pending_i
# 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
# 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한

View File

@ -47,6 +47,7 @@ class NegotiationDbContext:
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
class NegotiationContextLoader:
@ -107,6 +108,9 @@ class NegotiationContextLoader:
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
selected_nego_cards, selected_wild_cards = selected_cards
# 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
_, card_count = await self.crud.get_card_count(s, sid)
return NegotiationDbContext(
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
target_price=target,
@ -120,6 +124,7 @@ class NegotiationContextLoader:
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
selected_nego_card_numbers=selected_nego_cards,
selected_wild_card_numbers=selected_wild_cards,
card_count=card_count,
)
try:

View File

@ -82,8 +82,12 @@ class ChatService:
selected_wild_cards = db_ctx.selected_wild_card_numbers if db_ctx else []
# 운영 DB 세션은 견적 version_id 에 묶인 카드만 사용한다. 직접 호출/데모(DB context 없음)는
# 기존 테넌트 기본 action mapping 으로 폴백해 로컬 테스트와 콘솔 데모를 유지한다.
# 협상카드 사용 횟수 상한(quotation_settings.card_count)으로 실제 플레이 가능한 카드 수를 캡한다 —
# session.action_space_size 는 카드 소진 판정(cards_total) 전용이라 여기서 줄여도 Q-table 은
# engine.action_space_size(카탈로그 전체)로 별도 고정된다. card_count 미설정(None)이면 선택 수 그대로.
card_cap = db_ctx.card_count if (db_ctx and db_ctx.card_count and db_ctx.card_count > 0) else None
action_space_size = (
min(len(selected_nego_cards), engine.action_space_size)
min(len(selected_nego_cards), engine.action_space_size, *( [card_cap] if card_cap else [] ))
if db_ctx is not None
else engine.action_space_size
)

View File

@ -169,13 +169,21 @@ def test_price_confirm_script_renders_raise_correctly():
# ---- vars_for 전술 변수 치환 ---------------------------------------------------
def test_vars_for_supplies_tactic_variables():
eng = _engine()
s = _session(input_price=10300, prev_customer_price=10000, pending_counter_price=10100)
v = eng.vars_for(s)
assert v["prev_partner_price"] == 10300
assert v["prev_customer_price"] == 10000
assert v["target_mid_price"] == 10050 # (10000+10100)/2
assert v["middle_price"] == 10150 # (10000+10300)/2
assert v["counter_price"] == 10100
# 카운터 미제시(정보성): 절충/중간 변수는 원 계산값.
s0 = _session(input_price=10300, prev_customer_price=10000) # anchor=10000, target=10100 (기본)
v0 = eng.vars_for(s0)
assert v0["prev_partner_price"] == 10300
assert v0["prev_customer_price"] == 10000
assert v0["target_mid_price"] == 10050 # (anchor 10000 + target 10100)/2
assert v0["middle_price"] == 10150 # (prev_customer 10000 + input 10300)/2
# 카운터 제시 중: 표시 제시가(middle/target_mid/counter) == 타결가(pending) 로 고정.
# 회귀(표시가≠투찰가): 예전엔 middle_price 가 재계산값 10150 을 표시하면서 10100 으로 타결됐다.
s1 = _session(input_price=10300, prev_customer_price=10000, pending_counter_price=10100)
v1 = eng.vars_for(s1)
assert v1["counter_price"] == 10100
assert v1["middle_price"] == 10100 # 재계산 10150 이 아니라 pending
assert v1["target_mid_price"] == 10100
# ---- ⑤⑥ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------

View File

@ -195,6 +195,9 @@ async def test_loader_with_crud_double(db_engine):
async def get_item_lowest_price(self, cdb, item_id):
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
async def get_card_count(self, cdb, session_id):
return ErrorType.SUCCESS, 3 # 협상카드 사용 횟수 상한(quotation_settings.card_count)
async def get_supplier_total_revenue(self, cdb, supplier_id):
return ErrorType.SUCCESS, 12_000_000.0
@ -223,6 +226,7 @@ async def test_loader_with_crud_double(db_engine):
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.item_price == 7000
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인
assert ctx.partner_name == "테스트협력사"
assert ctx.product_name == "테스트상품"
assert ctx.revenue_amount == 12_000_000.0

View File

@ -162,6 +162,31 @@ def test_card_id_fixed_mapping_and_selection_mask():
assert ChatService._selection_mask(eng, session) is None
def test_counter_display_price_equals_settlement():
"""회귀(표시가≠투찰가): 카운터 제시 중 멘트에 보이는 절충/중간 변수
(middle_price·target_mid_price)는 수락 시 타결가(pending_counter_price)와 정확히 일치해야 한다.
버그: WC-05(중간값 절충)에서 compute_counter 는 target 클램프·prev_customer 갱신으로 1,700,000 을
pending 으로 적재하는데, vars_for 가 {middle_price} 를 재계산해 1,740,000 으로 표시 → 화면엔
1,740,000 인데 실제로는 1,700,000 으로 투찰되던 문제. pending 으로 고정해 표시가==타결가."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
repo = ScriptRepository(cfg, _TENANTS_DIR)
engine = ChatEngine(repo, rq_type="재협상")
session = ChatSession(
session_id="00000000-0000-0000-0000-000000000009",
tenant_id="imarketkorea", company_id="imarketkorea", action_space_size=0,
context={
"anchor_price": 2000000, "target_price": 1700000, "input_price": 1780000,
"prev_customer_price": 1700000, # 종결 전술이 counter 로 덮어쓴 상태
"pending_counter_price": 1700000, # compute_counter 의 target 클램프 결과(실제 타결가)
},
)
v = engine.vars_for(session)
assert v["counter_price"] == 1700000
assert v["middle_price"] == 1700000 # 재계산값 1,740,000 이 아니라 pending
assert v["target_mid_price"] == 1700000
def test_default_1pct_wildcard_still_runs_without_selected_wildcard():
"""1% 인하는 기본 제공 카드라 DB 견적에서 와일드카드를 선택하지 않아도 발동한다."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")

View File

@ -188,12 +188,12 @@ SELECT * FROM (VALUES
-- 3. 최종 통보 — tone 1(강경) · strategy 1(경쟁)
(NULL::uuid, '최종 통보', 'WC-03',
'합리적인 기준에 근거하여 목표 가격을 제안 드렸으나, 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다.
'합리적인 기준에 근거하여 당사의 최종 목표 가격 {target_price}원(VAT별도)을 제안 드립니다. 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다.
이번 협상이 결렬되는 경우 우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.
귀사와 앞으로 보다 많은 협력 기회를 만들어 나가기를 희망합니다. 다시 한번 고민하신 후 제안 가격을 입력해 주시기 바랍니다.',
'[{"type": "paragraph", "children": [{"text": "합리적인 기준에 근거하여 목표 가격을 제안 드렸으나, 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "이번 협상이 결렬되는 경우 "}, {"text": "우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "귀사와 앞으로 보다 많은 협력 기회를 만들어 나가기를 희망합니다. 다시 한번 고민하신 후 제안 가격을 입력해 주시기 바랍니다."}]}]'::jsonb,
본 금액을 수락하시면 해당 가격으로 최종 확정되며, 재검토가 필요하시면 다른 제안 가격을 입력해 주시기 바랍니다.',
'[{"type": "paragraph", "children": [{"text": "합리적인 기준에 근거하여 당사의 최종 목표 가격 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 제안 드립니다. 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "이번 협상이 결렬되는 경우 "}, {"text": "우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "본 금액을 수락하시면 해당 가격으로 최종 확정되며, 재검토가 필요하시면 다른 제안 가격을 입력해 주시기 바랍니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 1, 1),
-- 4. 단계적 인하 제안 — tone 1(강경) · strategy 1(경쟁)