Compare commits
5 Commits
main
...
feat/switc
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fab2f9cb2 | |||
| 8f4c82a282 | |||
| 94c7a6568d | |||
| 51b3820cc9 | |||
| d4acdd0ac5 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -28,3 +28,6 @@ CLAUDE.md
|
||||
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
|
||||
/Temp.md
|
||||
/new.md
|
||||
|
||||
# 서빙 번들 교체 시 자동 생성되는 직전본 백업 (export 도구가 만듦)
|
||||
agent/artifacts/*.prev
|
||||
|
||||
BIN
agent/artifacts/autonomy_serving.npz
Normal file
BIN
agent/artifacts/autonomy_serving.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v32.npz
Normal file
BIN
agent/artifacts/autonomy_v32.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v34_rejected.npz
Normal file
BIN
agent/artifacts/autonomy_v34_rejected.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v35.npz
Normal file
BIN
agent/artifacts/autonomy_v35.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/card_embeddings.npz
Normal file
BIN
agent/artifacts/card_embeddings.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/dqn_serving.npz
Normal file
BIN
agent/artifacts/dqn_serving.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/feature_dqn_ktcommerce.pt
Normal file
BIN
agent/artifacts/feature_dqn_ktcommerce.pt
Normal file
Binary file not shown.
BIN
agent/artifacts/full_autonomy.pt
Normal file
BIN
agent/artifacts/full_autonomy.pt
Normal file
Binary file not shown.
14
agent/artifacts/retrain_report.json
Normal file
14
agent/artifacts/retrain_report.json
Normal 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"
|
||||
}
|
||||
203
agent/docs/완전자율에이전트_변경정리.md
Normal file
203
agent/docs/완전자율에이전트_변경정리.md
Normal 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%" 표시 버그 후보
|
||||
104
agent/eval_harness/feature_buyer.py
Normal file
104
agent/eval_harness/feature_buyer.py
Normal 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)
|
||||
@ -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 = (
|
||||
|
||||
@ -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
|
||||
|
||||
188
agent/negotiation/chat/service/ment_generator.py
Normal file
188
agent/negotiation/chat/service/ment_generator.py
Normal 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
|
||||
@ -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 # 협상 전체 기간(초) — end−start
|
||||
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:
|
||||
|
||||
85
agent/negotiation/policies/autonomy_actions.py
Normal file
85
agent/negotiation/policies/autonomy_actions.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""완전 자율 협상 행동 공간 (numpy 전용 — 학습(tools)과 서빙(policy)이 공유).
|
||||
|
||||
카드 카탈로그 대신 행동의 '의미'만 남긴다:
|
||||
ACCEPT 현재 제시가로 타결
|
||||
WALK 협상 결렬 선언
|
||||
COUNTER(q, s) "C원이면 수락" 역제안. C = anchor + q×(target−anchor), 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×(target−anchor)
|
||||
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()
|
||||
132
agent/negotiation/policies/feature_dqn_policy.py
Normal file
132
agent/negotiation/policies/feature_dqn_policy.py
Normal 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)
|
||||
152
agent/negotiation/policy/autonomy_store.py
Normal file
152
agent/negotiation/policy/autonomy_store.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""AutonomyStore — 완전 자율 협상 정책 서빙 (룰 대체, numpy 전용).
|
||||
|
||||
AUTONOMY_MODE=1 이면 가격협상 판정 룰(앵커 이하 타결 / 와일드카드 존 / 라운드 상한)과
|
||||
카드 선택을 전부 이 정책의 행동 결정으로 대체한다:
|
||||
accept → 협상완료 (제시가 타결) walk → 협상실패
|
||||
counter → "C원이면 수락" 역제안 스텝 press → 전략별 압박 멘트 스텝
|
||||
|
||||
행동의 유일한 유인은 보상 함수다. 남는 제한은 두 가지뿐이며 비즈니스 룰이 아니다:
|
||||
- 역제안 후보 격자가 [anchor−5%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)
|
||||
118
agent/negotiation/policy/dqn_store.py
Normal file
118
agent/negotiation/policy/dqn_store.py
Normal 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)
|
||||
44
agent/negotiation/qtable/domain/service/feature_builder.py
Normal file
44
agent/negotiation/qtable/domain/service/feature_builder.py
Normal 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)
|
||||
@ -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,6 +312,12 @@ 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)))
|
||||
# 카드 '선택'은 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)
|
||||
|
||||
63
agent/tools/build_card_embeddings.py
Normal file
63
agent/tools/build_card_embeddings.py
Normal 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()
|
||||
251
agent/tools/compare_qtable_vs_dqn.py
Normal file
251
agent/tools/compare_qtable_vs_dqn.py
Normal 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()
|
||||
42
agent/tools/export_autonomy_serving.py
Normal file
42
agent/tools/export_autonomy_serving.py
Normal 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()
|
||||
79
agent/tools/export_dqn_serving.py
Normal file
79
agent/tools/export_dqn_serving.py
Normal 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()
|
||||
61
agent/tools/probe_serving_dqn.py
Normal file
61
agent/tools/probe_serving_dqn.py
Normal 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()
|
||||
238
agent/tools/retrain_from_logs.py
Normal file
238
agent/tools/retrain_from_logs.py
Normal 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()
|
||||
317
agent/tools/test_autonomy_defects.py
Normal file
317
agent/tools/test_autonomy_defects.py
Normal 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()
|
||||
148
agent/tools/train_feature_dqn.py
Normal file
148
agent/tools/train_feature_dqn.py
Normal 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()
|
||||
365
agent/tools/train_full_autonomy.py
Normal file
365
agent/tools/train_full_autonomy.py
Normal 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 + (1−W)×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): 실운영 기하 정합 — 앵커가 = 목표가×(1−a), 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()
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user