[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:
Mina Choi 2026-08-05 11:48:14 +09:00
parent 71ef28110b
commit 6f5d69b5cb
30 changed files with 1010 additions and 174 deletions

View File

@ -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

View 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())

View File

@ -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

View File

@ -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 = []

View File

@ -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)

View File

@ -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():

View 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)

View File

@ -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

View File

@ -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

View File

@ -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 == ["협력사배송", "지정택배배송", "픽업배송"]

View File

@ -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):

View File

@ -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 순위용

View File

@ -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}

View File

@ -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;

View 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;

View File

@ -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';

View File

@ -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;
}

View 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 ReqCreateCardTactic = unknown | null;

View File

@ -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;
}

View 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 ReqUpdateCardTactic = unknown | null;

View File

@ -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">

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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';

View 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}%';

View File

@ -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 -- 소프트 삭제 여부

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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 -- 소프트 삭제 여부