[feat] negodata/front·agent: 카드 제시 가격 변수 선택 + 종결 전용 토글 와일드카드 한정

- 카드 상세 "협상 전술"의 제시 가격을 표시 전용 → 셀렉트로: 기본 '자동(멘트의 마지막 가격 변수)',
  가격 변수가 여럿인 카드만 명시 선택이 의미. 선택지는 멘트에 실제 꽂힌 변수로 제한하고
  멘트 수정으로 선택 변수가 사라지면 자동으로 리셋 — 문구≠계산 어긋남 원천 차단.
  명시 선택 시에만 tactic.offer_variable 저장(agent build_card_spec 이 파싱보다 우선 적용, 기존 경로).
- 종결 전용 토글은 와일드카드 폼에만 노출 — 종결 국면이 와일드카드 목록에서만 카드를 뽑으므로
  협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. 협상카드는 저장 시 항상 false.
- 퍼즈 하네스 100케이스로 확대(시드 고정) — 100/100 불변식 위반 0(타결 94/결렬 6).
- override 우선순위 단위 테스트 추가(자동=마지막 변수, tactic.offer_variable 지정 시 지정 변수).

검증: agent 179 통과 · front tsc+eslint 통과 · 랜덤 협상 100회 완주 위반 0
This commit is contained in:
Mina Choi 2026-08-05 15:58:23 +09:00
parent c56cf8e3af
commit 54f8f7a6a1
5 changed files with 91 additions and 29 deletions

View File

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

View File

@ -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 회귀)."""

View File

@ -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<string, string> = {
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,20 +410,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>
{offerVarOptions.length === 0 ? (
<Typography as="p" variant="small" className="text-muted-foreground">
{offerLabel ?? '없음 — 설득 전용 (가격 변수를 넣으면 그 값을 제시합니다)'}
없음 — 설득 전용 (스크립트에 가격 변수를 넣으면 그 값을 제시합니다)
</Typography>
) : (
<Controller
control={control}
name="offerVariable"
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => field.onChange(v ?? 'auto')}>
<SelectTrigger id="form-card-offer-variable" className="w-full">
<SelectValue>
{(value) =>
value === 'auto' || !value
? `자동 — ${autoOfferVar ? OFFER_VARIABLE_LABEL[autoOfferVar] : ''} (멘트의 마지막 가격 변수)`
: OFFER_VARIABLE_LABEL[String(value)] ?? String(value)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
자동 — {autoOfferVar ? OFFER_VARIABLE_LABEL[autoOfferVar] : ''} (멘트의 마지막 가격 변수)
</SelectItem>
{offerVarOptions.map((name) => (
<SelectItem key={name} value={name}>{OFFER_VARIABLE_LABEL[name]}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
)}
<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">
{/* 종결 전용은 와일드카드에만 — 종결 국면은 와일드카드 목록에서만 카드를 뽑으므로
협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. */}
{isWildcard && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Typography as="label" variant="small" className="font-semibold">종결 전용</Typography>
@ -416,6 +467,7 @@ export function CardFormSheet({
켜면 협상 중반엔 아껴두고, 라운드 상한·카드 소진 시 마지막 제안으로만 발동합니다.
</Typography>
</div>
)}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">최소 라운드</Typography>
<Input

View File

@ -27,7 +27,7 @@ export type CardInput = {
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
triggerCondition?: string;
memo?: string;
tactic?: { min_round?: number; closing?: boolean }; // 전술 운영 규칙. 제안가는 스크립트 변수가 정한다
tactic?: { min_round?: number; closing?: boolean; offer_variable?: string }; // 전술 운영 규칙. 제안가는 기본 멘트 파싱, offer_variable 로 명시 지정 가능
};
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.

View File

@ -42,6 +42,7 @@ export interface NegotiationCard {
export interface CardTactic {
min_round?: number; // 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준)
closing?: boolean; // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만
offer_variable?: string; // 제시 가격 변수 명시 지정. 없으면 멘트 파싱(마지막 가격 변수) — 멘트에 있는 변수만 허용
}
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DEV_SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';