From 6f5d69b5cb11265852533cf1e9f775721222ad63 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 5 Aug 2026 11:48:14 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20agent=C2=B7negodata:=20=ED=98=91?= =?UTF-8?q?=EC=83=81=EC=B9=B4=EB=93=9C=20=EC=A0=84=EC=88=A0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=ED=99=94(tactic=20JSONB=C2=B7=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=8C=8C=EC=8B=B1)=20+=20?= =?UTF-8?q?=EB=B0=9C=EB=8F=99=EA=B7=9C=EC=B9=99=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장) --- agent/negotiation/cards/domain/tactics.py | 268 ++++++++++------- agent/tests/fuzz_negotiation.py | 157 ++++++++++ agent/tests/test_agent_integration.py | 2 +- agent/tests/test_card_selection_e2e.py | 2 +- agent/tests/test_card_tactics.py | 281 ++++++++++++++---- agent/tests/test_decision_rules.py | 9 +- agent/tests/test_negotiation_invariants.py | 190 ++++++++++++ agent/tests/test_p4_registry_middleware.py | 4 +- agent/tests/test_p5_warmstart.py | 16 +- agent/tests/test_scripts_resources.py | 26 +- .../backend/common/database/model/models.py | 2 + negodata/backend/router/v1/card/protocol.py | 3 + negodata/backend/services/card_service.py | 5 +- .../front/src/api/generated/model/cardData.ts | 2 + .../src/api/generated/model/cardDataTactic.ts | 8 + .../front/src/api/generated/model/index.ts | 3 + .../src/api/generated/model/reqCreateCard.ts | 2 + .../generated/model/reqCreateCardTactic.ts | 8 + .../src/api/generated/model/reqUpdateCard.ts | 2 + .../generated/model/reqUpdateCardTactic.ts | 8 + .../cards/components/CardFormSheet.tsx | 75 +++++ .../src/features/cards/hooks/useCards.ts | 2 + negodata/front/src/features/cards/types.ts | 1 + negodata/front/src/types.ts | 7 + .../alters/2026-08-04-card-tactic.sql | 70 +++++ postgres-init/dbeaver/1_init_dbeaver.sql | 2 + postgres-init/dbeaver/2_init_data_dbeaver.sql | 8 + .../dbeaver/4_cards_reset_dbeaver.sql | 8 + postgres-init/init-data/init-data.sql | 11 + postgres-init/init-data/init.sql | 2 + 30 files changed, 1010 insertions(+), 174 deletions(-) create mode 100644 agent/tests/fuzz_negotiation.py create mode 100644 agent/tests/test_negotiation_invariants.py create mode 100644 negodata/front/src/api/generated/model/cardDataTactic.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateCardTactic.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCardTactic.ts create mode 100644 postgres-init/alters/2026-08-04-card-tactic.sql diff --git a/agent/negotiation/cards/domain/tactics.py b/agent/negotiation/cards/domain/tactics.py index 9fdfe7c..cabb29a 100644 --- a/agent/negotiation/cards/domain/tactics.py +++ b/agent/negotiation/cards/domain/tactics.py @@ -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 diff --git a/agent/tests/fuzz_negotiation.py b/agent/tests/fuzz_negotiation.py new file mode 100644 index 0000000..78d8303 --- /dev/null +++ b/agent/tests/fuzz_negotiation.py @@ -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"(?= {"예", "아니오"}: + 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()) diff --git a/agent/tests/test_agent_integration.py b/agent/tests/test_agent_integration.py index 0e8738f..b08fae2 100644 --- a/agent/tests/test_agent_integration.py +++ b/agent/tests/test_agent_integration.py @@ -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 diff --git a/agent/tests/test_card_selection_e2e.py b/agent/tests/test_card_selection_e2e.py index 4cc1f36..febc526 100644 --- a/agent/tests/test_card_selection_e2e.py +++ b/agent/tests/test_card_selection_e2e.py @@ -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 = [] diff --git a/agent/tests/test_card_tactics.py b/agent/tests/test_card_tactics.py index fd27bf7..f61ad9d 100644 --- a/agent/tests/test_card_tactics.py +++ b/agent/tests/test_card_tactics.py @@ -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) diff --git a/agent/tests/test_decision_rules.py b/agent/tests/test_decision_rules.py index 5e475fa..392813c 100644 --- a/agent/tests/test_decision_rules.py +++ b/agent/tests/test_decision_rules.py @@ -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(): diff --git a/agent/tests/test_negotiation_invariants.py b/agent/tests/test_negotiation_invariants.py new file mode 100644 index 0000000..c8e6788 --- /dev/null +++ b/agent/tests/test_negotiation_invariants.py @@ -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) diff --git a/agent/tests/test_p4_registry_middleware.py b/agent/tests/test_p4_registry_middleware.py index 3f53cdd..19f4eb2 100644 --- a/agent/tests/test_p4_registry_middleware.py +++ b/agent/tests/test_p4_registry_middleware.py @@ -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 diff --git a/agent/tests/test_p5_warmstart.py b/agent/tests/test_p5_warmstart.py index 73f8e66..e6bd07f 100644 --- a/agent/tests/test_p5_warmstart.py +++ b/agent/tests/test_p5_warmstart.py @@ -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 diff --git a/agent/tests/test_scripts_resources.py b/agent/tests/test_scripts_resources.py index d222225..1710be1 100644 --- a/agent/tests/test_scripts_resources.py +++ b/agent/tests/test_scripts_resources.py @@ -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 == ["협력사배송", "지정택배배송", "픽업배송"] diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 6509aba..1a327e4 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -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): diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index f6f456e..523dc95 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -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 순위용 diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index a21381b..b993f63 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -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} diff --git a/negodata/front/src/api/generated/model/cardData.ts b/negodata/front/src/api/generated/model/cardData.ts index 65cddc8..3ff857c 100644 --- a/negodata/front/src/api/generated/model/cardData.ts +++ b/negodata/front/src/api/generated/model/cardData.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/cardDataTactic.ts b/negodata/front/src/api/generated/model/cardDataTactic.ts new file mode 100644 index 0000000..f0f3790 --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataTactic.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index dececef..924c176 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -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'; diff --git a/negodata/front/src/api/generated/model/reqCreateCard.ts b/negodata/front/src/api/generated/model/reqCreateCard.ts index 46e2b63..1fdd708 100644 --- a/negodata/front/src/api/generated/model/reqCreateCard.ts +++ b/negodata/front/src/api/generated/model/reqCreateCard.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/reqCreateCardTactic.ts b/negodata/front/src/api/generated/model/reqCreateCardTactic.ts new file mode 100644 index 0000000..1e9ff03 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateCardTactic.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqUpdateCard.ts b/negodata/front/src/api/generated/model/reqUpdateCard.ts index 646a297..f1d910b 100644 --- a/negodata/front/src/api/generated/model/reqUpdateCard.ts +++ b/negodata/front/src/api/generated/model/reqUpdateCard.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/reqUpdateCardTactic.ts b/negodata/front/src/api/generated/model/reqUpdateCardTactic.ts new file mode 100644 index 0000000..efaea5f --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardTactic.ts @@ -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; diff --git a/negodata/front/src/features/cards/components/CardFormSheet.tsx b/negodata/front/src/features/cards/components/CardFormSheet.tsx index 1146823..5e1082e 100644 --- a/negodata/front/src/features/cards/components/CardFormSheet.tsx +++ b/negodata/front/src/features/cards/components/CardFormSheet.tsx @@ -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 = { + 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({ )} + {/* 협상 전술 — 제시 가격은 스크립트의 변수가 정하고(읽기 전용 표시), 운영 규칙만 입력받는다. */} +
+ 협상 전술 +
+ 제시 가격 + + {offerLabel ?? '없음 — 설득 전용 (가격 변수를 넣으면 그 값을 제시합니다)'} + + + 스크립트의 마지막 가격 변수가 협력사에게 제시(수락 시 타결)할 금액입니다. + 제시 가격이 목표가를 넘거나 협력사 제시가보다 높으면 이 카드는 그 라운드에 발동하지 않습니다. + +
+
+
+
+ 종결 전용 + } + /> +
+ + 켜면 협상 중반엔 아껴두고, 라운드 상한·카드 소진 시 마지막 제안으로만 발동합니다. + +
+
+ 최소 라운드 + + + 협력사가 가격을 이 횟수 이상 제시한 뒤부터 발동합니다. (기본 1 = 첫 제안부터) + + {errors.minRound &&

{errors.minRound.message}

} +
+
+
+ {/* Wildcard-only fields */} {isWildcard && (
diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index 9ac242d..566f3a7 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -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, }; } diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index fa31dad..468598d 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -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, }; } diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 3f5fe22..62359da 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -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'; diff --git a/postgres-init/alters/2026-08-04-card-tactic.sql b/postgres-init/alters/2026-08-04-card-tactic.sql new file mode 100644 index 0000000..39050a2 --- /dev/null +++ b/postgres-init/alters/2026-08-04-card-tactic.sql @@ -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 -p -U -d -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}%'; diff --git a/postgres-init/dbeaver/1_init_dbeaver.sql b/postgres-init/dbeaver/1_init_dbeaver.sql index 70c1478..707385a 100644 --- a/postgres-init/dbeaver/1_init_dbeaver.sql +++ b/postgres-init/dbeaver/1_init_dbeaver.sql @@ -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 -- 소프트 삭제 여부 diff --git a/postgres-init/dbeaver/2_init_data_dbeaver.sql b/postgres-init/dbeaver/2_init_data_dbeaver.sql index cfcf8d3..ade1e35 100644 --- a/postgres-init/dbeaver/2_init_data_dbeaver.sql +++ b/postgres-init/dbeaver/2_init_data_dbeaver.sql @@ -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); diff --git a/postgres-init/dbeaver/4_cards_reset_dbeaver.sql b/postgres-init/dbeaver/4_cards_reset_dbeaver.sql index 3a52ef7..680615e 100644 --- a/postgres-init/dbeaver/4_cards_reset_dbeaver.sql +++ b/postgres-init/dbeaver/4_cards_reset_dbeaver.sql @@ -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); diff --git a/postgres-init/init-data/init-data.sql b/postgres-init/init-data/init-data.sql index 22b1bbd..effb0bf 100644 --- a/postgres-init/init-data/init-data.sql +++ b/postgres-init/init-data/init-data.sql @@ -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); diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index fd73d4c..accb0cb 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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 -- 소프트 삭제 여부