[feat] agent: 완전 자율 협상 모드 (AUTONOMY_MODE) + LLM 멘트
판정 룰(앵커타결/와일드존/3라운드결렬)과 카드 선택을 학습 정책으로 대체: 수락/역제안 금액/압박 화법/결렬 전부 행동 30개(수락1+결렬1+역제안 6단x화법4+압박4)에서 선택. - autonomy_actions: 행동 공간·특징 인코딩 (학습/서빙 공유) - autonomy_store: numpy 서빙 + 행동 봉투 7개(수락<=목표가 / 역제안 단조 / 역제시·결렬은 설득 2회 후 해금 / 마무리국면 압박 금지 / 첫 역제안 앵커 이하 / 최종제안 1회 보장) - chat_engine: 자율 스텝(역제안/최종제안/압박1~4), 최종제안 금액=목표가, 턴캡 12 - ment_generator: Gemini 멘트 생성 + 가드(숫자 화이트리스트·목표가 비공개·금지어·문장완결, 실패시 템플릿 폴백, 6초 컷), 인터넷최저가 근거 인용(수집됨+제시가 초과시만), 대화 기억 - chat_service: 자율 행동 experience_logs 로깅(AUT|종류|위치|전략), 대화기억 ctx 관리 - context loader/CRUD: 인터넷최저가·견적기간·협력사 이력 로드 (v3 상태 21차원) - train_full_autonomy: 시뮬 15k ep — 앵커율 0.8~6% 정합, 협력사 현실화(컷반발·반복짜증· 양보 상호성), 관측성 마스크(마감 40% 미관측·15% 전부미상 — 서빙 중립값 분포 정합), 보상 수정(목표가 초과 타결=결렬 취급) - 서빙 v3.5 (v3.3 목표가 즉시지르기 퇴화, v3.4 소액지형 첫턴 통보 퇴화 — 게이트 반려 이력 보관) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d4acdd0ac5
commit
51b3820cc9
BIN
agent/artifacts/autonomy_serving.npz
Normal file
BIN
agent/artifacts/autonomy_serving.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v32.npz
Normal file
BIN
agent/artifacts/autonomy_v32.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v34_rejected.npz
Normal file
BIN
agent/artifacts/autonomy_v34_rejected.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v35.npz
Normal file
BIN
agent/artifacts/autonomy_v35.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/full_autonomy.pt
Normal file
BIN
agent/artifacts/full_autonomy.pt
Normal file
Binary file not shown.
203
agent/docs/완전자율에이전트_변경정리.md
Normal file
203
agent/docs/완전자율에이전트_변경정리.md
Normal file
@ -0,0 +1,203 @@
|
||||
# 완전 자율 협상 에이전트 — 처음 대비 변경 정리
|
||||
|
||||
> 기준: 협상카드 + 룰 엔진 시절(처음) → 완전 자율 에이전트 v3.2 + LLM 멘트 (2026-07-10 현재)
|
||||
> 롤백: `docker-compose.yml` 의 `AUTONOMY_MODE=0` 하나로 룰 엔진 즉시 복귀 (재빌드 불필요)
|
||||
|
||||
---
|
||||
|
||||
## 1. 한눈에 보기 — 무엇이 바뀌었나
|
||||
|
||||
| 영역 | 처음 (룰 + 카드) | 지금 (자율 에이전트) |
|
||||
|---|---|---|
|
||||
| **협상 판정** | 하드코딩 룰 (앵커 이하 타결 / 와일드카드 존 / 3라운드 결렬) | RL 정책이 매 턴 행동을 직접 선택 |
|
||||
| **발화 선택** | DB 협상카드(NGC-001~011)를 UCB/Q-table 로 선택 | 카드 없음 — 행동 30개 중 신경망이 선택 |
|
||||
| **역제안 금액** | 카드에 박힌 고정값 | 앵커~목표가 6단 사다리에서 정책이 선택 |
|
||||
| **와일드카드** | 사람이 등록한 카드(WC-01~05) 발동 | 최종제안·역제시 타이밍을 정책+봉투가 자율 수행 |
|
||||
| **멘트** | 고정 템플릿 | Gemini LLM 생성 + 할루시네이션 가드 (실패 시 템플릿 폴백) |
|
||||
| **입력 상태** | 가격 스냅샷 9차원 | 21차원 (마감·협력사 이력·인터넷최저가·에피소드 기억 추가) |
|
||||
| **학습** | Q-table 온라인 갱신 | 시뮬레이터 DQN 학습 → 프로브 게이트 → npz 번들 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 의사결정 — 행동 공간 30개
|
||||
|
||||
```
|
||||
ACCEPT 수락 (협상완료, 제시가 타결)
|
||||
WALK 결렬 의사 → 최종제안 1회 보장 후 종료
|
||||
COUNTER 역제안: 금액 위치 6단 {-5%, 0, 25, 50, 75, 100% of (목표가-앵커가)} × 화법 4종
|
||||
PRESS 압박(설득): 화법 4종
|
||||
```
|
||||
|
||||
행동의 실체는 `Action(kind, counter_q, strategy)` — **(무엇을, 얼마에, 어떤 말투로)** 좌표 3개짜리 데이터다
|
||||
(`policies/autonomy_actions.py`, DB 아님). 에이전트는 매 턴 30개 중 조합 1개를 고르고,
|
||||
원화 환산(앵커 + q×스팬)과 문장(LLM)은 선택 이후의 실행 단계.
|
||||
|
||||
역제안 사다리 6단 (실스케일 앵커 418,966/목표 423,198 기준):
|
||||
q=−0.05→418,754 / 0→418,966(앵커) / 0.25→420,024 / 0.5→421,082 / 0.75→422,140 / 1.0→423,198(목표가).
|
||||
비율(q)이라 견적 스케일과 무관하게 같은 행동 공간이 재사용된다.
|
||||
|
||||
화법 4종은 기존 카드 전략 분류를 그대로 승계: **경쟁 압박 / 수용 공감 / 기준 고수 / 협력 파트너**.
|
||||
톤 선택도 학습 결과 — 라이브에서 초반 경쟁(1)→중반 수용(2)→교착 협력(4)으로 국면별 전환 관측.
|
||||
|
||||
설계 출처: 화법 4종·금액 범위(앵커~목표가)는 제품 승계, 수락·결렬 포함은 완전 자율 정의의 필연,
|
||||
**격자 6단만 설계 재량**(`COUNTER_GRID` 수정+재학습으로 변경 가능). 알려진 한계: 부를 수 있는
|
||||
금액이 격자 6지점뿐 — 연속 금액 미세조정은 불가(필요 시 격자 확장이 현실적).
|
||||
|
||||
- 모델: action-as-feature DQN (ScoreNet MLP — 상태 21 + 행동특징 9 → 점수 1개)
|
||||
- 서빙: **numpy 전용** (`autonomy_serving.npz`) — 컨테이너에 PyTorch 불필요
|
||||
- 현재 서빙본: **v3.5** (백업 `autonomy_v35.npz` / 반려본 v3.4 / 이전 v3.2)
|
||||
|
||||
## 3. 입력 상태 — 9차원 → 21차원
|
||||
|
||||
"완전한 에이전트에는 다 들어가야 한다" 요구로 확장:
|
||||
|
||||
| 그룹 | 차원 | 내용 | 출처 |
|
||||
|---|---|---|---|
|
||||
| 기본 | 9 | 매출액·유통코드·협력사수·수락률·제시가·앵커가·목표가·라운드 등 | 기존 스냅샷 |
|
||||
| 테넌트 | 5 | 보상 설정 특징 | reward config |
|
||||
| **에피소드 기억** | 2 | 직전 역제안 유무·위치 | ctx `autonomy_last` |
|
||||
| **마감** | 1 | 마감 잔여율 | quotations start/end_time |
|
||||
| **협력사 이력** | 3 | 과거 협상 횟수·성공률·평균 타결비율 | experience_logs ⨝ sessions |
|
||||
| **시장가** | 1 | 인터넷 최저가 갭 | items.internet_lowest_price |
|
||||
|
||||
- 소스가 없으면 중립값(0.5/0) — 학습 시뮬의 '미상' 표현과 동일
|
||||
- 상대 **발화 내용 파싱은 보류** (사용자 결정 — 프론트 입력 UI 변경 필요)
|
||||
|
||||
## 4. 행동 봉투 — 실전 테스트에서 잡은 결함의 구조적 방지
|
||||
|
||||
룰과 다름: **룰은 결과를 정하고, 봉투는 행동만 금지**한다. 나머지(타이밍·속도·금액)는 전부 정책 학습.
|
||||
|
||||
| # | 봉투 | 막는 결함 (실제 발생 사례) | 성격 |
|
||||
|---|---|---|---|
|
||||
| ① | 목표가 초과 제시가는 **수락 불가** | v3.1 이 보상 구멍을 착취해 목표가+14% 매입 | 안전 (영구) |
|
||||
| ② | 직전 역제안보다 **낮은 금액 재제시 금지** (단조 양보) | 423,198 → 420,024 제안 철회 사건 | 안전 (영구) |
|
||||
| ③ | 역제시는 **설득 ≥2회 후 해금** (`AUTONOMY_MIN_PRESS`) | 첫 턴부터 역제시 — 옛 의미론(일반카드=설득, 와일드카드만 역제시) 복원 | 예절 (해제 후보) |
|
||||
| ④ | 목표가 0.5% 이내 **마무리 국면에선 압박 금지** | 802원 차이에 "재검토 부탁" 반복하던 푼돈 흥정 | 예절 (해제 후보) |
|
||||
| ⑤ | **첫 역제안은 앵커가 이하만** (q ≤ 0) | 사다리 꼭대기 근처(422,140)에서 개시해 올라갈 계단이 없던 문제 | 예절 (해제 후보) |
|
||||
| ⑥ | 같은 금액 반복·결렬 의사 → **자율_최종제안 1회 보장, 금액은 목표가** | 확인 없이 결렬 / 직전 금액을 "최종"으로 반복해 승인 여지를 남긴 채 종료하던 문제 | 안전 (영구) |
|
||||
| ⑦ | **결렬(walk)도 해금 전 금지** — 설득 ≥2회 전에는 설득만 가능 | 설득 0회에 walk 선택 시 최종제안 보장(⑥)과 결합해 "첫 턴 목표가 통보"가 됨 (v3.4 라이브 결함) | 예절 (해제 후보) |
|
||||
|
||||
- 구현: 서빙 `policy/autonomy_store.py` 후보 마스크 + 학습 `tools/train_full_autonomy.py` `available_actions` **양쪽 동일**
|
||||
- 예절 봉투(③④⑤)는 실로그가 쌓이면 `AUTONOMY_MIN_PRESS=0` 등으로 해제 실험 가능
|
||||
|
||||
## 5. 멘트 — 템플릿 → LLM + 가드레일
|
||||
|
||||
**역할 분리(안전 설계):** 무엇을 말할지(금액/전략/수락/결렬)는 RL 이 결정, LLM 은 **표현만** 담당.
|
||||
|
||||
```
|
||||
설정: agent/config/config.local.toml [OpenAIConfig]
|
||||
모델: gemini-2.5-flash-lite (OpenAI 호환 base_url)
|
||||
· 2.5-flash → thinking 지연으로 백엔드 10초 한도 초과 ("협상 응답 지연" 토스트 원인)
|
||||
· 2.0-flash → 은퇴(404)
|
||||
시간: LLM_TIMEOUT_S=6 초과 시 템플릿 폴백 (검증 최대 응답 2.9초)
|
||||
끄기: AUTONOMY_LLM=0
|
||||
```
|
||||
|
||||
**할루시네이션 가드 (하나라도 걸리면 템플릿 폴백, 협상은 계속):**
|
||||
|
||||
| 가드 | 내용 |
|
||||
|---|---|
|
||||
| 숫자 화이트리스트 | 프롬프트로 준 금액(제시가·제안가·직전제안가·양보폭) 외 숫자 = 즉시 폐기 |
|
||||
| **목표가 비공개** | 압박 프롬프트에 목표가 미포함 + 화이트리스트에서도 제외 — 노출 사고 재발 방지 |
|
||||
| 금지어 | 보장/물량/독점/최저가/시장가/%/계약기간/법적 등 승인 안 된 전술·커밋 |
|
||||
| 문장 완결 | thinking 토큰 소진으로 잘린 문장 폐기 (max_tokens 2048) |
|
||||
| 제안가 포함 | 역제안·최종제안 멘트에 제안 금액 필수 |
|
||||
|
||||
**추가 기능:**
|
||||
- **인터넷 최저가 인용** (구 NGC-008 자율판): 수집돼 있고 제시가 > 최저가일 때만 근거 인용 허용 — 그 턴에만 '최저가' 금지어 해제, 수치는 화이트리스트 검증
|
||||
- **대화 기억**: 직전 제안 거절 사실·양보폭을 멘트에 반영("직전 제안에서 5원 상향한…") + 직전 멘트와 같은 문장구조 반복 금지 — "멘트가 다 똑같다" 해결. temperature 0.9
|
||||
|
||||
## 6. 학습 시뮬레이터 버전 이력 — 실패 2건 포함
|
||||
|
||||
| 버전 | 변경 | 결과 |
|
||||
|---|---|---|
|
||||
| v1 | 최초 학습 | 한 방 큰 컷 + 같은 숫자 반복 → "이게 협상이야??" |
|
||||
| v2 | 에피소드 기억·컷 특징·협력사 반복 짜증/이탈 | 개선되나 지형 불일치 잔존 |
|
||||
| v3 | 상태 21차원 확장 | — |
|
||||
| v3.1 | **지형 정합**: 앵커율 0.8~6% 샘플링 (실제 ~1% vs 시뮬 20%) | ⚠️ 보상 구멍 착취 — 목표가+14% 매입 학습 → 봉투 ① 신설 |
|
||||
| **v3.2** | 컷 반발·반복 짜증·**양보 상호성**(우리가 올리면 상대도 내림)·floor ≤ 첫제시가×0.98 | ✅ **현재 서빙본** (목표가 초과 타결 0/30) |
|
||||
| v3.3 | 단조·상호성 반영 재학습 | ❌ "무조건 목표가 즉시 지르기"로 퇴화 → **프로브 게이트 반려** (`full_autonomy.pt` 만 보관, 미서빙) |
|
||||
| v3.4 | 봉투 ①~⑤ 정합 + **보상 수정**(목표가 초과 타결 = 결렬 취급) 재학습 | ❌ **반려** — 초기 게이트(실스케일 단일 지형) 통과 후 라이브에서 퇴화 발견: 소액 지형에서 첫 턴 walk→목표가 통보 / walk 잠금 후엔 압박 12연발·최종제안 생략·화법 단조(전부 전략3). 게이트를 2개 지형으로 확장해 재판정 → v3.2 우위 확인, v3.2 복원 (`autonomy_v34_rejected.npz` 보관) |
|
||||
| **v3.5** | v3.4 + **관측성 마스크**: 마감 40% 미관측(0.5 고정)·15% 완전 미상 에피소드 — 서빙 중립값 상태를 시뮬 분포에 혼입 (v3.4 퇴화 원인 해소) | ✅ **현재 서빙본** — 게이트 78/78, 사다리 3단 사용, 협조 케이스 목표가 대비 -2,116원 타결. 게이트가 이 과정에서 **철회 실버그** 발견(아래) |
|
||||
|
||||
> **교훈 1 — 보상 = 유일한 스펙**: 룰을 제거하면 보상 함수의 구멍이 곧 행동이 된다 (v3.1).
|
||||
> **교훈 2 — 프로브 게이트**: 재학습은 퇴화할 수 있다. 배포 전 반드시 실스케일 제시가별 행동표(`tools/probe_serving_dqn.py`)로 비교 검증 (v3.3).
|
||||
> **교훈 3 — 지형 일반화**: 한 지형의 게이트 통과가 다른 지형을 보증하지 않는다 (v3.4 — 실스케일 통과, 소액 퇴화).
|
||||
> **교훈 4 — 시뮬은 관측까지 닮아야 한다**: 세계뿐 아니라 '무엇을 모르는지'도 서빙과 같아야 한다. 마감·이력 미상(중립값) 상태가 시뮬에 없으면 그 상태가 분포 밖이 된다 (v3.4 원인 → v3.5 해소).
|
||||
|
||||
**철회 실버그 (게이트가 발견, 2026-07-10 수정):** 단조 봉투의 기준 `autonomy_last`가 '마지막 행동'이라 counter→**press**→counter 순서에서 설득이 역제안 기억을 덮어써 봉투가 뚫렸다(9,975 제안 후 9,900 재제안). 역제안 기억을 `autonomy_last_counter`로 별도 보존하도록 수정 — 시뮬(역제안만 추적)과도 일치. v3.2는 이 패턴을 쓰지 않아 드러나지 않았을 뿐 프로덕션에 실존하던 구멍.
|
||||
|
||||
## 7. 현재 협상 흐름 (검증 완료)
|
||||
|
||||
```
|
||||
협력사 제시
|
||||
│
|
||||
▼
|
||||
설득(압박) ≥2회 ── 인터넷최저가 근거 인용 가능, 목표가 절대 비공개
|
||||
│
|
||||
▼
|
||||
역제안 해금 ── 첫 제안은 앵커가 이하로 개시 (봉투⑤)
|
||||
│
|
||||
▼
|
||||
단조 상향 사다리 ── 후퇴 금지(봉투②), 양보폭·속도는 정책이 결정
|
||||
│
|
||||
▼
|
||||
목표가 0.5% 이내 ── 압박 중단, 클로징만 (봉투④)
|
||||
│
|
||||
├─ 제시가 ≤ 목표가 → 수락 → 협상완료
|
||||
├─ 같은 금액 반복 / 결렬 의사 → 자율_최종제안 1회, 금액=목표가 (봉투⑥)
|
||||
│ ├─ 예 → 협상완료 └─ 아니오 → 협상실패
|
||||
└─ 12턴 초과(엔지니어링 캡) → 최종제안(목표가) 1회 거쳐 종료 — 캡도 봉투⑥을 우회하지 않음
|
||||
```
|
||||
|
||||
## 8. 운영 스위치 & 파이프라인
|
||||
|
||||
| 스위치 (docker-compose agent env) | 값 | 의미 |
|
||||
|---|---|---|
|
||||
| `AUTONOMY_MODE` | 1 | 자율 모드 (0 = 룰 엔진 복귀) |
|
||||
| `DQN_SERVING` | 1 | 카드 선택 DQN (0 = UCB Q-table) |
|
||||
| `AUTONOMY_LLM` | 1(기본) | LLM 멘트 (0 = 템플릿만) |
|
||||
| `AUTONOMY_MIN_PRESS` | 2(기본) | 역제시 해금에 필요한 설득 횟수 |
|
||||
| `LLM_TIMEOUT_S` | 6(기본) | LLM 시간 상한, 초과 시 템플릿 폴백 |
|
||||
|
||||
**학습→배포 파이프라인:**
|
||||
```
|
||||
tools/train_full_autonomy (시뮬 15k ep, 룰 베이스라인 비교)
|
||||
→ tools/export_autonomy_serving (artifacts/autonomy_serving.npz, .prev 자동 백업)
|
||||
→ tools/probe_serving_dqn (실스케일 행동표 — 눈으로 보는 진단)
|
||||
→ tools/test_autonomy_defects (결함 회귀 게이트 — 지형 2종×시나리오 3종 + 단위·멘트가드 검사,
|
||||
자동 합격/불합격. 단, v3.4 사례처럼 게이트 통과 ≠ 품질 보증:
|
||||
궤적 자체도 눈으로 비교할 것)
|
||||
→ docker compose build agent (npz 는 이미지에 베이크)
|
||||
```
|
||||
|
||||
**로깅:** 자율 행동도 experience_logs 에 기록 (card_id = `AUT|종류|위치|전략`, 진행 row + 종결 row). 카드 재학습(`retrain_from_logs`)은 AUT 세션 자동 제외.
|
||||
|
||||
## 9. 변경 파일 지도
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `negotiation/policy/autonomy_store.py` | **신규** — 자율 정책 numpy 서빙 + 봉투 ①~⑤ 마스크 |
|
||||
| `negotiation/policies/autonomy_actions.py` | **신규** — 행동 30개·특징 인코딩 (학습/서빙 공유) |
|
||||
| `negotiation/chat/service/ment_generator.py` | **신규** — LLM 멘트 생성 + 가드레일 |
|
||||
| `negotiation/chat/service/chat_engine.py` | 자율 스텝(자율_역제안/최종제안/압박_1~4) + `_autonomy_next` 봉투⑥ |
|
||||
| `services/chat_service.py` | decider 주입·행동 로깅·대화기억 ctx 관리 |
|
||||
| `negotiation/chat/infra/repository/nego_context_crud.py` | 인터넷최저가·견적기간·협력사이력 조회 |
|
||||
| `negotiation/chat/service/negotiation_context_loader.py` | 확장 컨텍스트 로드 (company_id) |
|
||||
| `tools/train_full_autonomy.py` | **신규** — 시뮬레이터(현실화 협력사 모델) + DQN 학습 |
|
||||
| `tools/export_autonomy_serving.py` / `probe_serving_dqn.py` | **신규** — 번들 내보내기 / 프로브 게이트 |
|
||||
| `tools/test_autonomy_defects.py` | **신규** — 결함 회귀 게이트: 실전에서 발견된 결함 41항목을 시나리오·단위·멘트가드 검사로 자동 재생 (서빙 실물 코드 구동, DB/LLM 불필요) |
|
||||
| `config/config.local.toml` | Gemini 접속 정보 (gitignore, 이미지에 베이크) |
|
||||
| `docker-compose.yml` | `AUTONOMY_MODE` / `DQN_SERVING` 플래그 |
|
||||
|
||||
## 10. 남은 일
|
||||
|
||||
- [x] 결함 회귀 게이트 구축 — `test_autonomy_defects.py` 41항목, v3.2 전항목 통과 확인 (2026-07-10)
|
||||
- [x] 보상 수정 — 목표가 초과 타결은 학습 보상에서 결렬 취급 (v3.1 구멍을 유인 수준에서 차단, 봉투 ①과 이중 방어)
|
||||
- [ ] ⚠️ **Gemini API 키 재발급** — 채팅에 노출된 키, 테스트 종료 후 반드시 교체 (config.local.toml + 이미지 리빌드)
|
||||
- [x] 봉투 정합 재학습 — v3.4 반려(소액 지형 퇴화) → 원인 규명(관측성 불일치) → **v3.5 관측성 마스크로 해소, 배포 완료** (2026-07-10)
|
||||
- [x] 철회 실버그 수정 — counter→press→counter 에서 단조 봉투 뚫림 → `autonomy_last_counter` 별도 보존
|
||||
- [x] 턴캡 최종제안 보장 — 캡 종료도 "끝내기 전 한 번 더"를 거침
|
||||
- [ ] 상대 발화 LLM 파싱 (보류 중 — 프론트 입력 UI 변경 필요)
|
||||
- [ ] 실로그 축적 후: 예절 봉투(③④⑤) 해제 실험 → LLM 협력사 셀프플레이 (집컴 GPU 단계)
|
||||
- [ ] (소소) negodata 프론트 "목표 마진율 1000%" 표시 버그 후보
|
||||
@ -26,13 +26,21 @@ _SESSIONS = table(
|
||||
column("deleted"),
|
||||
schema="negotiation",
|
||||
)
|
||||
_ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner")
|
||||
_ITEMS = table("items", column("item_id"), column("price"), column("internet_lowest_price"),
|
||||
column("deleted"), schema="partner")
|
||||
_SUPPLIERS = table("suppliers", column("supplier_id"), 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"),
|
||||
@ -98,6 +106,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]]:
|
||||
@ -131,6 +155,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 = (
|
||||
|
||||
@ -16,6 +16,50 @@ MAX_ROUNDS = 3
|
||||
_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 를 옳게 가른다.
|
||||
@ -70,8 +114,11 @@ 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()
|
||||
# 완전 자율 모드: ChatService 가 AutonomyStore 정책을 주입하면 가격협상 판정 룰을 대체한다.
|
||||
self.autonomy_decider = None # Callable[[dict], autonomy_actions.Action]
|
||||
|
||||
# ---- public --------------------------------------------------------
|
||||
def start(self, session: ChatSession) -> StepView:
|
||||
@ -99,6 +146,10 @@ class ChatEngine:
|
||||
# 직전 제시가로 잡히던 버그 수정 — 수락 시 실제 합의가는 인하가다.)
|
||||
if session.step == "wild_card_1pct" and user_input == "예" and session.context.get("offer_1pct"):
|
||||
session.context["input_price"] = float(session.context["offer_1pct"])
|
||||
# 자율 역제안/최종제안 수락("예") → 합의가는 에이전트 제안가다 (wild_card_1pct 와 동일 원리).
|
||||
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)
|
||||
@ -136,7 +187,15 @@ class ChatEngine:
|
||||
- anchor 살짝 초과(≤ anchor*1.05) + 와일드카드 미사용 → 와일드카드로 인하 압박.
|
||||
- 설정 카드(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)
|
||||
@ -172,6 +231,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*1.02)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
||||
앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다.
|
||||
@ -201,10 +306,25 @@ class ChatEngine:
|
||||
out["input_price"] = int(ctx["input_price"])
|
||||
if "target_price" in ctx:
|
||||
out["target"] = int(ctx["target_price"])
|
||||
# DB 카드 정본(negodata 편집, card.nego_cards.script)은 {target_price} 변수명을 쓴다
|
||||
# — 파일 스크립트의 {target}과 별개로 둘 다 지원(미치환 토큰 노출 방지).
|
||||
out["target_price"] = int(ctx["target_price"])
|
||||
if "anchor_price" in ctx:
|
||||
out["anchor"] = int(ctx["anchor_price"])
|
||||
out["anchoring_price"] = int(ctx["anchor_price"]) # DB 카드 정본 변수명(NGC-007 등)
|
||||
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): 수집값이 컨텍스트에 없으면 앵커가로 폴백 — 원형 토큰 노출 방지.
|
||||
# TODO: partner.item_internet_lowest_prices 최신 성공 수집값을 context loader 로 연결.
|
||||
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): 런타임 소스 미구현 — 중립 문구 폴백.
|
||||
# TODO: 견적/카드 편집 단계에서 입력받아 컨텍스트로 전달.
|
||||
out["customer_condition"] = ctx.get("customer_condition") or "상호 협의된 조건"
|
||||
# 인하율 = (기존 공급가 - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
|
||||
base = ctx.get("item_price") or 0
|
||||
if base > 1 and "input_price" in ctx:
|
||||
|
||||
188
agent/negotiation/chat/service/ment_generator.py
Normal file
188
agent/negotiation/chat/service/ment_generator.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""MentGenerator — 자율 협상 행동을 LLM 이 자연어 멘트로 표현 (v2: 행동은 RL, 문장은 LLM).
|
||||
|
||||
역할 분리(안전 설계):
|
||||
- 무엇을 말할지(수락/역제안 금액/압박 전략/결렬)는 RL 정책이 결정 — LLM 은 표현만 담당.
|
||||
- 가드레일: 역제안 멘트에 제안 금액이 정확히 포함되지 않으면 폐기, 예외/미설정 시 None
|
||||
→ 호출부(ChatService)가 기존 템플릿 멘트로 폴백한다. LLM 이 죽어도 협상은 계속된다.
|
||||
|
||||
설정: config.local.toml [OpenAIConfig] (Gemini 는 OpenAI 호환 base_url 로 접속).
|
||||
비활성화: AUTONOMY_LLM=0.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from common.logger import LOG
|
||||
from negotiation.profiling.config import LlmCredentials
|
||||
|
||||
_STRATEGY_TONE = {
|
||||
1: "경쟁 압박형 — 복수 공급처와 비교 검토 중임을 암시하며 긴장감을 준다",
|
||||
2: "수용 공감형 — 상대 제안의 취지에 공감하며 부드럽게 조정을 요청한다",
|
||||
3: "기준 고수형 — 내부 산정 기준과 목표가를 근거로 원칙을 지킨다",
|
||||
4: "협력 파트너형 — 장기 파트너십과 후속 거래 확대 가능성을 강조한다",
|
||||
}
|
||||
|
||||
_SYSTEM = """너는 대기업 구매팀의 가격 협상 챗봇이다. 주어진 '전달 의도'를 자연스러운 한국어 협상 멘트로 바꿔 쓴다.
|
||||
규칙 (위반 시 출력은 폐기된다):
|
||||
- 1~3문장, 정중하되 간결하게. 출력은 멘트 텍스트만 (따옴표·설명 없이).
|
||||
- 금액 숫자는 주어진 그대로 정확히 포함하고 단위는 '원'을 쓴다. 주어지지 않은 숫자·비율을 절대 만들지 않는다.
|
||||
- 지정된 '화법' 전략 안에서만 말한다. 그 외의 협상 전술(물량·기간 약속, 조건 교환, 거래 연계,
|
||||
독점 제안, 시장가·최저가 주장, 할인 약속 등)을 지어내지 않는다.
|
||||
- 회사의 정책·사실을 단정하지 않는다. 주어진 의도에 없는 정보는 말하지 않는다.
|
||||
- 상대는 협력사(판매자)이고 우리는 구매자다."""
|
||||
|
||||
# 생성문 금지어 — 승인되지 않은 커밋/주장 계열. 걸리면 템플릿 폴백(협상은 계속).
|
||||
_FORBIDDEN = ("보장", "물량", "독점", "무조건", "최저가", "시장 가격", "시장가", "계약 기간",
|
||||
"법적", "위약", "%")
|
||||
|
||||
|
||||
def _configured() -> bool:
|
||||
if os.getenv("AUTONOMY_LLM", "1").lower() in ("0", "false", "no"):
|
||||
return False
|
||||
try:
|
||||
return LlmCredentials.from_config().is_configured()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _digits(s) -> str:
|
||||
return re.sub(r"[^\d]", "", str(s))
|
||||
|
||||
|
||||
def _history_hints(ctx: dict) -> str:
|
||||
"""대화 기억 힌트 — 무기억 생성이 '매번 같은 멘트'를 만들던 문제의 해법.
|
||||
|
||||
① 직전 우리 제안이 거절된 사실과 이번 제안과의 관계(양보/입장유지)를 짚게 하고
|
||||
② 직전 봇 멘트를 보여주며 같은 문장 구조·표현의 반복을 금지한다."""
|
||||
hints = []
|
||||
prev = ctx.get("autonomy_prev")
|
||||
if prev and prev.get("kind") == "counter":
|
||||
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
|
||||
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
|
||||
cur_offer = int(ctx.get("autonomy_offer") or 0)
|
||||
if cur_offer > prev_offer:
|
||||
hints.append(f"참고: 직전 라운드에 우리가 {prev_offer:,}원을 제안했으나 거절당했고, "
|
||||
f"이번에는 {cur_offer - prev_offer:,}원 더 양보한 제안이다. 이 진전을 자연스럽게 짚어라.")
|
||||
elif cur_offer == prev_offer and cur_offer > 0:
|
||||
hints.append(f"참고: 직전에 제안한 {prev_offer:,}원을 거절당했지만 같은 금액을 유지한다. "
|
||||
f"입장이 확고함을 정중하게 전하라.")
|
||||
elif prev_offer > 0:
|
||||
hints.append(f"참고: 직전 제안({prev_offer:,}원)이 거절된 뒤의 재제안이다.")
|
||||
last_ment = ctx.get("autonomy_last_ment")
|
||||
if last_ment:
|
||||
hints.append(f'직전 봇 멘트: "{last_ment}" — 이와 같은 문장 구조·표현을 반복하지 말고 다르게 써라.')
|
||||
return " ".join(hints)
|
||||
|
||||
|
||||
def _prompt_for(step: str, ctx: dict) -> Optional[str]:
|
||||
price = int(ctx.get("input_price") or 0)
|
||||
rnd = ctx.get("round", 1)
|
||||
if step in ("자율_역제안", "자율_최종제안"):
|
||||
offer = int(ctx.get("autonomy_offer") or 0)
|
||||
if offer <= 0:
|
||||
return None
|
||||
strategy = int((ctx.get("autonomy_last") or {}).get("s") or 3)
|
||||
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
|
||||
final = ("이번이 우리가 제시할 수 있는 마지막 제안이며, 거절하시면 이번 협상은 종료됨을 "
|
||||
"분명하되 정중하게 밝혀라. " if step == "자율_최종제안" else "")
|
||||
return (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
|
||||
f"전달 의도: 우리는 **{offer:,}원**이면 즉시 수락하고 우선협상 대상으로 확정할 수 있다 — "
|
||||
f"이 핵심 의미는 유지하되 문장 표현은 자유롭게 새로 써라. {final}화법: {tone}. "
|
||||
f"{_history_hints(ctx)} 마지막에 수락 여부를 물어라.")
|
||||
if step.startswith("자율_압박_"):
|
||||
strategy = int(step.rsplit("_", 1)[1])
|
||||
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
|
||||
# 주의: 목표가는 프롬프트에 넣지 않는다 — 압박 중 목표가 노출은 우리 상한을 까는 것
|
||||
# (상대가 그 밑으로 내려올 이유가 사라진다). 숫자 커밋은 역제안/최종제안에서만.
|
||||
base = (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
|
||||
f"전달 의도: 어떤 금액도 언급하지 말고(내부 기준·목표가 숫자 금지), 제시가와 우리 기준의 "
|
||||
f"거리가 있다는 취지로 가격 재제안을 요청한다. 화법: {tone}. "
|
||||
f"{_history_hints(ctx)}")
|
||||
# 시장가 근거 (구 NGC-008 의 자율 버전): 수집된 인터넷최저가가 실재하고 제시가가 그보다
|
||||
# 높을 때만 사실 근거로 인용을 허용한다 — 미수집 품목에서 지어내는 주장은 가드가 차단.
|
||||
if _market_evidence(ctx):
|
||||
il = int(ctx["internet_lowest_price"])
|
||||
base += (f" 참고 사실(인용 허용되는 유일한 금액): 동일 품목의 인터넷 최저가가 {il:,}원으로 "
|
||||
f"확인된다. 현재 제시가가 이보다 높다는 점을 근거로 조정 여지를 정중히 짚어라.")
|
||||
return base
|
||||
return None
|
||||
|
||||
|
||||
def _market_evidence(ctx: dict) -> bool:
|
||||
"""시장가 근거 인용 가능 조건: 인터넷최저가 수집됨 + 제시가가 그보다 높음."""
|
||||
il = int(ctx.get("internet_lowest_price") or 0)
|
||||
return il > 0 and float(ctx.get("input_price") or 0) > il
|
||||
|
||||
|
||||
def _allowed_amounts(ctx: dict) -> set:
|
||||
"""멘트에 등장해도 되는 숫자 집합 — 우리가 프롬프트로 준 값들뿐. 이 밖의 금액 = 할루시네이션."""
|
||||
# 목표가는 화이트리스트에 없다 — 압박 멘트가 목표가를 새면(상한 노출) 즉시 폐기된다.
|
||||
# 역제안·최종제안의 제안가(autonomy_offer)가 목표가와 같은 경우만 그 값으로 허용된다.
|
||||
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
|
||||
out = {int(ctx.get("input_price") or 0), int(ctx.get("autonomy_offer") or 0),
|
||||
int(ctx.get("round") or 0)}
|
||||
if _market_evidence(ctx):
|
||||
out.add(int(ctx["internet_lowest_price"])) # 시장가 근거 인용 시 그 수치만 허용
|
||||
prev = ctx.get("autonomy_prev")
|
||||
if prev and prev.get("kind") == "counter":
|
||||
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
|
||||
out |= {prev_offer, abs(int(ctx.get("autonomy_offer") or 0) - prev_offer)}
|
||||
return {str(v) for v in out if v}
|
||||
|
||||
|
||||
def _guard(step: str, ctx: dict, text: str) -> bool:
|
||||
"""LLM 출력 검증(할루시네이션 차단) — 실패 시 템플릿 폴백.
|
||||
|
||||
① 길이/문장 완결 ② 금지어(승인 안 된 커밋·주장) ③ 숫자 화이트리스트: 멘트의 모든
|
||||
3자리+ 숫자는 우리가 준 값(제시가·제안가·목표가·직전제안가)이어야 한다 — 지어낸 금액 즉시 폐기.
|
||||
④ 역제안은 제안 금액 포함 필수."""
|
||||
if not text or len(text) < 10 or len(text) > 600:
|
||||
return False
|
||||
if not text.rstrip().endswith(("다.", "요.", "요?", "까?", "니까?", ".", "?")):
|
||||
return False # 문장 중간 잘림(thinking 토큰에 한도 소진 등) → 템플릿 폴백
|
||||
forbidden = _FORBIDDEN
|
||||
if _market_evidence(ctx):
|
||||
# 시장가 근거가 정당한 턴에는 '최저가/시장가' 언급을 허용 (수치는 아래 화이트리스트가 검증).
|
||||
forbidden = tuple(w for w in _FORBIDDEN if w not in ("최저가", "시장가", "시장 가격"))
|
||||
if any(w in text for w in forbidden):
|
||||
return False
|
||||
allowed = _allowed_amounts(ctx)
|
||||
for num in re.findall(r"\d{3,}", text.replace(",", "")):
|
||||
if num not in allowed:
|
||||
return False # 프롬프트에 없던 금액 생성 = 할루시네이션
|
||||
if step in ("자율_역제안", "자율_최종제안"):
|
||||
return _digits(ctx.get("autonomy_offer")) in _digits(text)
|
||||
return True
|
||||
|
||||
|
||||
async def generate(step: str, ctx: dict) -> Optional[str]:
|
||||
"""자율 스텝 멘트 생성. 미설정/실패/검증불통과 → None (호출부 템플릿 유지)."""
|
||||
if not _configured():
|
||||
return None
|
||||
prompt = _prompt_for(step, ctx)
|
||||
if prompt is None:
|
||||
return None
|
||||
try:
|
||||
from negotiation.profiling.infra.llm_adapter import chat_complete
|
||||
# openai SDK 는 동기 — 이벤트루프 블로킹 방지 위해 스레드로 넘긴다.
|
||||
# max_tokens 넉넉히 — Gemini 2.5 계열은 thinking 토큰이 한도에 포함돼 짧으면 본문이 잘린다.
|
||||
# 시간 상한: backend→agent 타임아웃(10s)보다 확실히 짧아야 한다 — 초과 시 템플릿 폴백으로
|
||||
# 협상은 즉시 계속된다("협상 응답 지연" 토스트 방지). LLM_TIMEOUT_S 로 조절.
|
||||
text = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
chat_complete,
|
||||
[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
|
||||
None, False, 0.9, 2048, # temperature 0.9 — 표현 다양성 (의미는 프롬프트 가드)
|
||||
),
|
||||
timeout=float(os.getenv("LLM_TIMEOUT_S", "6")),
|
||||
)
|
||||
text = (text or "").strip().strip('"')
|
||||
if _guard(step, ctx, text):
|
||||
return text
|
||||
LOG.w(f"[MentGenerator] 가드레일 불통과 → 템플릿 폴백 (step={step})")
|
||||
return None
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[MentGenerator] LLM 실패 → 템플릿 폴백: {ex}")
|
||||
return None
|
||||
@ -44,14 +44,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:
|
||||
@ -97,6 +106,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,
|
||||
@ -107,6 +131,12 @@ class NegotiationContextLoader:
|
||||
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
|
||||
selected_nego_card_numbers=selected_nego_cards,
|
||||
selected_wild_card_numbers=selected_wild_cards,
|
||||
internet_lowest_price=internet_lowest,
|
||||
deadline_end_ts=deadline_end_ts,
|
||||
deadline_total_s=deadline_total_s,
|
||||
hist_n=hist_n,
|
||||
hist_success=hist_success,
|
||||
hist_settle_ratio=hist_settle,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
85
agent/negotiation/policies/autonomy_actions.py
Normal file
85
agent/negotiation/policies/autonomy_actions.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""완전 자율 협상 행동 공간 (numpy 전용 — 학습(tools)과 서빙(policy)이 공유).
|
||||
|
||||
카드 카탈로그 대신 행동의 '의미'만 남긴다:
|
||||
ACCEPT 현재 제시가로 타결
|
||||
WALK 협상 결렬 선언
|
||||
COUNTER(q, s) "C원이면 수락" 역제안. C = anchor + q×(target−anchor), s = 화법 전략
|
||||
PRESS(s) 설득 압박 (카드의 일반화 — 전략 1경쟁/2수용/3고수/4협력)
|
||||
|
||||
특징 벡터(ACTION_DIM=8) = 유형 one-hot(3) + 가격 위치(1) + 전략 one-hot(4).
|
||||
ScoreNet(상태 + 행동특징) → 스칼라 점수로 후보 30개를 채점해 argmax 한다.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
COUNTER_GRID = [-0.05, 0.0, 0.25, 0.5, 0.75, 1.0] # C = anchor + q×(target−anchor)
|
||||
ACTION_DIM = 3 + 1 + 4 + 1 # 유형(3) + 위치(1) + 전략(4) + 컷폭(1: 현 제시가 대비 인하 요구율)
|
||||
|
||||
# 자율 전용 추가 상태 (v3):
|
||||
# [0] 직전 역제안 존재(0/1) [1] 직전 역제안 위치 q ← 에피소드 기억(같은 숫자 반복 방지)
|
||||
# [2] 마감 잔여율(남은시간/전체, 미상 0.5) ← 견적 마감(quotations.end_time)
|
||||
# [3] 과거 협상 횟수 min(n,5)/5 [4] 과거 성사율(미상 0.5)
|
||||
# [5] 과거 평균 타결수준 norm((타결가/목표가−0.8)/0.4, 미상 0.5) ← 이 협력사와의 이력(experience_logs)
|
||||
# [6] 인터넷최저가 갭 clip((최저가−앵커)/앵커/0.1, ±1, 미상 0) ← 숨은 하한가의 관측 가능한 힌트
|
||||
# 특징은 학습 시뮬에도 동일하게 존재해야 한다(train_full_autonomy 가 대응물을 생성).
|
||||
EXTRA_STATE_DIM = 7
|
||||
|
||||
|
||||
def extra_state(last_kind: str = "", last_q: float = 0.0, deadline: float = 0.5,
|
||||
hist_n: float = 0.0, hist_success: float = 0.5, hist_settle: float = 0.5,
|
||||
internet_gap: float = 0.0) -> np.ndarray:
|
||||
has_counter = 1.0 if last_kind == "counter" else 0.0
|
||||
return np.array([
|
||||
has_counter,
|
||||
float(np.clip(last_q, -1.0, 1.0)) * has_counter,
|
||||
float(np.clip(deadline, 0.0, 1.0)),
|
||||
float(np.clip(hist_n, 0.0, 1.0)),
|
||||
float(np.clip(hist_success, 0.0, 1.0)),
|
||||
float(np.clip(hist_settle, 0.0, 1.0)),
|
||||
float(np.clip(internet_gap, -1.0, 1.0)),
|
||||
], dtype=np.float32)
|
||||
|
||||
|
||||
def settle_norm(avg_settle_ratio: float) -> float:
|
||||
"""평균 (타결가/목표가) → 0~1 정규화 (0.8→0, 1.0→0.5, 1.2→1)."""
|
||||
return float(np.clip((avg_settle_ratio - 0.8) / 0.4, 0.0, 1.0))
|
||||
|
||||
|
||||
def internet_gap_feat(internet_lowest: float, anchor: float) -> float:
|
||||
"""인터넷최저가의 앵커 대비 갭 (±10% 스케일). 최저가 없으면 0을 쓴다."""
|
||||
if not internet_lowest or anchor <= 0:
|
||||
return 0.0
|
||||
return float(np.clip((internet_lowest - anchor) / anchor / 0.1, -1.0, 1.0))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
kind: str # accept | walk | counter | press
|
||||
counter_q: float = 0.0 # counter 위치 (anchor~target 스팬 비율)
|
||||
strategy: int = 0 # press/counter 의 화법 전략 (1~4, 0=없음)
|
||||
|
||||
def feat(self, price_pos: float, cut: float = 0.0) -> np.ndarray:
|
||||
"""cut: 이 행동이 요구하는 인하폭 (현 제시가 대비, counter 만 >0) — 대형컷의 무례함을
|
||||
정책이 지각하게 한다. 갭이 크면 역제안 대신 압박이 낫다는 걸 배우는 근거 특징."""
|
||||
t = {"accept": [1, 0, 0], "walk": [0, 1, 0]}.get(self.kind, [0, 0, 1])
|
||||
pos = price_pos if self.kind == "accept" else self.counter_q
|
||||
s = np.zeros(4, dtype=np.float32)
|
||||
if self.strategy:
|
||||
s[self.strategy - 1] = 1.0
|
||||
return np.concatenate([np.array(t, dtype=np.float32),
|
||||
np.array([float(np.clip(pos, -1.0, 2.0)),
|
||||
], dtype=np.float32), s,
|
||||
np.array([float(np.clip(cut, 0.0, 1.0))], dtype=np.float32)])
|
||||
|
||||
|
||||
def candidate_actions():
|
||||
"""전 행동 후보: 수락 1 + 결렬 1 + 역제안 6×전략4 + 압박 4 = 30."""
|
||||
out = [Action("accept"), Action("walk")]
|
||||
out += [Action("counter", q, s) for q in COUNTER_GRID for s in (1, 2, 3, 4)]
|
||||
out += [Action("press", 0.0, s) for s in (1, 2, 3, 4)]
|
||||
return out
|
||||
|
||||
|
||||
ACTIONS = candidate_actions()
|
||||
152
agent/negotiation/policy/autonomy_store.py
Normal file
152
agent/negotiation/policy/autonomy_store.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""AutonomyStore — 완전 자율 협상 정책 서빙 (룰 대체, numpy 전용).
|
||||
|
||||
AUTONOMY_MODE=1 이면 가격협상 판정 룰(앵커 이하 타결 / 와일드카드 존 / 라운드 상한)과
|
||||
카드 선택을 전부 이 정책의 행동 결정으로 대체한다:
|
||||
accept → 협상완료 (제시가 타결) walk → 협상실패
|
||||
counter → "C원이면 수락" 역제안 스텝 press → 전략별 압박 멘트 스텝
|
||||
|
||||
행동의 유일한 유인은 보상 함수다. 남는 제한은 두 가지뿐이며 비즈니스 룰이 아니다:
|
||||
- 역제안 후보 격자가 [anchor−5%span, target] 안 (행동 공간 정의)
|
||||
- 세션 턴 상한(엔지니어링 타임아웃, ChatEngine._AUTONOMY_TURN_CAP)
|
||||
|
||||
번들: artifacts/autonomy_serving.npz (tools/export_autonomy_serving.py).
|
||||
불가(플래그 꺼짐/번들 없음)면 None → 기존 룰 엔진 그대로 (즉시 롤백 경로).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common.logger import LOG
|
||||
from negotiation.policies.autonomy_actions import (
|
||||
ACTIONS, Action, extra_state, internet_gap_feat, settle_norm)
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||
from negotiation.qtable.domain.service.feature_builder import (
|
||||
build_state_features, build_tenant_features)
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BUNDLE_PATH = os.path.join(_HERE, "..", "..", "artifacts", "autonomy_serving.npz")
|
||||
|
||||
|
||||
class AutonomyPolicy:
|
||||
"""세션 컨텍스트 → 상태특징 → 행동(greedy). ChatEngine 에 decider 로 주입된다."""
|
||||
|
||||
name = "full_autonomy"
|
||||
|
||||
def __init__(self, z, reward_cfg):
|
||||
self._W = (z["W0"], z["b0"], z["W1"], z["b1"], z["W2"], z["b2"])
|
||||
self._state_dim = int(z["state_dim"])
|
||||
self._tenant_feat = build_tenant_features(reward_cfg)
|
||||
|
||||
@staticmethod
|
||||
def _acceptance(ctx: dict) -> float:
|
||||
base = ctx.get("item_price") or ctx.get("first_offer_price") or 0
|
||||
cur = ctx.get("input_price") or 0
|
||||
if base <= 0 or cur <= 0:
|
||||
return 0.0
|
||||
return max(0.0, (base - cur) / base)
|
||||
|
||||
def decide(self, ctx: dict) -> Action:
|
||||
"""ChatSession.context → Action. 상태 구성은 ChatService._snapshot 과 동일 규칙."""
|
||||
snap = NegotiationSnapshot(
|
||||
revenue_amount=ctx["revenue_amount"], distribution_code=ctx["distribution_code"],
|
||||
partner_count=ctx["partner_count"], acceptance_ratio=self._acceptance(ctx),
|
||||
input_price=ctx.get("input_price", ctx["anchor_price"]), anchor_price=ctx["anchor_price"],
|
||||
target_price=ctx["target_price"], round_number=ctx.get("round", 0),
|
||||
)
|
||||
# v3 추가 특징: 직전 역제안 기억 + 마감 잔여율 + 협력사 이력 + 인터넷최저가 갭.
|
||||
# 소스가 없으면 전부 중립값(0.5/0) — 학습 시뮬의 '미상' 표현과 동일해야 한다.
|
||||
# 역제안 기억은 autonomy_last_counter(역제안만 갱신) — autonomy_last(마지막 행동)를 쓰면
|
||||
# 사이에 낀 설득이 기억을 지워 단조 봉투가 뚫린다(counter→press→counter 철회 실버그).
|
||||
# 시뮬의 last_kind/last_q 도 역제안만 추적하므로 이쪽이 학습 분포와도 일치한다.
|
||||
last = ctx.get("autonomy_last_counter") or {}
|
||||
deadline = 0.5
|
||||
end_ts, total_s = ctx.get("deadline_end_ts"), ctx.get("deadline_total_s")
|
||||
if end_ts and total_s:
|
||||
import time
|
||||
deadline = float(np.clip((end_ts - time.time()) / total_s, 0.0, 1.0))
|
||||
hist_n = int(ctx.get("hist_n") or 0)
|
||||
hist_success = float(ctx["hist_success"]) if ctx.get("hist_success") is not None else 0.5
|
||||
hist_settle = (settle_norm(float(ctx["hist_settle_ratio"]))
|
||||
if ctx.get("hist_settle_ratio") is not None else 0.5)
|
||||
sf = np.concatenate([build_state_features(snap), self._tenant_feat, extra_state(
|
||||
last.get("kind", ""), float(last.get("q", 0.0)),
|
||||
deadline=deadline, hist_n=min(hist_n, 5) / 5.0,
|
||||
hist_success=hist_success if hist_n else 0.5,
|
||||
hist_settle=hist_settle,
|
||||
internet_gap=internet_gap_feat(float(ctx.get("internet_lowest_price") or 0),
|
||||
float(snap.anchor_price)),
|
||||
)])
|
||||
span = max(snap.target_price - snap.anchor_price, 1.0)
|
||||
pos = (snap.input_price - snap.anchor_price) / span
|
||||
# 행동 봉투 (학습 available_actions 와 동일해야 한다):
|
||||
# ① 목표가 초과 제시가는 '수락' 제외 — 매입 승인 범위(v3.1 착취 방지)
|
||||
# ② 직전 역제안보다 낮은 금액의 역제안 제외 — 단조 양보 원칙(제안 철회는 협상 예절 위반;
|
||||
# 올리는 '속도'는 정책 학습, 후퇴 '금지'만 구조로 보장)
|
||||
# ③ 역제시 해금 조건 — 옛 제품 의미론 복원(제품 결정 2026-07-10): 일반 카드는 설득만,
|
||||
# 역제시(숫자 제안)는 와일드카드처럼 마무리 수단. 최소 AUTONOMY_MIN_PRESS(기본 2)회
|
||||
# 설득 이후에만 역제시 후보가 열린다. 해금 후의 타이밍·금액은 정책 학습.
|
||||
# ④ 마무리 국면 — 제시가가 목표가 0.5% 이내로 붙으면 압박 제외(+역제시 잠금 해제):
|
||||
# 푼돈 차이에서 '재검토 부탁' 반복은 상대만 지치게 한다. 클로징(역제안/최종제안)하거나 끝내거나.
|
||||
min_press = int(os.getenv("AUTONOMY_MIN_PRESS", "2"))
|
||||
near_target = snap.input_price <= snap.target_price * 1.005
|
||||
counter_locked = (int(ctx.get("autonomy_press_n") or 0) < min_press) and not near_target
|
||||
last_counter_q = float(last["q"]) if last.get("kind") == "counter" else None
|
||||
if last_counter_q is not None:
|
||||
counter_locked = False # 이미 역제시를 시작했으면 잠그지 않는다(단조 봉투가 관리)
|
||||
# ⑤ 첫 역제안은 앵커가 이하(q ≤ 0)만 — 낮게 개시해 목표가까지 천천히 올라간다
|
||||
# (제품 결정: 사다리를 다 쓰는 앵커링 개시. 이후 단조 봉투가 상향을 관리).
|
||||
# ⑥ 결렬(walk)도 해금 전 금지 — 설득 0회에 walk 를 고르면 최종제안 보장(엔진)과 결합해
|
||||
# '첫 턴 목표가 통보'가 된다(v3.4 라이브 결함). 해금 전에는 설득만 가능.
|
||||
cands = [a for a in ACTIONS
|
||||
if not (a.kind == "accept" and snap.input_price > snap.target_price)
|
||||
and not (a.kind == "counter" and counter_locked)
|
||||
and not (a.kind == "walk" and counter_locked)
|
||||
and not (a.kind == "press" and near_target)
|
||||
and not (a.kind == "counter" and last_counter_q is None and a.counter_q > 1e-9)
|
||||
and not (a.kind == "counter" and last_counter_q is not None
|
||||
and a.counter_q < last_counter_q - 1e-9)]
|
||||
feats = []
|
||||
for a in cands:
|
||||
cut = 0.0
|
||||
if a.kind == "counter":
|
||||
c = snap.anchor_price + a.counter_q * span
|
||||
cut = max(0.0, (snap.input_price - c) / max(snap.input_price, 1.0))
|
||||
feats.append(a.feat(pos, cut))
|
||||
feats = np.stack(feats)
|
||||
W0, b0, W1, b1, W2, b2 = self._W
|
||||
x = np.concatenate([np.repeat(sf[None, :], feats.shape[0], axis=0), feats], axis=1)
|
||||
h = np.maximum(x @ W0.T + b0, 0.0)
|
||||
h = np.maximum(h @ W1.T + b1, 0.0)
|
||||
scores = (h @ W2.T + b2).squeeze(-1)
|
||||
return cands[int(np.argmax(scores))]
|
||||
|
||||
@staticmethod
|
||||
def counter_price(ctx: dict, act: Action) -> int:
|
||||
span = max(ctx["target_price"] - ctx["anchor_price"], 1.0)
|
||||
return int(round(ctx["anchor_price"] + act.counter_q * span))
|
||||
|
||||
|
||||
class AutonomyStore:
|
||||
_z = None
|
||||
_load_failed = False
|
||||
|
||||
@classmethod
|
||||
def enabled(cls) -> bool:
|
||||
return os.getenv("AUTONOMY_MODE", "0").lower() in ("1", "true", "yes")
|
||||
|
||||
@classmethod
|
||||
def policy_for(cls, engine) -> Optional[AutonomyPolicy]:
|
||||
"""engine: tenancy.registry.TenantEngine. 비활성/번들 없음 → None (룰 엔진 유지)."""
|
||||
if not cls.enabled() or cls._load_failed:
|
||||
return None
|
||||
if cls._z is None:
|
||||
try:
|
||||
cls._z = np.load(BUNDLE_PATH, allow_pickle=False)
|
||||
LOG.i("[Autonomy] 완전 자율 정책 번들 로드 완료 — 협상 판정 룰 대체 모드")
|
||||
except Exception as ex:
|
||||
cls._load_failed = True
|
||||
LOG.e_no_callstack(f"[Autonomy] 번들 로드 실패 → 룰 엔진 유지: {ex}")
|
||||
return None
|
||||
return AutonomyPolicy(cls._z, engine.config.reward)
|
||||
@ -13,12 +13,16 @@ from common.enums import DBType, ErrorType
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from config.server_configs import agent_config
|
||||
from negotiation.chat.service import ment_generator
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView
|
||||
from negotiation.chat.service.indicator import compute_indicator
|
||||
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
|
||||
from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader
|
||||
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
|
||||
@ -46,9 +50,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 가 따라옴),
|
||||
@ -101,6 +115,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)
|
||||
@ -129,6 +150,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
|
||||
@ -196,6 +228,12 @@ class ChatService:
|
||||
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size,
|
||||
available_mask=self._selection_mask(engine, session),
|
||||
episode=EpisodeState(used_action_ids=set(session.used_action_ids)))
|
||||
# 카드 '선택'은 DQN 서빙(활성 시), 학습/영속은 아래 Q-table 경로 그대로(오프폴리시 갱신).
|
||||
# DQN 불가(비활성/번들 없음/후보 특징 없음)면 None → 기존 UCB 선택 폴백.
|
||||
dqn = DQNServingStore.policy_for(engine)
|
||||
decision = dqn.select(ctx) if dqn is not None else None
|
||||
selector_name = dqn.name if decision is not None else policy.name
|
||||
if decision is None:
|
||||
decision = policy.select(ctx)
|
||||
session.used_action_ids.add(decision.action_id)
|
||||
card_id = self._card_id_for_action(engine, session, decision.action_id)
|
||||
@ -207,7 +245,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])
|
||||
@ -232,6 +270,49 @@ class ChatService:
|
||||
res.indicator_range = ind[1]
|
||||
res.bot_chat_type = "indicator"
|
||||
|
||||
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)
|
||||
|
||||
42
agent/tools/export_autonomy_serving.py
Normal file
42
agent/tools/export_autonomy_serving.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""full_autonomy 체크포인트(.pt) → 서빙 번들(autonomy_serving.npz) export.
|
||||
|
||||
dqn_serving 과 동일 패턴: ScoreNet 가중치만 numpy 로 묶어 PyTorch 없이 서빙한다.
|
||||
행동 특징은 코드(autonomy_actions)가 런타임 생성하므로 번들에는 가중치만 담는다.
|
||||
|
||||
실행(호스트, torch 필요): APP_ENV=local python -m tools.export_autonomy_serving
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from negotiation.policies.autonomy_actions import ACTION_DIM, EXTRA_STATE_DIM
|
||||
from negotiation.qtable.domain.service.feature_builder import STATE_FEATURE_DIM, TENANT_FEATURE_DIM
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "full_autonomy.pt")
|
||||
OUT_PATH = os.path.join(_HERE, "..", "artifacts", "autonomy_serving.npz")
|
||||
|
||||
STATE_DIM = STATE_FEATURE_DIM + TENANT_FEATURE_DIM + EXTRA_STATE_DIM
|
||||
|
||||
|
||||
def main():
|
||||
sd = torch.load(CKPT_PATH, map_location="cpu")
|
||||
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
|
||||
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
|
||||
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
|
||||
assert W0.shape[1] == STATE_DIM + ACTION_DIM, f"입력 차원 불일치: {W0.shape[1]}"
|
||||
|
||||
tmp = OUT_PATH + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
np.savez(f, W0=W0, b0=b0, W1=W1, b1=b1, W2=W2, b2=b2,
|
||||
state_dim=STATE_DIM, action_dim=ACTION_DIM)
|
||||
if os.path.exists(OUT_PATH):
|
||||
os.replace(OUT_PATH, OUT_PATH + ".prev")
|
||||
os.replace(tmp, OUT_PATH)
|
||||
print(f"[저장] {os.path.abspath(OUT_PATH)} (state {STATE_DIM} + action {ACTION_DIM})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
365
agent/tools/train_full_autonomy.py
Normal file
365
agent/tools/train_full_autonomy.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""train_full_autonomy — 행동 룰 0개, 완전 자율 협상 에이전트 (v2 시뮬 프로토타입).
|
||||
|
||||
기존 시스템의 룰(앵커 이하 강제타결 / 3라운드 강제결렬 / 와일드카드 존 / 카드 카탈로그)을
|
||||
전부 제거하고, 모든 결정을 에이전트 행동으로 이관한다:
|
||||
|
||||
행동 공간 (action-as-feature, ScoreNet 이 후보 열거 채점):
|
||||
ACCEPT 현재 제시가로 타결 ← '앵커 이하 강제타결' 룰 대체
|
||||
WALK 협상 결렬 선언 ← '3라운드 강제결렬' 룰 대체
|
||||
COUNTER(C) "C원이면 수락" 역제안 ← '와일드카드 1%' 룰 대체 (금액도 학습)
|
||||
PRESS(strategy) 설득 압박(카드의 일반화) ← 카드 카탈로그 대체 (전략만 남음)
|
||||
|
||||
룰이 사라진 자리는 보상이 채운다(유일한 스펙):
|
||||
R = W×R_price + (1−W)×R_end − λ×round (기존 RewardCalculator 그대로)
|
||||
협상이 끝나는 길: 에이전트의 ACCEPT/WALK, 협력사의 COUNTER 수락, 협력사의 인내심 소진(이탈).
|
||||
마지막 것은 시스템 룰이 아니라 상대방 특성이다.
|
||||
|
||||
베이스라인 = 현행 룰 시스템을 같은 환경에서 재현(앵커타결/1%클로징/3라운드결렬 + 압박).
|
||||
|
||||
실행: APP_ENV=local PYTHONUTF8=1 python -m tools.train_full_autonomy
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from eval_harness.feature_buyer import AFFINITY, STRATEGY_PROFILE, SupplierProfile, sample_supplier
|
||||
from negotiation.policies.autonomy_actions import (
|
||||
ACTION_DIM, ACTIONS, COUNTER_GRID, EXTRA_STATE_DIM, Action, extra_state,
|
||||
internet_gap_feat, settle_norm as extra_settle)
|
||||
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||
from negotiation.qtable.domain.service.feature_builder import (
|
||||
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features)
|
||||
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
from tools.train_feature_dqn import pref_config, sample_tenant_pref
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "full_autonomy.pt")
|
||||
|
||||
TARGET = 10000.0
|
||||
# 앵커율(v3.1): 실운영 기하 정합 — 앵커가 = 목표가×(1−a), a ∈ [0.8%, 6%] 를 에피소드마다 샘플링.
|
||||
# (기존 고정 20% 폭은 실제(≈1%)와 지형이 달라, 실서비스에서 압박/역제안 밸런스가 어긋났다.)
|
||||
ANCHOR_RATE_RANGE = (0.008, 0.06)
|
||||
# 행동 공간(Action/ACTIONS/COUNTER_GRID/ACTION_DIM)은 negotiation.policies.autonomy_actions 공유
|
||||
# — 서빙(autonomy_store, numpy 전용)과 학습이 같은 인코딩을 쓴다.
|
||||
|
||||
|
||||
# ---- 협력사 모델 (상대 반응: 역제안 수락/재제안 포함) ---------------------------------
|
||||
class AutonomousBuyer:
|
||||
"""FeatureBuyer 확장: 역제안(C)에 반응한다. 이탈은 '인내심' — 시스템 룰이 아닌 상대 특성."""
|
||||
|
||||
def __init__(self, sup: SupplierProfile, seed: int):
|
||||
self.sup = sup
|
||||
self.rng = np.random.default_rng(seed)
|
||||
# 기질 t ∈ [0,1]: 0=터프(하한 높고 안 물러섬) ↔ 1=수월. 관측 가능한 이력·최저가가
|
||||
# 이 숨은 기질과 상관되게 생성된다 → 에이전트가 이력/최저가 특징을 읽을 '이유'가 생긴다.
|
||||
# 하한은 '우리 앵커'가 아니라 협력사 사정(≈목표가 기준)으로 정해진다(v3.1) —
|
||||
# 하한 > 목표가(≈35%)면 애초에 성사 불가능한 협상이고, 그걸 빨리 알아채고 끊는 것도 실력이다.
|
||||
t = float(self.rng.uniform(0.0, 1.0))
|
||||
self.floor = TARGET * float(np.clip(1.12 - 0.24 * t + self.rng.normal(0, 0.02), 0.85, 1.18))
|
||||
self.patience = int(self.rng.integers(4, 9)) + (1 if t > 0.7 else 0)
|
||||
# 첫 제시가: 목표가의 105~150% — 실운영(기존 공급가가 목표가를 26%+ 상회) 분포를 덮는다.
|
||||
# 좁게(110~125%) 학습하면 큰 갭 상황에서 정책이 분포 밖 일반화(대형컷 역제안)를 한다.
|
||||
self.price = TARGET * float(self.rng.uniform(1.05, 1.50))
|
||||
# 하한가가 첫 제시가보다 높을 수 없다(자기 하한 밑으로 부르고 시작하는 판매자는 없음).
|
||||
# 이 보정이 없으면 on_press 의 max(floor,·)가 가격을 '역주행'시키는 비현실이 생긴다.
|
||||
self.floor = min(self.floor, self.price * 0.98)
|
||||
self._last_c: Optional[float] = None # 직전 역제안 (같은 숫자 반복 짜증 모델링)
|
||||
# ---- 관측 가능 부가정보 (v3 특징 소스 — 기질과 상관, 노이즈 있음) ----
|
||||
self.hist_n = int(self.rng.integers(0, 6)) # 과거 협상 횟수 (0=신규)
|
||||
if self.hist_n:
|
||||
self.hist_success = float(np.clip(0.25 + 0.6 * t + self.rng.normal(0, 0.10), 0.0, 1.0))
|
||||
self.hist_settle_ratio = float(np.clip(1.18 - 0.28 * t + self.rng.normal(0, 0.04), 0.80, 1.30))
|
||||
else:
|
||||
self.hist_success = self.hist_settle_ratio = None
|
||||
# 인터넷최저가: 숨은 하한가의 노이즈 관측치. 60% 확률로만 수집돼 있음(현실: 미수집 흔함).
|
||||
self.internet_lowest = (self.floor * float(self.rng.uniform(0.98, 1.08))
|
||||
if self.rng.random() < 0.6 else None)
|
||||
|
||||
def _powers(self, strategy: int) -> Tuple[float, float]:
|
||||
conc, acc = STRATEGY_PROFILE.get(strategy, (0.5, 0.5))
|
||||
m = AFFINITY[self.sup.segment].get(strategy, 0.5)
|
||||
scale = 0.35 + 0.85 * m
|
||||
return conc * scale, acc * scale
|
||||
|
||||
def on_press(self, strategy: int, turn: int) -> Tuple[bool, float]:
|
||||
"""(이탈여부, 새 제시가). 압박이 안 먹히는 세그먼트면 이탈 위험이 실재한다."""
|
||||
c_pow, a_pow = self._powers(strategy)
|
||||
walk_p = 0.04 + 0.30 * (1.0 - a_pow) * (turn / self.patience)
|
||||
if self.rng.random() < walk_p:
|
||||
return True, self.price
|
||||
concession = (self.price - self.floor) * (0.10 + 0.55 * c_pow)
|
||||
self.price = max(self.floor, self.price - concession)
|
||||
return False, self.price
|
||||
|
||||
def on_counter(self, c: float, strategy: int, turn: int) -> Tuple[str, float]:
|
||||
"""역제안 C 반응: 'accept'(C로 타결) | 'walk' | 'counter'(새 제시가).
|
||||
|
||||
현실화(v2): 현 제시가 대비 인하 요구폭(cut)이 클수록 수락률이 급감하고 이탈 위험이 커진다
|
||||
— 초기 버전에서 에이전트가 't1 원샷 로우볼'로 시뮬 허점을 착취하던 것을 막는다.
|
||||
압박으로 가격을 충분히 끌어내린 뒤 작은 컷으로 클로징해야 통하는 구조.
|
||||
"""
|
||||
_, a_pow = self._powers(strategy or 3)
|
||||
cut = max(0.0, (self.price - c) / max(self.price, 1.0)) # 인하 요구폭 (현 제시가 대비)
|
||||
prev_c = self._last_c
|
||||
repeated = prev_c is not None and abs(c - prev_c) < 1e-6 # 같은 숫자 반복
|
||||
self._last_c = c
|
||||
# 양보 상호성(v3.3): 직전 제안보다 올려 부르면(성의 있는 양보) 호의적으로 반응한다.
|
||||
# 이 신호가 있어야 '상대가 내리면 우리도 조금 올리는' tit-for-tat 이 학습으로 나온다.
|
||||
warm = 0.0
|
||||
if prev_c is not None and c > prev_c + 1e-9:
|
||||
warm = float(np.clip((c - prev_c) / max(self.price - self.floor, 1.0), 0.0, 0.35))
|
||||
if c >= self.floor:
|
||||
margin = (c - self.floor) / max(self.floor, 1.0)
|
||||
p_acc = float(np.clip(0.20 + 0.9 * margin / 0.08, 0.0, 0.95)) * (0.75 + 0.35 * a_pow)
|
||||
p_acc *= float(np.clip(1.0 - (cut - 0.05) / 0.20, 0.0, 1.0)) # 컷 5% 초과부터 반발, 25%면 수락 0
|
||||
if repeated:
|
||||
p_acc *= 0.25 # 이미 거절한 숫자를 또 내밀면 설득력 급감
|
||||
p_acc *= 1.0 + warm
|
||||
if self.rng.random() < min(p_acc, 0.97):
|
||||
return "accept", c
|
||||
# 모욕적 요구(하한 미달·과도한 원샷 컷·앵무새 반복) → 이탈 위험
|
||||
low = max(0.0, (self.floor - c) / max(self.floor, 1.0))
|
||||
p_walk = min(0.5, 2.0 * low) + 0.35 * max(0.0, cut - 0.20) / 0.20 + (0.15 if repeated else 0.0)
|
||||
if self.rng.random() < min(p_walk * (1.0 - warm), 0.7):
|
||||
return "walk", self.price
|
||||
self.price = max(self.floor, c + (self.price - c) * float(self.rng.uniform(0.30, 0.60) + warm))
|
||||
return "counter", self.price
|
||||
|
||||
|
||||
# ---- 에피소드 실행 (룰 없음 — 종료는 행동 또는 상대 특성으로만) ------------------------
|
||||
def make_snapshot(sup, price, turn, p0, anchor, outcome=NegotiationOutcome.ONGOING):
|
||||
return NegotiationSnapshot(
|
||||
revenue_amount=sup.revenue_amount, distribution_code=sup.distribution_code,
|
||||
partner_count=sup.partner_count, acceptance_ratio=max(0.0, (p0 - price) / p0),
|
||||
input_price=price, anchor_price=anchor, target_price=TARGET,
|
||||
round_number=turn, outcome=outcome)
|
||||
|
||||
|
||||
MIN_PRESS = int(os.getenv("AUTONOMY_MIN_PRESS", "2")) # 역제시 해금에 필요한 최소 설득 횟수
|
||||
|
||||
|
||||
def available_actions(price: float, last_counter_q: Optional[float] = None,
|
||||
counter_locked: bool = False) -> list:
|
||||
"""행동 봉투 (serving autonomy_store 와 동일해야 한다):
|
||||
① 목표가 초과 제시가는 '수락' 제외 — 매입 승인 범위(목표가 초과 수락 착취 방지)
|
||||
② 직전 역제안 미만 금액의 역제안 제외 — 단조 양보 원칙(제안 철회 금지;
|
||||
양보 '속도'는 정책이 배우고, 후퇴 '금지'만 구조로 보장)
|
||||
③ counter_locked: 설득 MIN_PRESS 회 전에는 역제시 잠금 — 옛 제품 의미론
|
||||
(일반 카드=설득, 역제시=와일드카드 성격의 마무리 수단) 복원
|
||||
④ 마무리 국면(제시가 ≤ 목표가×1.005): 압박 제외 — 푼돈 차이에서 재검토 요청 반복 방지
|
||||
⑤ 첫 역제안은 앵커 이하(q ≤ 0)만 — 낮게 개시해 사다리를 다 쓰며 올라간다"""
|
||||
near_target = price <= TARGET * 1.005
|
||||
return [a for a in ACTIONS
|
||||
if not (a.kind == "accept" and price > TARGET)
|
||||
and not (a.kind == "counter" and counter_locked and not near_target)
|
||||
and not (a.kind == "walk" and counter_locked and not near_target)
|
||||
and not (a.kind == "press" and near_target)
|
||||
and not (a.kind == "counter" and last_counter_q is None and a.counter_q > 1e-9)
|
||||
and not (a.kind == "counter" and last_counter_q is not None
|
||||
and a.counter_q < last_counter_q - 1e-9)]
|
||||
|
||||
|
||||
def action_feats(price: float, anchor: float, last_counter_q: Optional[float] = None,
|
||||
counter_locked: bool = False):
|
||||
"""현 제시가 기준 (가용 행동 리스트, 특징 [K, ACTION_DIM]). counter 는 컷폭 포함."""
|
||||
span = max(TARGET - anchor, 1.0)
|
||||
pos = (price - anchor) / span
|
||||
acts = available_actions(price, last_counter_q, counter_locked)
|
||||
rows = []
|
||||
for a in acts:
|
||||
cut = 0.0
|
||||
if a.kind == "counter":
|
||||
c = anchor + a.counter_q * span
|
||||
cut = max(0.0, (price - c) / max(price, 1.0))
|
||||
rows.append(a.feat(pos, cut))
|
||||
return acts, np.stack(rows)
|
||||
|
||||
|
||||
def run_episode(policy_fn, sup, rc: RewardCalculator, tf: np.ndarray, seed: int,
|
||||
learner: Optional[FeatureDQNPolicy] = None, trace: Optional[list] = None):
|
||||
"""policy_fn(state_feat, price_pos) → Action. learner 지정 시 replay 저장+학습."""
|
||||
buyer = AutonomousBuyer(sup, seed)
|
||||
p0 = buyer.price
|
||||
env_rng = np.random.default_rng(seed + 7)
|
||||
# 앵커율 샘플링(v3.1): 실운영처럼 앵커가 목표가 바로 아래(0.8~6%) — 좁은 스팬 지형에서 학습.
|
||||
anchor = TARGET * (1.0 - float(env_rng.uniform(*ANCHOR_RATE_RANGE)))
|
||||
span = max(TARGET - anchor, 1.0)
|
||||
turn, settled, walked = 0, None, False
|
||||
last_kind, last_q = "", 0.0 # 직전 역제안 기억 (같은 숫자 반복 방지의 학습 근거)
|
||||
press_n = 0 # 설득 횟수 — 역제시 해금(MIN_PRESS) 카운터
|
||||
# 견적 마감(환경 사실): 마감 도달 시 협상은 미타결 종료된다 — 룰이 아니라 세상의 시계.
|
||||
deadline_turns = int(env_rng.integers(3, 11))
|
||||
# 관측성 마스크(v3.5): 실서빙은 마감·이력·최저가가 '없는' 세션이 흔하고 로더가 중립값
|
||||
# (0.5/0)을 대입한다. 시뮬이 항상 다 아는 세계만 학습하면 그 중립 상태가 분포 밖이 된다
|
||||
# — v3.4 가 라이브 소액 지형에서 첫 턴 결렬로 퇴화한 원인 추정. 세계(마감 종료·상대 특성)는
|
||||
# 그대로 두고 관측만 가린다: 마감은 40% 미관측(0.5 고정), 15% 는 전부 미상(신규 견적의 전형).
|
||||
deadline_known = env_rng.random() < 0.6
|
||||
blind = env_rng.random() < 0.15
|
||||
if blind:
|
||||
deadline_known = False
|
||||
# 협력사 이력·최저가 특징 (에피소드 내 불변)
|
||||
known_hist = buyer.hist_n and not blind
|
||||
fixed_extra = dict(
|
||||
hist_n=min(buyer.hist_n, 5) / 5.0 if not blind else 0.0,
|
||||
hist_success=buyer.hist_success if known_hist else 0.5,
|
||||
hist_settle=extra_settle(buyer.hist_settle_ratio) if known_hist else 0.5,
|
||||
internet_gap=internet_gap_feat(buyer.internet_lowest or 0.0, anchor) if not blind else 0.0,
|
||||
)
|
||||
pending = None # (state_feat, action_feat) — 최종 결과 시점만 채점, 중간 r=0
|
||||
|
||||
while True:
|
||||
turn += 1
|
||||
price = buyer.price
|
||||
deadline_remain = (max(0.0, (deadline_turns - turn + 1) / deadline_turns)
|
||||
if deadline_known else 0.5) # 미관측 → 서빙 로더와 동일한 중립값
|
||||
sf = np.concatenate([build_state_features(make_snapshot(sup, price, turn, p0, anchor)), tf,
|
||||
extra_state(last_kind, last_q, deadline=deadline_remain, **fixed_extra)])
|
||||
lcq = last_q if last_kind == "counter" else None
|
||||
locked = lcq is None and press_n < MIN_PRESS
|
||||
act = policy_fn(sf, price, anchor, lcq, locked)
|
||||
if trace is not None:
|
||||
trace.append((turn, int(price), act))
|
||||
|
||||
if act.kind == "accept":
|
||||
settled = price
|
||||
elif act.kind == "walk":
|
||||
walked = True
|
||||
elif act.kind == "counter":
|
||||
c = anchor + act.counter_q * span
|
||||
resp, val = buyer.on_counter(c, act.strategy, turn)
|
||||
last_kind, last_q = "counter", act.counter_q # 역제안 기억 갱신
|
||||
if resp == "accept":
|
||||
settled = c
|
||||
elif resp == "walk":
|
||||
walked = True
|
||||
else: # press
|
||||
press_n += 1
|
||||
left, _ = buyer.on_press(act.strategy, turn)
|
||||
walked = walked or left
|
||||
if not settled and not walked and turn >= buyer.patience:
|
||||
walked = True # 인내심 소진(상대 특성) — 시스템 룰 아님
|
||||
if not settled and not walked and turn >= deadline_turns:
|
||||
walked = True # 견적 마감 도달(환경 사실) — 미타결 종료
|
||||
|
||||
done = settled is not None or walked
|
||||
final_price = settled if settled is not None else buyer.price
|
||||
# 성사 보너스는 목표가 이하 타결에만 — v3.1 이 '비싸게라도 성사'로 착취한 보상 구멍의
|
||||
# 원인 차단(봉투 ① 의 마스크와 이중 방어: 유인 자체를 올바르게). 초과 타결 = 결렬 취급.
|
||||
outcome = (NegotiationOutcome.SUCCESS if settled is not None and settled <= TARGET
|
||||
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
|
||||
r = rc.calculate(make_snapshot(sup, final_price, turn, p0, anchor, outcome)).total if done else 0.0
|
||||
|
||||
if learner is not None:
|
||||
pos = (price - anchor) / span
|
||||
cut = 0.0
|
||||
if act.kind == "counter":
|
||||
cut = max(0.0, (price - (anchor + act.counter_q * span)) / max(price, 1.0))
|
||||
af = act.feat(pos, cut)
|
||||
if pending:
|
||||
nxt_lcq = last_q if last_kind == "counter" else None
|
||||
learner.remember(*pending, 0.0, sf,
|
||||
action_feats(price, anchor, nxt_lcq,
|
||||
nxt_lcq is None and press_n < MIN_PRESS)[1], False)
|
||||
pending = (sf, af)
|
||||
if done:
|
||||
learner.remember(sf, af, r, None, None, True)
|
||||
learner.train_step()
|
||||
if done:
|
||||
return settled, turn, r
|
||||
|
||||
|
||||
# ---- 정책들 ------------------------------------------------------------------
|
||||
def dqn_policy(policy: FeatureDQNPolicy):
|
||||
def f(sf, price, anchor, last_counter_q=None, counter_locked=False):
|
||||
acts, feats = action_feats(price, anchor, last_counter_q, counter_locked)
|
||||
i, _, _ = policy.select(sf, feats)
|
||||
return acts[i]
|
||||
return f
|
||||
|
||||
|
||||
class RuleBaseline:
|
||||
"""현행 시스템 룰 재현: 앵커 이하 수락 / 존내 1% 클로징 / 3회 압박 후 결렬."""
|
||||
|
||||
def __init__(self):
|
||||
self.presses, self.closed = 0, False
|
||||
|
||||
def __call__(self, sf, price, anchor, last_counter_q=None, counter_locked=False) -> Action:
|
||||
span = max(TARGET - anchor, 1.0)
|
||||
if price <= anchor:
|
||||
return Action("accept")
|
||||
if price <= anchor * 1.02 and not self.closed:
|
||||
self.closed = True
|
||||
return Action("counter", (price * 0.99 - anchor) / span, 3)
|
||||
if self.presses < 3:
|
||||
self.presses += 1
|
||||
return Action("press", 0.0, 3)
|
||||
return Action("walk")
|
||||
|
||||
|
||||
# ---- 학습/평가 ----------------------------------------------------------------
|
||||
def evaluate(name, make_policy_fn, base_cfg, tcfg_state, episodes=3000, seed0=777):
|
||||
rc = RewardCalculator(pref_config(base_cfg, 0.5), tcfg_state)
|
||||
tf = build_tenant_features(pref_config(base_cfg, 0.5))
|
||||
rng = np.random.default_rng(seed0)
|
||||
rewards, settles, rounds = [], [], []
|
||||
for i in range(episodes):
|
||||
sup = sample_supplier(rng)
|
||||
settled, turn, r = run_episode(make_policy_fn(), sup, rc, tf, seed0 * 91 + i)
|
||||
rewards.append(r)
|
||||
rounds.append(turn)
|
||||
if settled is not None:
|
||||
settles.append(settled / TARGET)
|
||||
sr = len(settles) / episodes
|
||||
print(f"{name:<14} 보상 {np.mean(rewards):.4f} ±{np.std(rewards)/np.sqrt(episodes):.4f}"
|
||||
f" 성사율 {sr:.3f} 타결가/목표 {np.mean(settles):.3f} 평균라운드 {np.mean(rounds):.2f}")
|
||||
return dict(reward=float(np.mean(rewards)), success=sr,
|
||||
settle_ratio=float(np.mean(settles)) if settles else None, rounds=float(np.mean(rounds)))
|
||||
|
||||
|
||||
def main(episodes=15000, seed=42):
|
||||
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
|
||||
tcfg = TenantConfigLoader().load("ktcommerce")
|
||||
policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM + EXTRA_STATE_DIM,
|
||||
card_dim=ACTION_DIM, eps_decay=5000, gamma=0.97)
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
print(f"=== 완전 자율 학습 {episodes}ep (행동 {len(ACTIONS)}개, 룰 0개) ===")
|
||||
recent = []
|
||||
for ep in range(1, episodes + 1):
|
||||
sup = sample_supplier(rng)
|
||||
rcfg, tf = sample_tenant_pref(rng, tcfg.reward)
|
||||
rc = RewardCalculator(rcfg, tcfg.state)
|
||||
_, _, r = run_episode(dqn_policy(policy), sup, rc, tf, seed * 131 + ep, learner=policy)
|
||||
recent.append(r)
|
||||
if ep % 3000 == 0:
|
||||
print(f" ep {ep:>6} eps={policy.eps():.3f} 최근3000 평균보상={np.mean(recent[-3000:]):.4f}")
|
||||
policy.save(CKPT_PATH)
|
||||
|
||||
print("\n=== 평가 3000ep (중립 성향 p=0.5, 동일 협력사 분포) ===")
|
||||
policy.greedy = True
|
||||
evaluate("룰시스템(현행)", lambda: RuleBaseline(), tcfg.reward, tcfg.state)
|
||||
evaluate("완전자율 DQN", lambda: dqn_policy(policy), tcfg.reward, tcfg.state)
|
||||
|
||||
# 궤적 예시 — 에이전트가 룰 없이 뭘 하는지 눈으로
|
||||
print("\n=== 궤적 예시 (완전자율) ===")
|
||||
rc = RewardCalculator(pref_config(tcfg.reward, 0.5), tcfg.state)
|
||||
tf = build_tenant_features(pref_config(tcfg.reward, 0.5))
|
||||
rng2 = np.random.default_rng(7)
|
||||
for k in range(3):
|
||||
sup = sample_supplier(rng2)
|
||||
trace = []
|
||||
settled, turn, r = run_episode(dqn_policy(policy), sup, rc, tf, 5000 + k, trace=trace)
|
||||
seg = "·".join(sup.segment)
|
||||
print(f"[{seg}] " + " → ".join(
|
||||
f"t{t}:{p:,}원 {a.kind}{'' if a.kind in ('accept', 'walk') else f'({a.counter_q:.2f},전략{a.strategy})' if a.kind == 'counter' else f'(전략{a.strategy})'}"
|
||||
for t, p, a in trace) + f" ⇒ {'타결 ' + format(int(settled), ',') + '원' if settled else '결렬'} (r={r:.3f})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -64,6 +64,8 @@ services:
|
||||
environment:
|
||||
APP_ENV: local
|
||||
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override)
|
||||
DQN_SERVING: "1" # 카드 선택을 feature_dqn(numpy 서빙)으로. 0 이면 기존 UCB Q-table
|
||||
AUTONOMY_MODE: "1" # 완전 자율 협상(판정 룰·카드 제거, 정책이 수락/역제안/결렬 결정). 0 이면 룰 엔진
|
||||
ports:
|
||||
- "9500:9500"
|
||||
extra_hosts:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user