[feat] agent·negodata: 협상카드 전술 데이터화(tactic JSONB·스크립트 파싱) + 발동규칙 정비
IMK QA 2건(BB9A 카드 중복·8AB0 중간값 오계산)의 근본 원인이 전술 하드코딩(_TACTICS 번호 매칭)이라 전술을 데이터로 옮기고, 발동을 유효성 검사로 바꿨다. 전술 정본 = 카드 스크립트의 마지막 가격 변수(파싱), 문장으로 알 수 없는 운영 규칙(closing·min_round)만 card.*.tactic JSONB. 세션 시작 시 card_specs 스냅샷 박제. 발동 유효성(하나라도 걸리면 그 라운드 미발동 — 클램프 폐지): 목표가 초과 / 협력사 제시가 이상 / 당사 직전 제안 미만(역행 금지, IMK 논의) / 재료 결측 / 필수 변수 결측(시장가 카드 requires — 토큰 노출 방지) / 이미 쓴 카드(played_card_numbers 공용 이력) - agent: 와일드=비종결·종결=전용 풀 분리(같은 카드 2회 구조적 차단), 발동 시 자기 제안가 기록(절충가 수렴), 진입 존 프로브(빈 덱 재사용 교착 방지), 낼 카드 전무 시 소진→종결, 무효 금액 카드는 설득 폴백도 금지(playable), 에디터 anchor_price 별칭 등록, 에러 재렌더 변수 치환 - backend: 카드 사용 기록을 step 휴리스틱→번호 prefix 판정(종결 발동 card:null 누락 해소) - negodata: 카드 상세 "협상 전술" 섹션(제시 가격 파싱 표시·종결 전용·최소 라운드) + tactic API 배선 - postgres-init: tactic 컬럼·시드(WC-03/05 closing, WC-04 min_round 2), 멱등 alter 로 dev 정본화 (번호 WC-0x 정규화, WC-01·03·NGC-010 구멘트 교체, WC-05 변수 middle_price 교정) 검증: agent 178 통과 · 시나리오 하네스 14케이스(BB9A·8AB0·역행 실수치 재현) · 랜덤 퍼즈 50협상 불변식 위반 0 (불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
This commit is contained in:
parent
71ef28110b
commit
6f5d69b5cb
@ -1,120 +1,186 @@
|
||||
"""협상카드 전술 레지스트리 — "멘트 카드 → 전술 카드" 승격 (가격 행동 실행 계층).
|
||||
"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
|
||||
|
||||
카드 멘트에 이미 설계된 카운터 가격 제시({target_price}·{middle_price} 등)를 시스템 상태로
|
||||
실행한다: 카드가 카운터가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면
|
||||
그 가격으로 즉시 타결된다(기존 wild_card_1pct 의 offer_1pct 패턴을 일반화).
|
||||
카드 멘트가 제시하는 가격({target_price}·{middle_price} 등)을 파싱해 시스템 상태로 실행한다:
|
||||
카드가 제안가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면 그 가격으로 타결된다.
|
||||
|
||||
원칙:
|
||||
- 구매자(갑) 대리이므로 카운터는 항상 min(counter, target_price) 클램프 — 목표가 초과 제시 금지.
|
||||
- 협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(HOLD 강등, 순수 설득).
|
||||
- 미등록 카드번호(테넌트 데모 NGC-B*, 회사 커스텀 COMP-* 등)는 HOLD 폴백 → 기존 동작 그대로.
|
||||
세 계층으로 나뉜다.
|
||||
1) 스크립트 파싱 — 이 카드가 부를 금액이 무엇인지 (parse_offer_variable)
|
||||
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
||||
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
||||
|
||||
전술 정본은 이 코드 레지스트리다(v1). negodata 카드 편집은 멘트만 담당하고, 전술을 negodata
|
||||
에서 편집할 필요가 생기면 v2 에서 card.nego_cards 컬럼로 승격해 "DB 우선, 코드 폴백"으로 바꾼다.
|
||||
유효 조건을 카드가 아니라 '변수'에 붙이는 이유: 절충가가 목표가를 넘을 수 있는 것은
|
||||
(직전제안+제시가)/2 라는 계산식의 성질이지 특정 카드의 성질이 아니다. 같은 변수를 쓰는
|
||||
카드가 늘어도 규칙은 한 곳이고, 새 변수는 이 표에 한 줄 추가하면 코드 분기 없이 끝난다.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
# 제안가 변수 — 우리가 새로 부르는 금액. 값은 (계산식, 재료 설명).
|
||||
# 여기 없는 치환 변수({prev_partner_price}·{internet_lowest_price} 등)는 읽어주기 전용이라
|
||||
# 제안가가 되지 않는다 — 과거값·외부값을 협력사에게 "수락하라"고 내밀 수 없기 때문.
|
||||
OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float]]] = {
|
||||
# (target, anchor, price, prev_customer) -> 제안가 | None(재료 없음)
|
||||
"target_price": lambda target, anchor, price, prev: target,
|
||||
"anchoring_price": lambda target, anchor, price, prev: anchor or None,
|
||||
# negodata 카드 에디터 칩 표기(variables.ts) — DB 시드 표기(anchoring_price)와 같은 값의 별칭.
|
||||
"anchor_price": lambda target, anchor, price, prev: anchor or None,
|
||||
"target_mid_price": lambda target, anchor, price, prev: (anchor + target) / 2 if anchor else None,
|
||||
"middle_price": lambda target, anchor, price, prev: (prev + price) / 2 if prev else None,
|
||||
}
|
||||
|
||||
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
|
||||
|
||||
|
||||
class PriceAction(str, Enum):
|
||||
HOLD = "hold" # 카운터 없음 — 재제안 요구(순수 설득, 기존 동작)
|
||||
COUNTER_TARGET = "counter_target" # 목표가 제시
|
||||
COUNTER_ANCHOR = "counter_anchor" # 앵커가 제시 (예산 상한 프레이밍)
|
||||
COUNTER_TARGET_MID = "counter_target_mid" # (anchor+target)/2 — 시드 {target_mid_price}
|
||||
COUNTER_MID = "counter_mid" # (갑 직전 포지션+협력사 제시가)/2 — 시드 {middle_price}
|
||||
ONE_PCT = "one_pct" # 제시가 1% 인하 (기존 offer_1pct)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TacticSpec:
|
||||
"""카드 1장의 전술 명세.
|
||||
|
||||
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
|
||||
max_price_ratio: input_price ≤ anchor×ratio 일 때만 발동 (None=무제한).
|
||||
closing: 종결 국면(라운드 만료·카드 소진) 우선 전술.
|
||||
"""
|
||||
|
||||
price_action: PriceAction = PriceAction.HOLD
|
||||
min_round: int = 1
|
||||
max_price_ratio: Optional[float] = None
|
||||
closing: bool = False
|
||||
|
||||
|
||||
_DEFAULT = TacticSpec() # HOLD — 미등록 카드 폴백
|
||||
|
||||
# 카드번호 → 전술. 시드(init-data.sql) 멘트의 가격 변수와 1:1 정합.
|
||||
# NGC-001~006: 순수 설득(경쟁 압박/승인 핑계/관계/명분/공정성/TCO) — 가격 변수 없음.
|
||||
# NGC-008: {internet_lowest_price} 인용이나 데이터 소스 미보유 → v1 HOLD (소스 확보 시 승격).
|
||||
_TACTICS: Dict[str, TacticSpec] = {
|
||||
"NGC-007": TacticSpec(PriceAction.COUNTER_ANCHOR), # 예산 상한 안내
|
||||
"NGC-009": TacticSpec(PriceAction.COUNTER_TARGET), # 조건부 가격 조정
|
||||
"NGC-010": TacticSpec(PriceAction.COUNTER_TARGET), # 향후 거래 연계
|
||||
"NGC-011": TacticSpec(PriceAction.COUNTER_TARGET), # 양보 가치 강조
|
||||
"WC-01": TacticSpec(PriceAction.COUNTER_TARGET, min_round=1), # 목표가 선제안
|
||||
"WC-02": TacticSpec(PriceAction.COUNTER_TARGET_MID), # 역제안가 제시
|
||||
"WC-03": TacticSpec(PriceAction.COUNTER_TARGET, closing=True), # 최종 통보(최후통첩)
|
||||
"WC-04": TacticSpec(PriceAction.COUNTER_TARGET, min_round=2), # 단계적 인하 제안
|
||||
"WC-05": TacticSpec(PriceAction.COUNTER_MID, closing=True), # 중간값 절충(종결)
|
||||
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
||||
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
||||
# {internet_lowest_price} 토큰이 원형 노출된다(vars_for 가 미수집이면 키를 안 만드는 것과 짝).
|
||||
# 견적 생성 화면 게이팅(useCardGating)이 1차 방어, 여기가 2차(런타임) 방어다.
|
||||
_CONTEXT_REQUIRED_VARIABLES = {
|
||||
"internet_lowest_price": "internet_lowest_price",
|
||||
"internet_min_price": "internet_lowest_price",
|
||||
}
|
||||
|
||||
|
||||
def tactic_for(card_number: Optional[str]) -> TacticSpec:
|
||||
"""카드번호의 전술. 미등록/None 은 HOLD(기존 동작)."""
|
||||
return _TACTICS.get(str(card_number), _DEFAULT) if card_number else _DEFAULT
|
||||
@dataclass(frozen=True)
|
||||
class CardSpec:
|
||||
"""카드 1장의 전술. 스크립트 파싱 결과 + tactic JSONB 를 합친 값.
|
||||
|
||||
|
||||
def tactic_available(spec: TacticSpec, context: Dict[str, Any]) -> bool:
|
||||
"""발동 조건 평가 — action space 마스킹용. HOLD(설득)는 언제나 가능."""
|
||||
if spec.price_action is PriceAction.HOLD:
|
||||
return True
|
||||
rnd = int(context.get("round") or 0)
|
||||
if rnd < spec.min_round:
|
||||
return False
|
||||
if spec.max_price_ratio is not None:
|
||||
price = float(context.get("input_price") or 0)
|
||||
anchor = float(context.get("anchor_price") or 0)
|
||||
if anchor > 0 and price > anchor * spec.max_price_ratio:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_counter(spec: TacticSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||
"""전술의 카운터 제시가 계산 (결정론).
|
||||
|
||||
- 항상 min(counter, target) 클램프 — 구매자는 목표가 초과로 제시하지 않는다.
|
||||
- counter ≥ 협력사 제시가(input_price)면 카운터가 무의미(이미 더 싸게 제시받음) → None.
|
||||
- 필요한 컨텍스트(target/anchor/제시가)가 없으면 None → 호출부가 HOLD 로 강등.
|
||||
offer_variable: 이 카드가 제시할 금액의 변수명. None 이면 순수 설득 카드(HOLD).
|
||||
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
|
||||
closing: 종결 국면 전용 — 라운드 상한·카드 소진 시의 마지막 한 방으로만 쓴다.
|
||||
requires: 스크립트가 인용한 세션-의존 변수의 컨텍스트 키 — 값이 없으면 미발동(토큰 노출 방지).
|
||||
"""
|
||||
action = spec.price_action
|
||||
if action is PriceAction.HOLD:
|
||||
return None
|
||||
|
||||
offer_variable: Optional[str] = None
|
||||
min_round: int = 1
|
||||
closing: bool = False
|
||||
requires: tuple = ()
|
||||
|
||||
|
||||
HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스텀)의 폴백 — 기존 동작(설득만) 유지
|
||||
|
||||
|
||||
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
||||
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||||
|
||||
제안가 변수가 여럿이면 **마지막에 등장하는 것**이 제안가다 — 카드 문장은 배경을 먼저 깔고
|
||||
(예: "당초 검토한 적정가는 {anchoring_price}원이었으나") 실제 제안을 마지막에 하기 때문이다
|
||||
(예: "이에 {target_price}원으로 조정하여 제안 드립니다").
|
||||
"""
|
||||
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def build_card_spec(script: Optional[str], tactic: Optional[dict] = None) -> CardSpec:
|
||||
"""스크립트 + tactic JSONB → CardSpec. tactic 이 비어 있으면 전부 기본값."""
|
||||
t = tactic or {}
|
||||
cited = {m.group(1) for m in _TOKEN_RE.finditer(script or "")}
|
||||
return CardSpec(
|
||||
# 파싱이 정본 카드 전부를 맞히므로 offer_variable 은 예외 카드용 override 로만 둔다.
|
||||
offer_variable=t.get("offer_variable") or parse_offer_variable(script),
|
||||
min_round=int(t.get("min_round") or 1),
|
||||
closing=bool(t.get("closing")),
|
||||
requires=tuple(sorted({_CONTEXT_REQUIRED_VARIABLES[v] for v in cited if v in _CONTEXT_REQUIRED_VARIABLES})),
|
||||
)
|
||||
|
||||
|
||||
def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpec:
|
||||
"""세션 컨텍스트에 적재된 카드 스펙(card_specs)에서 꺼낸다. 없으면 HOLD 폴백.
|
||||
|
||||
스펙은 협상 시작 시 1회 적재된다(negotiation_context_loader) — 진행 중인 협상은
|
||||
카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다.
|
||||
"""
|
||||
if not number:
|
||||
return HOLD
|
||||
raw = (context.get("card_specs") or {}).get(str(number))
|
||||
if not raw:
|
||||
return HOLD
|
||||
return CardSpec(
|
||||
offer_variable=raw.get("offer_variable"),
|
||||
min_round=int(raw.get("min_round") or 1),
|
||||
closing=bool(raw.get("closing")),
|
||||
requires=tuple(raw.get("requires") or ()),
|
||||
)
|
||||
|
||||
|
||||
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||||
|
||||
변수 공통 유효조건 (전부 만족해야 발동):
|
||||
· 값 ≤ 목표가 — 구매자는 목표가를 넘겨 부르지 않는다. 넘으면 클램프가 아니라 **미발동**
|
||||
(목표가로 깎아 부르면 "중간에서 만나자"면서 목표가를 부르는 모순이 된다)
|
||||
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||||
· 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이
|
||||
좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
||||
"""
|
||||
variable = spec.offer_variable
|
||||
if not variable:
|
||||
return None # 설득 카드 — 제시할 금액 없음
|
||||
calc = OFFER_VARIABLES.get(variable)
|
||||
if calc is None:
|
||||
return None # 미등록 변수(오타·구버전 카드)
|
||||
target = float(context.get("target_price") or 0)
|
||||
anchor = float(context.get("anchor_price") or 0)
|
||||
price = float(context.get("input_price") or 0)
|
||||
# 갑의 직전 포지션. 첫 카운터 전에는 앵커가 갑의 포지션이다.
|
||||
prev_customer = float(context.get("prev_customer_price") or anchor or 0)
|
||||
if target <= 0 or price <= 0:
|
||||
return None
|
||||
return None # 목표가·제시가 없이는 어떤 변수도 판정 불가
|
||||
|
||||
if action is PriceAction.COUNTER_TARGET:
|
||||
counter = target
|
||||
elif action is PriceAction.COUNTER_ANCHOR:
|
||||
counter = anchor
|
||||
elif action is PriceAction.COUNTER_TARGET_MID:
|
||||
counter = (anchor + target) / 2 if anchor > 0 else target
|
||||
elif action is PriceAction.COUNTER_MID:
|
||||
# 갑의 직전 포지션(직전 카운터). 첫 카운터 전에는 앵커가 갑의 포지션이다.
|
||||
prev_customer = float(context.get("prev_customer_price") or anchor or target)
|
||||
counter = (prev_customer + price) / 2
|
||||
elif action is PriceAction.ONE_PCT:
|
||||
counter = price * 0.99
|
||||
else:
|
||||
return None
|
||||
value = calc(target, anchor, price, prev_customer)
|
||||
if not value or value <= 0:
|
||||
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||
if value > target:
|
||||
return None # 목표가 초과 — 이 변수는 지금 못 쓴다
|
||||
offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
|
||||
if offer >= price:
|
||||
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
|
||||
if prev_customer and offer < prev_customer:
|
||||
return None # 역행 금지 — 한번 부른 금액 아래로 되돌아가지 않는다(같은 금액 재제시는 허용)
|
||||
return offer
|
||||
|
||||
if counter <= 0:
|
||||
return None
|
||||
counter = min(counter, target) # 목표가 초과 제시 금지 (가드레일)
|
||||
counter_i = int(counter / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일(IMK 요청)
|
||||
if counter_i >= price:
|
||||
return None # 제시가가 이미 카운터 이하 → 카운터 무의미
|
||||
return counter_i
|
||||
|
||||
def available(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
|
||||
"""지금 이 카드를 꺼낼 수 있는지 — 금액과 무관한 조건들.
|
||||
|
||||
· 이미 쓴 카드는 다시 안 나간다(전 카드 공통 규칙 — 협상카드/와일드카드 구분 없음)
|
||||
· 종결 전용 카드는 종결 국면에서만, 종결 국면에선 종결 전용 카드만
|
||||
· min_round 미만이면 아직 이르다
|
||||
· 스크립트가 인용한 세션-의존 변수(인터넷 최저가 등)가 결측이면 미발동 — 토큰 원형 노출 방지
|
||||
"""
|
||||
if spec.closing != closing_phase:
|
||||
return False
|
||||
if int(context.get("round") or 0) < spec.min_round:
|
||||
return False
|
||||
return all(context.get(key) for key in spec.requires)
|
||||
|
||||
|
||||
def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
|
||||
"""이 카드를 지금 실제로 플레이할 수 있는지 — available + (금액 카드는) 제안가 유효까지.
|
||||
|
||||
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
||||
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
||||
글자로 박혀 나가 역행/모순 서사가 되기 때문(IMK 역행 논의). 설득 카드는 금액이 없으니 무관.
|
||||
"""
|
||||
if not available(spec, context, closing_phase=closing_phase):
|
||||
return False
|
||||
if not spec.offer_variable:
|
||||
return True
|
||||
return compute_offer(spec, context) is not None
|
||||
|
||||
|
||||
def is_played(context: Dict[str, Any], number: Optional[str]) -> bool:
|
||||
"""이 카드를 이 협상에서 이미 썼는지. 와일드 진입·종결·협상카드가 같은 이력을 본다."""
|
||||
return bool(number) and str(number) in (context.get("played_card_numbers") or [])
|
||||
|
||||
|
||||
def mark_played(context: Dict[str, Any], number: Optional[str]) -> None:
|
||||
"""카드를 실제로 내보낸 시점에 이력에 남긴다(노출되지 않은 후보는 남기지 않는다)."""
|
||||
if not number:
|
||||
return
|
||||
played = list(context.get("played_card_numbers") or [])
|
||||
if str(number) not in played:
|
||||
played.append(str(number))
|
||||
context["played_card_numbers"] = played
|
||||
|
||||
157
agent/tests/fuzz_negotiation.py
Normal file
157
agent/tests/fuzz_negotiation.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 N회 완주시키고 불변식 위반을 수집한다.
|
||||
시드 고정(재현 가능). test_ 접두사 없음 — pytest 수집 대상 아님, 수동 실행 전용:
|
||||
docker run --rm -v $PWD/agent:/work -w /work -e APP_ENV=local -e DB_HOST=host.docker.internal \
|
||||
o2o-negosium-agent sh -lc "pip install -q pytest pytest-asyncio httpx; python tests/fuzz_negotiation.py"
|
||||
|
||||
케이스마다 검사하는 불변식:
|
||||
1. 전 턴 success
|
||||
2. 같은 카드 2회 발동 금지
|
||||
3. 종결 전용(WC-03·05)은 가격협상_카운터에서만 / 비종결 와일드는 wild_card_dynamic 에서만
|
||||
4. 타결 시 타결가 ≤ 목표가
|
||||
5. 카운터/1% 수락으로 타결하면 그 멘트에 타결가 표기
|
||||
6. 멘트·버튼에 미치환 토큰({xxx}) 잔존 금지
|
||||
7. 턴 상한(60) 안에 반드시 종료
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, "/work")
|
||||
|
||||
from router.v1.chat.protocol import Req_Chat # noqa: E402
|
||||
from services.chat_service import ChatService, reset_sessions # noqa: E402
|
||||
from tenancy.config_loader import TenantConfigLoader # noqa: E402
|
||||
from tenancy.registry import TenantEngineRegistry # noqa: E402
|
||||
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session # noqa: E402
|
||||
|
||||
N = 50
|
||||
SEED = 20260805
|
||||
TARGET = 10_000
|
||||
NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005",
|
||||
"NGC-007", "NGC-008", "NGC-010", "NGC-011"]
|
||||
WILD_POOL = ["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]
|
||||
CLOSING = {"WC-03", "WC-05"}
|
||||
TOKEN_RE = re.compile(r"(?<!\{)\{([a-z_0-9]+)\}(?!\})")
|
||||
|
||||
|
||||
class Supplier:
|
||||
"""랜덤 협력사 — 높은 시작가에서 점진 양보, 카운터는 확률적으로 수락/거절."""
|
||||
|
||||
def __init__(self, rng, anchor):
|
||||
self.rng = rng
|
||||
self.anchor = anchor
|
||||
self.price = TARGET * rng.uniform(1.02, 1.30)
|
||||
self.accept_p = rng.uniform(0.15, 0.5)
|
||||
|
||||
def next_price(self):
|
||||
p = int(self.price)
|
||||
# 다음 라운드를 위해 양보 — 가끔 앵커 밑까지 다이브(우선협상 유도).
|
||||
self.price *= self.rng.uniform(0.90, 0.99)
|
||||
if self.rng.random() < 0.15:
|
||||
self.price = self.anchor * self.rng.uniform(0.95, 1.04)
|
||||
return str(max(p, 100))
|
||||
|
||||
def choose(self, options):
|
||||
if "수락" in options:
|
||||
return "수락" if self.rng.random() < self.accept_p else "다른 가격 제시"
|
||||
if set(options) >= {"예", "아니오"}:
|
||||
return "예" if self.rng.random() < max(self.accept_p, 0.5) else "아니오"
|
||||
return options[0] if options else "확인"
|
||||
|
||||
|
||||
async def run_case(idx, rng):
|
||||
anchor = int(TARGET * rng.choice([0.99, 0.99, 0.97, 0.95, 1.0]))
|
||||
nego = rng.sample(NEGO_POOL, rng.randint(1, 5))
|
||||
wild = rng.sample(WILD_POOL, rng.randint(0, 5))
|
||||
sup = Supplier(rng, anchor)
|
||||
|
||||
reset_sessions()
|
||||
sid = uuid.uuid4()
|
||||
qid, ver = await _seed_quote_session(sid, nego, wild_numbers=wild, target=TARGET, anchor=anchor)
|
||||
violations, fired, settled, outcome, ended = [], [], None, None, False
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
ui, last_input = None, None
|
||||
for _turn in range(60):
|
||||
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||
if r.result.success is not True:
|
||||
violations.append(f"턴 실패 input={ui} msg={r.msg}")
|
||||
break
|
||||
script, opts = r.script or "", list(r.input_options or [])
|
||||
if TOKEN_RE.search(script):
|
||||
violations.append(f"미치환 토큰(script): {TOKEN_RE.findall(script)} @ {r.step}")
|
||||
for o in opts:
|
||||
if TOKEN_RE.search(o):
|
||||
violations.append(f"미치환 토큰(option): {o} @ {r.step}")
|
||||
if r.card_id:
|
||||
fired.append((r.step, r.card_id))
|
||||
if r.settled_price is not None:
|
||||
settled = r.settled_price
|
||||
# 카운터/1% '수락' 타결이면 마지막 카운터 멘트에 타결가가 보였어야 한다.
|
||||
if last_input in ("수락",) and str(settled) not in (last_counter or ""):
|
||||
violations.append(f"표시가≠타결가: {settled} not in counter script")
|
||||
if r.step in ("가격협상_카운터", "wild_card_dynamic", "wild_card_1pct"):
|
||||
last_counter = script
|
||||
if r.chat_end:
|
||||
outcome, ended = r.outcome, True
|
||||
break
|
||||
# 다음 입력 결정
|
||||
last_input = None
|
||||
if r.input_mode == "price":
|
||||
ui = sup.next_price()
|
||||
elif opts:
|
||||
ui = sup.choose(opts)
|
||||
last_input = ui
|
||||
else:
|
||||
ui = "확인"
|
||||
if not ended:
|
||||
violations.append("60턴 내 미종료")
|
||||
|
||||
# 카드 불변식
|
||||
ids = [c for _, c in fired]
|
||||
if len(ids) != len(set(ids)):
|
||||
violations.append(f"카드 중복: {ids}")
|
||||
for step, c in fired:
|
||||
if c in CLOSING and step != "가격협상_카운터":
|
||||
violations.append(f"종결 카드 {c} 가 {step} 에서 발동")
|
||||
if c.startswith("WC") and c not in CLOSING and step != "wild_card_dynamic":
|
||||
violations.append(f"비종결 와일드 {c} 가 {step} 에서 발동")
|
||||
if outcome == "success":
|
||||
if settled is None:
|
||||
violations.append("성공인데 settled 없음")
|
||||
elif settled > TARGET:
|
||||
violations.append(f"목표가 초과 타결: {settled}")
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver)
|
||||
return {"idx": idx, "anchor": anchor, "nego": nego, "wild": wild,
|
||||
"fired": fired, "settled": settled, "outcome": outcome, "violations": violations}
|
||||
|
||||
|
||||
async def main():
|
||||
rng = random.Random(SEED)
|
||||
results, bad = [], []
|
||||
for i in range(N):
|
||||
res = await run_case(i, random.Random(rng.random()))
|
||||
results.append(res)
|
||||
if res["violations"]:
|
||||
bad.append(res)
|
||||
tag = "OK " if not res["violations"] else "BAD"
|
||||
print(f"[{tag}] #{i:02d} anchor={res['anchor']} nego={len(res['nego'])} wild={len(res['wild'])} "
|
||||
f"fired={'→'.join(c for _, c in res['fired']) or '-'} settled={res['settled']} {res['outcome']}")
|
||||
ok = sum(1 for r in results if not r["violations"])
|
||||
succ = sum(1 for r in results if r["outcome"] == "success")
|
||||
print(f"\n===== {ok}/{N} clean · 타결 {succ} / 결렬 {N - succ} =====")
|
||||
for r in bad:
|
||||
print(f"\n#{r['idx']} 위반: nego={r['nego']} wild={r['wild']} anchor={r['anchor']}")
|
||||
for v in r["violations"]:
|
||||
print(" -", v)
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
sys.exit(0 if not bad else 1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@ -53,7 +53,7 @@ async def test_4_4_company_id_auto_onboard():
|
||||
eng = await _reg().get_engine(COMPANY_ID)
|
||||
assert eng.tenant_id == COMPANY_ID
|
||||
assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리
|
||||
assert eng.action_space_size == 11 # base 기본 카드(162×11 정합)
|
||||
assert eng.action_space_size == 9 # DB 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
assert eng.state_space_size == 162
|
||||
|
||||
|
||||
|
||||
@ -91,7 +91,7 @@ async def test_selected_cards_only_are_played(db_engine):
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4())) # 자동 온보딩(_base type:db → 실 DB 카탈로그)
|
||||
assert eng.action_space_size == 11 # 카탈로그 11장(NGC-001~011)
|
||||
assert eng.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
|
||||
svc = ChatService()
|
||||
played = []
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"""카드 전술 재설계 검증 — "멘트 카드 → 전술 카드" (가격 행동 실행 계층).
|
||||
"""카드 전술 검증 — "스크립트에 꽂힌 변수가 곧 전술" (파싱 + 변수별 유효조건 + tactic JSONB).
|
||||
|
||||
① 카운터 산식 결정론 + min(counter, target) 클램프 + 무의미 카운터(HOLD 강등)
|
||||
② 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
|
||||
③ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
|
||||
④ 선택형 와일드카드(WC-05 중간값 절충) 발동 — 1.02~1.05 구간 갭 해소
|
||||
⑤ E2E: 견적 선택 카드(NGC-009 조건부 가격 조정)의 카운터를 수락하면 settled=target
|
||||
⑥ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
|
||||
① 제안가 파싱(마지막 제안가 변수) + 변수별 계산식 결정론
|
||||
② 변수 공통 유효조건 — 목표가 초과·제시가 이상이면 미발동(클램프 아님 — IMK 8AB0 회귀)
|
||||
③ 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
|
||||
④ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
|
||||
⑤ 와일드 진입 — 종결 전용 카드 예약(중반 미발동) + 카드 이력 공유(중복 발동 차단, IMK BB9A 회귀)
|
||||
⑥ E2E: 견적 선택 카드(NGC-010 목표가 제안)의 카운터를 수락하면 settled=target
|
||||
⑦ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -15,7 +16,8 @@ from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
|
||||
from negotiation.cards.domain.tactics import (
|
||||
PriceAction, TacticSpec, compute_counter, tactic_available, tactic_for,
|
||||
CardSpec, HOLD, available, build_card_spec, compute_offer,
|
||||
is_played, mark_played, parse_offer_variable, playable, spec_from_context,
|
||||
)
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
@ -23,6 +25,12 @@ from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||
|
||||
# 엔진 단위 테스트용 카드 스펙(로더가 DB 스크립트 파싱으로 만드는 것과 같은 형태).
|
||||
_SPECS = {
|
||||
"WC-02": {"offer_variable": "target_mid_price", "min_round": 1, "closing": False},
|
||||
"WC-05": {"offer_variable": "middle_price", "min_round": 1, "closing": True},
|
||||
}
|
||||
|
||||
|
||||
def _engine() -> ChatEngine:
|
||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||
@ -31,47 +39,125 @@ def _engine() -> ChatEngine:
|
||||
|
||||
def _session(step="가격협상_확인", **ctx_over):
|
||||
ctx = {"input_price": 10300, "anchor_price": 10000, "target_price": 10100,
|
||||
"round": 1, "allow_selected_wildcards": False}
|
||||
"round": 1, "allow_selected_wildcards": False, "card_specs": dict(_SPECS)}
|
||||
ctx.update(ctx_over)
|
||||
return ChatSession(session_id="00000000-0000-0000-0000-00000000e001", tenant_id="imarketkorea",
|
||||
company_id="imarketkorea", step=step, action_space_size=0, context=ctx)
|
||||
|
||||
|
||||
# ---- ① 카운터 산식 (결정론 + 가드레일 클램프) --------------------------------
|
||||
def test_counter_formulas_and_clamp():
|
||||
# ---- ① 제안가 파싱 + 계산식 ---------------------------------------------------
|
||||
def test_parse_offer_variable_last_offer_wins():
|
||||
"""제안가 변수가 여럿이면 마지막 것 — 카드 문장은 배경을 먼저, 제안을 마지막에 한다(WC-04)."""
|
||||
assert parse_offer_variable("적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안") == "target_price"
|
||||
assert parse_offer_variable("{target_price}원을 제안 드립니다") == "target_price"
|
||||
# 읽어주기 변수만 있으면 설득 카드 — 제안가 없음
|
||||
assert parse_offer_variable("시장가 {internet_lowest_price}원 안팎, 제시가 {prev_partner_price}원") is None
|
||||
assert parse_offer_variable("가격 변수 없는 설득 멘트") is None
|
||||
assert parse_offer_variable(None) is None
|
||||
# WC-05 정본: 읽어주기(직전 제안·제시가) 뒤 절충가 제안
|
||||
assert parse_offer_variable("당사 제안 {prev_customer_price}원과 귀사 제안 {prev_partner_price}원을 절반씩, {middle_price}원으로") == "middle_price"
|
||||
# negodata 에디터 칩 표기(anchor_price)도 앵커가 제안으로 인식 — DB 시드 표기(anchoring_price)의 별칭
|
||||
assert parse_offer_variable("예산 한도는 {anchor_price}원입니다") == "anchor_price"
|
||||
|
||||
|
||||
def test_build_card_spec_merges_script_and_tactic():
|
||||
spec = build_card_spec("{target_price}원으로 제안", {"min_round": 2, "closing": True})
|
||||
assert spec == CardSpec(offer_variable="target_price", min_round=2, closing=True)
|
||||
# tactic 없음 → 기본값. offer_variable override 는 파싱보다 우선.
|
||||
assert build_card_spec("설득 멘트", None) == HOLD
|
||||
assert build_card_spec("멘트", {"offer_variable": "anchoring_price"}).offer_variable == "anchoring_price"
|
||||
|
||||
|
||||
def test_offer_formulas():
|
||||
ctx = {"input_price": 11000, "anchor_price": 9900, "target_price": 10000}
|
||||
assert compute_counter(tactic_for("NGC-009"), ctx) == 10000 # COUNTER_TARGET
|
||||
assert compute_counter(tactic_for("NGC-007"), ctx) == 9900 # COUNTER_ANCHOR
|
||||
assert compute_counter(tactic_for("WC-02"), ctx) == 9950 # (anchor+target)/2
|
||||
# COUNTER_MID: 갑 직전 포지션 폴백 = anchor → (9900+11000)/2 = 10450 → target 클램프
|
||||
assert compute_counter(tactic_for("WC-05"), ctx) == 10000
|
||||
# 갑 직전 포지션이 있으면 그 기준: (9800+11000)/2 = 10400 → 역시 클램프 10000
|
||||
assert compute_counter(tactic_for("WC-05"), dict(ctx, prev_customer_price=9800)) == 10000
|
||||
# 클램프 미발동 구간: (9900+10050)/2 = 9975 ≤ target → 10원 단위 반올림 9980
|
||||
assert compute_counter(tactic_for("WC-05"), dict(ctx, input_price=10050)) == 9980
|
||||
offer = lambda var, c=None: compute_offer(CardSpec(offer_variable=var), c or ctx) # noqa: E731
|
||||
assert offer("target_price") == 10000
|
||||
assert offer("anchoring_price") == 9900
|
||||
assert offer("target_mid_price") == 9950 # (anchor+target)/2
|
||||
# 절충가: 갑 직전 포지션 폴백 = anchor → (8900+9500)/2 = 9200
|
||||
assert offer("middle_price", dict(ctx, input_price=9500, anchor_price=8900)) == 9200
|
||||
# 갑 직전 포지션이 있으면 그 기준: (9000+9500)/2 = 9250
|
||||
assert offer("middle_price", dict(ctx, input_price=9500, prev_customer_price=9000)) == 9250
|
||||
|
||||
|
||||
def test_counter_meaningless_degrades_to_hold():
|
||||
"""협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(순수 설득 유지)."""
|
||||
# ---- ② 변수 공통 유효조건 — 미발동(클램프 아님) --------------------------------
|
||||
def test_offer_over_target_does_not_fire_imk_8ab0():
|
||||
"""IMK 8AB0 회귀: 목표가 9,000 / 제시가 9,500 → 절충가 (8,910+9,500)/2 = 9,205 > 목표가.
|
||||
구현이 목표가로 깎아 부르면 '중간에서 만나자며 목표가를 부르는' 모순 — 클램프가 아니라 미발동이 정답."""
|
||||
ctx = {"input_price": 9500, "anchor_price": 8910, "target_price": 9000}
|
||||
assert compute_offer(CardSpec(offer_variable="middle_price"), ctx) is None
|
||||
|
||||
|
||||
def test_offer_at_or_above_input_price_does_not_fire():
|
||||
"""협력사 제시가가 이미 제안가 이하면 부를 이유가 없다 → 미발동."""
|
||||
ctx = {"input_price": 9950, "anchor_price": 9900, "target_price": 10000}
|
||||
assert compute_counter(tactic_for("NGC-009"), ctx) is None # target(10000) ≥ 제시가
|
||||
assert compute_counter(TacticSpec(PriceAction.COUNTER_ANCHOR), dict(ctx, input_price=9900)) is None
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) is None # target ≥ 제시가
|
||||
assert compute_offer(CardSpec(offer_variable="anchoring_price"), dict(ctx, input_price=9900)) is None
|
||||
|
||||
|
||||
def test_unknown_card_falls_back_to_hold():
|
||||
"""미등록 카드번호(데모 NGC-B*, 커스텀 COMP-*)는 HOLD — 기존 동작 그대로."""
|
||||
spec = tactic_for("NGC-B003")
|
||||
assert spec.price_action is PriceAction.HOLD
|
||||
assert compute_counter(spec, {"input_price": 11000, "target_price": 10000}) is None
|
||||
assert tactic_available(spec, {}) is True
|
||||
def test_offer_without_materials_does_not_fire():
|
||||
"""재료 결측(목표가·제시가·앵커) — 어떤 변수도 미발동."""
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), {"input_price": 11000}) is None # 목표가 없음
|
||||
assert compute_offer(CardSpec(offer_variable="anchoring_price"),
|
||||
{"input_price": 11000, "target_price": 10000}) is None # 앵커 없음
|
||||
assert compute_offer(HOLD, {"input_price": 11000, "target_price": 10000}) is None # 설득 카드
|
||||
assert compute_offer(CardSpec(offer_variable="없는변수"), {"input_price": 11000, "target_price": 10000}) is None
|
||||
|
||||
|
||||
def test_tactic_availability_conditions():
|
||||
assert tactic_available(tactic_for("WC-04"), {"round": 1}) is False # min_round=2
|
||||
assert tactic_available(tactic_for("WC-04"), {"round": 2}) is True
|
||||
def test_available_min_round_and_closing_phase():
|
||||
spec2 = CardSpec(offer_variable="target_price", min_round=2)
|
||||
assert available(spec2, {"round": 1}) is False # min_round 미만
|
||||
assert available(spec2, {"round": 2}) is True
|
||||
closing = CardSpec(offer_variable="middle_price", closing=True)
|
||||
assert available(closing, {"round": 1}) is False # 종결 전용 — 중반 미발동(예약)
|
||||
assert available(closing, {"round": 1}, closing_phase=True) is True
|
||||
assert available(spec2, {"round": 3}, closing_phase=True) is False # 종결 국면엔 종결 카드만
|
||||
|
||||
|
||||
# ---- ② 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
|
||||
def test_available_requires_context_value():
|
||||
"""검증: 시장가 인용 카드(NGC-008류)의 requires 게이트 — build_card_spec 이 스크립트에서 잡아내고,
|
||||
기대결과: 컨텍스트에 인터넷 최저가가 없으면(0/결측) 미발동, 있으면 발동(퍼즈 #3·13·23·40 회귀)."""
|
||||
spec = build_card_spec("유사 거래는 {internet_lowest_price}원 안팎에서 합의되고 있습니다.")
|
||||
assert spec.requires == ("internet_lowest_price",)
|
||||
assert available(spec, {"round": 1}) is False # 결측
|
||||
assert available(spec, {"round": 1, "internet_lowest_price": 0}) is False # 미수집(0)
|
||||
assert available(spec, {"round": 1, "internet_lowest_price": 6300}) is True
|
||||
# 일반 카드는 requires 없음 — 기존 동작 그대로.
|
||||
assert build_card_spec("귀사와의 협력을 소중히 생각합니다.").requires == ()
|
||||
|
||||
|
||||
def test_offer_monotonic_no_regression():
|
||||
"""검증: 역행 금지(IMK 논의 — 절충 16,980 후 예산 상한 16,810 제시) 재현.
|
||||
기대결과: 직전 당사 제안보다 낮은 제안가 카드는 미발동(설득 폴백으로도 안 나감).
|
||||
직전 제안이 없으면 앵커 제시 허용, 같은 금액 재제시 허용, 더 높은 제안은 정상."""
|
||||
anchor_card = CardSpec(offer_variable="anchoring_price")
|
||||
ctx = {"round": 2, "target_price": 17_300, "anchor_price": 16_810, "input_price": 17_500}
|
||||
assert compute_offer(anchor_card, ctx) == 16_810 # 첫 카운터 전(포지션=앵커): 같은 금액 → 허용
|
||||
ctx["prev_customer_price"] = 16_980 # 절충 카드가 이미 16,980 을 부른 상태
|
||||
assert compute_offer(anchor_card, ctx) is None # 앵커 16,810 은 역행 → 미발동
|
||||
assert playable(anchor_card, ctx) is False # 멘트에 금액이 박히므로 설득 폴백도 금지
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) == 17_300 # 상향 제안은 정상
|
||||
|
||||
|
||||
def test_played_history_is_shared_by_number():
|
||||
ctx = {}
|
||||
assert is_played(ctx, "WC-05") is False
|
||||
mark_played(ctx, "WC-05")
|
||||
assert is_played(ctx, "WC-05") is True
|
||||
mark_played(ctx, "WC-05") # 재기록해도 1건 유지
|
||||
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||
mark_played(ctx, None) # no-op(폴백 최후통첩)
|
||||
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||
|
||||
|
||||
def test_spec_from_context_reads_snapshot_and_falls_back_to_hold():
|
||||
ctx = {"card_specs": dict(_SPECS)}
|
||||
assert spec_from_context(ctx, "WC-05") == CardSpec(offer_variable="middle_price", min_round=1, closing=True)
|
||||
assert spec_from_context(ctx, "NGC-B003") == HOLD # 미등록 카드(데모) 폴백
|
||||
assert spec_from_context({}, "WC-05") == HOLD # 스펙 미적재(구세션·데모) 폴백
|
||||
|
||||
|
||||
# ---- ③ 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
|
||||
def test_accept_counter_settles_at_counter_price():
|
||||
eng = _engine()
|
||||
s = _session(step="가격협상_카운터", pending_counter_price=10000)
|
||||
@ -102,7 +188,7 @@ def test_wildcard_1pct_decline_keeps_original_price():
|
||||
assert s.context["input_price"] == 10000 # 거절 → 카운터 미적용
|
||||
|
||||
|
||||
# ---- ③ 목표가 초과 타결 금지 가드 --------------------------------------------
|
||||
# ---- ④ 목표가 초과 타결 금지 가드 --------------------------------------------
|
||||
def test_success_step_guard_rejects_over_target():
|
||||
eng = _engine()
|
||||
s = _session(input_price=10800, target_price=10000)
|
||||
@ -110,20 +196,46 @@ def test_success_step_guard_rejects_over_target():
|
||||
assert view.step == "협상실패" # 초과가 성공 진입 → 결렬 강제
|
||||
|
||||
|
||||
# ---- ④ 선택형 와일드카드 발동 (1.02~1.05 구간 갭 해소) -------------------------
|
||||
def test_selected_wildcard_fires_in_entry_zone():
|
||||
# ---- ⑤ 와일드 진입 — 종결 예약 + 중복 차단 (IMK BB9A 회귀) ---------------------
|
||||
def test_selected_wildcard_fires_in_entry_zone_and_records_position():
|
||||
eng = _engine()
|
||||
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + 비종결 WC-02 선택
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||
selected_wild_card_numbers=["WC-02"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "wild_card_dynamic"
|
||||
# 제안가 = (anchor 10000 + target 10100)/2 = 10050 ≤ target — 그대로 제시(클램프 없음)
|
||||
assert s.context["pending_counter_price"] == 10050
|
||||
assert s.context["prev_customer_price"] == 10050 # 갑 포지션 기록 — "당사 제안" 멘트 정합(BB9A ③)
|
||||
assert s.context["active_wild_card_number"] == "WC-02"
|
||||
assert is_played(s.context, "WC-02") # 카드 이력 기록
|
||||
# 수락 → 그 가격으로 타결
|
||||
view = eng.advance(s, "수락")
|
||||
assert view.step == "협상완료" and s.context["input_price"] == 10050
|
||||
|
||||
|
||||
def test_closing_card_is_reserved_never_fires_mid_negotiation():
|
||||
"""종결 전용 카드(WC-05)는 entry 존이라도 중반에 안 나간다 — 종결 국면의 마지막 한 방으로 예약.
|
||||
(BB9A 중복의 절반: 중반에 당겨 쓴 카드를 종결에서 또 쓰던 경로 차단.)"""
|
||||
eng = _engine()
|
||||
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + WC-05 선택
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||
selected_wild_card_numbers=["WC-05"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "wild_card_dynamic"
|
||||
# 카운터 = (anchor 10000 + 10300)/2 = 10150 → target(10100) 클램프
|
||||
assert s.context["pending_counter_price"] == 10100
|
||||
assert s.context["active_wild_card_number"] == "WC-05"
|
||||
# 수락 → 그 가격으로 타결
|
||||
view = eng.advance(s, "수락")
|
||||
assert view.step == "협상완료" and s.context["input_price"] == 10100
|
||||
assert view.step == "가격협상" # 종결 카드뿐 → 일반 카드 플레이로
|
||||
assert "active_wild_card_number" not in s.context
|
||||
assert not is_played(s.context, "WC-05") # 안 나갔으니 이력도 없음
|
||||
# 종결 국면에선 발동 가능 + 이력 없음 — 서비스 종결 루프가 이 카드를 쓴다
|
||||
spec = spec_from_context(s.context, "WC-05")
|
||||
assert available(spec, s.context, closing_phase=True) is True
|
||||
|
||||
|
||||
def test_played_wildcard_is_skipped_on_reentry():
|
||||
"""이미 쓴 카드는 같은 협상에서 다시 안 나간다 — 다음 후보로 넘어간다."""
|
||||
eng = _engine()
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True, wildcard_used=False,
|
||||
selected_wild_card_numbers=["WC-02"], played_card_numbers=["WC-02"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "가격협상" # 유일 후보가 사용됨 → 발동 없음
|
||||
|
||||
|
||||
def test_unselected_wildcard_zone_still_falls_to_nego():
|
||||
@ -186,7 +298,7 @@ def test_vars_for_supplies_tactic_variables():
|
||||
assert v1["target_mid_price"] == 10100
|
||||
|
||||
|
||||
# ---- ⑤⑥ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
|
||||
# ---- ⑥⑦ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
|
||||
from sqlalchemy import column, delete, insert, select, table # noqa: E402
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
||||
@ -211,24 +323,30 @@ _T_QUOTATIONS = table(
|
||||
)
|
||||
_T_VNC = table("version_nego_cards", column("vnc_id"), column("version_id"), column("nego_card_id"), schema="card")
|
||||
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
|
||||
_T_VWC = table("version_wild_cards", column("vwc_id"), column("version_id"), column("wild_card_id"), schema="card")
|
||||
_T_WILD = table("wild_cards", column("wild_card_id"), column("number"), column("deleted"), schema="card")
|
||||
|
||||
|
||||
async def _card_uuid(number: str):
|
||||
async def _card_uuid(number: str, *, wild=False):
|
||||
tbl, pk = (_T_WILD, _T_WILD.c.wild_card_id) if wild else (_T_NEGO, _T_NEGO.c.nego_card_id)
|
||||
|
||||
def _q(s):
|
||||
return DB_SESSION_MNG.execute(
|
||||
s, select(_T_NEGO.c.nego_card_id).where(
|
||||
_T_NEGO.c.number == number, _T_NEGO.c.deleted == False).limit(1)) # noqa: E712
|
||||
s, select(pk).where(tbl.c.number == number, tbl.c.deleted == False).limit(1)) # noqa: E712
|
||||
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
|
||||
async def _seed_quote_session(sid, selected_numbers, wild_numbers=(), target=10000, anchor=9900):
|
||||
qid, ver_id, iid, sup = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
||||
now = datetime.now(timezone.utc)
|
||||
card_ids = {}
|
||||
card_ids, wild_ids = {}, {}
|
||||
for n in selected_numbers:
|
||||
card_ids[n] = await _card_uuid(n)
|
||||
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||
for n in wild_numbers:
|
||||
wild_ids[n] = await _card_uuid(n, wild=True)
|
||||
assert wild_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||
|
||||
def _seed(s_):
|
||||
async def run(s):
|
||||
@ -243,6 +361,11 @@ async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
|
||||
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
for n in wild_numbers:
|
||||
e = await DB_SESSION_MNG.add(s, insert(_T_VWC).values(
|
||||
vwc_id=_uuid.uuid4(), version_id=ver_id, wild_card_id=wild_ids[n]))
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
|
||||
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
|
||||
qt_number=f"QT-TACTIC-{str(sid)[:8]}", qt_round=1, qt_type=1,
|
||||
@ -260,17 +383,18 @@ async def _cleanup(sid, qid, ver_id):
|
||||
[DBType.MAIN.value],
|
||||
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_VWC).where(_T_VWC.c.version_id == ver_id)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_counter_accept_settles_at_target(db_engine):
|
||||
"""견적 선택 카드 NGC-009(조건부 가격 조정 → COUNTER_TARGET)의 카운터를 수락하면
|
||||
합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
|
||||
"""견적 선택 카드 NGC-010(향후 거래 연계 — 스크립트 {target_price} 파싱 → 목표가 제안)의
|
||||
카운터를 수락하면 합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-009"])
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-010"])
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
@ -279,9 +403,9 @@ async def test_e2e_counter_accept_settles_at_target(db_engine):
|
||||
r = None
|
||||
for ui in [None, "확인", "예", "확인", "11000", "예"]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||
# 가격협상 카드 턴 → NGC-009 카운터(target) 제시 스텝
|
||||
# 가격협상 카드 턴 → NGC-010 카운터(target) 제시 스텝
|
||||
assert r.step == "가격협상_카운터", f"카운터 스텝 기대, 실제 {r.step}"
|
||||
assert r.card_id == "NGC-009"
|
||||
assert r.card_id == "NGC-010"
|
||||
assert r.input_options == ["수락", "다른 가격 제시"]
|
||||
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||
@ -297,7 +421,7 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
|
||||
그래도 거절 → 결렬(협상실패). 목표가 초과로는 절대 타결되지 않는다."""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # HOLD 카드 1장 → 빠른 소진
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # 설득 카드 1장 → 빠른 소진
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
@ -321,3 +445,46 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
|
||||
assert r.settled_price is None # 초과가 타결 없음
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_bb9a_no_duplicate_wildcard_and_real_middle(db_engine):
|
||||
"""IMK BB9A 재현 E2E — 와일드카드 2장(WC-02·WC-05) + 설득 카드 1장.
|
||||
|
||||
기대 흐름(수정 후):
|
||||
· 중반 와일드 진입 = 비종결 WC-02 (종결 전용 WC-05 는 예약 — 구현 전엔 WC-05 가 먼저 나갔다)
|
||||
· 종결 국면 = WC-05, 절충가 = (당사 직전 제안 + 협력사 제시가)/2 실계산 (구현 전엔 목표가로 클램프)
|
||||
· 같은 카드 2회 발동 없음 + 종결 발동도 card_id 기록
|
||||
"""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"], wild_numbers=["WC-02", "WC-05"])
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
session_id = str(sid)
|
||||
r = None
|
||||
# 10300: 1pct 존(≤ 9900×1.02=10098) 밖, entry 존(≤ 10395) 안 → 선택형 와일드 발동 구간
|
||||
for ui in [None, "확인", "예", "확인", "10300", "예"]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||
assert r.step == "wild_card_dynamic"
|
||||
assert r.card_id == "WC-02" # 종결 전용 WC-05 가 아니라 비종결 카드
|
||||
# WC-02 제안가 = (anchor 9900 + target 10000)/2 = 9950
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="다른 가격 제시"))
|
||||
# 10010 재제시 → 설득 카드(NGC-003) 1장 소진
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10010"))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||
assert r.step == "가격협상" and r.card_id == "NGC-003"
|
||||
# 10005 재제시 → 카드 소진 → 종결 국면: WC-05 절충가 = (9950 + 10005)/2 = 9980 (≤ target)
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10005"))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||
assert r.step == "가격협상_카운터"
|
||||
assert r.card_id == "WC-05" # 종결 발동도 카드 기록(구현 전 null)
|
||||
assert "9980" in r.script # 실제 절충가 — 목표가(10000) 클램프 아님
|
||||
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||
assert r.step == "협상완료"
|
||||
assert r.settled_price == 9980 # 표시가 = 타결가
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
|
||||
@ -49,9 +49,14 @@ def test_wildcard_threshold_is_config_driven():
|
||||
# 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상
|
||||
view = _engine().advance(_session(10800), "예")
|
||||
assert view.step == "가격협상"
|
||||
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
|
||||
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동.
|
||||
# 1%가(10800×0.99=10692)도 제안가 공통 유효조건(≤목표가)을 타므로 목표가를 그 위로 둔다 —
|
||||
# 기본 target(10100)이면 초과 제시 금지 규칙에 걸려 발동하지 않는 게 새 정답.
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800, target_price=11000), "예")
|
||||
assert view.step == "wild_card_1pct"
|
||||
# 목표가가 1%가 아래면(초과 제시 금지) 완화 임계라도 미발동 — 수락해도 결렬되는 모순 제안 차단.
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
|
||||
assert view.step == "가격협상"
|
||||
|
||||
|
||||
def test_max_counter_rounds_is_config_driven():
|
||||
|
||||
190
agent/tests/test_negotiation_invariants.py
Normal file
190
agent/tests/test_negotiation_invariants.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""협상 불변식 시나리오 하네스 — 실서비스 스택(ChatService + 실 DB 카드)으로 13개 협상을 완주시키고,
|
||||
IMK 가 잡은 두 부류의 사고(같은 카드 반복 · 이상한 금액)가 어떤 흐름에서도 안 나는지 검사한다.
|
||||
|
||||
시나리오별 기대 이벤트(카드가 나간 턴의 step·카드·금액)를 정확히 못박고, 공통 불변식을 전 턴에 건다:
|
||||
· 카드 중복 없음 — 한 협상에서 같은 card_id 2회 발동 금지
|
||||
· 카드 자리 규칙 — 종결 전용(WC-03·05)은 가격협상_카운터에서만, 비종결 와일드는 wild_card_dynamic 에서만
|
||||
· 타결가 ≤ 목표가 — 어떤 성공 경로도 목표가 초과로 안 끝남
|
||||
· 카운터 멘트의 금액 = 수락 시 타결가 (표시가=타결가)
|
||||
"""
|
||||
|
||||
import uuid as _uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session
|
||||
|
||||
# 종결 전용 와일드카드(DB tactic 시드와 동일) — 자리 규칙 검사용.
|
||||
_CLOSING_WILDS = {"WC-03", "WC-05"}
|
||||
_NONCLOSING_WILDS = {"WC-01", "WC-02", "WC-04"}
|
||||
# 카드가 나갈 수 있는 스텝(이벤트로 수집).
|
||||
_CARD_STEPS = {"가격협상", "wild_card_dynamic", "wild_card_1pct", "가격협상_카운터"}
|
||||
_BOILERPLATE = [None, "확인", "예", "확인"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
name: str
|
||||
inputs: list # 서두(안내~기존가격제시) 이후의 협력사 입력 시퀀스
|
||||
# 기대 이벤트: (step, card, offer_substring). card="NGC-*" 는 임의 협상카드(중복만 검사).
|
||||
events: list
|
||||
settled: Optional[int] # 기대 타결가(원). None=결렬
|
||||
nego: list = field(default_factory=lambda: ["NGC-001"])
|
||||
wild: list = field(default_factory=list)
|
||||
target: int = 10_000
|
||||
anchor: int = 9_900
|
||||
|
||||
|
||||
# 밴드(기본 target 10000·anchor 9900): 1% 존 ≤ 10,098 · 진입 존 ≤ 10,395.
|
||||
SCENARIOS = [
|
||||
# S01 BB9A 재현 — 중반 비종결 WC-02, 종결 WC-05 실절충가. 같은 카드 2회 없음.
|
||||
Scenario("S01_bb9a_mid_wc02_close_wc05",
|
||||
["10300", "예", "다른 가격 제시", "10010", "예", "10005", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-05", "9980")],
|
||||
settled=9980, wild=["WC-02", "WC-05"]),
|
||||
# S02 8AB0 재현 — 절충가(9,205)가 목표가(9,000) 초과 → WC-05 미발동, 목표가 최후통첩(카드 없음).
|
||||
Scenario("S02_8ab0_middle_over_target_skips",
|
||||
["9500", "예", "9500", "예", "다른 가격 제시", "9500", "예", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", None, "9000")],
|
||||
settled=None, wild=["WC-05"], target=9_000, anchor=8_910),
|
||||
# S03 와일드 5장 전부 + 협상카드 2장 — 중반 1장(WC-01)·종결 1장(WC-03)만, 협상카드는 서로 다른 2장.
|
||||
Scenario("S03_five_wilds_full_run",
|
||||
["10300", "예", "다른 가격 제시", "10200", "예", "10150", "예", "10100", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-01", "10000"),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=10_000, nego=["NGC-001", "NGC-003"],
|
||||
wild=["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]),
|
||||
# S04 종결 전용 와일드만 담김 + 제시가가 진입 존에 머무름 — 소진 판정이 막히지 않고
|
||||
# 종결로 넘어간다(프로브 픽스 회귀: 픽스 전엔 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상).
|
||||
Scenario("S04_closing_only_wild_no_deadlock",
|
||||
["10300", "예", "10250", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", None, "10000")], # WC-05 절충 10,075>목표가 → 미발동 → 최후통첩
|
||||
settled=10_000, wild=["WC-05"]),
|
||||
# S05 1% 존 — 시스템 1% 카드, 수락 시 표시 금액 그대로 타결.
|
||||
Scenario("S05_one_pct_zone_accept",
|
||||
["10050", "예", "예", "확인"],
|
||||
[("wild_card_1pct", None, "9950")],
|
||||
settled=9_950),
|
||||
# S06 앵커 이하 즉시 타결 — 카드 0장.
|
||||
Scenario("S06_priority_match_no_cards",
|
||||
["9800", "예", "확인"],
|
||||
[],
|
||||
settled=9_800),
|
||||
# S07 목표가 초과 고수 → 설득 1장 → 최후통첩 → 결렬.
|
||||
Scenario("S07_hold_high_fails",
|
||||
["11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||
[("가격협상", "NGC-003", None),
|
||||
("가격협상_카운터", None, "10000")],
|
||||
settled=None, nego=["NGC-003"]),
|
||||
# S08 협상카드 카운터(NGC-010 목표가 제안) 수락 — 협상카드도 카운터 스텝을 쓴다.
|
||||
Scenario("S08_nego_counter_accept",
|
||||
["11000", "예", "수락", "확인"],
|
||||
[("가격협상_카운터", "NGC-010", "10000")],
|
||||
settled=10_000, nego=["NGC-010"]),
|
||||
# S09 min_round=2 — WC-04 는 1라운드 진입 존에서 안 나가고 2라운드에 나간다.
|
||||
Scenario("S09_min_round_two_defers_wc04",
|
||||
["10300", "예", "10200", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("wild_card_dynamic", "WC-04", "10000")],
|
||||
settled=10_000, wild=["WC-04"]),
|
||||
# S10 종결 체인 폴백 — WC-05 무효(절충 10,175>목표) → 다음 종결 WC-03 발동.
|
||||
Scenario("S10_closing_chain_falls_to_wc03",
|
||||
["10500", "예", "10450", "예", "다른 가격 제시", "10450", "예", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=None, wild=["WC-05", "WC-03"]),
|
||||
# S11 중반+종결 콤보 — WC-02 중반, 종결은 WC-05 무효 건너뛰고 WC-03. 전 카드 1회씩.
|
||||
Scenario("S11_mid_and_closing_combo",
|
||||
["10300", "예", "다른 가격 제시", "10400", "예", "10350", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=10_000, wild=["WC-02", "WC-05", "WC-03"]),
|
||||
# S12 라운드 상한 — 협상카드 3장 각 1회(중복 없음) 후 상한 도달 → 최후통첩 → 결렬.
|
||||
Scenario("S12_round_cap_distinct_nego_cards",
|
||||
["11000", "예", "11000", "예", "11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||
[("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상_카운터", None, "10000")],
|
||||
settled=None, nego=["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005"]),
|
||||
# S13 재생성 아님·재료 극단 — 앵커 미박제 세션(anchor=target 폴백)에서도 초과 제시·중복 없음.
|
||||
Scenario("S13_anchor_equals_target_fallback",
|
||||
["10300", "예", "10200", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")], # WC-05 절충 (10000+10200)/2=10100>목표 → 스킵
|
||||
settled=10_000, wild=["WC-05", "WC-03"], anchor=10_000),
|
||||
# S14 역행 금지(IMK 논의 재현) — 절충 카드(9,950) 뒤에 예산 상한 카드(NGC-007, 앵커 9,900)가
|
||||
# 선택돼 있어도 발동하지 않는다(설득 폴백으로도 안 나감). 낼 카드가 없어져 종결(목표가 최후통첩)로.
|
||||
Scenario("S14_no_offer_regression",
|
||||
["10300", "예", "다른 가격 제시", "10200", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상_카운터", None, "10000")], # NGC-007 이벤트가 없어야 함(역행 차단)
|
||||
settled=10_000, nego=["NGC-007"], wild=["WC-02"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("sc", SCENARIOS, ids=[s.name for s in SCENARIOS])
|
||||
async def test_negotiation_invariants(db_engine, sc: Scenario):
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, sc.nego, wild_numbers=sc.wild,
|
||||
target=sc.target, anchor=sc.anchor)
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
trace, settled, outcome = [], None, None
|
||||
for ui in [*_BOILERPLATE, *sc.inputs]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||
assert r.result.success is True, f"{sc.name}: 턴 실패 input={ui} msg={r.msg}"
|
||||
trace.append(r)
|
||||
if r.settled_price is not None:
|
||||
settled = r.settled_price
|
||||
if r.chat_end:
|
||||
outcome = r.outcome
|
||||
|
||||
# ── 기대 이벤트(카드/카운터 턴) 정확 일치 ──
|
||||
events = [r for r in trace if r.step in _CARD_STEPS]
|
||||
got = [(r.step, r.card_id) for r in events]
|
||||
assert len(events) == len(sc.events), f"{sc.name}: 이벤트 수 {got} ≠ 기대 {sc.events}"
|
||||
for r, (step, card, offer) in zip(events, sc.events):
|
||||
assert r.step == step, f"{sc.name}: step {r.step} ≠ {step} (전체 {got})"
|
||||
if card == "NGC-*":
|
||||
assert r.card_id and r.card_id.startswith("NGC-"), f"{sc.name}: 협상카드 기대, 실제 {r.card_id}"
|
||||
else:
|
||||
assert r.card_id == card, f"{sc.name}: card {r.card_id} ≠ {card} (전체 {got})"
|
||||
if offer is not None:
|
||||
assert offer in (r.script or ""), f"{sc.name}: 멘트에 금액 {offer} 없음 — {r.script[:80]}"
|
||||
|
||||
# ── 공통 불변식 ──
|
||||
played = [r.card_id for r in events if r.card_id]
|
||||
assert len(played) == len(set(played)), f"{sc.name}: 카드 중복 발동 {played}"
|
||||
for r in events:
|
||||
if r.card_id in _CLOSING_WILDS:
|
||||
assert r.step == "가격협상_카운터", f"{sc.name}: 종결 카드 {r.card_id}가 중반({r.step})에 발동"
|
||||
if r.card_id in _NONCLOSING_WILDS:
|
||||
assert r.step == "wild_card_dynamic", f"{sc.name}: 비종결 와일드 {r.card_id}가 {r.step}에서 발동"
|
||||
|
||||
# ── 결말 ──
|
||||
if sc.settled is None:
|
||||
assert outcome == "failure" and settled is None, f"{sc.name}: 결렬 기대, settled={settled} outcome={outcome}"
|
||||
else:
|
||||
assert outcome == "success", f"{sc.name}: 타결 기대, outcome={outcome}"
|
||||
assert settled == sc.settled, f"{sc.name}: 타결가 {settled} ≠ 기대 {sc.settled}"
|
||||
assert settled <= sc.target, f"{sc.name}: 목표가 초과 타결 {settled} > {sc.target}"
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
@ -35,7 +35,7 @@ async def test_two_tenants_distinct_engines():
|
||||
assert e1.mapper.get_card_id(0) == "NGC-001"
|
||||
assert e2.mapper.get_card_id(0) == "NGC-B001"
|
||||
# 차원
|
||||
assert e1.state_space_size == 162 and e1.action_space_size == 11
|
||||
assert e1.state_space_size == 162 and e1.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -69,7 +69,7 @@ async def test_unregistered_company_id_auto_onboards():
|
||||
reg = _registry()
|
||||
# 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state).
|
||||
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
|
||||
assert eng.action_space_size == 11 and eng.state_space_size == 162
|
||||
assert eng.action_space_size == 9 and eng.state_space_size == 162 # DB 카탈로그 9장
|
||||
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
||||
assert reg.is_registered("imarketkorea") is True
|
||||
# 빈 키만 미등록 → KeyError
|
||||
|
||||
@ -69,27 +69,27 @@ async def test_cold_start_creates_warmstart_version(db_engine):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
|
||||
"""카탈로그 카드 수 변경(9→11) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||
"""카탈로그 카드 수 변경(7→9) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||
import uuid as _uuid
|
||||
cid = str(_uuid.uuid4())
|
||||
# 이 회사 활성 버전을 A=9 로 시드 + 셀 (5,2)=0.9
|
||||
# 이 회사 활성 버전을 A=7 로 시드 + 셀 (5,2)=0.9
|
||||
repo = LearningRepository(cid)
|
||||
vid = await repo.get_or_create_active_version(
|
||||
state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95,
|
||||
scope=2, version_name="old_v9")
|
||||
state_space_size=162, action_space_size=7, learning_rate=0.1, discount_factor=0.95,
|
||||
scope=2, version_name="old_v7")
|
||||
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7)
|
||||
|
||||
# 엔진(_base type:db → 카탈로그 11장) 로드 → 9≠11 감지 → 마이그레이션
|
||||
# 엔진(_base type:db → 카탈로그 9장) 로드 → 7≠9 감지 → 마이그레이션
|
||||
eng = await _reg().get_engine(cid)
|
||||
assert eng.action_space_size == 11
|
||||
assert eng.action_space_size == 9
|
||||
policy, new_vid, _ = await QTablePolicyStore.load(eng)
|
||||
assert str(new_vid) != str(vid) # 새 버전
|
||||
assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존
|
||||
assert policy.qtable.q[5, 10] == 0.0 # 새 카드(action 10) fresh
|
||||
assert policy.qtable.q[5, 8] == 0.0 # 새 카드(action 8) fresh
|
||||
assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존
|
||||
# 새 버전이 활성 · 차원 11
|
||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11
|
||||
assert str(active.version_id) == str(new_vid) and active.action_space_size == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -42,7 +42,10 @@ def test_requote_structure_preserved():
|
||||
for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]:
|
||||
assert key in s
|
||||
assert s["배송형태선택"]["next_input_mode"] == "delivery_type"
|
||||
assert s["배송형태선택"]["input_options"] == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
# 리소스 원본은 회사 용어 토큰({label_*}) — 렌더 시 회사 라벨(없으면 기본값)로 치환된다.
|
||||
assert s["배송형태선택"]["input_options"] == [
|
||||
"{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}",
|
||||
]
|
||||
|
||||
|
||||
def test_wildcard_present_and_merged():
|
||||
@ -170,3 +173,24 @@ async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
|
||||
|
||||
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
|
||||
assert out == "선택 카드 DB 멘트 **10200원**"
|
||||
|
||||
|
||||
def test_option_label_tokens_rendered():
|
||||
"""검증: 옵션에 회사 용어 토큰({label_delivery_type_*})이 있는 스텝을 정상 렌더·에러 재렌더로 출력.
|
||||
기대결과: 두 경로 모두 버튼 문자열이 기본 라벨(협력사배송 등)로 치환되고 토큰이 남지 않는다."""
|
||||
import os
|
||||
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
tenants = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||
cfg = TenantConfigLoader(tenants_dir=tenants, cache_ttl_seconds=0).load("_base")
|
||||
engine = ChatEngine(ScriptRepository(cfg, tenants), rq_type="재견적")
|
||||
session = ChatSession(session_id="s", tenant_id="_base", company_id="_base")
|
||||
|
||||
view = engine.render_step(session, "배송형태선택")
|
||||
assert view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
|
||||
# 에러 재렌더(잘못된 입력 등)도 같은 치환을 타야 한다 — raw 옵션이면 토큰이 버튼에 노출된다.
|
||||
err_view = engine._error(session, "다시 선택해 주세요.")
|
||||
assert err_view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
|
||||
@ -212,6 +212,7 @@ class nego_cards(MainTableMixin, MAIN_BASE):
|
||||
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
|
||||
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
|
||||
usage_type = Column(SmallInteger, nullable=False, default=1)
|
||||
tactic = Column(JSONB, nullable=True) # 전술 운영 규칙 {"min_round", "closing"} — 제안가는 script 변수 파싱(agent)
|
||||
|
||||
|
||||
class wild_cards(MainTableMixin, MAIN_BASE):
|
||||
@ -228,6 +229,7 @@ class wild_cards(MainTableMixin, MAIN_BASE):
|
||||
condition = Column(String(255), nullable=True) # 사용 조건(트리거)
|
||||
available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑)
|
||||
memo = Column(String(255), nullable=True) # 자유 메모
|
||||
tactic = Column(JSONB, nullable=True) # 전술 운영 규칙 {"min_round", "closing"} — 제안가는 script 변수 파싱(agent)
|
||||
|
||||
|
||||
class versions(MainTableMixin, MAIN_BASE):
|
||||
|
||||
@ -23,6 +23,7 @@ class Req_CreateCard(CardProtocol):
|
||||
status: int = CardStatus.ACTIVE.value # 와일드카드 적용 여부(available 매핑). 일반카드는 무시.
|
||||
condition: Optional[str] = None # 와일드카드 전용
|
||||
memo: Optional[str] = None # 와일드카드 전용
|
||||
tactic: Optional[Any] = None # 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent)
|
||||
|
||||
|
||||
class Req_UpdateCard(CardProtocol):
|
||||
@ -34,6 +35,7 @@ class Req_UpdateCard(CardProtocol):
|
||||
status: Optional[int] = None
|
||||
condition: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
tactic: Optional[Any] = None # 전술 운영 규칙 {"min_round": N, "closing": bool}
|
||||
|
||||
|
||||
# 통합 카드 표현(nego_cards + wild_cards 공통).
|
||||
@ -53,6 +55,7 @@ class CardData(WebPacketProtocol):
|
||||
status: CardStatus = CardStatus.ACTIVE
|
||||
condition: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
tactic: Optional[Any] = None # 전술 운영 규칙 {"min_round": N, "closing": bool}
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
success_rate: float = 0.0 # 카드 성공률(사용 세션 중 타결 비율). #12 순위용
|
||||
|
||||
@ -39,6 +39,7 @@ class CardService:
|
||||
edit_script=row.edit_script,
|
||||
usage_type=row.usage_type,
|
||||
status=CardStatus.ACTIVE.value,
|
||||
tactic=row.tactic,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
@ -59,6 +60,7 @@ class CardService:
|
||||
status=CardStatus.ACTIVE.value if row.available else CardStatus.INACTIVE.value,
|
||||
condition=row.condition,
|
||||
memo=row.memo,
|
||||
tactic=row.tactic,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
@ -209,6 +211,7 @@ class CardService:
|
||||
script=req.script,
|
||||
edit_script=req.edit_script,
|
||||
usage_type=req.usage_type,
|
||||
tactic=req.tactic,
|
||||
)
|
||||
if is_wildcard:
|
||||
card = wild_cards(
|
||||
@ -256,7 +259,7 @@ class CardService:
|
||||
return res
|
||||
|
||||
# 해당 테이블에 있는 컬럼만 추린다(없는 필드는 무시). status → available(와일드 전용).
|
||||
allowed = {"name", "number", "script", "edit_script", "usage_type"}
|
||||
allowed = {"name", "number", "script", "edit_script", "usage_type", "tactic"}
|
||||
if is_wild:
|
||||
allowed |= {"condition", "memo"}
|
||||
payload = {k: v for k, v in data.items() if k in allowed}
|
||||
|
||||
@ -14,6 +14,7 @@ import type { CardUsageType } from './cardUsageType';
|
||||
import type { CardStatus } from './cardStatus';
|
||||
import type { CardDataCondition } from './cardDataCondition';
|
||||
import type { CardDataMemo } from './cardDataMemo';
|
||||
import type { CardDataTactic } from './cardDataTactic';
|
||||
import type { CardDataCreatedAt } from './cardDataCreatedAt';
|
||||
import type { CardDataUpdatedAt } from './cardDataUpdatedAt';
|
||||
|
||||
@ -31,6 +32,7 @@ export interface CardData {
|
||||
status?: CardStatus;
|
||||
condition?: CardDataCondition;
|
||||
memo?: CardDataMemo;
|
||||
tactic?: CardDataTactic;
|
||||
created_at?: CardDataCreatedAt;
|
||||
updated_at?: CardDataUpdatedAt;
|
||||
success_rate?: number;
|
||||
|
||||
8
negodata/front/src/api/generated/model/cardDataTactic.ts
Normal file
8
negodata/front/src/api/generated/model/cardDataTactic.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type CardDataTactic = unknown | null;
|
||||
@ -15,6 +15,7 @@ export * from './cardDataMemo';
|
||||
export * from './cardDataName';
|
||||
export * from './cardDataNumber';
|
||||
export * from './cardDataScript';
|
||||
export * from './cardDataTactic';
|
||||
export * from './cardDataUpdatedAt';
|
||||
export * from './cardDataUserId';
|
||||
export * from './cardStatus';
|
||||
@ -155,6 +156,7 @@ export * from './reqCreateCardMemo';
|
||||
export * from './reqCreateCardName';
|
||||
export * from './reqCreateCardNumber';
|
||||
export * from './reqCreateCardScript';
|
||||
export * from './reqCreateCardTactic';
|
||||
export * from './reqCreateCompanyUser';
|
||||
export * from './reqCreateItem';
|
||||
export * from './reqCreateItemCategory';
|
||||
@ -215,6 +217,7 @@ export * from './reqUpdateCardName';
|
||||
export * from './reqUpdateCardNumber';
|
||||
export * from './reqUpdateCardScript';
|
||||
export * from './reqUpdateCardStatus';
|
||||
export * from './reqUpdateCardTactic';
|
||||
export * from './reqUpdateCardUsageType';
|
||||
export * from './reqUpdateCompanySettings';
|
||||
export * from './reqUpdateCompanySettingsSettings';
|
||||
|
||||
@ -10,6 +10,7 @@ import type { ReqCreateCardScript } from './reqCreateCardScript';
|
||||
import type { ReqCreateCardEditScript } from './reqCreateCardEditScript';
|
||||
import type { ReqCreateCardCondition } from './reqCreateCardCondition';
|
||||
import type { ReqCreateCardMemo } from './reqCreateCardMemo';
|
||||
import type { ReqCreateCardTactic } from './reqCreateCardTactic';
|
||||
|
||||
export interface ReqCreateCard {
|
||||
is_wildcard?: boolean;
|
||||
@ -22,4 +23,5 @@ export interface ReqCreateCard {
|
||||
status?: number;
|
||||
condition?: ReqCreateCardCondition;
|
||||
memo?: ReqCreateCardMemo;
|
||||
tactic?: ReqCreateCardTactic;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqCreateCardTactic = unknown | null;
|
||||
@ -12,6 +12,7 @@ import type { ReqUpdateCardUsageType } from './reqUpdateCardUsageType';
|
||||
import type { ReqUpdateCardStatus } from './reqUpdateCardStatus';
|
||||
import type { ReqUpdateCardCondition } from './reqUpdateCardCondition';
|
||||
import type { ReqUpdateCardMemo } from './reqUpdateCardMemo';
|
||||
import type { ReqUpdateCardTactic } from './reqUpdateCardTactic';
|
||||
|
||||
export interface ReqUpdateCard {
|
||||
name?: ReqUpdateCardName;
|
||||
@ -22,4 +23,5 @@ export interface ReqUpdateCard {
|
||||
status?: ReqUpdateCardStatus;
|
||||
condition?: ReqUpdateCardCondition;
|
||||
memo?: ReqUpdateCardMemo;
|
||||
tactic?: ReqUpdateCardTactic;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqUpdateCardTactic = unknown | null;
|
||||
@ -13,16 +13,37 @@ import { type NegotiationCard, type CardTab, generateCardCode } from '../types';
|
||||
import { CardUsageType } from '@/api/generated/model';
|
||||
import { CARD_USAGE_TYPE_LABEL, CARD_USAGE_TYPE_OPTIONS } from '@/lib/enumLabels';
|
||||
import type { CardInput } from '../hooks/useCards';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
CardScriptEditor,
|
||||
deserialize,
|
||||
serializeToText,
|
||||
serializeToMarker,
|
||||
hasConditionVariable,
|
||||
extractCondition,
|
||||
attachCondition,
|
||||
CONDITION_LABEL,
|
||||
} from '../editor';
|
||||
|
||||
// 제안가가 될 수 있는 변수 → 라벨. agent(tactics.OFFER_VARIABLES)와 동일 목록 — 스크립트의
|
||||
// 마지막 제안가 변수가 이 카드가 부를 금액이다(여기 없는 변수는 읽어주기 전용).
|
||||
const OFFER_VARIABLE_LABEL: Record<string, string> = {
|
||||
target_price: '목표가',
|
||||
anchoring_price: '앵커가',
|
||||
anchor_price: '앵커가',
|
||||
target_mid_price: '중간가 (앵커·목표의 중간)',
|
||||
middle_price: '절충가 (당사 직전 제안·제시가의 중간)',
|
||||
};
|
||||
|
||||
// 스크립트에서 제안가 변수를 파싱(마지막 매치) — agent parse_offer_variable 미러.
|
||||
function parseOfferLabel(editorScript: Descendant[]): string | null {
|
||||
const marker = serializeToMarker(editorScript);
|
||||
const found = [...marker.matchAll(/\{([a-z_]+)\}/g)]
|
||||
.map((m) => m[1])
|
||||
.filter((name) => name in OFFER_VARIABLE_LABEL);
|
||||
return found.length ? OFFER_VARIABLE_LABEL[found[found.length - 1]] : null;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
isWildcard: z.boolean(),
|
||||
usageType: z.number(),
|
||||
@ -35,6 +56,8 @@ const schema = z.object({
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']),
|
||||
triggerCondition: z.string(),
|
||||
memo: z.string(),
|
||||
closing: z.boolean(), // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만
|
||||
minRound: z.number({ message: '최소 라운드를 숫자로 입력해 주세요.' }).int().min(1, '최소 라운드는 1 이상이어야 합니다.'),
|
||||
}).refine(
|
||||
// 조건 전략 칩을 넣었으면 조건 내용도 작성해야 한다(빈 상태로 저장 시 문구가 비어버림).
|
||||
(v) => !hasConditionVariable(v.editorScript) || serializeToText(v.conditionScript).trim().length > 0,
|
||||
@ -73,6 +96,8 @@ function buildDefaults(
|
||||
status: card.status,
|
||||
triggerCondition: card.triggerCondition || '',
|
||||
memo: card.memo || '',
|
||||
closing: card.tactic?.closing ?? false,
|
||||
minRound: card.tactic?.min_round ?? 1,
|
||||
};
|
||||
}
|
||||
const wild = activeTab === 'WILD';
|
||||
@ -86,6 +111,8 @@ function buildDefaults(
|
||||
status: 'ACTIVE',
|
||||
triggerCondition: '',
|
||||
memo: '',
|
||||
closing: false,
|
||||
minRound: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@ -117,6 +144,8 @@ export function CardFormSheet({
|
||||
const isWildcard = watch('isWildcard');
|
||||
// 본문에 조건 전략 칩이 있으면 조건 내용 입력용 별도 에디터를 노출한다.
|
||||
const showConditionEditor = hasConditionVariable(watch('editorScript') || []);
|
||||
// 제시 가격 = 스크립트 파싱 결과(마지막 제안가 변수) — 입력이 아니라 표시(단일 진실은 스크립트).
|
||||
const offerLabel = parseOfferLabel(watch('editorScript') || []);
|
||||
|
||||
// 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
@ -142,6 +171,8 @@ export function CardFormSheet({
|
||||
usageType: v.usageType,
|
||||
triggerCondition: v.triggerCondition,
|
||||
memo: v.memo,
|
||||
// 항상 풀 객체로 전송 — 부분 전송(exclude_unset)이면 종결 해제가 DB 에 안 남는다.
|
||||
tactic: { min_round: v.minRound, closing: v.closing },
|
||||
};
|
||||
const kind = v.isWildcard ? '와일드카드' : '협상카드';
|
||||
try {
|
||||
@ -358,6 +389,50 @@ export function CardFormSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 협상 전술 — 제시 가격은 스크립트의 변수가 정하고(읽기 전용 표시), 운영 규칙만 입력받는다. */}
|
||||
<div className="space-y-3 p-3 bg-muted/40 rounded border border-border">
|
||||
<Typography variant="label" className="font-bold text-[10px] block">협상 전술</Typography>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">제시 가격</Typography>
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">
|
||||
{offerLabel ?? '없음 — 설득 전용 (가격 변수를 넣으면 그 값을 제시합니다)'}
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
스크립트의 마지막 가격 변수가 협력사에게 제시(수락 시 타결)할 금액입니다.
|
||||
제시 가격이 목표가를 넘거나 협력사 제시가보다 높으면 이 카드는 그 라운드에 발동하지 않습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="label" variant="small" className="font-semibold">종결 전용</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="closing"
|
||||
render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />}
|
||||
/>
|
||||
</div>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
켜면 협상 중반엔 아껴두고, 라운드 상한·카드 소진 시 마지막 제안으로만 발동합니다.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">최소 라운드</Typography>
|
||||
<Input
|
||||
id="form-card-min-round"
|
||||
type="number"
|
||||
min={1}
|
||||
className="text-xs"
|
||||
{...register('minRound', { valueAsNumber: true })}
|
||||
/>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
협력사가 가격을 이 횟수 이상 제시한 뒤부터 발동합니다. (기본 1 = 첫 제안부터)
|
||||
</Typography>
|
||||
{errors.minRound && <p className="text-[10px] text-rose-500">{errors.minRound.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wildcard-only fields */}
|
||||
{isWildcard && (
|
||||
<div className="space-y-4 p-3 bg-muted/40 rounded border border-border">
|
||||
|
||||
@ -27,6 +27,7 @@ export type CardInput = {
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
triggerCondition?: string;
|
||||
memo?: string;
|
||||
tactic?: { min_round?: number; closing?: boolean }; // 전술 운영 규칙. 제안가는 스크립트 변수가 정한다
|
||||
};
|
||||
|
||||
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.
|
||||
@ -50,6 +51,7 @@ function toReq(input: CardInput): ReqCreateCard {
|
||||
status: toCardStatusCode(input.status),
|
||||
condition: input.isWildcard ? input.triggerCondition : undefined,
|
||||
memo: input.isWildcard ? input.memo : undefined,
|
||||
tactic: input.tactic,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
creatorName: c.creator_name ?? undefined,
|
||||
successRate: c.success_rate ?? 0,
|
||||
usedCount: c.used_count ?? 0,
|
||||
tactic: (c.tactic as NegotiationCard['tactic']) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,13 @@ export interface NegotiationCard {
|
||||
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
|
||||
successRate: number; // 카드 성공률(사용 세션 중 타결 비율). #12 순위
|
||||
usedCount: number; // 카드 사용 세션 수(표본)
|
||||
tactic?: CardTactic; // 전술 운영 규칙. 제안가는 스크립트 변수 파싱으로 결정(agent)
|
||||
}
|
||||
|
||||
// 카드 전술 운영 규칙(card.*.tactic JSONB) — 스크립트로 알 수 없는 것만 담는다.
|
||||
export interface CardTactic {
|
||||
min_round?: number; // 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준)
|
||||
closing?: boolean; // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만
|
||||
}
|
||||
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||
|
||||
70
postgres-init/alters/2026-08-04-card-tactic.sql
Normal file
70
postgres-init/alters/2026-08-04-card-tactic.sql
Normal file
@ -0,0 +1,70 @@
|
||||
-- 2026-08-04 · 카드 전술 데이터화(tactic JSONB) + 카드번호 정본화 (기존 DB 보정)
|
||||
-- 배경: IMK 요청 2건(동일 카드 연속 사용 방지 · 중간값 절충 계산 확인) 대응 —
|
||||
-- 카드 전술을 코드 하드코딩(agent tactics.py _TACTICS)에서 DB 로 옮긴다.
|
||||
-- 제안가는 스크립트 변수 파싱으로 결정하고, 문장으로 알 수 없는 운영 규칙만 tactic 에 둔다:
|
||||
-- {"min_round": N, "closing": bool} -- NULL/빈 객체 = 기본값(1라운드부터·비종결)
|
||||
-- 멱등: ADD COLUMN IF NOT EXISTS + 조건 가드 UPDATE — 여러 번 실행해도 안전.
|
||||
-- 적용: psql -h <host> -p <port> -U <user> -d <db> -f postgres-init/alters/2026-08-04-card-tactic.sql
|
||||
-- (구번호 '1'~'11'/'1'~'5' DB 는 이 파일이 정본 번호로 먼저 정규화한 뒤,
|
||||
-- 2026-07-24-nego-card-scripts.sql 을 이어서 적용해야 스크립트 개정까지 맞는다.)
|
||||
|
||||
\connect negosium_db
|
||||
|
||||
-- 1) 전술 컬럼
|
||||
ALTER TABLE card.nego_cards ADD COLUMN IF NOT EXISTS tactic JSONB NULL;
|
||||
ALTER TABLE card.wild_cards ADD COLUMN IF NOT EXISTS tactic JSONB NULL;
|
||||
COMMENT ON COLUMN card.nego_cards.tactic IS '카드 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱으로 결정 — agent tactics.build_card_spec';
|
||||
COMMENT ON COLUMN card.wild_cards.tactic IS '카드 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱으로 결정 — agent tactics.build_card_spec';
|
||||
|
||||
-- 2) 카드번호 정본화 — 초기 시드(구번호 '1'~'11'/'1'~'5')를 정본 표기(NGC-0xx/WC-0x)로.
|
||||
-- agent 전술 스펙·backend 카드 UUID 역조회·negodata 표기가 전부 번호 기준이라 표기를 통일한다.
|
||||
-- (정본 번호가 이미 들어간 DB 는 정규식 가드에 안 걸려 no-op.)
|
||||
UPDATE card.nego_cards
|
||||
SET number = 'NGC-' || lpad(number, 3, '0'), updated_at = now()
|
||||
WHERE number ~ '^[0-9]{1,2}$' AND deleted = FALSE;
|
||||
UPDATE card.wild_cards
|
||||
SET number = 'WC-' || lpad(number, 2, '0'), updated_at = now()
|
||||
WHERE number ~ '^[0-9]{1,2}$' AND deleted = FALSE;
|
||||
|
||||
-- 3) 구시드 WC-01 스크립트의 미정의 변수 {customer_reference} 제거 — 치환표에 없어 토큰이
|
||||
-- 협력사 화면에 그대로 노출된다. 정본 문구(시장 상황과 거래 조건)로 교체.
|
||||
UPDATE card.wild_cards
|
||||
SET script = replace(script, '{customer_reference}을(를)', '시장 상황과 거래 조건을'),
|
||||
edit_script = replace(edit_script::text, '{customer_reference}을(를)', '시장 상황과 거래 조건을')::jsonb,
|
||||
updated_at = now()
|
||||
WHERE number = 'WC-01' AND deleted = FALSE AND script LIKE '%{customer_reference}%';
|
||||
|
||||
-- 4) 전술 값 — 문장으로 알 수 없는 운영 규칙만.
|
||||
-- WC-03 최종 통보 · WC-05 중간값 절충 = 종결 전용(라운드 상한·카드 소진 때의 마지막 한 방).
|
||||
-- WC-04 단계적 인하 제안 = 2라운드부터("당초 적정가 → 한발 물러서" 서사가 첫 제안엔 성립 안 함).
|
||||
UPDATE card.wild_cards SET tactic = '{"closing": true}'::jsonb, updated_at = now()
|
||||
WHERE number IN ('WC-03', 'WC-05') AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
UPDATE card.wild_cards SET tactic = '{"min_round": 2}'::jsonb, updated_at = now()
|
||||
WHERE number = 'WC-04' AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
|
||||
-- 5) 구시드 WC-03(최종 통보) 멘트 정본화 — 구멘트엔 가격 변수가 없어 "최종 제안 금액이 안 보이는 채
|
||||
-- 수락 버튼"이 뜨고, 전술 파싱도 설득 카드로 오판한다(종결에서 스킵 → 항상 최후통첩 폴백).
|
||||
-- 정본(init-data.sql)의 {target_price} 포함 멘트로 교체. 멱등: 가격 변수 없는 행만.
|
||||
UPDATE card.wild_cards
|
||||
SET script = '합리적인 기준에 근거하여 당사의 최종 목표 가격 {target_price}원(VAT별도)을 제안 드립니다. 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다.
|
||||
|
||||
이번 협상이 결렬되는 경우 우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.
|
||||
|
||||
본 금액을 수락하시면 해당 가격으로 최종 확정되며, 재검토가 필요하시면 다른 제안 가격을 입력해 주시기 바랍니다.',
|
||||
edit_script = '[{"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,
|
||||
updated_at = now()
|
||||
WHERE number = 'WC-03' AND deleted = FALSE
|
||||
AND script NOT LIKE '%{target_price}%';
|
||||
|
||||
-- 6) 구시드 NGC-010(향후 거래 연계) 멘트 정본화 — 구멘트의 {customer_condition} 토큰은 조건 미작성 시
|
||||
-- 협상 채팅에 원형 노출된다(정본은 "2년 장기계약" 평문). edit_script 는 비워 평문 폴백(재편집 시 복원).
|
||||
UPDATE card.nego_cards
|
||||
SET script = '이번 거래의 가격을 {target_price}원으로 조정하는 대신, 2년 장기계약을 함께 검토해 주실 것을 제안 드립니다.
|
||||
|
||||
당장의 단가 한 건만 보기보다 향후 이어질 거래까지 함께 고려한다면, 양사 모두에게 더 큰 가치를 만들 수 있습니다. 이번 합의를 장기적 관계의 출발점으로 삼아, 서로에게 이익이 되는 구조를 함께 설계하기를 바랍니다.',
|
||||
edit_script = NULL,
|
||||
updated_at = now()
|
||||
WHERE number = 'NGC-010' AND deleted = FALSE
|
||||
AND script LIKE '%{customer_condition}%';
|
||||
@ -192,6 +192,7 @@ CREATE TABLE IF NOT EXISTS card.nego_cards (
|
||||
usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용)
|
||||
tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
|
||||
strategy_type SMALLINT NULL, -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결
|
||||
tactic JSONB NULL, -- 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent tactics)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -210,6 +211,7 @@ CREATE TABLE IF NOT EXISTS card.wild_cards (
|
||||
condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함
|
||||
available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가
|
||||
memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모
|
||||
tactic JSONB NULL, -- 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent tactics)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
|
||||
@ -215,3 +215,11 @@ SELECT * FROM (VALUES
|
||||
1, NULL::varchar, TRUE, NULL::varchar, 4, 6)
|
||||
) AS v(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM card.wild_cards WHERE user_id IS NULL AND deleted = FALSE);
|
||||
|
||||
-- 와일드카드 전술 운영 규칙(tactic) — init-data.sql 과 동일(멱등).
|
||||
UPDATE card.wild_cards SET tactic = '{"closing": true}'::jsonb
|
||||
WHERE number IN ('WC-03', 'WC-05') AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
UPDATE card.wild_cards SET tactic = '{"min_round": 2}'::jsonb
|
||||
WHERE number = 'WC-04' AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
|
||||
@ -169,3 +169,11 @@ SELECT * FROM (VALUES
|
||||
1, NULL::varchar, TRUE, NULL::varchar, 4, 6)
|
||||
) AS v(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM card.wild_cards WHERE user_id IS NULL AND deleted = FALSE);
|
||||
|
||||
-- 와일드카드 전술 운영 규칙(tactic) — init-data.sql 과 동일(멱등).
|
||||
UPDATE card.wild_cards SET tactic = '{"closing": true}'::jsonb
|
||||
WHERE number IN ('WC-03', 'WC-05') AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
UPDATE card.wild_cards SET tactic = '{"min_round": 2}'::jsonb
|
||||
WHERE number = 'WC-04' AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
|
||||
@ -193,3 +193,14 @@ SELECT * FROM (VALUES
|
||||
1, NULL::varchar, TRUE, NULL::varchar, 4, 6)
|
||||
) AS v(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM card.wild_cards WHERE user_id IS NULL AND deleted = FALSE);
|
||||
|
||||
-- 와일드카드 전술 운영 규칙(tactic) — 제안가는 script 변수 파싱으로 결정되므로,
|
||||
-- 문장으로 알 수 없는 규칙(종결 전용·최소 라운드)만 여기 시드한다. 멱등(미설정 행만 갱신).
|
||||
-- WC-03 최종 통보 · WC-05 중간값 절충 = 종결 국면 전용(마지막 한 방으로 예약)
|
||||
-- WC-04 단계적 인하 제안 = 2라운드부터("당초 적정가 → 한발 물러서" 서사)
|
||||
UPDATE card.wild_cards SET tactic = '{"closing": true}'::jsonb
|
||||
WHERE number IN ('WC-03', 'WC-05') AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
UPDATE card.wild_cards SET tactic = '{"min_round": 2}'::jsonb
|
||||
WHERE number = 'WC-04' AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
|
||||
@ -226,6 +226,7 @@ CREATE TABLE IF NOT EXISTS card.nego_cards (
|
||||
usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용)
|
||||
tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
|
||||
strategy_type SMALLINT NULL, -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결
|
||||
tactic JSONB NULL, -- 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent tactics)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -244,6 +245,7 @@ CREATE TABLE IF NOT EXISTS card.wild_cards (
|
||||
condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함
|
||||
available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가
|
||||
memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모
|
||||
tactic JSONB NULL, -- 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent tactics)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
|
||||
Loading…
Reference in New Issue
Block a user