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