Compare commits

...

5 Commits

Author SHA1 Message Date
0fab2f9cb2 [chore] agent: 서빙 번들 .prev 백업은 추적 제외
export 도구가 번들 교체 때마다 자동 생성하는 직전본 백업 — 버전 기록은
autonomy_v3x.npz 명명본이 담당하므로 git 에는 중복이다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:04:07 +09:00
8f4c82a282 Merge feat/switch-model into feat/switch-model-v2 (main 1cce752 기준)
충돌 해결 원칙: main 의 협상 고도화(결정 스택 규칙층·ScriptNaturalizer 표현층·카드 전술·
LPS 최저가 연동)와 switch-model 의 완전 자율 모드(DQN·봉투·ment_generator)를 모두 유지.
- chat_engine: rules(config 규칙층) + autonomy_decider 공존, vars_for 는 main 전술 변수 +
  자율 변수(autonomy_offer/internet_lowest_price/customer_condition) 합집합,
  wild_card_1pct 수락 분기는 main 의 pending_counter_price 일반화로 대체(자율 분기만 유지)
- chat_service: tactics/naturalizer import + ment_generator import 병존,
  _play_closing_tactic(main) + _autonomy_learn(자율 로깅) 메서드 병존
- nego_context_crud: _ITEMS/_SUPPLIERS 컬럼 합집합 (name + internet_lowest_price)
- docker-compose: OPENAI_API_KEY passthrough + DQN_SERVING/AUTONOMY_MODE 플래그 병존
검증: py_compile + 결함 회귀 게이트 78/78 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:03:04 +09:00
94c7a6568d [test] agent: 자율 협상 결함 회귀 게이트 (배포 전 필수)
실전에서 발견된 결함 전부를 자동 재생하는 배포 게이트 — 실패 시 exit 1(배포 금지).
지형 2종(실스케일 423,198 / 소액 10,000) x 협력사 시나리오 3종(완고/목표가위 고정/협조)
+ 엔진 전환·봉투 마스크·멘트 가드 단위검사 = 76+항목.

검사 대상은 서빙 실물(AutonomyPolicy.decide + ChatEngine._autonomy_next + ment 가드).
결정론(스크립트 상대 + greedy 정책)이라 실행마다 동일 결과. DB/LLM/도커 불필요.
v3.4 교훈 반영: 한 지형 통과가 다른 지형을 보증하지 않는다 — 게이트 후 궤적 눈 비교 병행.

실행: APP_ENV=local python -m tools.test_autonomy_defects [번들경로]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:58:08 +09:00
51b3820cc9 [feat] agent: 완전 자율 협상 모드 (AUTONOMY_MODE) + LLM 멘트
판정 룰(앵커타결/와일드존/3라운드결렬)과 카드 선택을 학습 정책으로 대체:
수락/역제안 금액/압박 화법/결렬 전부 행동 30개(수락1+결렬1+역제안 6단x화법4+압박4)에서 선택.

- autonomy_actions: 행동 공간·특징 인코딩 (학습/서빙 공유)
- autonomy_store: numpy 서빙 + 행동 봉투 7개(수락<=목표가 / 역제안 단조 / 역제시·결렬은
  설득 2회 후 해금 / 마무리국면 압박 금지 / 첫 역제안 앵커 이하 / 최종제안 1회 보장)
- chat_engine: 자율 스텝(역제안/최종제안/압박1~4), 최종제안 금액=목표가, 턴캡 12
- ment_generator: Gemini 멘트 생성 + 가드(숫자 화이트리스트·목표가 비공개·금지어·문장완결,
  실패시 템플릿 폴백, 6초 컷), 인터넷최저가 근거 인용(수집됨+제시가 초과시만), 대화 기억
- chat_service: 자율 행동 experience_logs 로깅(AUT|종류|위치|전략), 대화기억 ctx 관리
- context loader/CRUD: 인터넷최저가·견적기간·협력사 이력 로드 (v3 상태 21차원)
- train_full_autonomy: 시뮬 15k ep — 앵커율 0.8~6% 정합, 협력사 현실화(컷반발·반복짜증·
  양보 상호성), 관측성 마스크(마감 40% 미관측·15% 전부미상 — 서빙 중립값 분포 정합),
  보상 수정(목표가 초과 타결=결렬 취급)
- 서빙 v3.5 (v3.3 목표가 즉시지르기 퇴화, v3.4 소액지형 첫턴 통보 퇴화 — 게이트 반려 이력 보관)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:57:38 +09:00
d4acdd0ac5 [feat] agent: 카드 선택 DQN 서빙 전환 (action-as-feature)
- feature_dqn_policy: ScoreNet(상태+카드특징 → 점수) 학습 정책 (replay+타깃넷)
- feature_builder: 이산화 없는 연속 상태 벡터(9) + 테넌트 성향 벡터(5)
- dqn_store: numpy 전용 서빙(컨테이너 PyTorch 불필요), DQN_SERVING 플래그,
  미지원 테넌트는 Q-table 자동 폴백
- 파이프라인: build_card_embeddings -> train_feature_dqn -> export_dqn_serving(npz)
- retrain_from_logs: 실로그 재학습 + OPE(SNIPS) 게이트, 통과 시에만 번들 교체(.prev 백업)
- probe_serving_dqn / compare_qtable_vs_dqn: 배포 전 행동 점검 도구

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:56:24 +09:00
32 changed files with 2929 additions and 8 deletions

3
.gitignore vendored
View File

