diff --git a/agent/tests/fuzz_negotiation.py b/agent/tests/fuzz_negotiation.py index 78d8303..8f402ec 100644 --- a/agent/tests/fuzz_negotiation.py +++ b/agent/tests/fuzz_negotiation.py @@ -26,7 +26,7 @@ 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 +N = 100 SEED = 20260805 TARGET = 10_000 NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005", diff --git a/agent/tests/test_card_tactics.py b/agent/tests/test_card_tactics.py index f61ad9d..f0503e2 100644 --- a/agent/tests/test_card_tactics.py +++ b/agent/tests/test_card_tactics.py @@ -114,6 +114,15 @@ def test_available_min_round_and_closing_phase(): assert available(spec2, {"round": 3}, closing_phase=True) is False # 종결 국면엔 종결 카드만 +def test_tactic_offer_variable_overrides_parse(): + """검증: tactic.offer_variable 명시 지정(negodata 셀렉트) — 파싱(마지막 변수) 대신 지정 변수 사용. + 기대결과: 멘트 마지막이 target_price 여도 지정한 anchoring_price 가 제안가 변수가 된다.""" + script = "적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안 드립니다." + assert build_card_spec(script).offer_variable == "target_price" # 자동: 마지막 변수 + spec = build_card_spec(script, {"offer_variable": "anchoring_price"}) + assert spec.offer_variable == "anchoring_price" # 명시 지정이 우선 + + def test_available_requires_context_value(): """검증: 시장가 인용 카드(NGC-008류)의 requires 게이트 — build_card_spec 이 스크립트에서 잡아내고, 기대결과: 컨텍스트에 인터넷 최저가가 없으면(0/결측) 미발동, 있으면 발동(퍼즈 #3·13·23·40 회귀).""" diff --git a/negodata/front/src/features/cards/components/CardFormSheet.tsx b/negodata/front/src/features/cards/components/CardFormSheet.tsx index 5e1082e..32dabcd 100644 --- a/negodata/front/src/features/cards/components/CardFormSheet.tsx +++ b/negodata/front/src/features/cards/components/CardFormSheet.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -35,13 +36,14 @@ const OFFER_VARIABLE_LABEL: Record = { middle_price: '절충가 (당사 직전 제안·제시가의 중간)', }; -// 스크립트에서 제안가 변수를 파싱(마지막 매치) — agent parse_offer_variable 미러. -function parseOfferLabel(editorScript: Descendant[]): string | null { +// 스크립트에 등장하는 제안가 변수들(등장 순서 그대로, 중복 포함) — agent parse_offer_variable 미러. +// 자동 모드의 제안가 = 마지막 원소. 셀렉트 선택지는 이 목록(중복 제거)으로 제한한다 +// (멘트에 없는 변수를 고르면 문구와 계산이 어긋나는 사고가 되살아나므로 원천 차단). +function parseOfferVariables(editorScript: Descendant[]): string[] { const marker = serializeToMarker(editorScript); - const found = [...marker.matchAll(/\{([a-z_]+)\}/g)] + return [...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({ @@ -58,6 +60,7 @@ const schema = z.object({ memo: z.string(), closing: z.boolean(), // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만 minRound: z.number({ message: '최소 라운드를 숫자로 입력해 주세요.' }).int().min(1, '최소 라운드는 1 이상이어야 합니다.'), + offerVariable: z.string(), // 제시 가격 변수. 'auto'=멘트에서 파싱(기본), 그 외=명시 지정(멘트에 있는 변수만) }).refine( // 조건 전략 칩을 넣었으면 조건 내용도 작성해야 한다(빈 상태로 저장 시 문구가 비어버림). (v) => !hasConditionVariable(v.editorScript) || serializeToText(v.conditionScript).trim().length > 0, @@ -98,6 +101,7 @@ function buildDefaults( memo: card.memo || '', closing: card.tactic?.closing ?? false, minRound: card.tactic?.min_round ?? 1, + offerVariable: card.tactic?.offer_variable ?? 'auto', }; } const wild = activeTab === 'WILD'; @@ -113,6 +117,7 @@ function buildDefaults( memo: '', closing: false, minRound: 1, + offerVariable: 'auto', }; } @@ -144,8 +149,19 @@ export function CardFormSheet({ const isWildcard = watch('isWildcard'); // 본문에 조건 전략 칩이 있으면 조건 내용 입력용 별도 에디터를 노출한다. const showConditionEditor = hasConditionVariable(watch('editorScript') || []); - // 제시 가격 = 스크립트 파싱 결과(마지막 제안가 변수) — 입력이 아니라 표시(단일 진실은 스크립트). - const offerLabel = parseOfferLabel(watch('editorScript') || []); + // 제시 가격 — 기본은 스크립트 파싱(마지막 제안가 변수), 필요 시 멘트에 있는 변수 중에서 명시 선택. + const offerVarsRaw = parseOfferVariables(watch('editorScript') || []); + const offerVarOptions = [...new Set(offerVarsRaw)]; // 셀렉트 선택지(중복 제거) + const autoOfferVar = offerVarsRaw.length ? offerVarsRaw[offerVarsRaw.length - 1] : null; + const offerVariable = watch('offerVariable'); + // 멘트를 고쳐 선택했던 변수가 사라지면 자동으로 되돌린다 — 멘트에 없는 변수 지정은 불가. + useEffect(() => { + if (offerVariable !== 'auto' && !offerVarOptions.includes(offerVariable)) { + setValue('offerVariable', 'auto'); + } + // offerVarOptions 는 매 렌더 새 배열 — 내용 키로만 감지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offerVariable, offerVarOptions.join(',')]); // 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙). const myUserId = useAuthStore((s) => s.user?.userId); @@ -171,8 +187,13 @@ export function CardFormSheet({ usageType: v.usageType, triggerCondition: v.triggerCondition, memo: v.memo, - // 항상 풀 객체로 전송 — 부분 전송(exclude_unset)이면 종결 해제가 DB 에 안 남는다. - tactic: { min_round: v.minRound, closing: v.closing }, + // 항상 풀 객체로 전송 — 부분 전송이면 해제가 DB에 안 남는다. 자동 모드는 offer_variable 키를 뺀다. + // 종결 전용은 와일드카드만(종결 국면이 와일드카드 목록에서만 뽑음) — 협상카드는 항상 false. + tactic: { + min_round: v.minRound, + closing: v.isWildcard ? v.closing : false, + ...(v.offerVariable !== 'auto' ? { offer_variable: v.offerVariable } : {}), + }, }; const kind = v.isWildcard ? '와일드카드' : '협상카드'; try { @@ -389,33 +410,64 @@ export function CardFormSheet({ )} - {/* 협상 전술 — 제시 가격은 스크립트의 변수가 정하고(읽기 전용 표시), 운영 규칙만 입력받는다. */} + {/* 협상 전술 — 제시 가격(기본=멘트 파싱, 멘트에 있는 변수 중 명시 선택 가능) + 운영 규칙. */}
협상 전술
제시 가격 - - {offerLabel ?? '없음 — 설득 전용 (가격 변수를 넣으면 그 값을 제시합니다)'} - + {offerVarOptions.length === 0 ? ( + + 없음 — 설득 전용 (스크립트에 가격 변수를 넣으면 그 값을 제시합니다) + + ) : ( + ( + + )} + /> + )} - 스크립트의 마지막 가격 변수가 협력사에게 제시(수락 시 타결)할 금액입니다. - 제시 가격이 목표가를 넘거나 협력사 제시가보다 높으면 이 카드는 그 라운드에 발동하지 않습니다. + 협력사에게 제시(수락 시 타결)할 금액입니다. 멘트에 넣은 가격 변수 중에서만 고를 수 있으며, + 제시 가격이 타결 상한을 넘거나 협력사 제시가보다 높거나 직전 당사 제안보다 낮으면 그 라운드에 발동하지 않습니다.
-
-
- 종결 전용 - } - /> + {/* 종결 전용은 와일드카드에만 — 종결 국면은 와일드카드 목록에서만 카드를 뽑으므로 + 협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. */} + {isWildcard && ( +
+
+ 종결 전용 + } + /> +
+ + 켜면 협상 중반엔 아껴두고, 라운드 상한·카드 소진 시 마지막 제안으로만 발동합니다. +
- - 켜면 협상 중반엔 아껴두고, 라운드 상한·카드 소진 시 마지막 제안으로만 발동합니다. - -
+ )}
최소 라운드