@ -28,3 +28,6 @@ CLAUDE.md
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
/Temp.md
/new.md
# 서빙 번들 교체 시 자동 생성되는 직전본 백업 (export 도구가 만듦)
agent/artifacts/*.prev

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,14 @@
{
"rows": 25,
"episodes": 4,
"skipped": {
"종료행/카드턴 없음(미완결 세션)": 8
},
"min_episodes": 1,
"deployed": false,
"ope_candidate": 0.9029104414200676,
"ope_candidate_ess": 1.0,
"ope_current": 0.9029104414200676,
"ope_current_ess": 1.0,
"result": "gate_failed"
}

View File

@ -0,0 +1,203 @@
# 완전 자율 협상 에이전트 — 처음 대비 변경 정리
> 기준: 협상카드 + 룰 엔진 시절(처음) → 완전 자율 에이전트 v3.2 + LLM 멘트 (2026-07-10 현재)
> 롤백: `docker-compose.yml``AUTONOMY_MODE=0` 하나로 룰 엔진 즉시 복귀 (재빌드 불필요)
---
## 1. 한눈에 보기 — 무엇이 바뀌었나
| 영역 | 처음 (룰 + 카드) | 지금 (자율 에이전트) |
|---|---|---|
| **협상 판정** | 하드코딩 룰 (앵커 이하 타결 / 와일드카드 존 / 3라운드 결렬) | RL 정책이 매 턴 행동을 직접 선택 |
| **발화 선택** | DB 협상카드(NGC-001~011)를 UCB/Q-table 로 선택 | 카드 없음 — 행동 30개 중 신경망이 선택 |
| **역제안 금액** | 카드에 박힌 고정값 | 앵커~목표가 6단 사다리에서 정책이 선택 |
| **와일드카드** | 사람이 등록한 카드(WC-01~05) 발동 | 최종제안·역제시 타이밍을 정책+봉투가 자율 수행 |
| **멘트** | 고정 템플릿 | Gemini LLM 생성 + 할루시네이션 가드 (실패 시 템플릿 폴백) |
| **입력 상태** | 가격 스냅샷 9차원 | 21차원 (마감·협력사 이력·인터넷최저가·에피소드 기억 추가) |
| **학습** | Q-table 온라인 갱신 | 시뮬레이터 DQN 학습 → 프로브 게이트 → npz 번들 배포 |
---
## 2. 의사결정 — 행동 공간 30개
```
ACCEPT 수락 (협상완료, 제시가 타결)
WALK 결렬 의사 → 최종제안 1회 보장 후 종료
COUNTER 역제안: 금액 위치 6단 {-5%, 0, 25, 50, 75, 100% of (목표가-앵커가)} × 화법 4종
PRESS 압박(설득): 화법 4종
```
행동의 실체는 `Action(kind, counter_q, strategy)`**(무엇을, 얼마에, 어떤 말투로)** 좌표 3개짜리 데이터다
(`policies/autonomy_actions.py`, DB 아님). 에이전트는 매 턴 30개 중 조합 1개를 고르고,
원화 환산(앵커 + q×스팬)과 문장(LLM)은 선택 이후의 실행 단계.
역제안 사다리 6단 (실스케일 앵커 418,966/목표 423,198 기준):
q=0.05→418,754 / 0→418,966(앵커) / 0.25→420,024 / 0.5→421,082 / 0.75→422,140 / 1.0→423,198(목표가).
비율(q)이라 견적 스케일과 무관하게 같은 행동 공간이 재사용된다.
화법 4종은 기존 카드 전략 분류를 그대로 승계: **경쟁 압박 / 수용 공감 / 기준 고수 / 협력 파트너**.
톤 선택도 학습 결과 — 라이브에서 초반 경쟁(1)→중반 수용(2)→교착 협력(4)으로 국면별 전환 관측.
설계 출처: 화법 4종·금액 범위(앵커~목표가)는 제품 승계, 수락·결렬 포함은 완전 자율 정의의 필연,
**격자 6단만 설계 재량**(`COUNTER_GRID` 수정+재학습으로 변경 가능). 알려진 한계: 부를 수 있는
금액이 격자 6지점뿐 — 연속 금액 미세조정은 불가(필요 시 격자 확장이 현실적).
- 모델: action-as-feature DQN (ScoreNet MLP — 상태 21 + 행동특징 9 → 점수 1개)
- 서빙: **numpy 전용** (`autonomy_serving.npz`) — 컨테이너에 PyTorch 불필요
- 현재 서빙본: **v3.5** (백업 `autonomy_v35.npz` / 반려본 v3.4 / 이전 v3.2)
## 3. 입력 상태 — 9차원 → 21차원
"완전한 에이전트에는 다 들어가야 한다" 요구로 확장:
| 그룹 | 차원 | 내용 | 출처 |
|---|---|---|---|
| 기본 | 9 | 매출액·유통코드·협력사수·수락률·제시가·앵커가·목표가·라운드 등 | 기존 스냅샷 |
| 테넌트 | 5 | 보상 설정 특징 | reward config |
| **에피소드 기억** | 2 | 직전 역제안 유무·위치 | ctx `autonomy_last` |
| **마감** | 1 | 마감 잔여율 | quotations start/end_time |
| **협력사 이력** | 3 | 과거 협상 횟수·성공률·평균 타결비율 | experience_logs ⨝ sessions |
| **시장가** | 1 | 인터넷 최저가 갭 | items.internet_lowest_price |
- 소스가 없으면 중립값(0.5/0) — 학습 시뮬의 '미상' 표현과 동일
- 상대 **발화 내용 파싱은 보류** (사용자 결정 — 프론트 입력 UI 변경 필요)
## 4. 행동 봉투 — 실전 테스트에서 잡은 결함의 구조적 방지
룰과 다름: **룰은 결과를 정하고, 봉투는 행동만 금지**한다. 나머지(타이밍·속도·금액)는 전부 정책 학습.
| # | 봉투 | 막는 결함 (실제 발생 사례) | 성격 |
|---|---|---|---|
| ① | 목표가 초과 제시가는 **수락 불가** | v3.1 이 보상 구멍을 착취해 목표가+14% 매입 | 안전 (영구) |
| ② | 직전 역제안보다 **낮은 금액 재제시 금지** (단조 양보) | 423,198 → 420,024 제안 철회 사건 | 안전 (영구) |
| ③ | 역제시는 **설득 ≥2회 후 해금** (`AUTONOMY_MIN_PRESS`) | 첫 턴부터 역제시 — 옛 의미론(일반카드=설득, 와일드카드만 역제시) 복원 | 예절 (해제 후보) |
| ④ | 목표가 0.5% 이내 **마무리 국면에선 압박 금지** | 802원 차이에 "재검토 부탁" 반복하던 푼돈 흥정 | 예절 (해제 후보) |
| ⑤ | **첫 역제안은 앵커가 이하만** (q ≤ 0) | 사다리 꼭대기 근처(422,140)에서 개시해 올라갈 계단이 없던 문제 | 예절 (해제 후보) |
| ⑥ | 같은 금액 반복·결렬 의사 → **자율_최종제안 1회 보장, 금액은 목표가** | 확인 없이 결렬 / 직전 금액을 "최종"으로 반복해 승인 여지를 남긴 채 종료하던 문제 | 안전 (영구) |
| ⑦ | **결렬(walk)도 해금 전 금지** — 설득 ≥2회 전에는 설득만 가능 | 설득 0회에 walk 선택 시 최종제안 보장(⑥)과 결합해 "첫 턴 목표가 통보"가 됨 (v3.4 라이브 결함) | 예절 (해제 후보) |
- 구현: 서빙 `policy/autonomy_store.py` 후보 마스크 + 학습 `tools/train_full_autonomy.py` `available_actions` **양쪽 동일**
- 예절 봉투(③④⑤)는 실로그가 쌓이면 `AUTONOMY_MIN_PRESS=0` 등으로 해제 실험 가능
## 5. 멘트 — 템플릿 → LLM + 가드레일
**역할 분리(안전 설계):** 무엇을 말할지(금액/전략/수락/결렬)는 RL 이 결정, LLM 은 **표현만** 담당.
```
설정: agent/config/config.local.toml [OpenAIConfig]
모델: gemini-2.5-flash-lite (OpenAI 호환 base_url)
· 2.5-flash → thinking 지연으로 백엔드 10초 한도 초과 ("협상 응답 지연" 토스트 원인)
· 2.0-flash → 은퇴(404)
시간: LLM_TIMEOUT_S=6 초과 시 템플릿 폴백 (검증 최대 응답 2.9초)
끄기: AUTONOMY_LLM=0
```
**할루시네이션 가드 (하나라도 걸리면 템플릿 폴백, 협상은 계속):**
| 가드 | 내용 |
|---|---|
| 숫자 화이트리스트 | 프롬프트로 준 금액(제시가·제안가·직전제안가·양보폭) 외 숫자 = 즉시 폐기 |
| **목표가 비공개** | 압박 프롬프트에 목표가 미포함 + 화이트리스트에서도 제외 — 노출 사고 재발 방지 |
| 금지어 | 보장/물량/독점/최저가/시장가/%/계약기간/법적 등 승인 안 된 전술·커밋 |
| 문장 완결 | thinking 토큰 소진으로 잘린 문장 폐기 (max_tokens 2048) |
| 제안가 포함 | 역제안·최종제안 멘트에 제안 금액 필수 |
**추가 기능:**
- **인터넷 최저가 인용** (구 NGC-008 자율판): 수집돼 있고 제시가 > 최저가일 때만 근거 인용 허용 — 그 턴에만 '최저가' 금지어 해제, 수치는 화이트리스트 검증
- **대화 기억**: 직전 제안 거절 사실·양보폭을 멘트에 반영("직전 제안에서 5원 상향한…") + 직전 멘트와 같은 문장구조 반복 금지 — "멘트가 다 똑같다" 해결. temperature 0.9
## 6. 학습 시뮬레이터 버전 이력 — 실패 2건 포함
| 버전 | 변경 | 결과 |
|---|---|---|
| v1 | 최초 학습 | 한 방 큰 컷 + 같은 숫자 반복 → "이게 협상이야??" |
| v2 | 에피소드 기억·컷 특징·협력사 반복 짜증/이탈 | 개선되나 지형 불일치 잔존 |
| v3 | 상태 21차원 확장 | — |
| v3.1 | **지형 정합**: 앵커율 0.8~6% 샘플링 (실제 ~1% vs 시뮬 20%) | ⚠️ 보상 구멍 착취 — 목표가+14% 매입 학습 → 봉투 ① 신설 |
| **v3.2** | 컷 반발·반복 짜증·**양보 상호성**(우리가 올리면 상대도 내림)·floor ≤ 첫제시가×0.98 | ✅ **현재 서빙본** (목표가 초과 타결 0/30) |
| v3.3 | 단조·상호성 반영 재학습 | ❌ "무조건 목표가 즉시 지르기"로 퇴화 → **프로브 게이트 반려** (`full_autonomy.pt` 만 보관, 미서빙) |
| v3.4 | 봉투 ①~⑤ 정합 + **보상 수정**(목표가 초과 타결 = 결렬 취급) 재학습 | ❌ **반려** — 초기 게이트(실스케일 단일 지형) 통과 후 라이브에서 퇴화 발견: 소액 지형에서 첫 턴 walk→목표가 통보 / walk 잠금 후엔 압박 12연발·최종제안 생략·화법 단조(전부 전략3). 게이트를 2개 지형으로 확장해 재판정 → v3.2 우위 확인, v3.2 복원 (`autonomy_v34_rejected.npz` 보관) |
| **v3.5** | v3.4 + **관측성 마스크**: 마감 40% 미관측(0.5 고정)·15% 완전 미상 에피소드 — 서빙 중립값 상태를 시뮬 분포에 혼입 (v3.4 퇴화 원인 해소) | ✅ **현재 서빙본** — 게이트 78/78, 사다리 3단 사용, 협조 케이스 목표가 대비 -2,116원 타결. 게이트가 이 과정에서 **철회 실버그** 발견(아래) |
> **교훈 1 — 보상 = 유일한 스펙**: 룰을 제거하면 보상 함수의 구멍이 곧 행동이 된다 (v3.1).
> **교훈 2 — 프로브 게이트**: 재학습은 퇴화할 수 있다. 배포 전 반드시 실스케일 제시가별 행동표(`tools/probe_serving_dqn.py`)로 비교 검증 (v3.3).
> **교훈 3 — 지형 일반화**: 한 지형의 게이트 통과가 다른 지형을 보증하지 않는다 (v3.4 — 실스케일 통과, 소액 퇴화).
> **교훈 4 — 시뮬은 관측까지 닮아야 한다**: 세계뿐 아니라 '무엇을 모르는지'도 서빙과 같아야 한다. 마감·이력 미상(중립값) 상태가 시뮬에 없으면 그 상태가 분포 밖이 된다 (v3.4 원인 → v3.5 해소).
**철회 실버그 (게이트가 발견, 2026-07-10 수정):** 단조 봉투의 기준 `autonomy_last`가 '마지막 행동'이라 counter→**press**→counter 순서에서 설득이 역제안 기억을 덮어써 봉투가 뚫렸다(9,975 제안 후 9,900 재제안). 역제안 기억을 `autonomy_last_counter`로 별도 보존하도록 수정 — 시뮬(역제안만 추적)과도 일치. v3.2는 이 패턴을 쓰지 않아 드러나지 않았을 뿐 프로덕션에 실존하던 구멍.
## 7. 현재 협상 흐름 (검증 완료)
```
협력사 제시
설득(압박) ≥2회 ── 인터넷최저가 근거 인용 가능, 목표가 절대 비공개
역제안 해금 ── 첫 제안은 앵커가 이하로 개시 (봉투⑤)
단조 상향 사다리 ── 후퇴 금지(봉투②), 양보폭·속도는 정책이 결정
목표가 0.5% 이내 ── 압박 중단, 클로징만 (봉투④)
├─ 제시가 ≤ 목표가 → 수락 → 협상완료
├─ 같은 금액 반복 / 결렬 의사 → 자율_최종제안 1회, 금액=목표가 (봉투⑥)
│ ├─ 예 → 협상완료 └─ 아니오 → 협상실패
└─ 12턴 초과(엔지니어링 캡) → 최종제안(목표가) 1회 거쳐 종료 — 캡도 봉투⑥을 우회하지 않음
```
## 8. 운영 스위치 & 파이프라인
| 스위치 (docker-compose agent env) | 값 | 의미 |
|---|---|---|
| `AUTONOMY_MODE` | 1 | 자율 모드 (0 = 룰 엔진 복귀) |
| `DQN_SERVING` | 1 | 카드 선택 DQN (0 = UCB Q-table) |
| `AUTONOMY_LLM` | 1(기본) | LLM 멘트 (0 = 템플릿만) |
| `AUTONOMY_MIN_PRESS` | 2(기본) | 역제시 해금에 필요한 설득 횟수 |
| `LLM_TIMEOUT_S` | 6(기본) | LLM 시간 상한, 초과 시 템플릿 폴백 |
**학습→배포 파이프라인:**
```
tools/train_full_autonomy (시뮬 15k ep, 룰 베이스라인 비교)
→ tools/export_autonomy_serving (artifacts/autonomy_serving.npz, .prev 자동 백업)
→ tools/probe_serving_dqn (실스케일 행동표 — 눈으로 보는 진단)
→ tools/test_autonomy_defects (결함 회귀 게이트 — 지형 2종×시나리오 3종 + 단위·멘트가드 검사,
자동 합격/불합격. 단, v3.4 사례처럼 게이트 통과 ≠ 품질 보증:
궤적 자체도 눈으로 비교할 것)
→ docker compose build agent (npz 는 이미지에 베이크)
```
**로깅:** 자율 행동도 experience_logs 에 기록 (card_id = `AUT|종류|위치|전략`, 진행 row + 종결 row). 카드 재학습(`retrain_from_logs`)은 AUT 세션 자동 제외.
## 9. 변경 파일 지도
| 파일 | 역할 |
|---|---|
| `negotiation/policy/autonomy_store.py` | **신규** — 자율 정책 numpy 서빙 + 봉투 ①~⑤ 마스크 |
| `negotiation/policies/autonomy_actions.py` | **신규** — 행동 30개·특징 인코딩 (학습/서빙 공유) |
| `negotiation/chat/service/ment_generator.py` | **신규** — LLM 멘트 생성 + 가드레일 |
| `negotiation/chat/service/chat_engine.py` | 자율 스텝(자율_역제안/최종제안/압박_1~4) + `_autonomy_next` 봉투⑥ |
| `services/chat_service.py` | decider 주입·행동 로깅·대화기억 ctx 관리 |
| `negotiation/chat/infra/repository/nego_context_crud.py` | 인터넷최저가·견적기간·협력사이력 조회 |
| `negotiation/chat/service/negotiation_context_loader.py` | 확장 컨텍스트 로드 (company_id) |
| `tools/train_full_autonomy.py` | **신규** — 시뮬레이터(현실화 협력사 모델) + DQN 학습 |
| `tools/export_autonomy_serving.py` / `probe_serving_dqn.py` | **신규** — 번들 내보내기 / 프로브 게이트 |
| `tools/test_autonomy_defects.py` | **신규** — 결함 회귀 게이트: 실전에서 발견된 결함 41항목을 시나리오·단위·멘트가드 검사로 자동 재생 (서빙 실물 코드 구동, DB/LLM 불필요) |
| `config/config.local.toml` | Gemini 접속 정보 (gitignore, 이미지에 베이크) |
| `docker-compose.yml` | `AUTONOMY_MODE` / `DQN_SERVING` 플래그 |
## 10. 남은 일
- [x] 결함 회귀 게이트 구축 — `test_autonomy_defects.py` 41항목, v3.2 전항목 통과 확인 (2026-07-10)
- [x] 보상 수정 — 목표가 초과 타결은 학습 보상에서 결렬 취급 (v3.1 구멍을 유인 수준에서 차단, 봉투 ①과 이중 방어)
- [ ] ⚠️ **Gemini API 키 재발급** — 채팅에 노출된 키, 테스트 종료 후 반드시 교체 (config.local.toml + 이미지 리빌드)
- [x] 봉투 정합 재학습 — v3.4 반려(소액 지형 퇴화) → 원인 규명(관측성 불일치) → **v3.5 관측성 마스크로 해소, 배포 완료** (2026-07-10)
- [x] 철회 실버그 수정 — counter→press→counter 에서 단조 봉투 뚫림 → `autonomy_last_counter` 별도 보존
- [x] 턴캡 최종제안 보장 — 캡 종료도 "끝내기 전 한 번 더"를 거침
- [ ] 상대 발화 LLM 파싱 (보류 중 — 프론트 입력 UI 변경 필요)
- [ ] 실로그 축적 후: 예절 봉투(③④⑤) 해제 실험 → LLM 협력사 셀프플레이 (집컴 GPU 단계)
- [ ] (소소) negodata 프론트 "목표 마진율 1000%" 표시 버그 후보

View File

@ -0,0 +1,104 @@
"""FeatureBuyer — 카드 '내용(전략)'과 협력사 '프로필'에 반응하는 시뮬 협력사 (Phase 2·3).
효과를 2축으로 분리한다(성향 조건화가 의미를 가지려면 트레이드오프가 필요):
- 양보력(concession power): 카드가 가격을 얼마나 끌어내리는가
- 수락력(accept power) : 카드가 합의(수락) 확률을 얼마나 높이는가
전략별 기본 프로필(트레이드오프):
경쟁(1): 양보력 수락력 세게 깎지만 결렬 위험
수용(2): 양보력 수락력
고수(3): 양보력·수락력 중간
협력(4): 양보력 수락력 성사되지만 깎임
여기에 협력사 세그먼트 적합도(AFFINITY) 곱해진다: 전략이 협력사에 맞으면 죽는다.
소형·경쟁多 경쟁압박이 먹힘 / 대형·단독 협력이 먹힘(압박 역효과)
'가격 중시' 고객사는 경쟁 카드(많이 깎음, 결렬 감수), '성사 중시' 고객사는 협력 카드가 정답이
되는 구조. 에이전트는 카드 특징 + 협력사 특징 + 고객사 성향으로 이를 학습해야 한다.
"""
from dataclasses import dataclass
from typing import Dict, Tuple
import numpy as np
from eval_harness.buyer import BuyerResponse, Scenario
# strategy_type: 1=경쟁, 2=수용, 3=고수, 4=협력 (card.nego_cards)
# 전략별 (양보력, 수락력) 기본 프로필 — 트레이드오프의 원천
STRATEGY_PROFILE: Dict[int, Tuple[float, float]] = {
1: (0.90, 0.25), # 경쟁: 세게 깎지만 성사 어려움
2: (0.35, 0.70), # 수용
3: (0.55, 0.50), # 고수: 중간
4: (0.30, 0.90), # 협력: 잘 성사되지만 덜 깎임
}
# 세그먼트별 전략 적합도 m ∈ [0,1] — 전략이 그 협력사에 얼마나 '먹히는가'
AFFINITY: Dict[Tuple[str, str], Dict[int, float]] = {
("small", "multi"): {1: 0.90, 2: 0.45, 3: 0.60, 4: 0.40}, # 소형·경쟁多 → 경쟁압박
("small", "single"): {1: 0.35, 2: 0.60, 3: 0.80, 4: 0.55}, # 소형·단독 → 고수/논리
("big", "multi"): {1: 0.65, 2: 0.50, 3: 0.70, 4: 0.60},
("big", "single"): {1: 0.20, 2: 0.70, 3: 0.50, 4: 0.90}, # 대형·단독 → 협력 (압박 역효과)
}
REVENUE_BIG = 50_000_000 # state config 'high' 경계와 정합
@dataclass
class SupplierProfile:
"""협력사 프로필 — 에피소드마다 달라지는 협상 상대. snapshot 필드와 정합."""
revenue_amount: float
partner_count: int # 이 품목의 대안 협력사 수 (BATNA. 1:1 채팅이어도 다양)
distribution_code: str
@property
def segment(self) -> Tuple[str, str]:
size = "big" if self.revenue_amount > REVENUE_BIG else "small"
comp = "multi" if self.partner_count >= 2 else "single"
return (size, comp)
def sample_supplier(rng: np.random.Generator) -> SupplierProfile:
"""무작위 협력사 생성 (학습 데이터 다양성)."""
return SupplierProfile(
revenue_amount=float(rng.choice([5_000_000, 20_000_000, 80_000_000, 200_000_000])),
partner_count=int(rng.choice([1, 1, 2, 3])), # 단독 비중 높게
distribution_code=str(rng.choice(["A", "B", "C"])),
)
class FeatureBuyer:
"""전략 프로필 x 세그먼트 적합도 기반 협력사 모델. (양보력, 수락력) 2축."""
def __init__(self, supplier: SupplierProfile, card_strategy: Dict[str, int], seed: int = 0,
accept_base: float = 0.08, max_turns: int = 5, jitter: float = 0.05):
self.supplier = supplier
self.card_strategy = card_strategy # {card_number: strategy_type}
self.rng = np.random.default_rng(seed)
self.accept_base = accept_base
self.max_turns = max_turns
self.jitter = jitter
# 숨은 하한가(reservation): 앵커의 94~110%. 앵커보다 높으면(약 60%) 가격만으로는
# 타결 불가 → 수락을 받아내야 함 → 수락력 낮은(경쟁) 카드에 진짜 결렬 위험이 생긴다.
self.floor_ratio = float(self.rng.uniform(0.94, 1.10))
def powers(self, card_number: str) -> Tuple[float, float]:
"""숨은 (양보력, 수락력). 전략 프로필 × 세그먼트 적합도 + 카드별 결정론적 지터."""
strat = self.card_strategy.get(card_number, 3)
conc_base, acc_base = STRATEGY_PROFILE.get(strat, (0.5, 0.5))
m = AFFINITY[self.supplier.segment].get(strat, 0.5)
scale = 0.35 + 0.85 * m # 적합도: 안 맞으면 둘 다 죽음 (0.35~1.2)
j = (hash(card_number) % 1000 / 1000.0 - 0.5) * 2 * self.jitter
c_pow = float(np.clip(conc_base * scale + j, 0.02, 0.98))
a_pow = float(np.clip(acc_base * scale + j, 0.02, 0.98))
return c_pow, a_pow
def respond(self, card_number: str, scenario: Scenario, turn: int, current_price: float) -> BuyerResponse:
c_pow, a_pow = self.powers(card_number)
floor = scenario.anchor_price * self.floor_ratio # 숨은 하한가 (앵커 이하 보장 없음)
concession = (current_price - floor) * (0.10 + 0.55 * c_pow)
new_price = max(floor, current_price - concession)
p_accept = min(0.97, self.accept_base + 0.80 * a_pow + 0.05 * (turn - 1))
accept = bool(self.rng.random() < p_accept)
walked = (not accept) and (turn >= self.max_turns)
return BuyerResponse(accept=accept, new_price=new_price, walked=walked)

View File

@ -26,13 +26,21 @@ _SESSIONS = table(
column("deleted"),
schema="negotiation",
)
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("deleted"), schema="partner")
_ITEMS = table("items", column("item_id"), column("name"), column("price"),
column("internet_lowest_price"), column("deleted"), schema="partner")
_SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table(
"quotations",
column("qt_id"), column("version_id"), column("supplier_type"), column("deleted"),
column("qt_id"), column("version_id"), column("supplier_type"),
column("start_time"), column("end_time"), column("deleted"),
schema="quotation",
)
# 자율 에이전트 이력 특징용 — agent 소유 learning 스키마 (done 행 = 협상 1건의 최종 결과).
_EXP_LOGS = table(
"experience_logs",
column("session_id"), column("company_id"), column("done"), column("settled_price"),
schema="learning",
)
_VERSION_NEGO_CARDS = table(
"version_nego_cards",
column("version_id"), column("nego_card_id"), column("created_at"), column("deleted"),
@ -108,6 +116,22 @@ class INegoContextCRUD(ABC):
"""견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록."""
pass
@abstractmethod
async def get_item_internet_lowest(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
"""상품 인터넷최저가(items.internet_lowest_price). 미수집이면 0."""
pass
@abstractmethod
async def get_quotation_period(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[tuple]]:
"""견적 협상 기간 (start_time, end_time). 없으면 None."""
pass
@abstractmethod
async def get_supplier_history(self, cdb: AsyncSession, company_id: str, supplier_id,
exclude_session_id) -> Tuple[ErrorType, tuple]:
"""이 협력사와의 과거 협상 이력 (횟수, 성사율, 평균 타결가/목표가). 없으면 (0, None, None)."""
pass
class NegoContextCRUD(INegoContextCRUD):
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
@ -141,6 +165,67 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_item_internet_lowest(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
"""상품의 인터넷최저가(partner.items.internet_lowest_price). 미수집이면 0."""
try:
query = (
select(_ITEMS.c.internet_lowest_price)
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_internet_lowest failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
return err_type, 0
return ErrorType.SUCCESS, int(rows[0])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_quotation_period(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[tuple]]:
"""견적 협상 기간 (start_time, end_time). 자율 에이전트의 마감 잔여율 특징용."""
try:
query = (
select(_QUOTATIONS.c.start_time, _QUOTATIONS.c.end_time)
.where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_quotation_period failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows:
return err_type, None
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_supplier_history(self, cdb: AsyncSession, company_id: str, supplier_id,
exclude_session_id) -> Tuple[ErrorType, tuple]:
"""이 협력사와의 과거 협상 이력 집계 → (횟수, 성사율, 평균 타결가/목표가).
소스 = learning.experience_logs 종료행(done=True) negotiation.sessions
(agent 직접 기록한 결과라 카드/자율 모드 무관하게 쌓인다). 이력 없으면 (0, None, None).
"""
try:
query = (
select(_EXP_LOGS.c.settled_price, _SESSIONS.c.target_price)
.select_from(_EXP_LOGS.join(_SESSIONS, _SESSIONS.c.session_id == _EXP_LOGS.c.session_id))
.where(_EXP_LOGS.c.done == True, # noqa: E712
_EXP_LOGS.c.company_id == company_id,
_SESSIONS.c.supplier_id == supplier_id,
_EXP_LOGS.c.session_id != exclude_session_id,
_SESSIONS.c.deleted == False) # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_supplier_history failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows:
return err_type, (0, None, None)
n = len(rows)
settled = [(int(sp), int(tp)) for sp, tp in rows if sp and tp]
success = len([1 for sp, tp in rows if sp]) / n
avg_ratio = (sum(sp / tp for sp, tp in settled) / len(settled)) if settled else None
return ErrorType.SUCCESS, (n, success, avg_ratio)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, (0, None, None)
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
try:
query = (

View File

@ -17,6 +17,50 @@ MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config n
_PRICE_MODES = ("price",)
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
# ---- 완전 자율 모드 (AUTONOMY_MODE, autonomy_store) --------------------------------
# 가격협상 판정 룰(check_price_match/wildcard_entry/iteration_limit)과 카드 선택을
# 정책 행동(수락/역제안/압박/결렬)으로 대체할 때 쓰는 스텝들. autonomy_decider 미주입이면 도달 불가.
_AUTONOMY_TURN_CAP = 12 # 엔지니어링 타임아웃(무한 세션 방지) — 협상 룰이 아니다
_AUTONOMY_PRESS_SCRIPTS = {
1: "동일 품목에 대해 복수 공급처의 견적이 함께 검토되고 있습니다. 현재 제시가로는 우선순위 확보가 어려운 상황입니다. 경쟁력 있는 가격으로 다시 제안해 주시겠어요?",
2: "제안하신 조건의 취지는 충분히 이해했습니다. 저희도 최대한 맞춰보려 합니다. 조금만 더 조정해 주시면 내부 설득이 가능할 것 같습니다. 다시 제안해 주시겠어요?",
3: "내부 산정 기준과 현재 제시가 사이에 아직 차이가 있습니다. 기준에 부합하는 수준으로 재검토하여 다시 제안해 주시기를 부탁드립니다.",
4: "귀사를 장기적으로 함께할 파트너로 검토하고 있습니다. 이번 협상이 원만히 마무리되면 후속 거래 확대도 논의하고 싶습니다. 서로 만족할 수 있는 가격으로 다시 제안해 주시겠어요?",
}
_AUTONOMY_STEPS = {
"자율_역제안": {
"script": "제안해 주신 **{input_price}원**, 내부 검토를 마쳤습니다. **{autonomy_offer}원**이라면 즉시 수락하고 우선협상 대상으로 확정하겠습니다. 수락하시겠습니까?",
"next_input_mode": "yes_no",
"input_options": ["", "아니오"],
"next_step": {"": "협상완료", "아니오": "가격협상_재입력"},
"type": "text",
"chat_end": False,
},
# 최종 통보(WC-03 의 자율 버전): 정책이 직전과 같은 금액을 다시 부르는 순간(단조 봉투상
# 더 올릴 수 없음 = 탄약 소진) 발동. 거절하면 협상을 정리한다 — 어정쩡한 반복 대신 명확한 마무리.
"자율_최종제안": {
"script": "지금까지 협의에 성실히 임해 주셔서 감사합니다. **{autonomy_offer}원**은 저희가 제시할 수 있는 마지막 제안입니다. 수락해 주시면 즉시 우선협상 대상으로 확정되며, 어려우시다면 이번 협상은 여기서 마무리하겠습니다.",
"next_input_mode": "yes_no",
"input_options": ["", "아니오"],
"next_step": {"": "협상완료", "아니오": "협상실패"},
"type": "text",
"chat_end": False,
},
**{
f"자율_압박_{s}": {
"script": t,
"next_input_mode": "price",
"input_options": [],
"next_step": {"default": "가격협상_확인"},
"type": "text",
"chat_end": False,
}
for s, t in _AUTONOMY_PRESS_SCRIPTS.items()
},
}
# 최종 타결/결렬 스텝. 재협상=협상완료(우선협상 타결), 재견적=결과제출(투찰확정). 둘 다 협상실패=결렬.
# 이 스텝들은 chat_end=False(뒤에 협상종료가 옴)라, outcome 을 컨텍스트에 적재했다가
# 실제 종료(chat_end=협상종료) 시점에 확정 보고한다 → backend 가 chat_end 에서 DONE/REJECTED 를 옳게 가른다.
@ -74,11 +118,14 @@ class ChatEngine:
def __init__(self, scripts_repo: ScriptRepository, rq_type: str = "재협상"):
self.repo = scripts_repo
self.rq_type = rq_type
self.scripts = scripts_repo.load_scripts(rq_type)
# 자율 스텝은 병합만 해둔다(repo 캐시 오염 방지 위해 새 dict) — decider 미주입 시 도달 불가.
self.scripts = {**scripts_repo.load_scripts(rq_type), **_AUTONOMY_STEPS}
self.step_map = scripts_repo.client_step_mapping()
# 결정 스택 규칙층(Phase 1): 와일드카드 진입 임계·라운드 상한을 테넌트 config 에서 읽는다.
# (하드코딩 1.02/1.05/3 을 데이터화 — 고객사별로 튜닝 가능, 코드 수정 불필요)
self.rules = scripts_repo.config.negotiation
# 완전 자율 모드: ChatService 가 AutonomyStore 정책을 주입하면 가격협상 판정 룰을 대체한다.
self.autonomy_decider = None # Callable[[dict], autonomy_actions.Action]
# ---- public --------------------------------------------------------
def start(self, session: ChatSession) -> StepView:
@ -104,6 +151,12 @@ class ChatEngine:
session.context["round"] = session.context.get("round", 0) + 1
nxt = self._default_next(node)
elif mode in _CHOICE_MODES:
# 자율 역제안/최종제안 수락("예") → 합의가는 에이전트 제안가다.
# (카드/와일드카드 경로는 아래 pending_counter_price 일반 메커니즘이 처리하지만
# 자율 스텝은 pending_counter 를 쓰지 않으므로 명시 분기 유지.)
if session.step in ("자율_역제안", "자율_최종제안") and user_input == "" \
and session.context.get("autonomy_offer"):
session.context["input_price"] = float(session.context["autonomy_offer"])
nxt = self._choice_next(node, user_input, session)
else:
nxt = self._default_next(node)
@ -149,7 +202,15 @@ class ChatEngine:
- anchor 살짝 초과( anchor×wildcard_entry_ratio) + 와일드카드 미사용 와일드카드로 인하 압박.
- 설정 카드(action_space) 모두 소진 협상실패.
- 가격협상(카드 1 플레이 재제안).
완전 자율 모드(autonomy_decider 주입)에서는 전체를 정책 행동으로 대체한다.
"""
# 가격협상 판정 지점(check_price_match 포함 조건 리스트)에서만 자율 정책이 개입한다.
if self.autonomy_decider is not None and any(
c.get("condition") == "check_price_match" for c in conds):
nxt = self._autonomy_next(session)
if nxt is not None:
return nxt # 정책 실패(예외) 시에만 아래 룰로 폴백
ctx = session.context
price = ctx.get("input_price", 0)
anchor = ctx.get("anchor_price", 0)
@ -195,6 +256,52 @@ class ChatEngine:
return c.get("next")
return "가격협상"
def _autonomy_next(self, session: ChatSession) -> Optional[str]:
"""완전 자율: 정책 행동 → 스텝. 수락/역제안 금액/압박 화법/결렬 타이밍 전부 정책이 결정.
유일한 강제 종료는 상한(_AUTONOMY_TURN_CAP) 무한 세션 방지용 엔지니어링 타임아웃.
정책 호출이 실패하면 None 반환해 기존 평가로 폴백한다(서비스 연속성).
"""
ctx = session.context
if ctx.get("round", 0) > _AUTONOMY_TURN_CAP:
# 턴 상한도 최종제안 보장(봉투 ⑥)을 우회하지 않는다 — 어떤 경로로 끝나든
# "끝내기 전에 한 번 더"(제품 결정)를 거친다. 최종 거절 후에만 협상실패.
if not ctx.get("autonomy_final_asked"):
ctx["autonomy_final_asked"] = True
ctx["autonomy_offer"] = int(ctx.get("target_price", 0))
return "자율_최종제안"
return "협상실패"
try:
act = self.autonomy_decider(ctx)
except Exception: # 정책 오류 → 룰 폴백 (호출부에서 로깅)
return None
session.context["autonomy_action"] = f"{act.kind}:{act.strategy}:{act.counter_q}"
span = max(ctx.get("target_price", 0) - ctx.get("anchor_price", 0), 1.0)
# 탄약소진(같은 금액 재호출) 판정은 '마지막 역제안' 기준 — autonomy_last(마지막 행동)는
# 사이에 낀 설득이 덮어써 판정이 리셋된다 (chat_service 가 counter 마다 별도 보존).
last = ctx.get("autonomy_last_counter") or {}
if act.kind == "accept":
return "협상완료"
if act.kind == "walk":
# 결렬 전 마지막 제안 1회 보장 — "끝내기 전에 한 번 더 물어보고 종료" (제품 결정).
# 최종제안을 이미 거쳤으면(autonomy_final_asked) 그대로 종료한다.
if not ctx.get("autonomy_final_asked"):
ctx["autonomy_final_asked"] = True
# 최종제안 금액 = 목표가. 마지막 기회에 직전 역제안 금액을 반복하면 승인 범위의
# 여지(목표가까지)를 남긴 채 결렬된다 — 최종에는 우리가 수락 가능한 최대치를 부른다.
ctx["autonomy_offer"] = int(ctx.get("target_price", 0))
return "자율_최종제안"
return "협상실패"
if act.kind == "counter":
ctx["autonomy_offer"] = int(round(ctx.get("anchor_price", 0) + act.counter_q * span))
# 직전과 같은 금액을 다시 부름 = 단조 봉투상 더 올릴 수 없음(탄약 소진) → 최종 통보로 전환.
if last.get("kind") == "counter" and act.counter_q <= float(last.get("q", -9)) + 1e-9:
ctx["autonomy_final_asked"] = True
ctx["autonomy_offer"] = int(ctx.get("target_price", 0))
return "자율_최종제안"
return "자율_역제안"
return f"자율_압박_{act.strategy or 3}"
def _pick_wildcard(self, session: ChatSession) -> str:
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
앵커가 이하로 유도한다. 구간은 일반 가격협상(카드 플레이)으로 돌린다.
@ -247,6 +354,15 @@ class ChatEngine:
out["product_name"] = str(ctx["product_name"])
if "offer_1pct" in ctx:
out["offer_1pct"] = int(ctx["offer_1pct"])
if "autonomy_offer" in ctx:
out["autonomy_offer"] = int(ctx["autonomy_offer"])
# 인터넷 최저가(NGC-008): 수집값이 컨텍스트에 없으면 앵커가로 폴백 — 원형 토큰 노출 방지.
if ctx.get("internet_lowest_price"):
out["internet_lowest_price"] = int(ctx["internet_lowest_price"])
elif "anchor_price" in ctx:
out["internet_lowest_price"] = int(ctx["anchor_price"])
# 고객사 교환·요구 조건(NGC-009/010): 런타임 소스 미구현 — 중립 문구 폴백.
out["customer_condition"] = ctx.get("customer_condition") or "상호 협의된 조건"
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0

View File

@ -0,0 +1,188 @@
"""MentGenerator — 자율 협상 행동을 LLM 이 자연어 멘트로 표현 (v2: 행동은 RL, 문장은 LLM).
역할 분리(안전 설계):
- 무엇을 말할지(수락/역제안 금액/압박 전략/결렬) RL 정책이 결정 LLM 표현만 담당.
- 가드레일: 역제안 멘트에 제안 금액이 정확히 포함되지 않으면 폐기, 예외/미설정 None
호출부(ChatService) 기존 템플릿 멘트로 폴백한다. LLM 죽어도 협상은 계속된다.
설정: config.local.toml [OpenAIConfig] (Gemini OpenAI 호환 base_url 접속).
비활성화: AUTONOMY_LLM=0.
"""
import asyncio
import os
import re
from typing import Optional
from common.logger import LOG
from negotiation.profiling.config import LlmCredentials
_STRATEGY_TONE = {
1: "경쟁 압박형 — 복수 공급처와 비교 검토 중임을 암시하며 긴장감을 준다",
2: "수용 공감형 — 상대 제안의 취지에 공감하며 부드럽게 조정을 요청한다",
3: "기준 고수형 — 내부 산정 기준과 목표가를 근거로 원칙을 지킨다",
4: "협력 파트너형 — 장기 파트너십과 후속 거래 확대 가능성을 강조한다",
}
_SYSTEM = """너는 대기업 구매팀의 가격 협상 챗봇이다. 주어진 '전달 의도'를 자연스러운 한국어 협상 멘트로 바꿔 쓴다.
규칙 (위반 출력은 폐기된다):
- 1~3문장, 정중하되 간결하게. 출력은 멘트 텍스트만 (따옴표·설명 없이).
- 금액 숫자는 주어진 그대로 정확히 포함하고 단위는 '' 쓴다. 주어지지 않은 숫자·비율을 절대 만들지 않는다.
- 지정된 '화법' 전략 안에서만 말한다. 외의 협상 전술(물량·기간 약속, 조건 교환, 거래 연계,
독점 제안, 시장가·최저가 주장, 할인 약속 ) 지어내지 않는다.
- 회사의 정책·사실을 단정하지 않는다. 주어진 의도에 없는 정보는 말하지 않는다.
- 상대는 협력사(판매자)이고 우리는 구매자다."""
# 생성문 금지어 — 승인되지 않은 커밋/주장 계열. 걸리면 템플릿 폴백(협상은 계속).
_FORBIDDEN = ("보장", "물량", "독점", "무조건", "최저가", "시장 가격", "시장가", "계약 기간",
"법적", "위약", "%")
def _configured() -> bool:
if os.getenv("AUTONOMY_LLM", "1").lower() in ("0", "false", "no"):
return False
try:
return LlmCredentials.from_config().is_configured()
except Exception:
return False
def _digits(s) -> str:
return re.sub(r"[^\d]", "", str(s))
def _history_hints(ctx: dict) -> str:
"""대화 기억 힌트 — 무기억 생성이 '매번 같은 멘트'를 만들던 문제의 해법.
직전 우리 제안이 거절된 사실과 이번 제안과의 관계(양보/입장유지) 짚게 하고
직전 멘트를 보여주며 같은 문장 구조·표현의 반복을 금지한다."""
hints = []
prev = ctx.get("autonomy_prev")
if prev and prev.get("kind") == "counter":
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
cur_offer = int(ctx.get("autonomy_offer") or 0)
if cur_offer > prev_offer:
hints.append(f"참고: 직전 라운드에 우리가 {prev_offer:,}원을 제안했으나 거절당했고, "
f"이번에는 {cur_offer - prev_offer:,}원 더 양보한 제안이다. 이 진전을 자연스럽게 짚어라.")
elif cur_offer == prev_offer and cur_offer > 0:
hints.append(f"참고: 직전에 제안한 {prev_offer:,}원을 거절당했지만 같은 금액을 유지한다. "
f"입장이 확고함을 정중하게 전하라.")
elif prev_offer > 0:
hints.append(f"참고: 직전 제안({prev_offer:,}원)이 거절된 뒤의 재제안이다.")
last_ment = ctx.get("autonomy_last_ment")
if last_ment:
hints.append(f'직전 봇 멘트: "{last_ment}" — 이와 같은 문장 구조·표현을 반복하지 말고 다르게 써라.')
return " ".join(hints)
def _prompt_for(step: str, ctx: dict) -> Optional[str]:
price = int(ctx.get("input_price") or 0)
rnd = ctx.get("round", 1)
if step in ("자율_역제안", "자율_최종제안"):
offer = int(ctx.get("autonomy_offer") or 0)
if offer <= 0:
return None
strategy = int((ctx.get("autonomy_last") or {}).get("s") or 3)
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
final = ("이번이 우리가 제시할 수 있는 마지막 제안이며, 거절하시면 이번 협상은 종료됨을 "
"분명하되 정중하게 밝혀라. " if step == "자율_최종제안" else "")
return (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
f"전달 의도: 우리는 **{offer:,}원**이면 즉시 수락하고 우선협상 대상으로 확정할 수 있다 — "
f"이 핵심 의미는 유지하되 문장 표현은 자유롭게 새로 써라. {final}화법: {tone}. "
f"{_history_hints(ctx)} 마지막에 수락 여부를 물어라.")
if step.startswith("자율_압박_"):
strategy = int(step.rsplit("_", 1)[1])
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
# 주의: 목표가는 프롬프트에 넣지 않는다 — 압박 중 목표가 노출은 우리 상한을 까는 것
# (상대가 그 밑으로 내려올 이유가 사라진다). 숫자 커밋은 역제안/최종제안에서만.
base = (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
f"전달 의도: 어떤 금액도 언급하지 말고(내부 기준·목표가 숫자 금지), 제시가와 우리 기준의 "
f"거리가 있다는 취지로 가격 재제안을 요청한다. 화법: {tone}. "
f"{_history_hints(ctx)}")
# 시장가 근거 (구 NGC-008 의 자율 버전): 수집된 인터넷최저가가 실재하고 제시가가 그보다
# 높을 때만 사실 근거로 인용을 허용한다 — 미수집 품목에서 지어내는 주장은 가드가 차단.
if _market_evidence(ctx):
il = int(ctx["internet_lowest_price"])
base += (f" 참고 사실(인용 허용되는 유일한 금액): 동일 품목의 인터넷 최저가가 {il:,}원으로 "
f"확인된다. 현재 제시가가 이보다 높다는 점을 근거로 조정 여지를 정중히 짚어라.")
return base
return None
def _market_evidence(ctx: dict) -> bool:
"""시장가 근거 인용 가능 조건: 인터넷최저가 수집됨 + 제시가가 그보다 높음."""
il = int(ctx.get("internet_lowest_price") or 0)
return il > 0 and float(ctx.get("input_price") or 0) > il
def _allowed_amounts(ctx: dict) -> set:
"""멘트에 등장해도 되는 숫자 집합 — 우리가 프롬프트로 준 값들뿐. 이 밖의 금액 = 할루시네이션."""
# 목표가는 화이트리스트에 없다 — 압박 멘트가 목표가를 새면(상한 노출) 즉시 폐기된다.
# 역제안·최종제안의 제안가(autonomy_offer)가 목표가와 같은 경우만 그 값으로 허용된다.
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
out = {int(ctx.get("input_price") or 0), int(ctx.get("autonomy_offer") or 0),
int(ctx.get("round") or 0)}
if _market_evidence(ctx):
out.add(int(ctx["internet_lowest_price"])) # 시장가 근거 인용 시 그 수치만 허용
prev = ctx.get("autonomy_prev")
if prev and prev.get("kind") == "counter":
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
out |= {prev_offer, abs(int(ctx.get("autonomy_offer") or 0) - prev_offer)}
return {str(v) for v in out if v}
def _guard(step: str, ctx: dict, text: str) -> bool:
"""LLM 출력 검증(할루시네이션 차단) — 실패 시 템플릿 폴백.
길이/문장 완결 금지어(승인 커밋·주장) 숫자 화이트리스트: 멘트의 모든
3자리+ 숫자는 우리가 (제시가·제안가·목표가·직전제안가)이어야 한다 지어낸 금액 즉시 폐기.
역제안은 제안 금액 포함 필수."""
if not text or len(text) < 10 or len(text) > 600:
return False
if not text.rstrip().endswith(("다.", "요.", "요?", "까?", "니까?", ".", "?")):
return False # 문장 중간 잘림(thinking 토큰에 한도 소진 등) → 템플릿 폴백
forbidden = _FORBIDDEN
if _market_evidence(ctx):
# 시장가 근거가 정당한 턴에는 '최저가/시장가' 언급을 허용 (수치는 아래 화이트리스트가 검증).
forbidden = tuple(w for w in _FORBIDDEN if w not in ("최저가", "시장가", "시장 가격"))
if any(w in text for w in forbidden):
return False
allowed = _allowed_amounts(ctx)
for num in re.findall(r"\d{3,}", text.replace(",", "")):
if num not in allowed:
return False # 프롬프트에 없던 금액 생성 = 할루시네이션
if step in ("자율_역제안", "자율_최종제안"):
return _digits(ctx.get("autonomy_offer")) in _digits(text)
return True
async def generate(step: str, ctx: dict) -> Optional[str]:
"""자율 스텝 멘트 생성. 미설정/실패/검증불통과 → None (호출부 템플릿 유지)."""
if not _configured():
return None
prompt = _prompt_for(step, ctx)
if prompt is None:
return None
try:
from negotiation.profiling.infra.llm_adapter import chat_complete
# openai SDK 는 동기 — 이벤트루프 블로킹 방지 위해 스레드로 넘긴다.
# max_tokens 넉넉히 — Gemini 2.5 계열은 thinking 토큰이 한도에 포함돼 짧으면 본문이 잘린다.
# 시간 상한: backend→agent 타임아웃(10s)보다 확실히 짧아야 한다 — 초과 시 템플릿 폴백으로
# 협상은 즉시 계속된다("협상 응답 지연" 토스트 방지). LLM_TIMEOUT_S 로 조절.
text = await asyncio.wait_for(
asyncio.to_thread(
chat_complete,
[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
None, False, 0.9, 2048, # temperature 0.9 — 표현 다양성 (의미는 프롬프트 가드)
),
timeout=float(os.getenv("LLM_TIMEOUT_S", "6")),
)
text = (text or "").strip().strip('"')
if _guard(step, ctx, text):
return text
LOG.w(f"[MentGenerator] 가드레일 불통과 → 템플릿 폴백 (step={step})")
return None
except Exception as ex:
LOG.e_no_callstack(f"[MentGenerator] LLM 실패 → 템플릿 폴백: {ex}")
return None

View File

@ -46,14 +46,23 @@ class NegotiationDbContext:
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
# ---- 자율 에이전트 v3 상태 특징 소스 (없으면 0/None — 특징은 중립 기본값으로 폴백) ----
internet_lowest_price: int = 0 # items.internet_lowest_price (미수집 0)
deadline_end_ts: Optional[float] = None # 견적 마감(epoch 초) — quotations.end_time
deadline_total_s: Optional[float] = None # 협상 전체 기간(초) — endstart
hist_n: int = 0 # 이 협력사와의 과거 협상 횟수
hist_success: Optional[float] = None # 과거 성사율 (이력 없으면 None)
hist_settle_ratio: Optional[float] = None # 과거 평균 타결가/목표가 (성사 이력 없으면 None)
class NegotiationContextLoader:
def __init__(self, crud: Optional[INegoContextCRUD] = None):
self.crud: INegoContextCRUD = crud or NegoContextCRUD()
async def load(self, session_id: Optional[str]) -> Optional[NegotiationDbContext]:
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백)."""
async def load(self, session_id: Optional[str],
company_id: Optional[str] = None) -> Optional[NegotiationDbContext]:
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백).
company_id 협력사 이력 집계(experience_logs 테넌트 스코프) 없으면 이력 특징 생략."""
if not session_id:
return None
try:
@ -103,6 +112,21 @@ class NegotiationContextLoader:
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
selected_nego_cards, selected_wild_cards = selected_cards
# ---- 자율 에이전트 v3 특징 소스 (조회 실패는 전부 중립 폴백 — 협상은 계속돼야 한다) ----
_, internet_lowest = await self.crud.get_item_internet_lowest(s, item_id)
_, period = await self.crud.get_quotation_period(s, quotation_id)
deadline_end_ts = deadline_total_s = None
if period and period[1] is not None:
end_ts = period[1].timestamp()
start_ts = period[0].timestamp() if period[0] is not None else None
total = (end_ts - start_ts) if start_ts else None
if total and total > 0:
deadline_end_ts, deadline_total_s = end_ts, total
hist_n, hist_success, hist_settle = 0, None, None
if company_id:
_, hist = await self.crud.get_supplier_history(s, company_id, supplier_id, sid)
hist_n, hist_success, hist_settle = hist
return NegotiationDbContext(
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
target_price=target,
@ -115,6 +139,12 @@ class NegotiationContextLoader:
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
selected_nego_card_numbers=selected_nego_cards,
selected_wild_card_numbers=selected_wild_cards,
internet_lowest_price=internet_lowest,
deadline_end_ts=deadline_end_ts,
deadline_total_s=deadline_total_s,
hist_n=hist_n,
hist_success=hist_success,
hist_settle_ratio=hist_settle,
)
try:

View File

@ -0,0 +1,85 @@
"""완전 자율 협상 행동 공간 (numpy 전용 — 학습(tools)과 서빙(policy)이 공유).
카드 카탈로그 대신 행동의 '의미' 남긴다:
ACCEPT 현재 제시가로 타결
WALK 협상 결렬 선언
COUNTER(q, s) "C원이면 수락" 역제안. C = anchor + q×(targetanchor), s = 화법 전략
PRESS(s) 설득 압박 (카드의 일반화 전략 1경쟁/2수용/3고수/4협력)
특징 벡터(ACTION_DIM=8) = 유형 one-hot(3) + 가격 위치(1) + 전략 one-hot(4).
ScoreNet(상태 + 행동특징) 스칼라 점수로 후보 30개를 채점해 argmax 한다.
"""
from dataclasses import dataclass
import numpy as np
COUNTER_GRID = [-0.05, 0.0, 0.25, 0.5, 0.75, 1.0] # C = anchor + q×(targetanchor)
ACTION_DIM = 3 + 1 + 4 + 1 # 유형(3) + 위치(1) + 전략(4) + 컷폭(1: 현 제시가 대비 인하 요구율)
# 자율 전용 추가 상태 (v3):
# [0] 직전 역제안 존재(0/1) [1] 직전 역제안 위치 q ← 에피소드 기억(같은 숫자 반복 방지)
# [2] 마감 잔여율(남은시간/전체, 미상 0.5) ← 견적 마감(quotations.end_time)
# [3] 과거 협상 횟수 min(n,5)/5 [4] 과거 성사율(미상 0.5)
# [5] 과거 평균 타결수준 norm((타결가/목표가0.8)/0.4, 미상 0.5) ← 이 협력사와의 이력(experience_logs)
# [6] 인터넷최저가 갭 clip((최저가−앵커)/앵커/0.1, ±1, 미상 0) ← 숨은 하한가의 관측 가능한 힌트
# 특징은 학습 시뮬에도 동일하게 존재해야 한다(train_full_autonomy 가 대응물을 생성).
EXTRA_STATE_DIM = 7
def extra_state(last_kind: str = "", last_q: float = 0.0, deadline: float = 0.5,
hist_n: float = 0.0, hist_success: float = 0.5, hist_settle: float = 0.5,
internet_gap: float = 0.0) -> np.ndarray:
has_counter = 1.0 if last_kind == "counter" else 0.0
return np.array([
has_counter,
float(np.clip(last_q, -1.0, 1.0)) * has_counter,
float(np.clip(deadline, 0.0, 1.0)),
float(np.clip(hist_n, 0.0, 1.0)),
float(np.clip(hist_success, 0.0, 1.0)),
float(np.clip(hist_settle, 0.0, 1.0)),
float(np.clip(internet_gap, -1.0, 1.0)),
], dtype=np.float32)
def settle_norm(avg_settle_ratio: float) -> float:
"""평균 (타결가/목표가) → 0~1 정규화 (0.8→0, 1.0→0.5, 1.2→1)."""
return float(np.clip((avg_settle_ratio - 0.8) / 0.4, 0.0, 1.0))
def internet_gap_feat(internet_lowest: float, anchor: float) -> float:
"""인터넷최저가의 앵커 대비 갭 (±10% 스케일). 최저가 없으면 0을 쓴다."""
if not internet_lowest or anchor <= 0:
return 0.0
return float(np.clip((internet_lowest - anchor) / anchor / 0.1, -1.0, 1.0))
@dataclass(frozen=True)
class Action:
kind: str # accept | walk | counter | press
counter_q: float = 0.0 # counter 위치 (anchor~target 스팬 비율)
strategy: int = 0 # press/counter 의 화법 전략 (1~4, 0=없음)
def feat(self, price_pos: float, cut: float = 0.0) -> np.ndarray:
"""cut: 이 행동이 요구하는 인하폭 (현 제시가 대비, counter 만 >0) — 대형컷의 무례함을
정책이 지각하게 한다. 갭이 크면 역제안 대신 압박이 낫다는 배우는 근거 특징."""
t = {"accept": [1, 0, 0], "walk": [0, 1, 0]}.get(self.kind, [0, 0, 1])
pos = price_pos if self.kind == "accept" else self.counter_q
s = np.zeros(4, dtype=np.float32)
if self.strategy:
s[self.strategy - 1] = 1.0
return np.concatenate([np.array(t, dtype=np.float32),
np.array([float(np.clip(pos, -1.0, 2.0)),
], dtype=np.float32), s,
np.array([float(np.clip(cut, 0.0, 1.0))], dtype=np.float32)])
def candidate_actions():
"""전 행동 후보: 수락 1 + 결렬 1 + 역제안 6×전략4 + 압박 4 = 30."""
out = [Action("accept"), Action("walk")]
out += [Action("counter", q, s) for q in COUNTER_GRID for s in (1, 2, 3, 4)]
out += [Action("press", 0.0, s) for s in (1, 2, 3, 4)]
return out
ACTIONS = candidate_actions()

View File

@ -0,0 +1,132 @@
"""FeatureDQNPolicy — action-as-feature DQN (Phase 2·3).
고정 슬롯 Q(s)[11] 대신 ScoreNet(상태벡터 + 카드임베딩) 스칼라 점수.
결정 가용 카드 풀을 순회 채점해 argmax 카드 추가/삭제/ 카드(zero-shot) 구조 변화 없음.
협력사 특징은 상태벡터에 포함(feature_builder) '협력사를 입력으로' 달성.
가변 행동 학습: replay 다음 상태의 '가용 카드 임베딩들' 함께 저장,
target = r + γ · max_{c'∈next_avail} Q(s', c') · (1-done)
"""
import math
import random
from collections import deque
from typing import Dict, List, Optional, Tuple
import numpy as np
import torch
import torch.nn as nn
class ScoreNet(nn.Module):
"""(상태 + 카드임베딩) → 스칼라 점수."""
def __init__(self, state_dim: int, card_dim: int, hidden: int = 128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim + card_dim, hidden), nn.ReLU(),
nn.Linear(hidden, hidden), nn.ReLU(),
nn.Linear(hidden, 1),
)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [B, state+card]
return self.net(x).squeeze(-1) # [B]
class FeatureDQNPolicy:
name = "feature_dqn"
def __init__(self, state_dim: int, card_dim: int, device: str = "cpu",
lr: float = 1e-3, gamma: float = 0.95, hidden: int = 128,
eps_start: float = 1.0, eps_end: float = 0.05, eps_decay: int = 6000,
buffer_size: int = 50_000, batch_size: int = 64, target_sync: int = 500):
self.device = device
self.gamma = gamma
self.batch_size = batch_size
self.target_sync = target_sync
self.q = ScoreNet(state_dim, card_dim, hidden).to(device)
self.tgt = ScoreNet(state_dim, card_dim, hidden).to(device)
self.tgt.load_state_dict(self.q.state_dict())
self.opt = torch.optim.Adam(self.q.parameters(), lr=lr)
self.buf: deque = deque(maxlen=buffer_size)
self.eps_start, self.eps_end, self.eps_decay = eps_start, eps_end, eps_decay
self.steps = 0
self.greedy = False # 평가 모드(탐색 끔)
# ---- 탐색 스케줄 ----------------------------------------------------
def eps(self) -> float:
if self.greedy:
return 0.0
return self.eps_end + (self.eps_start - self.eps_end) * math.exp(-self.steps / self.eps_decay)
# ---- 채점/선택 -------------------------------------------------------
def scores(self, state_feat: np.ndarray, card_embs: np.ndarray) -> np.ndarray:
"""가용 카드 K개 일괄 채점. card_embs: [K, card_dim] → [K]."""
k = card_embs.shape[0]
x = np.concatenate([np.repeat(state_feat[None, :], k, axis=0), card_embs], axis=1)
with torch.no_grad():
return self.q(torch.tensor(x, device=self.device)).cpu().numpy()
def select(self, state_feat: np.ndarray, card_embs: np.ndarray) -> Tuple[int, float, float]:
"""(선택 인덱스, propensity, 선택 점수). 인덱스는 card_embs 행 기준."""
k = card_embs.shape[0]
sc = self.scores(state_feat, card_embs)
e = self.eps()
if random.random() < e:
i = random.randrange(k)
prop = e / k
else:
i = int(sc.argmax())
prop = (1.0 - e) + e / k
return i, prop, float(sc[i])
# ---- 경험/학습 -------------------------------------------------------
def remember(self, state_feat: np.ndarray, card_emb: np.ndarray, reward: float,
next_state_feat: Optional[np.ndarray], next_card_embs: Optional[np.ndarray],
done: bool):
self.buf.append((state_feat, card_emb, reward, next_state_feat, next_card_embs, done))
def train_step(self) -> Optional[float]:
if len(self.buf) < self.batch_size:
return None
batch = random.sample(self.buf, self.batch_size)
# Q(s, a_chosen)
xs = np.stack([np.concatenate([s, c]) for s, c, *_ in batch])
q_sa = self.q(torch.tensor(xs, device=self.device))
# target = r + γ·max_{c'} Q_tgt(s', c') — 가변 후보라 후보 전체를 한 번에 forward 후 세그먼트 max
rewards = torch.tensor([b[2] for b in batch], device=self.device, dtype=torch.float32)
dones = torch.tensor([float(b[5]) for b in batch], device=self.device)
next_rows, owner = [], []
for bi, (_, _, _, s2, cands, done) in enumerate(batch):
if done or s2 is None or cands is None or len(cands) == 0:
continue
for c in cands:
next_rows.append(np.concatenate([s2, c]))
owner.append(bi)
q_next_max = torch.zeros(self.batch_size, device=self.device)
if next_rows:
with torch.no_grad():
q_all = self.tgt(torch.tensor(np.stack(next_rows), device=self.device))
owner_t = torch.tensor(owner, device=self.device)
q_next_max = q_next_max.index_reduce_(0, owner_t, q_all, "amax", include_self=False)
target = rewards + self.gamma * q_next_max * (1.0 - dones)
loss = nn.functional.smooth_l1_loss(q_sa, target)
self.opt.zero_grad()
loss.backward()
self.opt.step()
self.steps += 1
if self.steps % self.target_sync == 0:
self.tgt.load_state_dict(self.q.state_dict())
return float(loss)
# ---- 저장/로드 -------------------------------------------------------
def save(self, path: str):
torch.save(self.q.state_dict(), path)
def load(self, path: str):
sd = torch.load(path, map_location=self.device)
self.q.load_state_dict(sd)
self.tgt.load_state_dict(sd)

View File

@ -0,0 +1,152 @@
"""AutonomyStore — 완전 자율 협상 정책 서빙 (룰 대체, numpy 전용).
AUTONOMY_MODE=1 이면 가격협상 판정 (앵커 이하 타결 / 와일드카드 / 라운드 상한)
카드 선택을 전부 정책의 행동 결정으로 대체한다:
accept 협상완료 (제시가 타결) walk 협상실패
counter "C원이면 수락" 역제안 스텝 press 전략별 압박 멘트 스텝
행동의 유일한 유인은 보상 함수다. 남는 제한은 가지뿐이며 비즈니스 룰이 아니다:
- 역제안 후보 격자가 [anchor5%span, target] (행동 공간 정의)
- 세션 상한(엔지니어링 타임아웃, ChatEngine._AUTONOMY_TURN_CAP)
번들: artifacts/autonomy_serving.npz (tools/export_autonomy_serving.py).
불가(플래그 꺼짐/번들 없음) None 기존 엔진 그대로 (즉시 롤백 경로).
"""
import os
from typing import Optional
import numpy as np
from common.logger import LOG
from negotiation.policies.autonomy_actions import (
ACTIONS, Action, extra_state, internet_gap_feat, settle_norm)
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
from negotiation.qtable.domain.service.feature_builder import (
build_state_features, build_tenant_features)
_HERE = os.path.dirname(os.path.abspath(__file__))
BUNDLE_PATH = os.path.join(_HERE, "..", "..", "artifacts", "autonomy_serving.npz")
class AutonomyPolicy:
"""세션 컨텍스트 → 상태특징 → 행동(greedy). ChatEngine 에 decider 로 주입된다."""
name = "full_autonomy"
def __init__(self, z, reward_cfg):
self._W = (z["W0"], z["b0"], z["W1"], z["b1"], z["W2"], z["b2"])
self._state_dim = int(z["state_dim"])
self._tenant_feat = build_tenant_features(reward_cfg)
@staticmethod
def _acceptance(ctx: dict) -> float:
base = ctx.get("item_price") or ctx.get("first_offer_price") or 0
cur = ctx.get("input_price") or 0
if base <= 0 or cur <= 0:
return 0.0
return max(0.0, (base - cur) / base)
def decide(self, ctx: dict) -> Action:
"""ChatSession.context → Action. 상태 구성은 ChatService._snapshot 과 동일 규칙."""
snap = NegotiationSnapshot(
revenue_amount=ctx["revenue_amount"], distribution_code=ctx["distribution_code"],
partner_count=ctx["partner_count"], acceptance_ratio=self._acceptance(ctx),
input_price=ctx.get("input_price", ctx["anchor_price"]), anchor_price=ctx["anchor_price"],
target_price=ctx["target_price"], round_number=ctx.get("round", 0),
)
# v3 추가 특징: 직전 역제안 기억 + 마감 잔여율 + 협력사 이력 + 인터넷최저가 갭.
# 소스가 없으면 전부 중립값(0.5/0) — 학습 시뮬의 '미상' 표현과 동일해야 한다.
# 역제안 기억은 autonomy_last_counter(역제안만 갱신) — autonomy_last(마지막 행동)를 쓰면
# 사이에 낀 설득이 기억을 지워 단조 봉투가 뚫린다(counter→press→counter 철회 실버그).
# 시뮬의 last_kind/last_q 도 역제안만 추적하므로 이쪽이 학습 분포와도 일치한다.
last = ctx.get("autonomy_last_counter") or {}
deadline = 0.5
end_ts, total_s = ctx.get("deadline_end_ts"), ctx.get("deadline_total_s")
if end_ts and total_s:
import time
deadline = float(np.clip((end_ts - time.time()) / total_s, 0.0, 1.0))
hist_n = int(ctx.get("hist_n") or 0)
hist_success = float(ctx["hist_success"]) if ctx.get("hist_success") is not None else 0.5
hist_settle = (settle_norm(float(ctx["hist_settle_ratio"]))
if ctx.get("hist_settle_ratio") is not None else 0.5)
sf = np.concatenate([build_state_features(snap), self._tenant_feat, extra_state(
last.get("kind", ""), float(last.get("q", 0.0)),
deadline=deadline, hist_n=min(hist_n, 5) / 5.0,
hist_success=hist_success if hist_n else 0.5,
hist_settle=hist_settle,
internet_gap=internet_gap_feat(float(ctx.get("internet_lowest_price") or 0),
float(snap.anchor_price)),
)])
span = max(snap.target_price - snap.anchor_price, 1.0)
pos = (snap.input_price - snap.anchor_price) / span
# 행동 봉투 (학습 available_actions 와 동일해야 한다):
# ① 목표가 초과 제시가는 '수락' 제외 — 매입 승인 범위(v3.1 착취 방지)
# ② 직전 역제안보다 낮은 금액의 역제안 제외 — 단조 양보 원칙(제안 철회는 협상 예절 위반;
# 올리는 '속도'는 정책 학습, 후퇴 '금지'만 구조로 보장)
# ③ 역제시 해금 조건 — 옛 제품 의미론 복원(제품 결정 2026-07-10): 일반 카드는 설득만,
# 역제시(숫자 제안)는 와일드카드처럼 마무리 수단. 최소 AUTONOMY_MIN_PRESS(기본 2)회
# 설득 이후에만 역제시 후보가 열린다. 해금 후의 타이밍·금액은 정책 학습.
# ④ 마무리 국면 — 제시가가 목표가 0.5% 이내로 붙으면 압박 제외(+역제시 잠금 해제):
# 푼돈 차이에서 '재검토 부탁' 반복은 상대만 지치게 한다. 클로징(역제안/최종제안)하거나 끝내거나.
min_press = int(os.getenv("AUTONOMY_MIN_PRESS", "2"))
near_target = snap.input_price <= snap.target_price * 1.005
counter_locked = (int(ctx.get("autonomy_press_n") or 0) < min_press) and not near_target
last_counter_q = float(last["q"]) if last.get("kind") == "counter" else None
if last_counter_q is not None:
counter_locked = False # 이미 역제시를 시작했으면 잠그지 않는다(단조 봉투가 관리)
# ⑤ 첫 역제안은 앵커가 이하(q ≤ 0)만 — 낮게 개시해 목표가까지 천천히 올라간다
# (제품 결정: 사다리를 다 쓰는 앵커링 개시. 이후 단조 봉투가 상향을 관리).
# ⑥ 결렬(walk)도 해금 전 금지 — 설득 0회에 walk 를 고르면 최종제안 보장(엔진)과 결합해
# '첫 턴 목표가 통보'가 된다(v3.4 라이브 결함). 해금 전에는 설득만 가능.
cands = [a for a in ACTIONS
if not (a.kind == "accept" and snap.input_price > snap.target_price)
and not (a.kind == "counter" and counter_locked)
and not (a.kind == "walk" and counter_locked)
and not (a.kind == "press" and near_target)
and not (a.kind == "counter" and last_counter_q is None and a.counter_q > 1e-9)
and not (a.kind == "counter" and last_counter_q is not None
and a.counter_q < last_counter_q - 1e-9)]
feats = []
for a in cands:
cut = 0.0
if a.kind == "counter":
c = snap.anchor_price + a.counter_q * span
cut = max(0.0, (snap.input_price - c) / max(snap.input_price, 1.0))
feats.append(a.feat(pos, cut))
feats = np.stack(feats)
W0, b0, W1, b1, W2, b2 = self._W
x = np.concatenate([np.repeat(sf[None, :], feats.shape[0], axis=0), feats], axis=1)
h = np.maximum(x @ W0.T + b0, 0.0)
h = np.maximum(h @ W1.T + b1, 0.0)
scores = (h @ W2.T + b2).squeeze(-1)
return cands[int(np.argmax(scores))]
@staticmethod
def counter_price(ctx: dict, act: Action) -> int:
span = max(ctx["target_price"] - ctx["anchor_price"], 1.0)
return int(round(ctx["anchor_price"] + act.counter_q * span))
class AutonomyStore:
_z = None
_load_failed = False
@classmethod
def enabled(cls) -> bool:
return os.getenv("AUTONOMY_MODE", "0").lower() in ("1", "true", "yes")
@classmethod
def policy_for(cls, engine) -> Optional[AutonomyPolicy]:
"""engine: tenancy.registry.TenantEngine. 비활성/번들 없음 → None (룰 엔진 유지)."""
if not cls.enabled() or cls._load_failed:
return None
if cls._z is None:
try:
cls._z = np.load(BUNDLE_PATH, allow_pickle=False)
LOG.i("[Autonomy] 완전 자율 정책 번들 로드 완료 — 협상 판정 룰 대체 모드")
except Exception as ex:
cls._load_failed = True
LOG.e_no_callstack(f"[Autonomy] 번들 로드 실패 → 룰 엔진 유지: {ex}")
return None
return AutonomyPolicy(cls._z, engine.config.reward)

View File

@ -0,0 +1,118 @@
"""DQNServingStore — action-as-feature DQN 서빙 (선택 전용, 학습 없음).
tools/export_dqn_serving.py 만든 dqn_serving.npz(ScoreNet 가중치 + 카드특징 392차원)
numpy 추론한다 서빙 컨테이너에 PyTorch 불필요.
역할 분담(계획서 H 트랙으로 가기 파일럿):
- 카드 '선택' DQN(greedy). Q-table 학습/영속/experience_logs 로깅 경로는 기존 그대로 유지
(Q-learning 오프폴리시라 DQN 고른 행동으로 갱신해도 유효, 로그는 DQN 오프라인 재학습 재료).
- 폴백: 플래그 꺼짐 / 번들 없음 / 가용 카드 전부 특징 미보유(신규 카드) None 반환,
호출부(ChatService) 기존 UCB Q-table 선택으로 진행한다.
활성화: 환경변수 DQN_SERVING=1 (docker-compose agent environment).
신규 카드 주의: 번들에 없는 카드번호는 후보에서 제외된다 카드 추가
tools/build_card_embeddings.py tools/export_dqn_serving.py 재실행 재배포 필요.
"""
import os
from typing import List, Optional
import numpy as np
from common.logger import LOG
from negotiation.policies.base import ActionDecision, PolicyContext
from negotiation.qtable.domain.service.feature_builder import (
build_state_features, build_tenant_features)
_HERE = os.path.dirname(os.path.abspath(__file__))
BUNDLE_PATH = os.path.join(_HERE, "..", "..", "artifacts", "dqn_serving.npz")
class _Bundle:
def __init__(self, z):
self.W0, self.b0 = z["W0"], z["b0"]
self.W1, self.b1 = z["W1"], z["b1"]
self.W2, self.b2 = z["W2"], z["b2"]
self.state_dim = int(z["state_dim"])
self.card_feats = {str(n): z["card_feats"][i]
for i, n in enumerate(z["card_numbers"])}
def scores(self, state_feat: np.ndarray, card_feats: np.ndarray) -> np.ndarray:
"""가용 카드 K개 일괄 채점: [K, state+card] → [K]."""
k = card_feats.shape[0]
x = np.concatenate([np.repeat(state_feat[None, :], k, axis=0), card_feats], axis=1)
h = np.maximum(x @ self.W0.T + self.b0, 0.0)
h = np.maximum(h @ self.W1.T + self.b1, 0.0)
return (h @ self.W2.T + self.b2).squeeze(-1)
class DQNServingPolicy:
"""UCBQTablePolicy.select 와 동일한 PolicyContext → ActionDecision 계약(선택 전용)."""
name = "feature_dqn"
_EPS = 0.1 # propensity 근사용 ε (UCB 정책과 동일 관례 — OPE 지지 확보용, 선택은 greedy)
def __init__(self, bundle: _Bundle, engine): # engine: tenancy.registry.TenantEngine
self._bundle = bundle
self._mapper = engine.mapper
self._tenant_feat = build_tenant_features(engine.config.reward)
def _available(self, ctx: PolicyContext) -> List[int]:
# UCBQTablePolicy._available 과 동일 규칙 (마스크 → used 제외 → 소진 시 전체 허용)
if ctx.available_mask is not None:
avail = [a for a in range(ctx.action_space_size) if ctx.available_mask[a]]
else:
used = ctx.episode.used_action_ids if ctx.episode else set()
avail = [a for a in range(ctx.action_space_size) if a not in used]
return avail or list(range(ctx.action_space_size))
def select(self, ctx: PolicyContext) -> Optional[ActionDecision]:
"""카드특징이 있는 가용 카드가 없으면 None → 호출부가 Q-table 로 폴백."""
candidates = [] # (action_id, card_feat)
for a in self._available(ctx):
num = self._mapper.get_card_id(a)
feat = self._bundle.card_feats.get(num) if num else None
if feat is not None:
candidates.append((a, feat))
if not candidates:
return None
state_feat = np.concatenate([build_state_features(ctx.snapshot), self._tenant_feat])
if state_feat.shape[0] != self._bundle.state_dim:
LOG.e_no_callstack(
f"[DQNServing] state_dim 불일치: {state_feat.shape[0]} != {self._bundle.state_dim}")
return None
sc = self._bundle.scores(state_feat, np.stack([f for _, f in candidates]))
i = int(sc.argmax())
n = len(candidates)
return ActionDecision(
action_id=candidates[i][0],
propensity=(1.0 - self._EPS) + self._EPS / n,
q_value=float(sc[i]),
ucb_score=float(sc[i]),
available_actions=[a for a, _ in candidates],
)
class DQNServingStore:
"""번들 lazy 로드 + 캐시. 비활성/부재 시 None (호출부 Q-table 폴백)."""
_bundle: Optional[_Bundle] = None
_load_failed = False
@classmethod
def enabled(cls) -> bool:
return os.getenv("DQN_SERVING", "0").lower() in ("1", "true", "yes")
@classmethod
def policy_for(cls, engine) -> Optional[DQNServingPolicy]:
if not cls.enabled() or cls._load_failed:
return None
if cls._bundle is None:
try:
cls._bundle = _Bundle(np.load(BUNDLE_PATH, allow_pickle=False))
LOG.i(f"[DQNServing] 번들 로드 완료: 카드 {len(cls._bundle.card_feats)}")
except Exception as ex:
cls._load_failed = True # 요청마다 재시도하지 않음
LOG.e_no_callstack(f"[DQNServing] 번들 로드 실패 → Q-table 폴백: {ex}")
return None
return DQNServingPolicy(cls._bundle, engine)

View File

@ -0,0 +1,44 @@
"""build_state_features — snapshot(raw 연속값) → 실수 벡터 (DQN/action-as-feature 용).
state_calculator.build_state(이산화) 대비되는 연속 표현. 이산화(등급/162) 하지 않고
정규화된 raw 값을 그대로 벡터로 내보낸다. 협력사 특징(매출·경쟁사수·유통) 벡터에 포함되므로
'협력사를 입력으로'(Phase 3) 자연스럽게 달성된다.
"""
import numpy as np
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
DIST_CLASSES = ("A", "B", "C")
STATE_FEATURE_DIM = 9 # build_state_features 벡터 길이. feature 추가 시 갱신.
TENANT_FEATURE_DIM = 5 # build_tenant_features 벡터 길이.
def build_tenant_features(reward_cfg) -> np.ndarray:
"""고객사 '성향'을 ID 가 아니라 보상 설정값(내용)으로 벡터화 (Phase 3 고객사 조건화).
고객사도 tenant.yaml reward 설정만 있으면 즉시 조건화된다 (cold-start 없음).
"""
return np.array([
reward_cfg.max_weight, # 가격 중시 정도 (W↑ = 가격보상 비중↑)
reward_cfg.success_reward / 2.0, # 성사를 얼마나 크게 치는가
-reward_cfg.failure_penalty / 2.0, # 결렬을 얼마나 무서워하는가
reward_cfg.penalty_lambda * 20.0, # 속도 성향 (오래 끌수록 벌점)
reward_cfg.beta, # 앵커 초과달성 보너스 성향
], dtype=np.float32)
def build_state_features(s: NegotiationSnapshot) -> np.ndarray:
"""정규화된 연속 상태 벡터. 등급화 없음 — 990원과 850원이 구별된다."""
dist_onehot = [1.0 if s.distribution_code == c else 0.0 for c in DIST_CLASSES]
anchor = max(s.anchor_price, 1.0)
target = max(s.target_price, 1.0)
return np.array([
min(s.revenue_amount, 5e8) / 5e8, # 협력사 매출 (0~1)
*dist_onehot, # 유통 A/B/C
min(s.partner_count, 5) / 5.0, # 대안 협력사 수 (BATNA)
float(np.clip(s.acceptance_ratio, 0.0, 1.0)), # 수용률
float(np.clip((s.input_price - anchor) / anchor, -1.0, 2.0)), # 앵커 대비 격차 (연속!)
float(np.clip((target - s.input_price) / target, -2.0, 1.0)), # 목표 대비 여유
min(s.round_number, 10) / 10.0, # 라운드
], dtype=np.float32)

View File

@ -14,6 +14,7 @@ from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from config.server_configs import agent_config
from negotiation.cards.domain.tactics import compute_counter, tactic_available, tactic_for
from negotiation.chat.service import ment_generator
from negotiation.chat.service.chat_engine import (
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
)
@ -24,6 +25,9 @@ from negotiation.chat.service.negotiation_context_loader import NegotiationConte
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer, build_situation
from negotiation.chat.service.script_repository import ScriptRepository
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
from negotiation.policies.autonomy_actions import ACTIONS as AUTONOMY_ACTIONS
from negotiation.policy.autonomy_store import AutonomyStore
from negotiation.policy.dqn_store import DQNServingStore
from negotiation.policy.model_store import QTablePolicyStore
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot, PartnerType
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
@ -59,9 +63,19 @@ class ChatService:
session = await sess_repo.get(req.session_id) if req.session_id else None
# 새 세션 컨텍스트: 요청 페이로드 대신 DB(negotiation.sessions 등)에서 1회 조회.
# 행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
db_ctx = None if session else await NegotiationContextLoader().load(req.session_id)
db_ctx = None if session else await NegotiationContextLoader().load(req.session_id, engine.company_id)
rq_type = session.rq_type if session else (db_ctx.rq_type if db_ctx else _DEFAULT_RQ_TYPE)
chat_engine = ChatEngine(repo, rq_type=rq_type)
# 완전 자율 모드(AUTONOMY_MODE=1 + 번들 존재): 가격협상 판정 룰·카드 선택을 정책 행동으로 대체.
# decider 를 감싸 결정을 컨텍스트에 기록 → advance() 후 experience_logs 에 적재(_autonomy_learn).
autonomy = AutonomyStore.policy_for(engine)
if autonomy is not None:
def _decide(ctx, _p=autonomy):
act = _p.decide(ctx)
ctx["autonomy_pending"] = {"idx": AUTONOMY_ACTIONS.index(act), "kind": act.kind,
"q": act.counter_q, "s": act.strategy}
return act
chat_engine.autonomy_decider = _decide
# ① step desync 감지: backend 가 본 직전 봇 step(client_step)이 agent 세션 step 과 다르면 경고.
# agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴),
@ -117,6 +131,13 @@ class ChatService:
"selected_nego_card_numbers": selected_nego_cards,
"selected_wild_card_numbers": selected_wild_cards,
"allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards),
# ---- 자율 에이전트 v3 특징 소스 (미상이면 키 자체를 중립값으로 — JSON 직렬화 안전) ----
"internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0,
"deadline_end_ts": db_ctx.deadline_end_ts if db_ctx else None,
"deadline_total_s": db_ctx.deadline_total_s if db_ctx else None,
"hist_n": db_ctx.hist_n if db_ctx else 0,
"hist_success": db_ctx.hist_success if db_ctx else None,
"hist_settle_ratio": db_ctx.hist_settle_ratio if db_ctx else None,
},
)
view = chat_engine.start(session)
@ -160,6 +181,17 @@ class ChatService:
elif view.outcome is not None:
await self._terminal_learn(engine, session, view.outcome, res)
# 3-b) 완전 자율 모드: 정책 결정·종료 결과를 experience_logs 에 적재 (실로그 재학습 재료).
if autonomy is not None and view.error is None:
await self._autonomy_learn(engine, session, view, res)
# 자율 스텝 멘트를 LLM 으로 생성 (행동은 RL, 문장은 LLM). 실패/미설정 → 템플릿 유지.
if view.step.startswith("자율_"):
llm_ment = await ment_generator.generate(view.step, session.context)
if llm_ment:
res.script = llm_ment
# 직전 봇 멘트 보존 — 다음 생성에서 같은 문장 구조 반복을 금지하는 힌트.
session.context["autonomy_last_ment"] = (res.script or "")[:200]
if view.error:
res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
res.msg = view.error
@ -280,7 +312,13 @@ class ChatService:
available_mask=self._combined_mask(engine, session),
prior_bonus=self._selection_prior(engine, session),
episode=EpisodeState(used_action_ids=set(session.used_action_ids)))
decision = policy.select(ctx)
# 카드 '선택'은 DQN 서빙(활성 시), 학습/영속은 아래 Q-table 경로 그대로(오프폴리시 갱신).
# DQN 불가(비활성/번들 없음/후보 특징 없음)면 None → 기존 UCB 선택 폴백.
dqn = DQNServingStore.policy_for(engine)
decision = dqn.select(ctx) if dqn is not None else None
selector_name = dqn.name if decision is not None else policy.name
if decision is None:
decision = policy.select(ctx)
session.used_action_ids.add(decision.action_id)
card_id = self._card_id_for_action(engine, session, decision.action_id)
# 전술 실행(재설계): 카드의 가격 행동 — 카운터 제시가를 계산해 세션에 적재한다.
@ -299,7 +337,7 @@ class ChatService:
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
res.card_id = card_id
res.policy = policy.name
res.policy = selector_name
res.q_value = decision.q_value
res.updated_q = float(policy.qtable.q[idx, decision.action_id])
res.visit_count = int(policy.qtable.visits[idx, decision.action_id])
@ -372,6 +410,49 @@ class ChatService:
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
res.card_id = closing_number
async def _autonomy_learn(self, engine: TenantEngine, session: ChatSession, view: StepView, res: Res_Chat):
"""완전 자율 행동 로깅 — Q-table 은 건드리지 않고 experience_logs 만 적재한다.
action_id = autonomy_actions.ACTIONS 인덱스, card_id = "AUT|종류|위치|전략" (카드 재학습
파이프라인이 임베딩 매칭에서 자동 제외하도록 프리픽스로 구분). 종료 최종 보상 (done=True)
남겨 retrain 에피소드 재구성 규약(카드턴 N + 종료 1) 정합을 맞춘다.
"""
def _card_id(d) -> str:
return f"AUT|{d['kind']}|{d['q']:g}|{d['s']}"[:40]
ctx = session.context
lrepo = LearningRepository(engine.company_id)
pending = ctx.pop("autonomy_pending", None)
if pending is not None:
snap = self._snapshot(session, NegotiationOutcome.ONGOING)
try:
idx = state_index(snap, engine.config.state) # 로깅 호환용 이산 인덱스
except ValueError:
idx = 0 # 자율 모드는 이산 상태를 쓰지 않으므로 폴백해도 학습 오염 없음
if ctx.get("autonomy_last"):
ctx["autonomy_prev"] = ctx["autonomy_last"] # 직전 결정 보존 — 멘트 생성 힌트(양보 언급)용
ctx["autonomy_last"] = dict(pending, state_index=idx)
if pending.get("kind") == "counter":
# 역제안 기억은 별도 키로 보존 — autonomy_last 는 '마지막 행동'이라 사이에 낀
# 설득이 덮어쓴다. 단조 봉투·탄약소진 판정이 이 기억을 기준으로 해야
# counter→press→counter 에서 제안 철회가 새지 않는다 (게이트가 잡은 실버그).
ctx["autonomy_last_counter"] = dict(pending)
if pending.get("kind") == "press":
# 설득 횟수 누적 — 역제시 해금 조건(autonomy_store ③)의 카운터.
ctx["autonomy_press_n"] = int(ctx.get("autonomy_press_n") or 0) + 1
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
await self._log(lrepo, session, idx, pending["idx"], _card_id(pending), snap,
reward, (1.0 - 0.1) + 0.1 / len(AUTONOMY_ACTIONS), done=False)
res.policy = "full_autonomy"
last = ctx.get("autonomy_last")
if view.outcome is not None and last is not None:
oc = NegotiationOutcome.SUCCESS if view.outcome == "success" else NegotiationOutcome.FAILURE
snap = self._snapshot(session, oc)
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
res.reward_total = reward.total
await self._log(lrepo, session, last["state_index"], last["idx"], _card_id(last), snap,
reward, None, done=True)
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE
snap = self._snapshot(session, oc)

View File

@ -0,0 +1,63 @@
"""카드 스크립트 → 임베딩 캐시 생성 (action-as-feature 준비, 1회 실행).
card.nego_cards(11) name+script 문장 임베딩으로 변환해 artifacts/card_embeddings.npz 저장.
카드가 추가되면 스크립트를 다시 돌리면 된다( 카드만 임베딩돼 캐시에 합류).
실행:
APP_ENV=local python -m tools.build_card_embeddings
출력:
artifacts/card_embeddings.npz (numbers, names, strategy, tone, embeddings[N,384])
"""
import asyncio
import os
import numpy as np
_HERE = os.path.dirname(os.path.abspath(__file__))
ARTIFACTS = os.path.join(_HERE, "..", "artifacts")
OUT_PATH = os.path.join(ARTIFACTS, "card_embeddings.npz")
MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2" # 384차원, 한국어 지원, 로컬/무료
async def load_cards():
"""card.nego_cards 에서 (number, name, script, strategy_type, tone) 로드."""
import asyncpg
conn = await asyncpg.connect(
host="127.0.0.1", port=5432, user="postgres", password="password", database="negosium_db")
try:
rows = await conn.fetch(
"SELECT number, name, script, strategy_type, tone FROM card.nego_cards "
"WHERE deleted = FALSE ORDER BY number")
return [(r["number"], r["name"], r["script"], r["strategy_type"], r["tone"]) for r in rows]
finally:
await conn.close()
def main():
cards = asyncio.run(load_cards())
if not cards:
raise SystemExit("card.nego_cards 가 비어있음 — DB 시드 확인 (docker start negosium-pg)")
print(f"카드 {len(cards)}장 로드: {[c[0] for c in cards]}")
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(MODEL_NAME)
texts = [f"{name}. {script}" for _, name, script, _, _ in cards]
emb = model.encode(texts, normalize_embeddings=True) # [N, 384], 단위벡터
print(f"임베딩 shape: {emb.shape}")
os.makedirs(ARTIFACTS, exist_ok=True)
np.savez(
OUT_PATH,
numbers=np.array([c[0] for c in cards]),
names=np.array([c[1] for c in cards]),
strategy=np.array([c[3] for c in cards], dtype=np.int64),
tone=np.array([c[4] for c in cards], dtype=np.int64),
embeddings=emb.astype(np.float32),
)
print(f"저장: {OUT_PATH}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,251 @@
"""기존 Q-Table(UCB) vs action-as-feature DQN 공정 비교 — 고객사 성향 조건화 환경 (최종).
같은 환경(FeatureBuyer 2 + 협력사·고객사성향 랜덤)에서 동일 에피소드로 학습·평가.
- Q-Table: 이산 state 162 + 카드=슬롯. 성향(고객사) 입력 자체가 불가능 평균 성향에 수렴
- DQN : 연속 상태 + 성향 벡터 + 카드 특징(임베딩+전략/ one-hot)
평가 4:
학습 카드 9 평균보상(진짜 목적함수) + top3 적중(MC 정답 기준)
zero-shot 11 카드 2 포함
카드 사용률 구조적 차이
성향 극단 테스트 같은 협력사, 성향만 바꿨을 카드를 바꾸는가
실행: APP_ENV=local python -m tools.compare_qtable_vs_dqn
"""
import random
import numpy as np
import torch
from eval_harness.buyer import Scenario
from eval_harness.feature_buyer import FeatureBuyer, SupplierProfile, sample_supplier
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
from negotiation.policies.qtable_policy import UCBQTablePolicy
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
from negotiation.qtable.domain.model.q_table import QTable
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome
from negotiation.qtable.domain.service.feature_builder import (
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features)
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
from negotiation.qtable.domain.service.state_calculator import state_index
from tenancy.config_loader import TenantConfigLoader
from tools.train_feature_dqn import (
ANCHOR, HOLDOUT, MAX_TURNS, TARGET, load_cards, make_snapshot, pref_config, sample_tenant_pref)
# ---- 정책 어댑터 ------------------------------------------------------------------
class DQNAdapter:
name = "feature_dqn"
def __init__(self, policy, feat):
self.p, self.feat = policy, feat
def _sf(self, snap, tf):
return np.concatenate([build_state_features(snap), tf])
def choose(self, snap, tf, avail, greedy):
self.p.greedy = greedy
i, _, _ = self.p.select(self._sf(snap, tf), np.stack([self.feat[c] for c in avail]))
return avail[i]
def learn(self, snap, tf, card, reward, next_snap, next_avail, done):
sf = self._sf(snap, tf)
if done or next_snap is None:
self.p.remember(sf, self.feat[card], reward, None, None, True)
else:
self.p.remember(sf, self.feat[card], reward, self._sf(next_snap, tf),
np.stack([self.feat[c] for c in next_avail]), False)
self.p.train_step()
class QTableAdapter:
"""기존 UCBQTablePolicy. 성향(tf)은 구조상 받을 수 없다 — 이산 state 162칸에 그 축이 없음."""
name = "qtable_ucb"
def __init__(self, all_numbers, state_cfg, lr=0.1, gamma=0.95):
self.numbers = list(all_numbers)
self.a_of = {n: i for i, n in enumerate(self.numbers)}
self.state_cfg = state_cfg
self.qt = QTable(162, len(self.numbers), learning_rate=lr, discount_factor=gamma)
self.pol = UCBQTablePolicy(self.qt)
def choose(self, snap, tf, avail, greedy):
idx = state_index(snap, self.state_cfg)
if greedy:
q = self.qt.row(idx)
return max(avail, key=lambda c: q[self.a_of[c]])
mask = np.zeros(len(self.numbers), dtype=bool)
for c in avail:
mask[self.a_of[c]] = True
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=len(self.numbers),
episode=EpisodeState(), available_mask=mask)
return self.numbers[self.pol.select(ctx).action_id]
def learn(self, snap, tf, card, reward, next_snap, next_avail, done):
idx = state_index(snap, self.state_cfg)
nidx = state_index(next_snap, self.state_cfg) if (next_snap is not None and not done) else None
self.pol.update(Transition(state_index=idx, action_id=self.a_of[card], reward=reward,
next_state_index=nidx, done=done))
class RandomAdapter:
name = "random"
def __init__(self, seed=0):
self.rng = np.random.default_rng(seed)
def choose(self, snap, tf, avail, greedy):
return avail[self.rng.integers(len(avail))]
def learn(self, *a, **k):
pass
# ---- 공용 에피소드 -----------------------------------------------------------------
def run_episode(adapter, sup, tf, pool, strat, rc, seed, learn=True, greedy=False, forced_first=None):
buyer = FeatureBuyer(sup, strat, seed=seed, max_turns=MAX_TURNS)
scenario = Scenario(anchor_price=ANCHOR, target_price=TARGET, revenue_amount=sup.revenue_amount,
distribution_code=sup.distribution_code, partner_count=sup.partner_count)
price0 = TARGET * 1.15
price, used, total_r, first_card = price0, set(), 0.0, None
for turn in range(1, MAX_TURNS + 1):
acceptance = max(0.0, (price0 - price) / price0)
snap = make_snapshot(sup, price, turn, acceptance)
avail = [c for c in pool if c not in used] or list(pool)
if turn == 1 and forced_first is not None:
card = forced_first
else:
card = adapter.choose(snap, tf, avail, greedy)
used.add(card)
if first_card is None:
first_card = card
resp = buyer.respond(card, scenario, turn, price)
price = resp.new_price
done = resp.accept or price <= ANCHOR or turn >= MAX_TURNS
success = resp.accept or price <= ANCHOR
outcome = (NegotiationOutcome.SUCCESS if success
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
# 채점은 최종 결과 시점만 (중간 턴 0 → γ 부트스트랩으로 전파).
# 진행 중 보상을 누적하면 '질질 끄는 전략'이 부당하게 유리해지는 인공물이 생긴다.
r = rc.calculate(make_snapshot(sup, price, turn, acceptance, outcome)).total if done else 0.0
total_r += r
if learn:
if done:
adapter.learn(snap, tf, card, r, None, None, True)
else:
acc2 = max(0.0, (price0 - price) / price0)
nsnap = make_snapshot(sup, price, turn + 1, acc2)
navail = [c for c in pool if c not in used] or list(pool)
adapter.learn(snap, tf, card, r, nsnap, navail, False)
if done:
return total_r, success, price, first_card
return total_r, False, price, first_card
# ---- MC 정답 랭킹: 이 (협력사, 성향)에서 진짜 좋은 첫 카드 top-k ---------------------
_rand = RandomAdapter(seed=1)
def rank_cards_mc(sup, tf, pool, strat, rc, seed, sims=6, k=3):
means = {}
for c in pool:
rs = [run_episode(_rand, sup, tf, pool, strat, rc, seed=seed + 17 * s,
learn=False, greedy=False, forced_first=c)[0] for s in range(sims)]
means[c] = np.mean(rs)
return sorted(means, key=lambda c: -means[c])[:k]
# ---- 학습/평가 ---------------------------------------------------------------------
def train(adapter, pool, strat, base_reward, state_cfg, episodes, seed):
rng = np.random.default_rng(seed)
for ep in range(1, episodes + 1):
sup = sample_supplier(rng)
rcfg, tf = sample_tenant_pref(rng, base_reward)
rc = RewardCalculator(rcfg, state_cfg)
run_episode(adapter, sup, tf, pool, strat, rc, seed=seed * 100 + ep, learn=True)
def evaluate(adapter, pool, strat, base_reward, state_cfg, n=300, seed0=777, label=""):
from negotiation.qtable.domain.service.feature_builder import build_tenant_features
rng = np.random.default_rng(seed0)
rewards, succ, ratios, hits, holdout_first = [], 0, [], 0, 0
for i in range(n):
sup = sample_supplier(rng)
rcfg, tf = sample_tenant_pref(rng, base_reward)
rc = RewardCalculator(rcfg, state_cfg)
good = rank_cards_mc(sup, tf, pool, strat, rc, seed=seed0 * 7 + i)
r, ok, price, first = run_episode(adapter, sup, tf, pool, strat, rc,
seed=seed0 * 1000 + i, learn=False, greedy=True)
rewards.append(r); succ += ok; ratios.append(price / TARGET)
hits += (first in good); holdout_first += (first in HOLDOUT)
m, ci = float(np.mean(rewards)), float(1.96 * np.std(rewards) / np.sqrt(n))
print(f"{label:<14} mean_rwd={m:.4f} ±{ci:.4f} success={succ/n:.3f} "
f"settled/tgt={np.mean(ratios):.3f} top3_hit={hits/n:.3f} 새카드첫턴={holdout_first/n:.3f}")
def pref_behavior_test(adapters, pool, strat, base_reward, state_cfg):
"""④ 같은 협력사, 성향만 바꿨을 때 카드를 바꾸는가 (greedy).
턴은 '일단 깎기' 공통 정답이라 성향 차이가 드러난다.
협상 중반(가격이 이미 target 근처, 3턴째) 상태를 함께 프로브: 여기서
성사중시는 '마무리(수락 잘 되는) 카드', 가격중시는 '더 깎는 카드' 갈려야 한다.
"""
from negotiation.qtable.domain.service.feature_builder import build_tenant_features
sups = [SupplierProfile(5_000_000, 3, "A"), # 소형·경쟁多
SupplierProfile(200_000_000, 1, "A")] # 대형·단독
probes = [("첫턴", TARGET * 1.15, 1, 0.0),
("중반(3턴,가격↓)", TARGET * 1.02, 3, 0.11)]
for pr_name, price, turn, acc in probes:
print(f"\n ── 프로브: {pr_name} (price={price:.0f}) ──")
print(f" {'협력사':<13} {'성향':<9} " + " ".join(f"{a.name:<15}" for a in adapters))
for sup in sups:
row = {}
for p, pname in [(0.05, "성사중시"), (0.95, "가격중시")]:
rcfg = pref_config(base_reward, p)
tf = build_tenant_features(rcfg)
picks = []
for a in adapters:
snap = make_snapshot(sup, price, turn, acc)
picks.append(a.choose(snap, tf, pool, True))
seg = f"{sup.segment[0]}·{sup.segment[1]}"
print(f" {seg:<13} {pname:<9} " + " ".join(f"{c}(전략{strat[c]})".ljust(15) for c in picks))
def main(episodes=10000, seed=42):
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
numbers, feat, strat = load_cards()
train_pool = [c for c in numbers if c not in HOLDOUT]
tcfg = TenantConfigLoader().load("ktcommerce")
card_dim = feat[numbers[0]].shape[0]
print(f"환경: 2축 FeatureBuyer + 성향 랜덤 · 학습 {episodes}ep · 카드특징 {card_dim}차원 "
f"(임베딩384+전략4+톤4) · 학습 {len(train_pool)}장 / 홀드아웃 {HOLDOUT}")
qt = QTableAdapter(numbers, tcfg.state)
dqn = DQNAdapter(FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM,
card_dim=card_dim, eps_decay=4000), feat)
print("\n[학습] qtable_ucb ...")
train(qt, train_pool, strat, tcfg.reward, tcfg.state, episodes, seed)
print("[학습] feature_dqn ...")
train(dqn, train_pool, strat, tcfg.reward, tcfg.state, episodes, seed)
print("\n=== ① 학습 카드 9장 풀 ===")
evaluate(RandomAdapter(seed), train_pool, strat, tcfg.reward, tcfg.state, label="random")
evaluate(qt, train_pool, strat, tcfg.reward, tcfg.state, label="qtable_ucb")
evaluate(dqn, train_pool, strat, tcfg.reward, tcfg.state, label="feature_dqn")
print("\n=== ② zero-shot 11장 풀 (안 본 카드 2장 포함) ===")
evaluate(RandomAdapter(seed), numbers, strat, tcfg.reward, tcfg.state, label="random")
evaluate(qt, numbers, strat, tcfg.reward, tcfg.state, label="qtable_ucb")
evaluate(dqn, numbers, strat, tcfg.reward, tcfg.state, label="feature_dqn")
print("\n=== ④ 성향 극단 테스트 — 같은 협력사, 성향만 바꾸면 카드를 바꾸는가 (11장 풀) ===")
pref_behavior_test([qt, dqn], numbers, strat, tcfg.reward, tcfg.state)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,42 @@
"""full_autonomy 체크포인트(.pt) → 서빙 번들(autonomy_serving.npz) export.
dqn_serving 동일 패턴: ScoreNet 가중치만 numpy 묶어 PyTorch 없이 서빙한다.
행동 특징은 코드(autonomy_actions) 런타임 생성하므로 번들에는 가중치만 담는다.
실행(호스트, torch 필요): APP_ENV=local python -m tools.export_autonomy_serving
"""
import os
import numpy as np
import torch
from negotiation.policies.autonomy_actions import ACTION_DIM, EXTRA_STATE_DIM
from negotiation.qtable.domain.service.feature_builder import STATE_FEATURE_DIM, TENANT_FEATURE_DIM
_HERE = os.path.dirname(os.path.abspath(__file__))
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "full_autonomy.pt")
OUT_PATH = os.path.join(_HERE, "..", "artifacts", "autonomy_serving.npz")
STATE_DIM = STATE_FEATURE_DIM + TENANT_FEATURE_DIM + EXTRA_STATE_DIM
def main():
sd = torch.load(CKPT_PATH, map_location="cpu")
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
assert W0.shape[1] == STATE_DIM + ACTION_DIM, f"입력 차원 불일치: {W0.shape[1]}"
tmp = OUT_PATH + ".tmp"
with open(tmp, "wb") as f:
np.savez(f, W0=W0, b0=b0, W1=W1, b1=b1, W2=W2, b2=b2,
state_dim=STATE_DIM, action_dim=ACTION_DIM)
if os.path.exists(OUT_PATH):
os.replace(OUT_PATH, OUT_PATH + ".prev")
os.replace(tmp, OUT_PATH)
print(f"[저장] {os.path.abspath(OUT_PATH)} (state {STATE_DIM} + action {ACTION_DIM})")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,79 @@
"""feature_dqn 체크포인트(.pt) → 서빙 번들(dqn_serving.npz) export.
서빙 컨테이너에 PyTorch 넣지 않기 위해 ScoreNet(3 MLP) 가중치와 카드 특징
(임베딩384 + 전략 one-hot4 + one-hot4 = 392) numpy 번들 하나로 묶는다.
추론은 negotiation.policy.dqn_store numpy forward 수행한다.
실행(호스트, torch 필요): APP_ENV=local python -m tools.export_dqn_serving
산출: agent/artifacts/dqn_serving.npz (.dockerignore 미제외 이미지에 포함)
"""
import os
import numpy as np
import torch
from tools.train_feature_dqn import load_cards
_HERE = os.path.dirname(os.path.abspath(__file__))
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "feature_dqn_ktcommerce.pt")
OUT_PATH = os.path.join(_HERE, "..", "artifacts", "dqn_serving.npz")
STATE_DIM = 14 # build_state_features(9) + build_tenant_features(5)
CARD_DIM = 392
def _np_forward(x, W0, b0, W1, b1, W2, b2):
h = np.maximum(x @ W0.T + b0, 0.0)
h = np.maximum(h @ W1.T + b1, 0.0)
return h @ W2.T + b2
def export_bundle(sd, out_path: str) -> str:
"""state_dict → 서빙 번들 npz (원자적 교체: .tmp 작성 후 replace). 반환: 절대경로.
retrain_from_logs 재학습 배포도 함수를 쓴다 검증(torch/numpy 일치) main() 전용.
"""
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
assert W0.shape[1] == STATE_DIM + CARD_DIM, f"입력 차원 불일치: {W0.shape[1]}"
numbers, feat, _ = load_cards()
card_feats = np.stack([feat[n] for n in numbers]).astype(np.float32)
tmp = out_path + ".tmp"
with open(tmp, "wb") as f:
np.savez(
f,
W0=W0, b0=b0, W1=W1, b1=b1, W2=W2, b2=b2,
card_numbers=np.array(numbers), card_feats=card_feats,
state_dim=STATE_DIM, card_dim=CARD_DIM,
)
if os.path.exists(out_path):
os.replace(out_path, out_path + ".prev") # 직전 번들 백업(롤백용)
os.replace(tmp, out_path)
return os.path.abspath(out_path)
def main():
sd = torch.load(CKPT_PATH, map_location="cpu")
# 정합성 검증: torch forward == numpy forward
from negotiation.policies.feature_dqn_policy import ScoreNet
net = ScoreNet(STATE_DIM, CARD_DIM)
net.load_state_dict(sd)
net.eval()
x = np.random.default_rng(0).normal(size=(8, STATE_DIM + CARD_DIM)).astype(np.float32)
with torch.no_grad():
ref = net(torch.tensor(x)).numpy()
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
out = _np_forward(x, W0, b0, W1, b1, W2, b2).squeeze(-1)
diff = float(np.abs(ref - out).max())
assert diff < 1e-4, f"numpy/torch forward 불일치: {diff}"
path = export_bundle(sd, OUT_PATH)
print(f"[저장] {path} forward 오차 {diff:.2e}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,61 @@
"""probe_serving_dqn — 서빙 번들(dqn_serving.npz)의 상황별 카드 선택 프로브.
배포된 모델이 '상황에 맞게' 고르는지 눈으로 확인하는 진단 도구:
협력사 세그먼트 × 고객사 성향 × 협상 국면(가격대) 선택 카드를 표로 출력한다.
전부 다르길 기대하는 아니라, 축을 바꿨을 선택이 '움직이는지' 본다.
실행: APP_ENV=local python -m tools.probe_serving_dqn (numpy 필요, DB 불필요)
"""
import numpy as np
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
from negotiation.qtable.domain.service.feature_builder import build_state_features, build_tenant_features
from tenancy.config_loader import TenantConfigLoader
from tools.export_dqn_serving import OUT_PATH
from tools.retrain_from_logs import np_scorer_from_bundle
from tools.train_feature_dqn import pref_config
ANCHOR, TARGET = 495_000.0, 500_000.0 # BUGCHECK 견적과 동일 스케일
SUPPLIERS = {
"소형·경쟁多": dict(revenue_amount=5_000_000, partner_count=3, distribution_code="A"),
"소형·단독": dict(revenue_amount=5_000_000, partner_count=1, distribution_code="A"),
"대형·경쟁多": dict(revenue_amount=200_000_000, partner_count=3, distribution_code="A"),
"대형·단독": dict(revenue_amount=200_000_000, partner_count=1, distribution_code="A"),
}
PHASES = { # (라운드, 제시가): 첫턴 높은 가격 / 중반 목표가 근접 / 막판 앵커존 직전
"첫턴(575k)": (1, 575_000.0),
"중반(510k)": (2, 510_000.0),
"막판(501k)": (3, 501_000.0),
}
PREFS = {"성사중시": 0.1, "가격중시": 0.9}
def main():
score = np_scorer_from_bundle(OUT_PATH)
z = np.load(OUT_PATH, allow_pickle=False)
numbers = [str(n) for n in z["card_numbers"]]
feats = z["card_feats"]
base = TenantConfigLoader().load("ktcommerce").reward
for phase, (turn, price) in PHASES.items():
print(f"\n=== {phase} (앵커 {int(ANCHOR):,} / 목표 {int(TARGET):,}) ===")
print(f"{'협력사':<12}" + "".join(f"{p:>16}" for p in PREFS))
for sup_name, sup in SUPPLIERS.items():
row = []
for _, p in PREFS.items():
tf = build_tenant_features(pref_config(base, p))
snap = NegotiationSnapshot(
revenue_amount=sup["revenue_amount"], distribution_code=sup["distribution_code"],
partner_count=sup["partner_count"],
acceptance_ratio=max(0.0, (575_000.0 - price) / 575_000.0),
input_price=price, anchor_price=ANCHOR, target_price=TARGET, round_number=turn,
)
sf = np.concatenate([build_state_features(snap), tf])
row.append(numbers[int(np.argmax(score(sf, feats)))])
print(f"{sup_name:<12}" + "".join(f"{c:>16}" for c in row))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,238 @@
"""retrain_from_logs — experience_logs 실데이터로 feature_dqn 오프라인 재학습 + OPE 게이트.
파이프라인:
learning.experience_logs 로드( 테넌트 범용 에이전트는 테넌트를 특징으로 조건화하므로 통합 학습)
세션별 에피소드 재구성: 카드턴(done=False) N개 + 종료행(done=True) 1.
보상은 학습 규약(최종 결과 시점만 채점) 맞춰 종료행 reward 쓰고 중간턴은 0.
현재 체크포인트에서 fine-tune (낮은 lr 시뮬 사전학습 망각 방지)
OPE(SNIPS, 궤적 IS): 후보 모델 vs 현재 서빙 번들. 후보가 넘으면 배포하지 않는다.
통과 dqn_serving.npz 원자적 교체(직전본 .prev 백업) `docker compose build agent && up -d agent` 배포.
실행(호스트, torch+DB 필요):
APP_ENV=local python -m tools.retrain_from_logs
환경변수:
MIN_EPISODES(기본 200) 재학습 최소 에피소드 미달 skip (과적합 방지)
EPOCHS(기본 20) / LR(기본 1e-4) / FORCE_DEPLOY=1 (OPE 게이트 무시 테스트 전용)
주의: 서빙이 greedy(탐색 없음) 로그가 선택 편향됨 OPE 유효표본(ESS) 작으면
게이트가 보수적으로 배포를 막는다. 이는 의도된 동작이다(조용한 성능저하 방지).
"""
import asyncio
import json
import os
from collections import defaultdict
import numpy as np
import torch
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import ExperienceLog
from common.enums import DBType, DBWRType
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
from negotiation.qtable.domain.service.feature_builder import (
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features)
from sqlalchemy import select
from tenancy.config_loader import TenantConfigLoader
from tools.export_dqn_serving import CKPT_PATH, OUT_PATH, export_bundle
from tools.train_feature_dqn import load_cards
_HERE = os.path.dirname(os.path.abspath(__file__))
RETRAIN_CKPT = os.path.join(_HERE, "..", "artifacts", "feature_dqn_retrained.pt")
REPORT_PATH = os.path.join(_HERE, "..", "artifacts", "retrain_report.json")
MIN_EPISODES = int(os.getenv("MIN_EPISODES", "200"))
EPOCHS = int(os.getenv("EPOCHS", "20"))
LR = float(os.getenv("LR", "1e-4"))
FORCE_DEPLOY = os.getenv("FORCE_DEPLOY") == "1"
PROPENSITY_FALLBACK = 0.9 # 구로그 propensity 누락 시 (UCB/DQN 모두 greedy≈(1-ε)+ε/n)
# ---- ① 로그 로드 -------------------------------------------------------------
async def fetch_logs():
def _q(s):
q = (select(ExperienceLog.company_id, ExperienceLog.session_id, ExperienceLog.card_id,
ExperienceLog.reward, ExperienceLog.done, ExperienceLog.snapshot,
ExperienceLog.propensity, ExperienceLog.turn, ExperienceLog.id)
.where(ExperienceLog.is_invalidated == False) # noqa: E712
.order_by(ExperienceLog.company_id, ExperienceLog.session_id, ExperienceLog.id))
return DB_SESSION_MNG.execute(s, q)
err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
return rows
# ---- ② 에피소드 재구성 --------------------------------------------------------
def build_episodes(rows, known_cards: set):
"""→ [{tenant, steps:[(snapshot, card, propensity)], terminal_reward}], 스킵 사유 카운트."""
by_session = defaultdict(list)
for r in rows:
if r[1] is not None:
by_session[(r[0], str(r[1]))].append(r)
episodes, skipped = [], defaultdict(int)
for (company_id, _sid), items in by_session.items():
selects = [r for r in items if not r[4] and r[5]] # done=False, snapshot 有
terminals = [r for r in items if r[4] and r[3] is not None] # done=True, reward 有
if not selects or not terminals:
skipped["종료행/카드턴 없음(미완결 세션)"] += 1
continue
if any(str(r[2] or "").startswith("AUT|") for r in selects):
skipped["완전 자율 세션(카드 재학습 대상 아님)"] += 1
continue
if any(r[2] not in known_cards for r in selects):
skipped["임베딩 없는 카드(파일매핑 테넌트 등)"] += 1
continue
episodes.append(dict(
tenant=company_id,
steps=[(r[5], r[2], r[6] if r[6] else PROPENSITY_FALLBACK) for r in selects],
terminal_reward=float(terminals[-1][3]),
))
return episodes, skipped
def tenant_feat_for(cache: dict, loader: TenantConfigLoader, company_id: str) -> np.ndarray:
"""테넌트 보상설정 → 성향 특징. 미온보딩/로드 실패는 _base 폴백."""
if company_id not in cache:
try:
cfg = loader.load(company_id)
except Exception:
cfg = loader.load("_base")
cache[company_id] = build_tenant_features(cfg.reward)
return cache[company_id]
def to_transitions(episodes, feat, tenant_feats):
"""학습 규약(train_feature_dqn 과 동일): 중간턴 r=0, 종료턴만 terminal_reward. 다음 후보 = 전체 사용분."""
all_cards = list(feat.keys())
out = []
for ep in episodes:
tf = tenant_feats[ep["tenant"]]
used = set()
n = len(ep["steps"])
for i, (snap_d, card, _p) in enumerate(ep["steps"]):
sf = np.concatenate([build_state_features(NegotiationSnapshot.from_dict(snap_d)), tf])
used.add(card)
if i == n - 1:
out.append((sf, feat[card], ep["terminal_reward"], None, None, True))
else:
s2_d = ep["steps"][i + 1][0]
s2 = np.concatenate([build_state_features(NegotiationSnapshot.from_dict(s2_d)), tf])
cands = [c for c in all_cards if c not in used] or all_cards
out.append((sf, feat[card], 0.0, s2, np.stack([feat[c] for c in cands]), False))
return out
# ---- ④ OPE (SNIPS, 궤적 단위 IS) ----------------------------------------------
def _greedy_match(score_fn, ep, feat, tf) -> float:
"""궤적 IS 가중치: Π 1[greedy(sᵢ)=aᵢ]/pᵢ. 한 턴이라도 불일치면 0."""
all_cards = list(feat.keys())
w, used = 1.0, set()
for snap_d, card, p in ep["steps"]:
sf = np.concatenate([build_state_features(NegotiationSnapshot.from_dict(snap_d)), tf])
cands = [c for c in all_cards if c not in used] or all_cards
sc = score_fn(sf, np.stack([feat[c] for c in cands]))
if cands[int(np.argmax(sc))] != card:
return 0.0
w /= max(p, 1e-3)
used.add(card)
return w
def snips(score_fn, episodes, feat, tenant_feats):
"""SNIPS 추정치 + 유효표본크기(ESS). 매치 0건이면 (None, 0)."""
ws, rs = [], []
for ep in episodes:
w = _greedy_match(score_fn, ep, feat, tenant_feats[ep["tenant"]])
ws.append(w)
rs.append(ep["terminal_reward"])
ws, rs = np.array(ws), np.array(rs)
if ws.sum() <= 0:
return None, 0.0
est = float((ws * rs).sum() / ws.sum())
ess = float(ws.sum() ** 2 / (ws ** 2).sum())
return est, ess
def np_scorer_from_bundle(path):
"""현재 서빙 번들(npz) → score_fn (dqn_store 와 동일 forward)."""
z = np.load(path, allow_pickle=False)
W0, b0, W1, b1, W2, b2 = z["W0"], z["b0"], z["W1"], z["b1"], z["W2"], z["b2"]
def score(sf, card_feats):
x = np.concatenate([np.repeat(sf[None, :], card_feats.shape[0], axis=0), card_feats], axis=1)
h = np.maximum(x @ W0.T + b0, 0.0)
h = np.maximum(h @ W1.T + b1, 0.0)
return (h @ W2.T + b2).squeeze(-1)
return score
# ---- 메인 ---------------------------------------------------------------------
async def run():
numbers, feat, _ = load_cards()
rows = await fetch_logs()
episodes, skipped = build_episodes(rows, set(numbers))
print(f"로그 {len(rows)}행 → 에피소드 {len(episodes)}개 (스킵: {dict(skipped) or '없음'})")
report = dict(rows=len(rows), episodes=len(episodes), skipped=dict(skipped),
min_episodes=MIN_EPISODES, deployed=False)
if len(episodes) < MIN_EPISODES and not FORCE_DEPLOY:
print(f"[skip] 에피소드 {len(episodes)} < MIN_EPISODES {MIN_EPISODES} — 과적합 위험으로 재학습 안 함")
report["result"] = "skipped_insufficient_data"
return report
loader = TenantConfigLoader()
tenant_feats = {}
for ep in episodes:
tenant_feat_for(tenant_feats, loader, ep["tenant"])
# ③ fine-tune (시뮬 사전학습 체크포인트에서 이어서, 낮은 lr)
transitions = to_transitions(episodes, feat, tenant_feats)
batch = min(64, max(8, len(transitions) // 4))
policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM,
card_dim=feat[numbers[0]].shape[0], lr=LR, batch_size=batch)
if os.path.exists(CKPT_PATH):
policy.load(CKPT_PATH)
print(f"[fine-tune] 시작점: {os.path.basename(CKPT_PATH)} lr={LR} batch={batch}")
policy.buf.extend(transitions)
steps = EPOCHS * max(1, len(transitions) // batch)
losses = [l for _ in range(steps) if (l := policy.train_step()) is not None]
print(f"[fine-tune] {steps} step loss {losses[0]:.4f}{losses[-1]:.4f}" if losses else "[fine-tune] 스텝 없음")
# ④ OPE 게이트: 후보 vs 현재 서빙
def cand_score(sf, cf):
return policy.scores(sf, cf)
cand_est, cand_ess = snips(cand_score, episodes, feat, tenant_feats)
cur_est, cur_ess = (snips(np_scorer_from_bundle(OUT_PATH), episodes, feat, tenant_feats)
if os.path.exists(OUT_PATH) else (None, 0.0))
print(f"[OPE/SNIPS] 후보 {cand_est} (ESS {cand_ess:.1f}) vs 현재 {cur_est} (ESS {cur_ess:.1f})")
report.update(ope_candidate=cand_est, ope_candidate_ess=cand_ess,
ope_current=cur_est, ope_current_ess=cur_ess)
min_ess = max(3.0, 0.02 * len(episodes))
passed = (cand_est is not None and cand_ess >= min_ess
and (cur_est is None or cand_est >= cur_est - 0.01))
if not passed and not FORCE_DEPLOY:
print(f"[게이트 불통과] 배포하지 않음 (필요 ESS ≥ {min_ess:.1f}). 현재 번들 유지.")
report["result"] = "gate_failed"
return report
# ⑤ 배포: 후보 저장 + 번들 교체 (.prev 백업)
policy.save(RETRAIN_CKPT)
path = export_bundle(policy.q.state_dict(), OUT_PATH)
print(f"[배포] {path} (직전본 → dqn_serving.npz.prev)")
print(" 적용: docker compose build agent && docker compose up -d agent")
report.update(result="deployed" if passed else "force_deployed", deployed=True,
ckpt=os.path.abspath(RETRAIN_CKPT))
return report
def main():
report = asyncio.run(run())
with open(REPORT_PATH, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"[리포트] {os.path.abspath(REPORT_PATH)}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,317 @@
"""결함 회귀 게이트 — 실전에서 발견된 협상 결함을 시나리오로 재생해 서빙 번들을 검증한다.
프로브(probe_serving_dqn) '눈으로 보는 행동 표'라면 이것은 '자동 합격/불합격'이다.
모든 검사 항목은 과거 실제 발생했던 결함이며, 하나라도 실패하면 exit 1 배포 금지.
재학습 번들은 반드시 게이트를 통과한 뒤에만 autonomy_serving.npz 교체한다.
검사 대상은 서빙 실물이다: AutonomyPolicy.decide(봉투 마스크 포함) + ChatEngine._autonomy_next
(최종제안 전환) + ment_generator 가드(목표가 누설·할루시네이션). 시뮬 협력사는 스크립트라
결정론적이고, 정책도 greedy 실행마다 같은 결과가 나온다. DB/LLM/도커 불필요.
지형은 복수로 검사한다 v3.4 실스케일(423,198)에선 통과하고 드라이브 지형(10,000)에서
'첫 턴 목표가 통보' 퇴화했던 사고: 지형 통과는 다른 지형을 보증하지 않는다.
실행: agent 디렉터리에서 APP_ENV=local python -m tools.test_autonomy_defects [번들경로]
"""
import os
import sys
from types import SimpleNamespace
import numpy as np
from negotiation.chat.service import ment_generator
from negotiation.chat.service.chat_engine import ChatEngine
from negotiation.policy import autonomy_store
from negotiation.policy.autonomy_store import AutonomyPolicy
from tenancy.config_loader import TenantConfigLoader
# 검사 지형: 실제 견적(앵커율 ~1%) + 로컬 드라이브 견적(소액) — 스케일이 달라도 예절은 같아야 한다.
GEOS = {
"실스케일": dict(anchor=418_966, target=423_198, first=540_000, il=459_000),
"소액": dict(anchor=9_900, target=10_000, first=11_500, il=0),
}
MIN_PRESS = int(os.getenv("AUTONOMY_MIN_PRESS", "2"))
_RESULTS = []
def check(name: str, ok: bool, detail: str = ""):
_RESULTS.append((name, ok, detail))
print(f" {'' if ok else '✘ FAIL'} {name}" + (f"{detail}" if detail and not ok else ""))
# ---- 하니스: 서빙 실물 구동 (chat_service 의 ctx 관리 순서를 그대로 재현) ----------------
def base_ctx(geo) -> dict:
return dict(revenue_amount=50_000_000, distribution_code="A", partner_count=3,
item_price=geo["first"], input_price=geo["first"], round=1,
anchor_price=geo["anchor"], target_price=geo["target"],
internet_lowest_price=geo["il"])
def run_scenario(policy, supplier, geo, max_steps=30):
"""정책 결정 → 스텝 전환 → 컨텍스트 부기(chat_service 순서) → 스크립트 협력사 반응 루프.
trace 원소: (step, kind, q, 당시 제시가, autonomy_offer, 결정 시점 press_n)
"""
eng = ChatEngine.__new__(ChatEngine) # _autonomy_next 는 decider 와 ctx 만 쓴다
ctx = base_ctx(geo)
def decide(c):
act = policy.decide(c)
c["autonomy_pending"] = {"kind": act.kind, "q": act.counter_q, "s": act.strategy}
return act
eng.autonomy_decider = decide
sess = SimpleNamespace(context=ctx)
trace, end = [], None
for _ in range(max_steps):
press_n_at = int(ctx.get("autonomy_press_n") or 0)
step = eng._autonomy_next(sess)
pending = ctx.pop("autonomy_pending", None)
if pending: # chat_service 부기: pending → last(+prev), 역제안 별도 보존, press 카운터
if ctx.get("autonomy_last"):
ctx["autonomy_prev"] = ctx["autonomy_last"]
ctx["autonomy_last"] = dict(pending)
if pending["kind"] == "counter":
ctx["autonomy_last_counter"] = dict(pending)
if pending["kind"] == "press":
ctx["autonomy_press_n"] = press_n_at + 1
trace.append((step, (pending or {}).get("kind"), (pending or {}).get("q"),
ctx["input_price"], ctx.get("autonomy_offer"), press_n_at))
if step in ("협상완료", "협상실패"):
end = step
break
if step == "자율_최종제안": # 예→그 금액 타결 / 아니오→협상실패 (엔진 스텝 정의)
end = "협상완료" if supplier.final_yes(ctx) else "협상실패"
break
if step == "자율_역제안" and supplier.counter_yes(ctx):
ctx["input_price"] = ctx["autonomy_offer"]
end = "협상완료"
break
ctx["input_price"] = int(supplier.next_price(ctx))
ctx["round"] = ctx.get("round", 1) + 1
return trace, end, ctx
def fmt(trace):
out = []
for step, kind, q, price, offer, _ in trace:
s = f"{price:,}{step}"
if kind == "counter":
s += f"({offer:,})"
out.append(s)
return " ".join(out)
# ---- 스크립트 협력사 (결정론, 지형 비율로 정의) -----------------------------------------
class Stubborn:
"""조금씩 내리지만 하한이 목표가 위(×1.028) — 성사 불가능. 역제안·최종 전부 거절.
기대 궤적: 설득 2 앵커 이하 개시 단조 상향 사다리 최종제안(목표가) 결렬."""
def __init__(self, geo):
self.floor = int(geo["target"] * 1.028)
def next_price(self, ctx):
return max(self.floor, int(ctx["input_price"] * 0.96))
def counter_yes(self, ctx):
return False
def final_yes(self, ctx):
return False
class HoverNearTarget:
"""목표가 +0.19% 고정 — 마무리 국면. 압박이 나오면 안 되는 구간."""
def __init__(self, geo):
self.price = int(geo["target"] * 1.0019)
def next_price(self, ctx):
return self.price
def counter_yes(self, ctx):
return False
def final_yes(self, ctx):
return False
class Dealable:
"""4% 씩 내려와 목표가 바로 아래까지 협조 — 성사 가능 케이스."""
def __init__(self, geo):
self.floor = int(geo["target"] * 0.9995)
self.accept_from = geo["anchor"] + 0.4 * (geo["target"] - geo["anchor"])
def next_price(self, ctx):
return max(self.floor, int(ctx["input_price"] * 0.96))
def counter_yes(self, ctx):
return ctx["autonomy_offer"] >= self.accept_from # 목표가 부근 제안은 수락
def final_yes(self, ctx):
return True
# ---- 시나리오 검사 (각 항목 = 과거 실제 결함) -------------------------------------------
def assert_defects(tag, trace, end, geo):
anchor, target = geo["anchor"], geo["target"]
near = target * 1.005
# '역제안' 검사는 일반 역제안 스텝만 센다 — 같은 금액 재시도가 자율_최종제안으로 전환된 것은
# 반복이 아니라 설계된 최종 통보(제품 결정: 같은 금액 재호출 = 탄약 소진 → 마지막으로 묻고 종료).
counters = [(i, t) for i, t in enumerate(trace) if t[0] == "자율_역제안"]
presses = [t for t in trace if t[1] == "press"]
if trace and trace[0][3] > near:
check(f"[{tag}] 개시 턴은 설득 (결함: v3.4 첫턴 walk→목표가 통보)",
trace[0][1] == "press", f"첫 결정이 {trace[0][1]}")
if counters:
i0, first = counters[0]
check(f"[{tag}] 첫 역제안은 앵커 이하 (결함: 사다리 꼭대기 개시)",
first[4] <= anchor, f"첫 역제안 {first[4]:,} > 앵커 {anchor:,}")
pressed_before = sum(1 for t in trace[:i0] if t[1] == "press")
if first[3] > near: # 마무리 국면은 해금 예외
check(f"[{tag}] 역제시 해금 전 설득 ≥{MIN_PRESS}회 (결함: 첫턴 역제시)",
pressed_before >= MIN_PRESS, f"설득 {pressed_before}회 만에 역제안")
offers = [t[4] for _, t in counters]
check(f"[{tag}] 역제안 단조 상향 (결함: 제안 철회 423,198→420,024)",
all(b >= a for a, b in zip(offers, offers[1:])), f"철회 발생: {offers}")
check(f"[{tag}] 역제안 ≤ 목표가", all(o <= target for o in offers), f"{offers}")
check(f"[{tag}] 같은 금액 역제안 반복 없음 (결함: 421,082 반복)",
all(b != a for a, b in zip(offers, offers[1:])), f"{offers}")
check(f"[{tag}] 마무리 국면(≤목표가×1.005) 압박 없음 (결함: 802원 푼돈 흥정)",
all(t[3] > near for t in presses), "목표가 코앞에서 압박")
check(f"[{tag}] 목표가 초과 제시가 수락 없음 (결함: 목표가+14% 매입)",
not any(t[1] == "accept" and t[3] > target for t in trace), "")
finals = [t for t in trace if t[0] == "자율_최종제안"]
for f in finals:
check(f"[{tag}] 최종제안 금액 = 목표가 (결함: 직전 금액 재사용 60,548)",
f[4] == target, f"최종제안 {f[4]:,} ≠ 목표가 {target:,}")
# 결렬 의사(walk)로 끝났다면 반드시 최종제안을 거쳤어야 한다 (턴캡 종료는 예외)
walked_direct = any(t[1] == "walk" and t[0] == "협상실패" for t in trace)
capped = trace and trace[-1][0] == "협상실패" and trace[-1][1] is None
check(f"[{tag}] 결렬 전 최종제안 1회 보장 (결함: 최종 의사 확인 없이 종료)",
not walked_direct or capped or bool(finals), "walk 즉시 결렬")
check(f"[{tag}] 종료 보장 (무한 세션 없음)", end is not None, "max_steps 내 미종료")
# ---- 엔진 단위 검사 (정책 무관 — 전환 로직 자체) ----------------------------------------
def engine_unit_tests():
print("\n[엔진 전환 로직 단위 검사]")
geo = GEOS["실스케일"]
target = geo["target"]
def force(kind, q=0.0, s=3):
eng = ChatEngine.__new__(ChatEngine)
eng.autonomy_decider = lambda c: SimpleNamespace(kind=kind, counter_q=q, strategy=s)
return eng
# walk → 최종제안(목표가) → 재차 walk → 협상실패
ctx = base_ctx(geo)
eng = force("walk")
sess = SimpleNamespace(context=ctx)
step1 = eng._autonomy_next(sess)
check("walk 1회차 → 자율_최종제안 전환", step1 == "자율_최종제안", f"got {step1}")
check("walk 전환 최종제안 금액 = 목표가", ctx.get("autonomy_offer") == target,
f"{ctx.get('autonomy_offer')}")
step2 = eng._autonomy_next(sess)
check("walk 2회차(최종 거절 후) → 협상실패", step2 == "협상실패", f"got {step2}")
# 같은 q 역제안 반복 → 최종제안(목표가) 전환
ctx = base_ctx(geo)
ctx["autonomy_last"] = ctx["autonomy_last_counter"] = {"kind": "counter", "q": 0.5, "s": 3}
sess = SimpleNamespace(context=ctx)
step = force("counter", q=0.5)._autonomy_next(sess)
check("같은 금액 재역제안 → 자율_최종제안 전환", step == "자율_최종제안", f"got {step}")
check("탄약소진 최종제안 금액 = 목표가", ctx.get("autonomy_offer") == target,
f"{ctx.get('autonomy_offer')}")
# 턴 상한 — 캡 종료도 최종제안 보장을 우회하지 않는다
ctx = base_ctx(geo)
ctx["round"] = 13
sess = SimpleNamespace(context=ctx)
step = force("press")._autonomy_next(sess)
check("턴 상한 초과(최종 미실시) → 자율_최종제안", step == "자율_최종제안", f"got {step}")
check("턴캡 최종제안 금액 = 목표가", ctx.get("autonomy_offer") == target,
f"{ctx.get('autonomy_offer')}")
step = force("press")._autonomy_next(sess)
check("턴 상한 초과(최종 거절 후) → 협상실패", step == "협상실패", f"got {step}")
# ---- 봉투 마스크 단위 검사 (모델 무관 — 후보 필터 자체) ----------------------------------
def envelope_unit_tests(policy):
print("\n[봉투 마스크 단위 검사]")
geo = GEOS["소액"]
ctx = base_ctx(geo) # 설득 0회, 제시가 목표가 위 → 설득만 가능해야 한다
act = policy.decide(ctx)
check("설득 0회 상태의 결정은 press 만 가능 (walk·counter·accept 잠금)",
act.kind == "press", f"got {act.kind}")
# ---- 멘트 가드 검사 (목표가 누설·할루시네이션 — LLM 호출 없음) ---------------------------
def ment_guard_tests():
print("\n[멘트 가드 검사]")
geo = GEOS["실스케일"]
target, anchor, il = geo["target"], geo["anchor"], geo["il"]
ctx = base_ctx(geo)
prompt = ment_generator._prompt_for("자율_압박_3", ctx)
check("압박 프롬프트에 목표가 숫자 없음 (결함: 목표가 노출 멘트)",
str(target) not in prompt.replace(",", ""), "프롬프트가 목표가를 담고 있음")
leak = f"저희 내부 산정 기준은 {target:,}원입니다. 이 가격에 맞춰 재검토 부탁드립니다."
check("목표가 포함 압박 멘트 → 폐기", not ment_generator._guard("자율_압박_3", ctx, leak), "")
invented = "시장 상황을 고려해 400,000원 수준으로 재검토 부탁드립니다."
check("지어낸 금액 멘트 → 폐기 (할루시네이션)",
not ment_generator._guard("자율_압박_3", ctx, invented), "")
ctx2 = dict(ctx, autonomy_offer=anchor)
ok_ment = f"내부 검토 결과 {anchor:,}원이면 즉시 진행이 가능합니다. 수락해 주시겠습니까?"
check("정상 역제안 멘트(제안가 포함) → 통과",
ment_generator._guard("자율_역제안", ctx2, ok_ment), "")
no_offer = "말씀하신 조건을 검토했고 조정이 필요합니다. 수락해 주시겠습니까?"
check("제안가 없는 역제안 멘트 → 폐기",
not ment_generator._guard("자율_역제안", ctx2, no_offer), "")
ev = f"동일 품목 인터넷 최저가가 {il:,}원으로 확인됩니다. 재검토 부탁드립니다."
check("최저가 인용: 근거 있음(수집됨+제시가>최저가) → 허용",
ment_generator._guard("자율_압박_1", ctx, ev), "")
ctx3 = dict(ctx, internet_lowest_price=0)
ev0 = "동일 품목 인터넷 최저가 대비 높은 수준입니다. 재검토 부탁드립니다."
check("최저가 인용: 미수집 품목 → 폐기 (지어낸 시장 주장)",
not ment_generator._guard("자율_압박_1", ctx3, ev0), "")
def main():
bundle = sys.argv[1] if len(sys.argv) > 1 else autonomy_store.BUNDLE_PATH
z = np.load(bundle, allow_pickle=False)
policy = AutonomyPolicy(z, TenantConfigLoader().load("ktcommerce").reward)
print(f"번들: {os.path.abspath(bundle)} (state_dim={int(z['state_dim'])})")
for geo_name, geo in GEOS.items():
print(f"\n{'' * 60}\n지형 [{geo_name}] 앵커 {geo['anchor']:,} / 목표 {geo['target']:,} "
f"/ 첫 제시가 {geo['first']:,}")
for tag, sup_cls in (("완고", Stubborn), ("목표가위 고정", HoverNearTarget),
("협조", Dealable)):
trace, end, _ = run_scenario(policy, sup_cls(geo), geo)
full_tag = f"{geo_name}·{tag}"
print(f"\n[{full_tag}] {fmt(trace)}{end}")
assert_defects(full_tag, trace, end, geo)
engine_unit_tests()
envelope_unit_tests(policy)
ment_guard_tests()
fails = [(n, d) for n, ok, d in _RESULTS if not ok]
print(f"\n{'=' * 60}\n결과: {len(_RESULTS) - len(fails)}/{len(_RESULTS)} 통과")
if fails:
print("실패 항목 — 이 번들은 배포 금지:")
for n, d in fails:
print(f"{n} {d}")
sys.exit(1)
print("전 항목 통과 — 배포 가능.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,148 @@
"""action-as-feature DQN 학습 (Phase 2·3) — 공용 환경 헬퍼 + 단독 학습 엔트리.
카드 특징 = 스크립트 임베딩(384) + 전략 one-hot(4) + one-hot(4) = 392차원
상태 특징 = 연속 상태(9) + 고객사 성향(5) = 14차원 협력사·고객사 조건화
학습 환경 = FeatureBuyer(양보력/수락력 2) + 에피소드마다 협력사·고객사성향 랜덤 샘플링
비교 평가는 tools.compare_qtable_vs_dqn 에서 수행한다.
실행: APP_ENV=local python -m tools.train_feature_dqn
"""
import os
import random
import numpy as np
import torch
from eval_harness.buyer import Scenario
from eval_harness.feature_buyer import FeatureBuyer, sample_supplier
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
from negotiation.qtable.domain.service.feature_builder import (
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features)
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
from tenancy.config_loader import TenantConfigLoader
_HERE = os.path.dirname(os.path.abspath(__file__))
EMB_PATH = os.path.join(_HERE, "..", "artifacts", "card_embeddings.npz")
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "feature_dqn_ktcommerce.pt")
# zero-shot 실험용 홀드아웃 (전략 1·4 — 남은 풀에도 같은 전략 존재).
# 서빙용 최종 학습은 전체 풀 사용: FULL_POOL=1 python -m tools.train_feature_dqn
HOLDOUT = [] if os.getenv("FULL_POOL") == "1" else ["NGC-002", "NGC-010"]
ANCHOR, TARGET = 8000.0, 10000.0
MAX_TURNS = 5
N_STRATEGY, N_TONE = 4, 4
# ---- 카드 특징: 임베딩 + 전략/톤 one-hot ------------------------------------------
def load_cards():
z = np.load(EMB_PATH, allow_pickle=True)
numbers = [str(n) for n in z["numbers"]]
feat, strat = {}, {}
for i, n in enumerate(numbers):
s, t = int(z["strategy"][i]), int(z["tone"][i])
s_oh = np.eye(N_STRATEGY, dtype=np.float32)[s - 1]
t_oh = np.eye(N_TONE, dtype=np.float32)[t - 1]
feat[n] = np.concatenate([z["embeddings"][i].astype(np.float32), s_oh, t_oh])
strat[n] = s
return numbers, feat, strat
# ---- 고객사 성향: 보상 설정 샘플링 ---------------------------------------------------
def sample_tenant_pref(rng: np.random.Generator, base_cfg):
"""p ∈ [0,1]: 0=성사중시(협력 유리) ↔ 1=가격중시(경쟁 유리). 반환: (RewardConfig, tenant_feat)."""
p = float(rng.uniform(0.0, 1.0))
cfg = base_cfg.model_copy(update=dict(
max_weight=0.25 + 0.60 * p, # 가격보상 비중
min_weight=(0.25 + 0.60 * p) * 0.7,
success_reward=1.6 - 1.2 * p, # 성사중시일수록 성공보상↑
failure_penalty=-(1.4 - 1.1 * p), # 성사중시일수록 결렬이 아픔
beta=0.1 + 0.4 * p,
penalty_lambda=float(rng.uniform(0.005, 0.05)),
))
return cfg, build_tenant_features(cfg)
def pref_config(base_cfg, p: float, lam: float = 0.02):
"""평가용: 성향 p 를 고정해 RewardConfig 생성 (극단 테스트)."""
return base_cfg.model_copy(update=dict(
max_weight=0.25 + 0.60 * p, min_weight=(0.25 + 0.60 * p) * 0.7,
success_reward=1.6 - 1.2 * p, failure_penalty=-(1.4 - 1.1 * p),
beta=0.1 + 0.4 * p, penalty_lambda=lam,
))
def make_snapshot(sup, price: float, turn: int, acceptance: float,
outcome=NegotiationOutcome.ONGOING) -> NegotiationSnapshot:
return NegotiationSnapshot(
revenue_amount=sup.revenue_amount, distribution_code=sup.distribution_code,
partner_count=sup.partner_count, acceptance_ratio=acceptance,
input_price=price, anchor_price=ANCHOR, target_price=TARGET,
round_number=turn, outcome=outcome,
)
# ---- 단독 학습 엔트리 (비교는 compare_qtable_vs_dqn) --------------------------------
def main(episodes=10000, seed=42):
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
numbers, feat, strat = load_cards()
train_pool = [c for c in numbers if c not in HOLDOUT]
card_dim = feat[numbers[0]].shape[0]
print(f"카드 {len(numbers)}장 (학습 {len(train_pool)} / 홀드아웃 {HOLDOUT}) card_dim={card_dim}")
tcfg = TenantConfigLoader().load("ktcommerce")
policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM,
card_dim=card_dim, eps_decay=4000, gamma=0.95)
rng = np.random.default_rng(seed)
print(f"=== 학습 {episodes} 에피소드 (협력사·성향 랜덤, CPU) ===")
recent = []
for ep in range(1, episodes + 1):
sup = sample_supplier(rng)
rcfg, tf = sample_tenant_pref(rng, tcfg.reward)
rc = RewardCalculator(rcfg, tcfg.state)
buyer = FeatureBuyer(sup, strat, seed=seed * 100 + ep, max_turns=MAX_TURNS)
scenario = Scenario(anchor_price=ANCHOR, target_price=TARGET, revenue_amount=sup.revenue_amount,
distribution_code=sup.distribution_code, partner_count=sup.partner_count)
price0 = TARGET * 1.15
price, used, total_r = price0, set(), 0.0
for turn in range(1, MAX_TURNS + 1):
acceptance = max(0.0, (price0 - price) / price0)
s = make_snapshot(sup, price, turn, acceptance)
sf = np.concatenate([build_state_features(s), tf])
avail = [c for c in train_pool if c not in used] or list(train_pool)
embs = np.stack([feat[c] for c in avail])
i, _, _ = policy.select(sf, embs)
card = avail[i]; used.add(card)
resp = buyer.respond(card, scenario, turn, price)
price = resp.new_price
done = resp.accept or price <= ANCHOR or turn >= MAX_TURNS
success = resp.accept or price <= ANCHOR
outcome = (NegotiationOutcome.SUCCESS if success
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
# 최종 결과 시점만 채점 (중간 0 → γ 부트스트랩) — compare 스크립트와 동일 규칙.
r = rc.calculate(make_snapshot(sup, price, turn, acceptance, outcome)).total if done else 0.0
total_r += r
if done:
policy.remember(sf, feat[card], r, None, None, True)
else:
acc2 = max(0.0, (price0 - price) / price0)
s2 = make_snapshot(sup, price, turn + 1, acc2)
navail = [c for c in train_pool if c not in used] or list(train_pool)
policy.remember(sf, feat[card], r, np.concatenate([build_state_features(s2), tf]),
np.stack([feat[c] for c in navail]), False)
policy.train_step()
if done:
break
recent.append(total_r)
if ep % 2000 == 0:
print(f" ep {ep:>6} eps={policy.eps():.3f} 최근2000 평균보상={np.mean(recent[-2000:]):.4f}")
policy.save(CKPT_PATH)
print(f"[저장] {CKPT_PATH}")
return policy
if __name__ == "__main__":
main()

View File

@ -0,0 +1,365 @@
"""train_full_autonomy — 행동 룰 0개, 완전 자율 협상 에이전트 (v2 시뮬 프로토타입).
기존 시스템의 (앵커 이하 강제타결 / 3라운드 강제결렬 / 와일드카드 / 카드 카탈로그)
전부 제거하고, 모든 결정을 에이전트 행동으로 이관한다:
행동 공간 (action-as-feature, ScoreNet 후보 열거 채점):
ACCEPT 현재 제시가로 타결 '앵커 이하 강제타결' 대체
WALK 협상 결렬 선언 '3라운드 강제결렬' 대체
COUNTER(C) "C원이면 수락" 역제안 '와일드카드 1%' 대체 (금액도 학습)
PRESS(strategy) 설득 압박(카드의 일반화) 카드 카탈로그 대체 (전략만 남음)
룰이 사라진 자리는 보상이 채운다(유일한 스펙):
R = W×R_price + (1W)×R_end λ×round (기존 RewardCalculator 그대로)
협상이 끝나는 : 에이전트의 ACCEPT/WALK, 협력사의 COUNTER 수락, 협력사의 인내심 소진(이탈).
마지막 것은 시스템 룰이 아니라 상대방 특성이다.
베이스라인 = 현행 시스템을 같은 환경에서 재현(앵커타결/1%클로징/3라운드결렬 + 압박).
실행: APP_ENV=local PYTHONUTF8=1 python -m tools.train_full_autonomy
"""
import os
import random
from typing import Optional, Tuple
import numpy as np
import torch
from eval_harness.feature_buyer import AFFINITY, STRATEGY_PROFILE, SupplierProfile, sample_supplier
from negotiation.policies.autonomy_actions import (
ACTION_DIM, ACTIONS, COUNTER_GRID, EXTRA_STATE_DIM, Action, extra_state,
internet_gap_feat, settle_norm as extra_settle)
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
from negotiation.qtable.domain.service.feature_builder import (
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features)
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
from tenancy.config_loader import TenantConfigLoader
from tools.train_feature_dqn import pref_config, sample_tenant_pref
_HERE = os.path.dirname(os.path.abspath(__file__))
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "full_autonomy.pt")
TARGET = 10000.0
# 앵커율(v3.1): 실운영 기하 정합 — 앵커가 = 목표가×(1a), a ∈ [0.8%, 6%] 를 에피소드마다 샘플링.
# (기존 고정 20% 폭은 실제(≈1%)와 지형이 달라, 실서비스에서 압박/역제안 밸런스가 어긋났다.)
ANCHOR_RATE_RANGE = (0.008, 0.06)
# 행동 공간(Action/ACTIONS/COUNTER_GRID/ACTION_DIM)은 negotiation.policies.autonomy_actions 공유
# — 서빙(autonomy_store, numpy 전용)과 학습이 같은 인코딩을 쓴다.
# ---- 협력사 모델 (상대 반응: 역제안 수락/재제안 포함) ---------------------------------
class AutonomousBuyer:
"""FeatureBuyer 확장: 역제안(C)에 반응한다. 이탈은 '인내심' — 시스템 룰이 아닌 상대 특성."""
def __init__(self, sup: SupplierProfile, seed: int):
self.sup = sup
self.rng = np.random.default_rng(seed)
# 기질 t ∈ [0,1]: 0=터프(하한 높고 안 물러섬) ↔ 1=수월. 관측 가능한 이력·최저가가
# 이 숨은 기질과 상관되게 생성된다 → 에이전트가 이력/최저가 특징을 읽을 '이유'가 생긴다.
# 하한은 '우리 앵커'가 아니라 협력사 사정(≈목표가 기준)으로 정해진다(v3.1) —
# 하한 > 목표가(≈35%)면 애초에 성사 불가능한 협상이고, 그걸 빨리 알아채고 끊는 것도 실력이다.
t = float(self.rng.uniform(0.0, 1.0))
self.floor = TARGET * float(np.clip(1.12 - 0.24 * t + self.rng.normal(0, 0.02), 0.85, 1.18))
self.patience = int(self.rng.integers(4, 9)) + (1 if t > 0.7 else 0)
# 첫 제시가: 목표가의 105~150% — 실운영(기존 공급가가 목표가를 26%+ 상회) 분포를 덮는다.
# 좁게(110~125%) 학습하면 큰 갭 상황에서 정책이 분포 밖 일반화(대형컷 역제안)를 한다.
self.price = TARGET * float(self.rng.uniform(1.05, 1.50))
# 하한가가 첫 제시가보다 높을 수 없다(자기 하한 밑으로 부르고 시작하는 판매자는 없음).
# 이 보정이 없으면 on_press 의 max(floor,·)가 가격을 '역주행'시키는 비현실이 생긴다.
self.floor = min(self.floor, self.price * 0.98)
self._last_c: Optional[float] = None # 직전 역제안 (같은 숫자 반복 짜증 모델링)
# ---- 관측 가능 부가정보 (v3 특징 소스 — 기질과 상관, 노이즈 있음) ----
self.hist_n = int(self.rng.integers(0, 6)) # 과거 협상 횟수 (0=신규)
if self.hist_n:
self.hist_success = float(np.clip(0.25 + 0.6 * t + self.rng.normal(0, 0.10), 0.0, 1.0))
self.hist_settle_ratio = float(np.clip(1.18 - 0.28 * t + self.rng.normal(0, 0.04), 0.80, 1.30))
else:
self.hist_success = self.hist_settle_ratio = None
# 인터넷최저가: 숨은 하한가의 노이즈 관측치. 60% 확률로만 수집돼 있음(현실: 미수집 흔함).
self.internet_lowest = (self.floor * float(self.rng.uniform(0.98, 1.08))
if self.rng.random() < 0.6 else None)
def _powers(self, strategy: int) -> Tuple[float, float]:
conc, acc = STRATEGY_PROFILE.get(strategy, (0.5, 0.5))
m = AFFINITY[self.sup.segment].get(strategy, 0.5)
scale = 0.35 + 0.85 * m
return conc * scale, acc * scale
def on_press(self, strategy: int, turn: int) -> Tuple[bool, float]:
"""(이탈여부, 새 제시가). 압박이 안 먹히는 세그먼트면 이탈 위험이 실재한다."""
c_pow, a_pow = self._powers(strategy)
walk_p = 0.04 + 0.30 * (1.0 - a_pow) * (turn / self.patience)
if self.rng.random() < walk_p:
return True, self.price
concession = (self.price - self.floor) * (0.10 + 0.55 * c_pow)
self.price = max(self.floor, self.price - concession)
return False, self.price
def on_counter(self, c: float, strategy: int, turn: int) -> Tuple[str, float]:
"""역제안 C 반응: 'accept'(C로 타결) | 'walk' | 'counter'(새 제시가).
현실화(v2): 제시가 대비 인하 요구폭(cut) 클수록 수락률이 급감하고 이탈 위험이 커진다
초기 버전에서 에이전트가 't1 원샷 로우볼' 시뮬 허점을 착취하던 것을 막는다.
압박으로 가격을 충분히 끌어내린 작은 컷으로 클로징해야 통하는 구조.
"""
_, a_pow = self._powers(strategy or 3)
cut = max(0.0, (self.price - c) / max(self.price, 1.0)) # 인하 요구폭 (현 제시가 대비)
prev_c = self._last_c
repeated = prev_c is not None and abs(c - prev_c) < 1e-6 # 같은 숫자 반복
self._last_c = c
# 양보 상호성(v3.3): 직전 제안보다 올려 부르면(성의 있는 양보) 호의적으로 반응한다.
# 이 신호가 있어야 '상대가 내리면 우리도 조금 올리는' tit-for-tat 이 학습으로 나온다.
warm = 0.0
if prev_c is not None and c > prev_c + 1e-9:
warm = float(np.clip((c - prev_c) / max(self.price - self.floor, 1.0), 0.0, 0.35))
if c >= self.floor:
margin = (c - self.floor) / max(self.floor, 1.0)
p_acc = float(np.clip(0.20 + 0.9 * margin / 0.08, 0.0, 0.95)) * (0.75 + 0.35 * a_pow)
p_acc *= float(np.clip(1.0 - (cut - 0.05) / 0.20, 0.0, 1.0)) # 컷 5% 초과부터 반발, 25%면 수락 0
if repeated:
p_acc *= 0.25 # 이미 거절한 숫자를 또 내밀면 설득력 급감
p_acc *= 1.0 + warm
if self.rng.random() < min(p_acc, 0.97):
return "accept", c
# 모욕적 요구(하한 미달·과도한 원샷 컷·앵무새 반복) → 이탈 위험
low = max(0.0, (self.floor - c) / max(self.floor, 1.0))
p_walk = min(0.5, 2.0 * low) + 0.35 * max(0.0, cut - 0.20) / 0.20 + (0.15 if repeated else 0.0)
if self.rng.random() < min(p_walk * (1.0 - warm), 0.7):
return "walk", self.price
self.price = max(self.floor, c + (self.price - c) * float(self.rng.uniform(0.30, 0.60) + warm))
return "counter", self.price
# ---- 에피소드 실행 (룰 없음 — 종료는 행동 또는 상대 특성으로만) ------------------------
def make_snapshot(sup, price, turn, p0, anchor, outcome=NegotiationOutcome.ONGOING):
return NegotiationSnapshot(
revenue_amount=sup.revenue_amount, distribution_code=sup.distribution_code,
partner_count=sup.partner_count, acceptance_ratio=max(0.0, (p0 - price) / p0),
input_price=price, anchor_price=anchor, target_price=TARGET,
round_number=turn, outcome=outcome)
MIN_PRESS = int(os.getenv("AUTONOMY_MIN_PRESS", "2")) # 역제시 해금에 필요한 최소 설득 횟수
def available_actions(price: float, last_counter_q: Optional[float] = None,
counter_locked: bool = False) -> list:
"""행동 봉투 (serving autonomy_store 와 동일해야 한다):
목표가 초과 제시가는 '수락' 제외 매입 승인 범위(목표가 초과 수락 착취 방지)
직전 역제안 미만 금액의 역제안 제외 단조 양보 원칙(제안 철회 금지;
양보 '속도' 정책이 배우고, 후퇴 '금지' 구조로 보장)
counter_locked: 설득 MIN_PRESS 전에는 역제시 잠금 제품 의미론
(일반 카드=설득, 역제시=와일드카드 성격의 마무리 수단) 복원
마무리 국면(제시가 목표가×1.005): 압박 제외 푼돈 차이에서 재검토 요청 반복 방지
역제안은 앵커 이하(q 0) 낮게 개시해 사다리를 쓰며 올라간다"""
near_target = price <= TARGET * 1.005
return [a for a in ACTIONS
if not (a.kind == "accept" and price > TARGET)
and not (a.kind == "counter" and counter_locked and not near_target)
and not (a.kind == "walk" and counter_locked and not near_target)
and not (a.kind == "press" and near_target)
and not (a.kind == "counter" and last_counter_q is None and a.counter_q > 1e-9)
and not (a.kind == "counter" and last_counter_q is not None
and a.counter_q < last_counter_q - 1e-9)]
def action_feats(price: float, anchor: float, last_counter_q: Optional[float] = None,
counter_locked: bool = False):
"""현 제시가 기준 (가용 행동 리스트, 특징 [K, ACTION_DIM]). counter 는 컷폭 포함."""
span = max(TARGET - anchor, 1.0)
pos = (price - anchor) / span
acts = available_actions(price, last_counter_q, counter_locked)
rows = []
for a in acts:
cut = 0.0
if a.kind == "counter":
c = anchor + a.counter_q * span
cut = max(0.0, (price - c) / max(price, 1.0))
rows.append(a.feat(pos, cut))
return acts, np.stack(rows)
def run_episode(policy_fn, sup, rc: RewardCalculator, tf: np.ndarray, seed: int,
learner: Optional[FeatureDQNPolicy] = None, trace: Optional[list] = None):
"""policy_fn(state_feat, price_pos) → Action. learner 지정 시 replay 저장+학습."""
buyer = AutonomousBuyer(sup, seed)
p0 = buyer.price
env_rng = np.random.default_rng(seed + 7)
# 앵커율 샘플링(v3.1): 실운영처럼 앵커가 목표가 바로 아래(0.8~6%) — 좁은 스팬 지형에서 학습.
anchor = TARGET * (1.0 - float(env_rng.uniform(*ANCHOR_RATE_RANGE)))
span = max(TARGET - anchor, 1.0)
turn, settled, walked = 0, None, False
last_kind, last_q = "", 0.0 # 직전 역제안 기억 (같은 숫자 반복 방지의 학습 근거)
press_n = 0 # 설득 횟수 — 역제시 해금(MIN_PRESS) 카운터
# 견적 마감(환경 사실): 마감 도달 시 협상은 미타결 종료된다 — 룰이 아니라 세상의 시계.
deadline_turns = int(env_rng.integers(3, 11))
# 관측성 마스크(v3.5): 실서빙은 마감·이력·최저가가 '없는' 세션이 흔하고 로더가 중립값
# (0.5/0)을 대입한다. 시뮬이 항상 다 아는 세계만 학습하면 그 중립 상태가 분포 밖이 된다
# — v3.4 가 라이브 소액 지형에서 첫 턴 결렬로 퇴화한 원인 추정. 세계(마감 종료·상대 특성)는
# 그대로 두고 관측만 가린다: 마감은 40% 미관측(0.5 고정), 15% 는 전부 미상(신규 견적의 전형).
deadline_known = env_rng.random() < 0.6
blind = env_rng.random() < 0.15
if blind:
deadline_known = False
# 협력사 이력·최저가 특징 (에피소드 내 불변)
known_hist = buyer.hist_n and not blind
fixed_extra = dict(
hist_n=min(buyer.hist_n, 5) / 5.0 if not blind else 0.0,
hist_success=buyer.hist_success if known_hist else 0.5,
hist_settle=extra_settle(buyer.hist_settle_ratio) if known_hist else 0.5,
internet_gap=internet_gap_feat(buyer.internet_lowest or 0.0, anchor) if not blind else 0.0,
)
pending = None # (state_feat, action_feat) — 최종 결과 시점만 채점, 중간 r=0
while True:
turn += 1
price = buyer.price
deadline_remain = (max(0.0, (deadline_turns - turn + 1) / deadline_turns)
if deadline_known else 0.5) # 미관측 → 서빙 로더와 동일한 중립값
sf = np.concatenate([build_state_features(make_snapshot(sup, price, turn, p0, anchor)), tf,
extra_state(last_kind, last_q, deadline=deadline_remain, **fixed_extra)])
lcq = last_q if last_kind == "counter" else None
locked = lcq is None and press_n < MIN_PRESS
act = policy_fn(sf, price, anchor, lcq, locked)
if trace is not None:
trace.append((turn, int(price), act))
if act.kind == "accept":
settled = price
elif act.kind == "walk":
walked = True
elif act.kind == "counter":
c = anchor + act.counter_q * span
resp, val = buyer.on_counter(c, act.strategy, turn)
last_kind, last_q = "counter", act.counter_q # 역제안 기억 갱신
if resp == "accept":
settled = c
elif resp == "walk":
walked = True
else: # press
press_n += 1
left, _ = buyer.on_press(act.strategy, turn)
walked = walked or left
if not settled and not walked and turn >= buyer.patience:
walked = True # 인내심 소진(상대 특성) — 시스템 룰 아님
if not settled and not walked and turn >= deadline_turns:
walked = True # 견적 마감 도달(환경 사실) — 미타결 종료
done = settled is not None or walked
final_price = settled if settled is not None else buyer.price
# 성사 보너스는 목표가 이하 타결에만 — v3.1 이 '비싸게라도 성사'로 착취한 보상 구멍의
# 원인 차단(봉투 ① 의 마스크와 이중 방어: 유인 자체를 올바르게). 초과 타결 = 결렬 취급.
outcome = (NegotiationOutcome.SUCCESS if settled is not None and settled <= TARGET
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
r = rc.calculate(make_snapshot(sup, final_price, turn, p0, anchor, outcome)).total if done else 0.0
if learner is not None:
pos = (price - anchor) / span
cut = 0.0
if act.kind == "counter":
cut = max(0.0, (price - (anchor + act.counter_q * span)) / max(price, 1.0))
af = act.feat(pos, cut)
if pending:
nxt_lcq = last_q if last_kind == "counter" else None
learner.remember(*pending, 0.0, sf,
action_feats(price, anchor, nxt_lcq,
nxt_lcq is None and press_n < MIN_PRESS)[1], False)
pending = (sf, af)
if done:
learner.remember(sf, af, r, None, None, True)
learner.train_step()
if done:
return settled, turn, r
# ---- 정책들 ------------------------------------------------------------------
def dqn_policy(policy: FeatureDQNPolicy):
def f(sf, price, anchor, last_counter_q=None, counter_locked=False):
acts, feats = action_feats(price, anchor, last_counter_q, counter_locked)
i, _, _ = policy.select(sf, feats)
return acts[i]
return f
class RuleBaseline:
"""현행 시스템 룰 재현: 앵커 이하 수락 / 존내 1% 클로징 / 3회 압박 후 결렬."""
def __init__(self):
self.presses, self.closed = 0, False
def __call__(self, sf, price, anchor, last_counter_q=None, counter_locked=False) -> Action:
span = max(TARGET - anchor, 1.0)
if price <= anchor:
return Action("accept")
if price <= anchor * 1.02 and not self.closed:
self.closed = True
return Action("counter", (price * 0.99 - anchor) / span, 3)
if self.presses < 3:
self.presses += 1
return Action("press", 0.0, 3)
return Action("walk")
# ---- 학습/평가 ----------------------------------------------------------------
def evaluate(name, make_policy_fn, base_cfg, tcfg_state, episodes=3000, seed0=777):
rc = RewardCalculator(pref_config(base_cfg, 0.5), tcfg_state)
tf = build_tenant_features(pref_config(base_cfg, 0.5))
rng = np.random.default_rng(seed0)
rewards, settles, rounds = [], [], []
for i in range(episodes):
sup = sample_supplier(rng)
settled, turn, r = run_episode(make_policy_fn(), sup, rc, tf, seed0 * 91 + i)
rewards.append(r)
rounds.append(turn)
if settled is not None:
settles.append(settled / TARGET)
sr = len(settles) / episodes
print(f"{name:<14} 보상 {np.mean(rewards):.4f} ±{np.std(rewards)/np.sqrt(episodes):.4f}"
f" 성사율 {sr:.3f} 타결가/목표 {np.mean(settles):.3f} 평균라운드 {np.mean(rounds):.2f}")
return dict(reward=float(np.mean(rewards)), success=sr,
settle_ratio=float(np.mean(settles)) if settles else None, rounds=float(np.mean(rounds)))
def main(episodes=15000, seed=42):
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
tcfg = TenantConfigLoader().load("ktcommerce")
policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM + EXTRA_STATE_DIM,
card_dim=ACTION_DIM, eps_decay=5000, gamma=0.97)
rng = np.random.default_rng(seed)
print(f"=== 완전 자율 학습 {episodes}ep (행동 {len(ACTIONS)}개, 룰 0개) ===")
recent = []
for ep in range(1, episodes + 1):
sup = sample_supplier(rng)
rcfg, tf = sample_tenant_pref(rng, tcfg.reward)
rc = RewardCalculator(rcfg, tcfg.state)
_, _, r = run_episode(dqn_policy(policy), sup, rc, tf, seed * 131 + ep, learner=policy)
recent.append(r)
if ep % 3000 == 0:
print(f" ep {ep:>6} eps={policy.eps():.3f} 최근3000 평균보상={np.mean(recent[-3000:]):.4f}")
policy.save(CKPT_PATH)
print("\n=== 평가 3000ep (중립 성향 p=0.5, 동일 협력사 분포) ===")
policy.greedy = True
evaluate("룰시스템(현행)", lambda: RuleBaseline(), tcfg.reward, tcfg.state)
evaluate("완전자율 DQN", lambda: dqn_policy(policy), tcfg.reward, tcfg.state)
# 궤적 예시 — 에이전트가 룰 없이 뭘 하는지 눈으로
print("\n=== 궤적 예시 (완전자율) ===")
rc = RewardCalculator(pref_config(tcfg.reward, 0.5), tcfg.state)
tf = build_tenant_features(pref_config(tcfg.reward, 0.5))
rng2 = np.random.default_rng(7)
for k in range(3):
sup = sample_supplier(rng2)
trace = []
settled, turn, r = run_episode(dqn_policy(policy), sup, rc, tf, 5000 + k, trace=trace)
seg = "·".join(sup.segment)
print(f"[{seg}] " + "".join(
f"t{t}:{p:,}{a.kind}{'' if a.kind in ('accept', 'walk') else f'({a.counter_q:.2f},전략{a.strategy})' if a.kind == 'counter' else f'(전략{a.strategy})'}"
for t, p, a in trace) + f"{'타결 ' + format(int(settled), ',') + '' if settled else '결렬'} (r={r:.3f})")
if __name__ == "__main__":
main()

View File

@ -68,6 +68,8 @@ services:
APP_ENV: local
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override)
OPENAI_API_KEY: ${OPENAI_API_KEY:-} # LLM 키 passthrough (호스트 env/.env → 컨테이너). 빈 값이면 toml 폴백
DQN_SERVING: "1" # 카드 선택을 feature_dqn(numpy 서빙)으로. 0 이면 기존 UCB Q-table
AUTONOMY_MODE: "1" # 완전 자율 협상(판정 룰·카드 제거, 정책이 수락/역제안/결렬 결정). 0 이면 룰 엔진
ports:
- "9500:9500"
extra_hosts: