Compare commits
266 Commits
feat/switc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 69e4240039 | |||
| 8112394aef | |||
| 1a859b198c | |||
| d33648327c | |||
| dce2c42d74 | |||
| 0e4ddf0f43 | |||
| 6eb4dc26f8 | |||
| d02ba20520 | |||
| ac6366a898 | |||
| f1e924931f | |||
| 84090433ab | |||
| e1e519c20c | |||
| e816bfbba4 | |||
| 37fa65707f | |||
| 86d50c1291 | |||
| 436384fa36 | |||
| a405e00bf9 | |||
|
|
7dc925bcd4 | ||
|
|
0c6c431f0a | ||
|
|
67612b477c | ||
|
|
6e401615eb | ||
| 11b29b45e3 | |||
| 4ed23c56b8 | |||
|
|
6ddb2c4504 | ||
|
|
95086cb3f6 | ||
|
|
d1d1ed6ec5 | ||
|
|
74db218410 | ||
|
|
b278f58d9c | ||
|
|
1e966f7121 | ||
| 77d96cd4a4 | |||
|
|
04b697a9eb | ||
|
|
14bcf1481b | ||
|
|
152aed9908 | ||
|
|
f483839b11 | ||
|
|
8d15fe6bb0 | ||
|
|
9380760869 | ||
|
|
3bdff74ceb | ||
|
|
b403ca21c9 | ||
| d3e33b10f4 | |||
| 17a698a7af | |||
|
|
7b9d895ead | ||
| 3214e4f782 | |||
|
|
08afc7d4ad | ||
| e809129ae9 | |||
| 631a501c5c | |||
| ab77900dac | |||
| 09d24f5022 | |||
| 4536a48e40 | |||
| ea1249429a | |||
| d2f1a8f6f4 | |||
| be039dd92b | |||
| 2888ff2c28 | |||
| c53eeefcf6 | |||
| f214fccc49 | |||
| 63717f5eb9 | |||
| 7f15bc10d1 | |||
|
|
7b1bcb6d24 | ||
|
|
377389f495 | ||
| d3bcc61609 | |||
| 55e3ab02aa | |||
| a416668256 | |||
| 54f8f7a6a1 | |||
|
|
042e2d3012 | ||
|
|
b2b5708a6a | ||
|
|
8574df4c16 | ||
| c56cf8e3af | |||
|
|
6c7ff5af51 | ||
|
|
5c9567919f | ||
|
|
b7fc327779 | ||
|
|
18fe18dd39 | ||
| 6f5d69b5cb | |||
| 71ef28110b | |||
|
|
3b345929d4 | ||
|
|
495e16b951 | ||
|
|
f80cece9c1 | ||
|
|
23c1c48634 | ||
|
|
55c18e6a83 | ||
|
|
005ebc3d76 | ||
| 91b8dc77a0 | |||
| 2bf91d64f5 | |||
| bae0da42d2 | |||
| 15369a016d | |||
| 5917942fe3 | |||
| 87b896fc2f | |||
| 6785c8a6f4 | |||
| 3c3f60c7d4 | |||
| bd0f39f131 | |||
| 775984fe48 | |||
| 5520e3c2b6 | |||
| 2a004734d8 | |||
| 35d38e0818 | |||
| 82cca894fc | |||
| 9716528ce7 | |||
| 9dca78dc72 | |||
| 671904c762 | |||
| 6f32b466b9 | |||
| fe9adcaa26 | |||
| f554202c58 | |||
| a56589c6d9 | |||
| b33ae05cd6 | |||
| 30f134836b | |||
|
|
8fa77af866 | ||
|
|
89652fac1f | ||
|
|
5e24741395 | ||
|
|
0cefe315c4 | ||
|
|
ba9d34fd31 | ||
| 6534d53696 | |||
|
|
ca1291708f | ||
| a5fa44ee25 | |||
| 7b612824be | |||
| bacf6271df | |||
| 9fb4a52c56 | |||
| f9d37e08f4 | |||
| b539e073cb | |||
| 36afcd68a6 | |||
| a55975e62a | |||
| e9be649e1f | |||
| 624a8846b0 | |||
| d580e4d5ea | |||
| 5318ff3263 | |||
| 51d07bb779 | |||
| d7c4e86f30 | |||
| 562dafa5ca | |||
| 3eec80d989 | |||
| 2541a391b7 | |||
| ce551090b3 | |||
| 4f9789c08a | |||
| cfee6e89d0 | |||
| db07a60cfa | |||
| 0f10b6a70f | |||
| 4927c9184e | |||
| 03322d6ca3 | |||
| fa9113da55 | |||
| 7c4b66aba5 | |||
| f8f8f70f54 | |||
| bcb87de35e | |||
| 0242ecff09 | |||
| bb576af346 | |||
| e67e713270 | |||
| 08c1e66582 | |||
| 5d0fbbcf6a | |||
| 88edcfed63 | |||
| 1e4726f4cc | |||
| e8d2acaade | |||
| a9db0f7a1b | |||
| 3e5f8c6cd4 | |||
| e7a51b1b75 | |||
| e9427543c8 | |||
| 6ffdb9a389 | |||
| 9ccf42cf6b | |||
| e93820a51b | |||
| dd3609173e | |||
| 2386cb8867 | |||
| b6852ec6aa | |||
| 03c22f9474 | |||
| 25e13d7b01 | |||
|
|
dc8c288f68 | ||
|
|
d111610555 | ||
| 3d515055fb | |||
|
|
e6005b4f94 | ||
|
|
479cb862b1 | ||
|
|
a306ad4cf3 | ||
|
|
c126d56631 | ||
|
|
3c8d0af2e4 | ||
|
|
74fce19be9 | ||
|
|
1006ad11ea | ||
|
|
645a0362b0 | ||
|
|
29e54ad838 | ||
|
|
10ea8ddd45 | ||
|
|
433b973505 | ||
|
|
a750c1098a | ||
|
|
5e286d7949 | ||
|
|
992a3246a8 | ||
|
|
6eb71eb035 | ||
|
|
61b438d8d0 | ||
|
|
8fb54be830 | ||
|
|
c81df5bd88 | ||
|
|
993301be8b | ||
|
|
ca7f057e41 | ||
|
|
e7d2c87fbe | ||
|
|
8b9e81777c | ||
|
|
3c5286aeec | ||
|
|
f97e407f7c | ||
| 21f90ed84a | |||
| ac487268d8 | |||
|
|
9ef9bfbca3 | ||
|
|
4a20194dc8 | ||
|
|
1cce752b47 | ||
|
|
5ddf5f6249 | ||
|
|
3171311746 | ||
|
|
28b4a9e797 | ||
| c0e6987c3c | |||
|
|
523661e3d2 | ||
| b41f7c67f1 | |||
|
|
9e9de52200 | ||
| 26d9fa3c4b | |||
| 22500eae4c | |||
| afacd74382 | |||
| 1682481b01 | |||
|
|
18f6466a8b | ||
|
|
d4cee1caf7 | ||
|
|
a267326bf7 | ||
|
|
c075dfbcfb | ||
|
|
4381649658 | ||
|
|
463416c3a2 | ||
|
|
fbd2cec1da | ||
|
|
5e485e9b6e | ||
| 7f1c0be2b3 | |||
|
|
89fe2c9805 | ||
|
|
64d5063041 | ||
|
|
47c64eaf8b | ||
|
|
ddb6f6bd2f | ||
|
|
08a9c52088 | ||
|
|
0e30354ba5 | ||
|
|
9c2432c818 | ||
|
|
a63793daf0 | ||
|
|
eba78dbbb9 | ||
|
|
97e566f844 | ||
|
|
eb1ff8fee1 | ||
|
|
d6dd47e652 | ||
|
|
3a8d669970 | ||
|
|
94d4e54d5e | ||
|
|
e2cd2570e3 | ||
|
|
10eb67e8c6 | ||
|
|
77a36c43d4 | ||
|
|
0cd817a61d | ||
|
|
8a594b667d | ||
| a80dac20ed | |||
| 1b9ca062f3 | |||
|
|
4b8dee8583 | ||
|
|
ba67275cd8 | ||
|
|
ad26e6ce6f | ||
|
|
bf19d4a8fd | ||
| 0d62440ee1 | |||
|
|
a80109cdcf | ||
|
|
c955a8f367 | ||
|
|
9c478e80fd | ||
|
|
69f6641c27 | ||
|
|
61b9e6ae82 | ||
|
|
7e5814b0e5 | ||
|
|
f9714c3f06 | ||
|
|
e365e5c6e0 | ||
|
|
3a2c6e43d4 | ||
|
|
2eccf4de50 | ||
|
|
7806b35723 | ||
|
|
8460a5d833 | ||
|
|
61bc719e28 | ||
|
|
313d882df4 | ||
|
|
4863425ca5 | ||
|
|
8bf34ea346 | ||
|
|
5906acc48a | ||
|
|
bca8032b79 | ||
|
|
7911ba1745 | ||
|
|
2481c79e3f | ||
|
|
4de85d43ed | ||
|
|
d54dee4f24 | ||
|
|
b0a86b9720 | ||
|
|
9557c1b1af | ||
|
|
7491bbecd9 | ||
|
|
c4b8692f3a | ||
|
|
fb72ab625f | ||
|
|
abcb58ba05 | ||
|
|
bebb6a71e6 | ||
|
|
da9bd7750c | ||
|
|
e27b73c018 | ||
|
|
11a7be7154 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -24,3 +24,11 @@ CLAUDE.md
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
|
||||||
|
/Temp.md
|
||||||
|
/new.md
|
||||||
|
|
||||||
|
/mobile.mov
|
||||||
|
.gstack/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
BIN
0729~30_테스트.xlsx
Normal file
BIN
0729~30_테스트.xlsx
Normal file
Binary file not shown.
BIN
0803_가격협상 우선 적용 및 논의 정리.xlsx
Normal file
BIN
0803_가격협상 우선 적용 및 논의 정리.xlsx
Normal file
Binary file not shown.
BIN
260727_AIO2O 테스트 및 요청사항.xlsx
Normal file
BIN
260727_AIO2O 테스트 및 요청사항.xlsx
Normal file
Binary file not shown.
190
README.md
190
README.md
@ -1,63 +1,198 @@
|
|||||||
# O2O Negosium
|
# O2O Negosium
|
||||||
|
|
||||||
동일 구조의 두 서비스(**negosium**, **negodata**)가 **하나의 PostgreSQL 인스턴스**를 공유한다.
|
AI 협상 솔루션. 여러 백엔드·프론트·배치가 **하나의 PostgreSQL 인스턴스**를 공유하고,
|
||||||
|
전부 `docker compose` 하나로 뜬다. (인터넷 최저가 검색 LPS 만 별도 DB `lps_db` 사용.)
|
||||||
|
|
||||||
|
## 서비스 소개
|
||||||
|
|
||||||
|
구매기업(바이어)이 협력사(공급사)와 벌이는 **가격 협상을 AI 봇이 대신 수행**하는 B2B 협상 자동화 솔루션이다.
|
||||||
|
바이어가 상품·목표가·기간만 정해 견적을 열면, 각 협력사와의 1:1 협상은 강화학습 기반 에이전트가
|
||||||
|
**협상 카드로 밀당**하며 진행하고, 마감 시각에 최저 투찰가를 기준으로 자동 **낙찰/개찰**을 판정한다.
|
||||||
|
|
||||||
|
크게 세 축 + 부속으로 나뉜다.
|
||||||
|
|
||||||
|
| 축 | 구성요소 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| **바이어 측** | negodata (backend + front) | 어드민. 상품·협력사 관리, 견적 생성, 마감·낙찰 관리 |
|
||||||
|
| **공급사 측** | negosium (backend + frontend) | 협력사 포털. 초청받은 협상 챗에 참여해 가격 제시 |
|
||||||
|
| **협상 엔진** | agent | 실제 AI 협상 봇. 앵커링가·협상 카드로 자동 협상 (강화학습) |
|
||||||
|
| 부속 | lps · anchoring · landing | 인터넷 최저가(목표가 재료) · 앵커값 자동 조정 배치 · 솔루션 소개 랜딩 |
|
||||||
|
|
||||||
## 구성
|
## 구성
|
||||||
|
|
||||||
```
|
```
|
||||||
o2o-negosium/
|
o2o-negosium/
|
||||||
├── docker-compose.yml # 두 backend (DB 는 외부)
|
├── docker-compose.yml # 전체 서비스 (DB 는 compose 밖, config 로 외부 연결)
|
||||||
├── postgres-init/ # DB·테이블 셋업 SQL (대상 DB 에 1회 적용)
|
├── postgres-init/ # DB·스키마·시드 SQL (대상 DB 에 1회 적용)
|
||||||
├── backend/ # negosium 백엔드 (포트 9300)
|
│
|
||||||
├── negodata/backend/ # negodata 백엔드 (포트 9400)
|
├── backend/ # negosium 백엔드 — 공급사/협상 API (:9300)
|
||||||
├── agent/ front/ # (예정)
|
├── frontend/ # negosium 공급사 프론트 (:3300, 프로덕션 빌드 정적 서빙)
|
||||||
└── negodata/front/ # (예정)
|
├── agent/ # 협상 에이전트 — RL(learning 스키마) (:9500)
|
||||||
|
│
|
||||||
|
├── negodata/backend/ # negodata 백엔드 — 바이어/어드민 API (:9400)
|
||||||
|
├── negodata/front/ # negodata 어드민 프론트 (Vite, :3000)
|
||||||
|
│
|
||||||
|
├── landing/ # 솔루션 랜딩페이지 (react-router SSG, :3100)
|
||||||
|
│
|
||||||
|
├── lps/ # 인터넷 최저가 검색: lps-api(:9600) + lps-worker(크롤)
|
||||||
|
├── lps-admin/ # LPS 관리자 UI (nginx → lps-api 프록시, :3400)
|
||||||
|
│
|
||||||
|
└── schedules/anchoring/ # 앵커링 값 자동 조정 배치 (포트 없음, 상주 스케줄러 + Redis)
|
||||||
```
|
```
|
||||||
|
|
||||||
두 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다.
|
negosium·negodata·agent 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다.
|
||||||
아키텍처/패턴 상세는 각 서버 README 참고: [backend](backend/README.md) · [negodata/backend](negodata/backend/README.md)
|
아키텍처/패턴 상세는 각 서브 README 참고:
|
||||||
|
- 백엔드: [backend](backend/README.md) · [negodata/backend](negodata/backend/README.md) · [agent](agent/README.md) · [lps](lps/README.md)
|
||||||
|
- 프론트: [frontend](frontend/README.md) · [negodata/front](negodata/front/README.md)
|
||||||
|
- 배치: [schedules/anchoring](schedules/anchoring/README.md) · 관리자 UI: [lps-admin](lps-admin/README.md)
|
||||||
|
|
||||||
|
### 서비스 / 포트
|
||||||
|
|
||||||
|
| 서비스 | 주소 | 역할 | DB |
|
||||||
|
|---|---|---|---|
|
||||||
|
| negosium-backend | http://localhost:9300/docs | 공급사·협상 API | negosium_db |
|
||||||
|
| negosium-front | http://localhost:3300 | 공급사 프론트 | — |
|
||||||
|
| agent | http://localhost:9500/docs | 협상 에이전트(RL) | negosium_db (learning) |
|
||||||
|
| negodata-backend | http://localhost:9400/docs | 바이어·어드민 API | negosium_db |
|
||||||
|
| negodata-front | http://localhost:3000 | 어드민 프론트 | — |
|
||||||
|
| landing | http://localhost:3100 | 솔루션 랜딩 | — |
|
||||||
|
| lps-api | http://localhost:9600/docs | 최저가 검색 접수/조회 | lps_db |
|
||||||
|
| lps-worker | 포트 없음 | 크롤 워커(헤드풀 Chromium) | lps_db |
|
||||||
|
| lps-admin | http://localhost:3400 | LPS 관리자 UI | — |
|
||||||
|
| anchoring | 포트 없음 | 앵커링 조정 배치(격주 토 00:00 KST) | negosium_db (anchoring) |
|
||||||
|
| anchoring-redis | 127.0.0.1:6380 | anchoring 조회 캐시 | — |
|
||||||
|
| autoheal | — | unhealthy 컨테이너 자동 재시작 | — |
|
||||||
|
|
||||||
### DB 는 compose 밖 (config 로 연결)
|
### DB 는 compose 밖 (config 로 연결)
|
||||||
|
|
||||||
DB 는 docker-compose 에서 관리하지 않는다. 각 backend 는 `config.<APP_ENV>.toml` 의 접속 정보대로
|
DB 는 docker-compose 에서 관리하지 않는다. 각 backend 는 `config.<APP_ENV>.toml` 의 접속 정보대로
|
||||||
**외부 PostgreSQL**(호스트 로컬 postgres, 또는 따로 떠 있는 docker postgres)에 연결한다.
|
**외부 PostgreSQL**(호스트 로컬 postgres, 또는 따로 떠 있는 docker postgres)에 연결한다.
|
||||||
한 PostgreSQL 안에 서비스별 database 를 둔다.
|
|
||||||
|
한 PostgreSQL 인스턴스 안에 **단일 `negosium_db`** 를 두고 도메인별 **schema** 로 묶는다.
|
||||||
|
LPS 만 별도 database(`lps_db`) 를 쓴다.
|
||||||
|
|
||||||
```
|
```
|
||||||
PostgreSQL (외부, 5432)
|
PostgreSQL (외부, 5432)
|
||||||
├── negosium_db ← negosium-backend
|
├── negosium_db ← negosium-backend · negodata-backend · agent · anchoring 공유
|
||||||
└── negodata_db ← negodata-backend
|
│ ├── company / supplier / partner : 회사·유저·협력사·상품
|
||||||
|
│ ├── card / quotation / negotiation: 협상 카드·견적·협상 세션
|
||||||
|
│ ├── learning : RL 자산 (agent 소유)
|
||||||
|
│ └── anchoring : 앵커링 조정 (schedules/anchoring 소유)
|
||||||
|
└── lps_db ← lps-api · lps-worker
|
||||||
```
|
```
|
||||||
- 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (`config.docker.toml`)
|
- 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (compose 가 `DB_HOST` 로 override)
|
||||||
- 로컬 실행/테스트(local·test env): `127.0.0.1:5432` (`config.local/test.toml`)
|
- 로컬 실행/테스트(local·test env): `127.0.0.1:5432` (`config.local/test.toml`)
|
||||||
- 계정/database 명은 config 에 맞춘다 (기본 `postgres` / `password`).
|
- 계정/database 명은 config 에 맞춘다 (기본 `postgres` / `password`).
|
||||||
|
|
||||||
| 서비스 | 서버 | docs | database |
|
## 핵심 플로우
|
||||||
|---|---|---|---|
|
|
||||||
| negosium-backend | http://localhost:9300 | /docs | negosium_db |
|
### 1. 견적 라이프사이클 (전체 개요)
|
||||||
| negodata-backend | http://localhost:9400 | /docs | negodata_db |
|
|
||||||
|
바이어가 견적을 열고 → 협력사가 협상에 참여 → 마감 시각에 판정되는 큰 흐름.
|
||||||
|
견적 유형은 두 축(재/신규 × 협상 1:1 / 견적 1:N)으로 4종.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["바이어: 상품·협력사·기간 선택<br/>견적 유형 4종 + 낙찰 기준(mid/over_action) 설정"] --> B["목표가·앵커링가 산정<br/>(MD제시가 → 인터넷최저가/매입가/판매가)"]
|
||||||
|
B --> C["협력사 초청 (이메일)"]
|
||||||
|
C --> D{"견적 유형"}
|
||||||
|
D -->|"협상 1:1 (재협상·신규협상)"| E["AI 봇 밀당 협상<br/>(협상 카드 사용)"]
|
||||||
|
D -->|"견적 1:N (재견적·신규견적)"| F["정형 흐름<br/>(배송형태·추가할인 확인)"]
|
||||||
|
E --> G["세션별 투찰가 확정<br/>(협상완료) 또는 실패"]
|
||||||
|
F --> G
|
||||||
|
G --> H{"마감 트리거<br/>①마감시각 ②전세션종결 ③수동"}
|
||||||
|
H --> I["마감 판정<br/>(최저 투찰가 기준)"]
|
||||||
|
I --> J["낙찰 (승자 1)"]
|
||||||
|
I --> K["개찰 (낙찰자 미정)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 1:1 협상 봇 판정 (agent)
|
||||||
|
|
||||||
|
협력사가 가격을 제시할 때마다 봇이 **앵커링가** 기준으로 판정한다.
|
||||||
|
재제안은 카드를 한 장씩 쓰며 **최대 3번**, 카드 소진·3번 초과에도 앵커 밑으로 못 내리면 실패(투찰 없음).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
P["협력사 가격 제시"] --> Q{"제시가 vs 앵커링가"}
|
||||||
|
Q -->|"≤ 앵커링가"| R["협상완료 — 투찰 확정"]
|
||||||
|
Q -->|"앵커 ~ 앵커×1.02"| S["와일드카드: 1% 인하 요청<br/>(세션당 1회)"]
|
||||||
|
Q -->|"앵커×1.02 초과"| T["협상 카드로 재제안"]
|
||||||
|
S --> U{"재제안 횟수 ≤ 3?<br/>카드 남음?"}
|
||||||
|
T --> U
|
||||||
|
U -->|"예"| P
|
||||||
|
U -->|"아니오 (소진·3번 초과)"| V["협상 실패 — 낙찰 후보 아님"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 마감 판정
|
||||||
|
|
||||||
|
마감 시 **협상완료 세션의 최저 투찰가**를 본다. 공통 전제: 완료 세션이 없거나(전원 미응찰·협상거부)
|
||||||
|
동가 최저가 2곳 이상이면 유형과 무관하게 **개찰**. 그 외 단독 최저가일 때만 낙찰 후보가 되며,
|
||||||
|
이후 판정이 유형별로 갈린다.
|
||||||
|
|
||||||
|
#### 3-1. 견적 1:N — 단독 최저면 무조건 낙찰
|
||||||
|
|
||||||
|
가격 구간을 보지 않는다. 생성 시 `mid/over_action`이 낙찰(AWARD)로 강제되기 때문.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
M1["마감: 협상완료 세션 최저 투찰가"] --> N1{"완료 세션 있나?"}
|
||||||
|
N1 -->|"없음 (전원 미응찰·협상거부)"| O1["개찰"]
|
||||||
|
N1 -->|"동가 최저 2곳+"| O1
|
||||||
|
N1 -->|"단독 최저"| X1["낙찰 (가격 구간 무관, 무조건)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-2. 협상 1:1 — 가격 구간별, 생성 때 정한 값 적용
|
||||||
|
|
||||||
|
앵커링가 이하는 무조건 낙찰. 그 위 구간은 **견적 생성 때 미리 정해둔 값**(`mid_action`/`over_action`,
|
||||||
|
1=낙찰·2=개찰)을 마감 시 그대로 적용한다. 두 필드는 적용 구간만 다를 뿐 동작은 동일.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
M2["마감: 협상완료 세션 최저 투찰가"] --> N2{"완료 세션 있나?"}
|
||||||
|
N2 -->|"없음 (전원 미응찰·협상거부)"| O2["개찰"]
|
||||||
|
N2 -->|"동가 최저 2곳+"| O2
|
||||||
|
N2 -->|"단독 최저"| W2{"투찰가 위치"}
|
||||||
|
W2 -->|"≤ 앵커링가"| X2["낙찰"]
|
||||||
|
W2 -->|"앵커 ~ 목표가"| Y2["생성 시 정한 mid_action 적용<br/>(1=낙찰 / 2=개찰)"]
|
||||||
|
W2 -->|"목표가 초과"| Z2["생성 시 정한 over_action 적용<br/>(1=낙찰 / 2=개찰)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 개찰 = 낙찰자 미정 마감(결렬 아님). 개찰 후 수동 처리로 **직접 낙찰 확정**(`/v1/quotation/award`)
|
||||||
|
> 또는 **재견적 재생성**(`/v1/quotation/regenerate`)이 있다.
|
||||||
|
> 비즈니스 로직 정본은 [negodata/docs/business-logic.md](negodata/docs/business-logic.md).
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 + 시드 적용
|
# 1) DB 준비 (최초 1회) — 사용할 PostgreSQL 에 스키마 + 시드 적용
|
||||||
psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/00-init.sql # 스키마 전체 (negosium_db + 도메인·learning·anchoring schema)
|
# 스키마 DDL (구 01~05 통합, 전부 IF NOT EXISTS 라 재실행 안전)
|
||||||
psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/temp-data.sql # 임시 데이터 시드 (admin / admin1234)
|
psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f postgres-init/init-data/init.sql
|
||||||
|
# 로컬/개발 시드 (admin / admin1234, 회사·유저·협상 카드)
|
||||||
|
psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f postgres-init/init-data/init-data.sql
|
||||||
|
|
||||||
# 2) 백엔드 기동
|
# (DBeaver 로 처음부터 새로 깔 때는 postgres-init/dbeaver/ 의 0~5 순서 스크립트를 쓴다:
|
||||||
docker compose up -d # 두 backend (DB 는 config 대로 외부 연결)
|
# 0 drop&create → 1 스키마 → 2 시드 → 3 lps_db → 4 카드 리셋 → 5 o2o OWNER 유저)
|
||||||
docker compose logs -f
|
|
||||||
|
# 2) 전체 기동
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f # 컨테이너별 로그는 ./logs.sh 메뉴로도 확인
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 스키마 변경 보정은 `postgres-init/alters/` 의 날짜별 SQL 을 대상 DB 에 수동 적용한다
|
||||||
|
> (postgres-init 은 DB 최초 생성 때만 자동 실행되므로, 기존 DB 엔 alter 를 직접 돌려야 새 컬럼이 반영된다).
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# config.test.toml 의 PostgreSQL(기본 127.0.0.1:5432) 이 떠 있어야 한다
|
# config.test.toml 의 PostgreSQL(기본 127.0.0.1:5432) 이 떠 있어야 한다
|
||||||
cd backend # 또는 negodata/backend
|
cd backend # 또는 negodata/backend, agent, lps ...
|
||||||
pip install pytest pytest-asyncio httpx
|
pip install pytest pytest-asyncio httpx
|
||||||
python -m pytest
|
python -m pytest
|
||||||
```
|
```
|
||||||
- httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e (각 5개).
|
- httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e.
|
||||||
- `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`).
|
- `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`).
|
||||||
|
- ⚠️ 테스트는 `APP_ENV=test` 로 격리한다(테스트 DB). dev DB(`negosium_db`)에 대고 돌리면 데이터가 날아간다.
|
||||||
|
|
||||||
## 성능 / 벤치마크
|
## 성능 / 벤치마크
|
||||||
|
|
||||||
@ -99,4 +234,9 @@ python -m locust -f loadtest/locustfile.py --host http://localhost:9300 --headle
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn · Docker Compose
|
- **백엔드**: FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn
|
||||||
|
- **프론트**: React · react-router v7 · Vite · TanStack Query (negosium-front 는 프로덕션 빌드 정적 서빙)
|
||||||
|
- **에이전트**: 강화학습(Q-learning, learning 스키마) · OpenAI
|
||||||
|
- **LPS**: 헤드풀 Chromium + Patchright(스텔스 Playwright 포크, 크롤) · autoheal
|
||||||
|
- **배치**: Redis(anchoring 캐시) · 상주 스케줄러
|
||||||
|
- **공통**: Docker Compose
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Negosium Agent
|
# Negosium Agent
|
||||||
|
|
||||||
범용 멀티테넌트 협상 솔루션 PoC. 여러 회사(ktcommerce·imarketkorea 등)가 **각자 데이터로 분기 학습**하는
|
범용 멀티테넌트 협상 솔루션 PoC. 여러 회사(imarketkorea 등)가 **각자 데이터로 분기 학습**하는
|
||||||
협상 카드 선택 에이전트. Q-Learning(UCB) 기반 `Chat_server`(단일 테넌트)를 참고해 신규 구축한다.
|
협상 카드 선택 에이전트. Q-Learning(UCB) 기반 `Chat_server`(단일 테넌트)를 참고해 신규 구축한다.
|
||||||
|
|
||||||
PoC 목표 두 가지:
|
PoC 목표 두 가지:
|
||||||
@ -99,10 +99,10 @@ APP_ENV=test python -m pytest # 테스트 (config.local.toml 사
|
|||||||
|
|
||||||
**1) 의사결정 루프 데모** — 테넌트별 config 주입·상태분류·보상·DB 격리를 눈으로 확인:
|
**1) 의사결정 루프 데모** — 테넌트별 config 주입·상태분류·보상·DB 격리를 눈으로 확인:
|
||||||
```bash
|
```bash
|
||||||
APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 3턴 시나리오
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 기본 3턴 시나리오
|
||||||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 다른 테넌트(다른 카드셋·임계값)
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 다른 테넌트(다른 카드셋·임계값)
|
||||||
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive # 직접 입력
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --interactive # 직접 입력
|
||||||
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --no-db # DB 없이
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 없이
|
||||||
```
|
```
|
||||||
> ⚠️ 카드선택은 임시 placeholder 정책(실제 UCB Q-Table 은 H1/P5). 학습은 아직 일어나지 않는다.
|
> ⚠️ 카드선택은 임시 placeholder 정책(실제 UCB Q-Table 은 H1/P5). 학습은 아직 일어나지 않는다.
|
||||||
|
|
||||||
@ -113,13 +113,13 @@ curl localhost:9500/healthz # 200
|
|||||||
curl localhost:9500/v1/health # {"status":"ok",...}
|
curl localhost:9500/v1/health # {"status":"ok",...}
|
||||||
curl localhost:9500/v1/foo # 400 TENANT_HEADER_MISSING (헤더 없음)
|
curl localhost:9500/v1/foo # 400 TENANT_HEADER_MISSING (헤더 없음)
|
||||||
curl localhost:9500/v1/foo -H 'X-Tenant-ID: nonexistent' # 404 TENANT_NOT_REGISTERED
|
curl localhost:9500/v1/foo -H 'X-Tenant-ID: nonexistent' # 404 TENANT_NOT_REGISTERED
|
||||||
curl localhost:9500/v1/foo -H 'X-Tenant-ID: ktcommerce' # 통과(라우트 미존재라 404 Not Found)
|
curl localhost:9500/v1/foo -H 'X-Tenant-ID: imarketkorea' # 통과(라우트 미존재라 404 Not Found)
|
||||||
```
|
```
|
||||||
|
|
||||||
**2-1) 협상 한 라운드 (HTTP 프리뷰)** — `POST /v1/negotiation/step` (Swagger: http://localhost:9500/docs):
|
**2-1) 협상 한 라운드 (HTTP 프리뷰)** — `POST /v1/negotiation/step` (Swagger: http://localhost:9500/docs):
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST localhost:9500/v1/negotiation/step \
|
curl -s -X POST localhost:9500/v1/negotiation/step \
|
||||||
-H 'X-Tenant-ID: ktcommerce' -H 'Content-Type: application/json' \
|
-H 'X-Tenant-ID: imarketkorea' -H 'Content-Type: application/json' \
|
||||||
-d '{"revenue_amount":20000000,"distribution_code":"A","partner_count":1,
|
-d '{"revenue_amount":20000000,"distribution_code":"A","partner_count":1,
|
||||||
"acceptance_ratio":0.11,"input_price":990,"anchor_price":800,"target_price":1000,
|
"acceptance_ratio":0.11,"input_price":990,"anchor_price":800,"target_price":1000,
|
||||||
"round_number":3,"outcome":"success"}'
|
"round_number":3,"outcome":"success"}'
|
||||||
@ -156,7 +156,7 @@ APP_ENV=local python -m tools.show_logs # learning.experience_logs 를 co
|
|||||||
**가격협상 턴 UCB 카드선택·학습 + 종료보상 역전파**. 브라우저 채팅 UI. (`tests/test_p7_chat.py` 5/5)
|
**가격협상 턴 UCB 카드선택·학습 + 종료보상 역전파**. 브라우저 채팅 UI. (`tests/test_p7_chat.py` 5/5)
|
||||||
- ✅ **H5 학습검증 하네스 (PoC 본체)**: 카드별 효과가 다른 시뮬 구매자(`eval_harness/`) → 정책 비교.
|
- ✅ **H5 학습검증 하네스 (PoC 본체)**: 카드별 효과가 다른 시뮬 구매자(`eval_harness/`) → 정책 비교.
|
||||||
**학습형(qtable_ucb)이 random/static 대비 평균보상 우위(95%CI 분리)·좋은카드 적중 0.9 vs 0.32** →
|
**학습형(qtable_ucb)이 random/static 대비 평균보상 우위(95%CI 분리)·좋은카드 적중 0.9 vs 0.32** →
|
||||||
"학습하면 성과가 오른다" 정량 입증. `python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce`. (`tests/test_h5_*` 5/5)
|
"학습하면 성과가 오른다" 정량 입증. `python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea`. (`tests/test_h5_*` 5/5)
|
||||||
- ✅ **P7 14개 API**: chat / q-table(versions·switch·current) / experience-logs / reset-learning ·
|
- ✅ **P7 14개 API**: chat / q-table(versions·switch·current) / experience-logs / reset-learning ·
|
||||||
reset-all(타테넌트 무영향) / invalidate-session / **train**(오프라인 Q-learning) / verification-report /
|
reset-all(타테넌트 무영향) / invalidate-session / **train**(오프라인 Q-learning) / verification-report /
|
||||||
card-update · card-search. 전부 X-Tenant-ID 격리. (`tests/test_p7_apis.py` 5/5)
|
card-update · card-search. 전부 X-Tenant-ID 격리. (`tests/test_p7_apis.py` 5/5)
|
||||||
@ -177,7 +177,7 @@ APP_ENV=local python -m tools.show_logs # learning.experience_logs 를 co
|
|||||||
|
|
||||||
### PoC 본체 결과 (H5, 위 경제모델 기준 / target=10000·anchor=8000 시나리오)
|
### PoC 본체 결과 (H5, 위 경제모델 기준 / target=10000·anchor=8000 시나리오)
|
||||||
```
|
```
|
||||||
python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
|
||||||
policy success settled/tgt turns mean_rwd ±95%CI good_hit
|
policy success settled/tgt turns mean_rwd ±95%CI good_hit
|
||||||
random 0.988 0.904 2.02 1.1176 0.0206 0.350
|
random 0.988 0.904 2.02 1.1176 0.0206 0.350
|
||||||
static 1.000 0.923 2.56 0.9995 0.0020 0.000
|
static 1.000 0.923 2.56 0.9995 0.0020 0.000
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@ -40,3 +40,20 @@ def _apply_db_env_override(cfg: MainDBConfig):
|
|||||||
|
|
||||||
|
|
||||||
_apply_db_env_override(main_db_config)
|
_apply_db_env_override(main_db_config)
|
||||||
|
|
||||||
|
|
||||||
|
# LLM 키/설정 env override (DB 와 동일 패턴). 로컬은 config.local.toml [OpenAIConfig] 에 기재,
|
||||||
|
# Docker/CI/운영은 toml 없이 env 로 주입한다(docker-compose 가 OPENAI_API_KEY passthrough).
|
||||||
|
# env 미설정 시 no-op → toml 값 그대로.
|
||||||
|
def _apply_llm_env_override(cfg: OpenAIConfig):
|
||||||
|
if os.environ.get("OPENAI_API_KEY"):
|
||||||
|
cfg.api_key = os.environ["OPENAI_API_KEY"]
|
||||||
|
if os.environ.get("OPENAI_MODEL"):
|
||||||
|
cfg.model = os.environ["OPENAI_MODEL"]
|
||||||
|
if os.environ.get("OPENAI_BASE_URL"):
|
||||||
|
cfg.base_url = os.environ["OPENAI_BASE_URL"]
|
||||||
|
if os.environ.get("OPENAI_PROVIDER"):
|
||||||
|
cfg.provider = os.environ["OPENAI_PROVIDER"]
|
||||||
|
|
||||||
|
|
||||||
|
_apply_llm_env_override(openai_config)
|
||||||
|
|||||||
@ -4,10 +4,23 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("APP_ENV", "local")
|
os.environ.setdefault("APP_ENV", "local")
|
||||||
|
|
||||||
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_real_llm(monkeypatch):
|
||||||
|
"""실 LLM 호출 차단 — imarketkorea 가 llm.enabled=true 라 로컬에 실키가 있으면
|
||||||
|
챗 플로우가 자연화/NLU(과금·비결정 응답)를 시도한다. LLM 검증 테스트는
|
||||||
|
available 을 테스트 안에서 직접 덮어써 이 가드를 우회한다."""
|
||||||
|
from negotiation.chat.service.input_interpreter import InputInterpreter
|
||||||
|
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer
|
||||||
|
|
||||||
|
monkeypatch.setattr(ScriptNaturalizer, "available", staticmethod(lambda: False))
|
||||||
|
monkeypatch.setattr(InputInterpreter, "available", staticmethod(lambda: False))
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||||
async def _dispose_app_engines():
|
async def _dispose_app_engines():
|
||||||
"""테스트 세션 종료 시 앱 싱글톤 엔진 정리 ('Event loop is closed' 경고 제거)."""
|
"""테스트 세션 종료 시 앱 싱글톤 엔진 정리 ('Event loop is closed' 경고 제거)."""
|
||||||
|
|||||||
@ -1,203 +0,0 @@
|
|||||||
# 완전 자율 협상 에이전트 — 처음 대비 변경 정리
|
|
||||||
|
|
||||||
> 기준: 협상카드 + 룰 엔진 시절(처음) → 완전 자율 에이전트 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%" 표시 버그 후보
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# 알고리즘 비교 실험 기본 설정 (H5). E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
# 알고리즘 비교 실험 기본 설정 (H5). E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
|
||||||
episodes: 600 # 정책당 협상 에피소드 수 (action 11장 탐색 수렴 위해 상향)
|
episodes: 600 # 정책당 협상 에피소드 수 (action 11장 탐색 수렴 위해 상향)
|
||||||
seed: 42 # 재현용 (구매자 randomness 페어드)
|
seed: 42 # 재현용 (구매자 randomness 페어드)
|
||||||
max_turns: 5 # 협상 라운드 상한
|
max_turns: 5 # 협상 라운드 상한
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"""eval_harness 러너 — 정책 비교 + 학습곡선 (H5, PoC 본체).
|
"""eval_harness 러너 — 정책 비교 + 학습곡선 (H5, PoC 본체).
|
||||||
|
|
||||||
E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
|
||||||
|
|
||||||
판정: 학습형(qtable_ucb)이 random/static 대비 평균보상·성공률 우상향이면 "학습 루프 유효".
|
판정: 학습형(qtable_ucb)이 random/static 대비 평균보상·성공률 우상향이면 "학습 루프 유효".
|
||||||
구매자는 카드별 효과가 다른 시뮬(HeuristicBuyer) — 학습 정책만 좋은 카드를 알아내 성과가 오른다.
|
구매자는 카드별 효과가 다른 시뮬(HeuristicBuyer) — 학습 정책만 좋은 카드를 알아내 성과가 오른다.
|
||||||
@ -143,7 +143,7 @@ def _print(report: dict):
|
|||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="협상 정책 비교 하네스 (H5)")
|
ap = argparse.ArgumentParser(description="협상 정책 비교 하네스 (H5)")
|
||||||
ap.add_argument("--config", default="configs/exp_default.yaml")
|
ap.add_argument("--config", default="configs/exp_default.yaml")
|
||||||
ap.add_argument("--tenant", default="ktcommerce")
|
ap.add_argument("--tenant", default="imarketkorea")
|
||||||
ap.add_argument("--save", action="store_true", help="reports/ 에 JSON 저장")
|
ap.add_argument("--save", action="store_true", help="reports/ 에 JSON 저장")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,13 @@ from negotiation.cards.ports.card_script_port import ICardScriptRepository
|
|||||||
|
|
||||||
_NEGO_CARDS = table(
|
_NEGO_CARDS = table(
|
||||||
"nego_cards",
|
"nego_cards",
|
||||||
|
column("number"), column("script"), column("tone"), column("strategy_type"),
|
||||||
|
column("created_at"), column("deleted"),
|
||||||
|
schema="card",
|
||||||
|
)
|
||||||
|
|
||||||
|
_WILD_CARDS = table(
|
||||||
|
"wild_cards",
|
||||||
column("number"), column("script"), column("created_at"), column("deleted"),
|
column("number"), column("script"), column("created_at"), column("deleted"),
|
||||||
schema="card",
|
schema="card",
|
||||||
)
|
)
|
||||||
@ -27,17 +34,40 @@ _NEGO_CARDS = table(
|
|||||||
|
|
||||||
class CardScriptDbRepository(ICardScriptRepository):
|
class CardScriptDbRepository(ICardScriptRepository):
|
||||||
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
||||||
|
err, card = await self.get_card_by_number(cdb, number)
|
||||||
|
return err, (card[0] if card else None)
|
||||||
|
|
||||||
|
async def get_wild_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
||||||
|
"""card.wild_cards 멘트 조회 (WC-01~05 선택형 와일드카드/종결 전술 발동용)."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(_NEGO_CARDS.c.script)
|
select(_WILD_CARDS.c.script)
|
||||||
|
.where(_WILD_CARDS.c.number == number, _WILD_CARDS.c.deleted == False) # noqa: E712
|
||||||
|
.order_by(desc(_WILD_CARDS.c.created_at))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_wild_card_script failed.", raise_error=False)
|
||||||
|
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||||
|
return err_type, None
|
||||||
|
return ErrorType.SUCCESS, str(rows[0]) if not isinstance(rows[0], tuple) else str(rows[0][0])
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[tuple]]:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(_NEGO_CARDS.c.script, _NEGO_CARDS.c.tone, _NEGO_CARDS.c.strategy_type)
|
||||||
.where(_NEGO_CARDS.c.number == number, _NEGO_CARDS.c.deleted == False) # noqa: E712
|
.where(_NEGO_CARDS.c.number == number, _NEGO_CARDS.c.deleted == False) # noqa: E712
|
||||||
.order_by(desc(_NEGO_CARDS.c.created_at))
|
.order_by(desc(_NEGO_CARDS.c.created_at))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_card_script failed.", raise_error=False)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_card_script failed.", raise_error=False)
|
||||||
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
if err_type != ErrorType.SUCCESS or not rows or not rows[0] or not rows[0][0]:
|
||||||
return err_type, None
|
return err_type, None
|
||||||
return ErrorType.SUCCESS, str(rows[0])
|
script, tone, strategy = rows[0]
|
||||||
|
return ErrorType.SUCCESS, (str(script), int(tone) if tone is not None else None,
|
||||||
|
int(strategy) if strategy is not None else None)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|||||||
240
agent/negotiation/cards/domain/tactics.py
Normal file
240
agent/negotiation/cards/domain/tactics.py
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
|
||||||
|
|
||||||
|
카드 멘트가 제시하는 가격({target_price}·{middle_price} 등)을 파싱해 시스템 상태로 실행한다:
|
||||||
|
카드가 제안가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면 그 가격으로 타결된다.
|
||||||
|
|
||||||
|
세 계층으로 나뉜다.
|
||||||
|
1) 스크립트 파싱 — 이 카드가 부를 금액이 무엇인지 (parse_offer_variable)
|
||||||
|
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
||||||
|
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
||||||
|
|
||||||
|
유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의
|
||||||
|
성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
# 제안가 변수 — 우리가 새로 부르는 금액. 값은 (계산식, 재료 설명).
|
||||||
|
# 여기 없는 치환 변수({prev_partner_price}·{internet_lowest_price} 등)는 읽어주기 전용이라
|
||||||
|
# 제안가가 되지 않는다 — 과거값·외부값을 협력사에게 "수락하라"고 내밀 수 없기 때문.
|
||||||
|
OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float]]] = {
|
||||||
|
# (target, anchor, price, prev_customer) -> 제안가 | None(재료 없음)
|
||||||
|
"target_price": lambda target, anchor, price, prev: target,
|
||||||
|
"anchoring_price": lambda target, anchor, price, prev: anchor or None,
|
||||||
|
# negodata 카드 에디터 칩 표기(variables.ts) — DB 시드 표기(anchoring_price)와 같은 값의 별칭.
|
||||||
|
"anchor_price": lambda target, anchor, price, prev: anchor or None,
|
||||||
|
"target_mid_price": lambda target, anchor, price, prev: (anchor + target) / 2 if anchor else None,
|
||||||
|
"middle_price": lambda target, anchor, price, prev: (prev + price) / 2 if prev else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
|
||||||
|
|
||||||
|
# 절충 계열 변수 — 양측 사이/우리 두 값 사이의 중간을 부르는 카드. 목표가 이상이면 미발동한다.
|
||||||
|
_MID_VARIABLES = ("middle_price", "target_mid_price")
|
||||||
|
|
||||||
|
|
||||||
|
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
||||||
|
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
||||||
|
# {internet_lowest_price} 토큰이 원형 노출된다(vars_for 가 미수집이면 키를 안 만드는 것과 짝).
|
||||||
|
# 견적 생성 화면 게이팅(useCardGating)이 1차 방어, 여기가 2차(런타임) 방어다.
|
||||||
|
_CONTEXT_REQUIRED_VARIABLES = {
|
||||||
|
"internet_lowest_price": "internet_lowest_price",
|
||||||
|
"internet_min_price": "internet_lowest_price",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CardSpec:
|
||||||
|
"""카드 1장의 전술. 스크립트 파싱 결과 + tactic JSONB 를 합친 값.
|
||||||
|
|
||||||
|
offer_variable: 이 카드가 제시할 금액의 변수명. None 이면 순수 설득 카드(HOLD).
|
||||||
|
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
|
||||||
|
closing: 종결 국면 전용 — 라운드 상한·카드 소진 시의 마지막 한 방으로만 쓴다.
|
||||||
|
requires: 스크립트가 인용한 세션-의존 변수의 컨텍스트 키 — 값이 없으면 미발동(토큰 노출 방지).
|
||||||
|
"""
|
||||||
|
|
||||||
|
offer_variable: Optional[str] = None
|
||||||
|
min_round: int = 1
|
||||||
|
closing: bool = False
|
||||||
|
requires: tuple = ()
|
||||||
|
|
||||||
|
|
||||||
|
HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스텀)의 폴백 — 기존 동작(설득만) 유지
|
||||||
|
|
||||||
|
|
||||||
|
def settle_ceiling(context: Dict[str, Any]) -> float:
|
||||||
|
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
|
||||||
|
|
||||||
|
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)) — 목표가를 조금
|
||||||
|
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값. 박제가 없으면 목표가로 폴백한다.
|
||||||
|
"""
|
||||||
|
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
||||||
|
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||||||
|
|
||||||
|
변수가 여럿이면 마지막에 등장하는 것이 제안가다 — 카드 문장은 배경을 먼저 깔고 실제 제안을
|
||||||
|
마지막에 하기 때문이다.
|
||||||
|
"""
|
||||||
|
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
|
||||||
|
return found[-1] if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_card_spec(script: Optional[str], tactic: Optional[dict] = None) -> CardSpec:
|
||||||
|
"""스크립트 + tactic JSONB → CardSpec. tactic 이 비어 있으면 전부 기본값."""
|
||||||
|
t = tactic or {}
|
||||||
|
cited = {m.group(1) for m in _TOKEN_RE.finditer(script or "")}
|
||||||
|
return CardSpec(
|
||||||
|
# 파싱이 정본 카드 전부를 맞히므로 offer_variable 은 예외 카드용 override 로만 둔다.
|
||||||
|
offer_variable=t.get("offer_variable") or parse_offer_variable(script),
|
||||||
|
min_round=int(t.get("min_round") or 1),
|
||||||
|
closing=bool(t.get("closing")),
|
||||||
|
requires=tuple(sorted({_CONTEXT_REQUIRED_VARIABLES[v] for v in cited if v in _CONTEXT_REQUIRED_VARIABLES})),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpec:
|
||||||
|
"""세션 컨텍스트에 적재된 카드 스펙(card_specs)에서 꺼낸다. 없으면 HOLD 폴백.
|
||||||
|
|
||||||
|
스펙은 협상 시작 시 1회 적재된다(negotiation_context_loader) — 진행 중인 협상은
|
||||||
|
카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다.
|
||||||
|
"""
|
||||||
|
if not number:
|
||||||
|
return HOLD
|
||||||
|
raw = (context.get("card_specs") or {}).get(str(number))
|
||||||
|
if not raw:
|
||||||
|
return HOLD
|
||||||
|
return CardSpec(
|
||||||
|
offer_variable=raw.get("offer_variable"),
|
||||||
|
min_round=int(raw.get("min_round") or 1),
|
||||||
|
closing=bool(raw.get("closing")),
|
||||||
|
requires=tuple(raw.get("requires") or ()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Offer:
|
||||||
|
"""확정된 제안 한 건 — 금액과 그 금액을 만든 재료를 함께 들고 다닌다.
|
||||||
|
|
||||||
|
멘트 치환이 재료를 다시 계산하지 않게 하기 위한 것 — 재계산하면 그 사이 갱신된
|
||||||
|
prev_customer 를 읽어 문장이 자기모순이 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
price: int # 협력사에게 제시할 금액(수락 시 타결가)
|
||||||
|
variable: str # 이 금액을 만든 멘트 변수
|
||||||
|
prev_customer: int # 계산에 쓴 당사 직전 제안
|
||||||
|
prev_partner: int # 계산에 쓴 협력사 제시가
|
||||||
|
|
||||||
|
|
||||||
|
def record_offer(context: Dict[str, Any], offer: Offer) -> None:
|
||||||
|
"""확정 제안을 세션에 기록한다 — 수락 판정용 금액과 멘트 치환용 재료를 한 자리에서 쓴다.
|
||||||
|
|
||||||
|
두 키를 항상 함께 써야 표시가와 타결가가 갈라지지 않으므로 기록 지점을 여기 하나로 묶는다.
|
||||||
|
"""
|
||||||
|
context["pending_counter_price"] = offer.price
|
||||||
|
context["pending_offer"] = {
|
||||||
|
"price": offer.price, "variable": offer.variable,
|
||||||
|
"prev_customer": offer.prev_customer, "prev_partner": offer.prev_partner,
|
||||||
|
}
|
||||||
|
context["prev_customer_price"] = offer.price # 갑의 최신 포지션 — 다음 라운드 계산·역행 금지 기준
|
||||||
|
|
||||||
|
|
||||||
|
def compute_offer_detail(spec: CardSpec, context: Dict[str, Any]) -> Optional[Offer]:
|
||||||
|
"""카드가 제시할 금액 + 그 계산에 쓴 재료. 쓸 수 없는 상황이면 None."""
|
||||||
|
price = int(float(context.get("input_price") or 0))
|
||||||
|
prev_customer = int(float(context.get("prev_customer_price") or context.get("anchor_price") or 0))
|
||||||
|
value = compute_offer(spec, context)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return Offer(price=value, variable=spec.offer_variable or "", prev_customer=prev_customer, prev_partner=price)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||||
|
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||||||
|
|
||||||
|
변수 공통 유효조건 (전부 만족해야 발동):
|
||||||
|
· 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동
|
||||||
|
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||||||
|
· 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
||||||
|
"""
|
||||||
|
variable = spec.offer_variable
|
||||||
|
if not variable:
|
||||||
|
return None # 설득 카드 — 제시할 금액 없음
|
||||||
|
calc = OFFER_VARIABLES.get(variable)
|
||||||
|
if calc is None:
|
||||||
|
return None # 미등록 변수(오타·구버전 카드)
|
||||||
|
target = float(context.get("target_price") or 0)
|
||||||
|
anchor = float(context.get("anchor_price") or 0)
|
||||||
|
price = float(context.get("input_price") or 0)
|
||||||
|
# 갑의 직전 포지션. 첫 카운터 전에는 앵커가 갑의 포지션이다.
|
||||||
|
prev_customer = float(context.get("prev_customer_price") or anchor or 0)
|
||||||
|
if target <= 0 or price <= 0:
|
||||||
|
return None # 목표가·제시가 없이는 어떤 변수도 판정 불가
|
||||||
|
|
||||||
|
value = calc(target, anchor, price, prev_customer)
|
||||||
|
if not value or value <= 0:
|
||||||
|
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||||
|
if value > settle_ceiling(context):
|
||||||
|
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||||
|
if variable in _MID_VARIABLES and value >= target:
|
||||||
|
# 절충 계열은 목표가 미만일 때만 의미가 있다. 목표가 이상이면 "절반씩 나누자"면서 목표가를
|
||||||
|
# 부르는 꼴이라 미발동 — 목표가 제시는 목표가 카드(최후통첩)가 할 일이다.
|
||||||
|
return None
|
||||||
|
if variable in ("target_price", "anchoring_price", "anchor_price"):
|
||||||
|
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
|
||||||
|
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
|
||||||
|
offer = int(value)
|
||||||
|
else:
|
||||||
|
offer = int(value / 10 + 0.5) * 10 # 파생가(절충·중간) 10원 반올림 — 앵커·목표가 산정과 표기 통일
|
||||||
|
if offer >= price:
|
||||||
|
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
|
||||||
|
if prev_customer and offer < prev_customer:
|
||||||
|
return None # 역행 금지 — 한번 부른 금액 아래로 되돌아가지 않는다(같은 금액 재제시는 허용)
|
||||||
|
return offer
|
||||||
|
|
||||||
|
|
||||||
|
def available(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
|
||||||
|
"""지금 이 카드를 꺼낼 수 있는지 — 금액과 무관한 조건들.
|
||||||
|
|
||||||
|
· 이미 쓴 카드는 다시 안 나간다(전 카드 공통 규칙 — 협상카드/와일드카드 구분 없음)
|
||||||
|
· 종결 전용 카드는 종결 국면에서만, 종결 국면에선 종결 전용 카드만
|
||||||
|
· min_round 미만이면 아직 이르다
|
||||||
|
· 스크립트가 인용한 세션-의존 변수(인터넷 최저가 등)가 결측이면 미발동 — 토큰 원형 노출 방지
|
||||||
|
"""
|
||||||
|
if spec.closing != closing_phase:
|
||||||
|
return False
|
||||||
|
if int(context.get("round") or 0) < spec.min_round:
|
||||||
|
return False
|
||||||
|
return all(context.get(key) for key in spec.requires)
|
||||||
|
|
||||||
|
|
||||||
|
def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
|
||||||
|
"""이 카드를 지금 실제로 플레이할 수 있는지 — available + (금액 카드는) 제안가 유효까지.
|
||||||
|
|
||||||
|
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
||||||
|
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
||||||
|
글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관.
|
||||||
|
"""
|
||||||
|
if not available(spec, context, closing_phase=closing_phase):
|
||||||
|
return False
|
||||||
|
if not spec.offer_variable:
|
||||||
|
return True
|
||||||
|
return compute_offer(spec, context) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_played(context: Dict[str, Any], number: Optional[str]) -> bool:
|
||||||
|
"""이 카드를 이 협상에서 이미 썼는지. 와일드 진입·종결·협상카드가 같은 이력을 본다."""
|
||||||
|
return bool(number) and str(number) in (context.get("played_card_numbers") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def mark_played(context: Dict[str, Any], number: Optional[str]) -> None:
|
||||||
|
"""카드를 실제로 내보낸 시점에 이력에 남긴다(노출되지 않은 후보는 남기지 않는다)."""
|
||||||
|
if not number:
|
||||||
|
return
|
||||||
|
played = list(context.get("played_card_numbers") or [])
|
||||||
|
if str(number) not in played:
|
||||||
|
played.append(str(number))
|
||||||
|
context["played_card_numbers"] = played
|
||||||
@ -21,3 +21,18 @@ class ICardScriptRepository(ABC):
|
|||||||
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
||||||
"""카드코드(card.nego_cards.number)로 멘트(script 평문/마커)를 조회. 없으면 None."""
|
"""카드코드(card.nego_cards.number)로 멘트(script 평문/마커)를 조회. 없으면 None."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
async def get_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[tuple]]:
|
||||||
|
"""멘트 + 메타 (script, tone, strategy_type) 조회 — LLM 표현층의 톤/전략 지시용.
|
||||||
|
|
||||||
|
기본 구현은 script 만 조회하고 메타는 None (파일 소스·구형 더블 호환).
|
||||||
|
"""
|
||||||
|
err, script = await self.get_script_by_number(cdb, number)
|
||||||
|
return err, ((script, None, None) if script else None)
|
||||||
|
|
||||||
|
async def get_wild_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
||||||
|
"""와일드카드(card.wild_cards.number) 멘트 조회 — 선택형 WC/종결 전술 발동용.
|
||||||
|
|
||||||
|
기본 구현은 미보유(None) — DB 어댑터만 실조회한다(더블 호환).
|
||||||
|
"""
|
||||||
|
return ErrorType.SUCCESS, None
|
||||||
|
|||||||
@ -23,23 +23,48 @@ _SESSIONS = table(
|
|||||||
"sessions",
|
"sessions",
|
||||||
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
|
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
|
||||||
column("qt_type"), column("target_price"), column("anchoring_price"),
|
column("qt_type"), column("target_price"), column("anchoring_price"),
|
||||||
|
column("done_ceiling_price"), # 타결 상한가 — 견적 생성 시 박제(목표가×(1+타결상한율))
|
||||||
|
column("qt_setting_id"),
|
||||||
column("deleted"),
|
column("deleted"),
|
||||||
schema="negotiation",
|
schema="negotiation",
|
||||||
)
|
)
|
||||||
_ITEMS = table("items", column("item_id"), column("price"), column("internet_lowest_price"),
|
# 견적 설정 — card_count(협상 내 협상카드 사용 횟수 상한) 조회용.
|
||||||
column("deleted"), schema="partner")
|
_QUOTATION_SETTINGS = table(
|
||||||
_SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner")
|
"quotation_settings",
|
||||||
_QUOTATIONS = table(
|
column("qt_setting_id"), column("card_count"), column("deleted"),
|
||||||
"quotations",
|
|
||||||
column("qt_id"), column("version_id"), column("supplier_type"),
|
|
||||||
column("start_time"), column("end_time"), column("deleted"),
|
|
||||||
schema="quotation",
|
schema="quotation",
|
||||||
)
|
)
|
||||||
# 자율 에이전트 이력 특징용 — agent 소유 learning 스키마 (done 행 = 협상 1건의 최종 결과).
|
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
|
||||||
_EXP_LOGS = table(
|
column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
|
||||||
"experience_logs",
|
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼을 여기서 정한다.
|
||||||
column("session_id"), column("company_id"), column("done"), column("settled_price"),
|
_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
|
||||||
schema="learning",
|
|
||||||
|
# 협상 기준가 후보 컬럼. 어느 컬럼을 고르든 공급사 화면 호칭은 '공급가'로 고정한다 —
|
||||||
|
# 같은 돈을 고객사는 매입가·상품 단가 등으로 부르지만 챗은 공급사가 보는 화면이라
|
||||||
|
# 공급사 관점 용어 하나만 쓴다. 회사 용어 사전(labels)은 관리자 화면 전용.
|
||||||
|
_BASELINE_PRICE = "price"
|
||||||
|
_BASELINE_PURCHASE = "purchase_price"
|
||||||
|
_SUPPLIER_PRICE_LABEL = "공급가"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_baseline(settings: dict) -> str:
|
||||||
|
"""회사 설정 → 협상 기준가로 쓸 items 컬럼명.
|
||||||
|
|
||||||
|
1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field).
|
||||||
|
미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지
|
||||||
|
않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망."""
|
||||||
|
chosen = (settings.get("features") or {}).get("nego_baseline_field")
|
||||||
|
if chosen in (_BASELINE_PRICE, _BASELINE_PURCHASE):
|
||||||
|
return chosen
|
||||||
|
hidden = set(settings.get("hidden_fields") or [])
|
||||||
|
if "price" in hidden and "purchase_price" not in hidden:
|
||||||
|
return _BASELINE_PURCHASE
|
||||||
|
return _BASELINE_PRICE
|
||||||
|
_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"),
|
||||||
|
schema="quotation",
|
||||||
)
|
)
|
||||||
_VERSION_NEGO_CARDS = table(
|
_VERSION_NEGO_CARDS = table(
|
||||||
"version_nego_cards",
|
"version_nego_cards",
|
||||||
@ -53,12 +78,13 @@ _VERSION_WILD_CARDS = table(
|
|||||||
)
|
)
|
||||||
_NEGO_CARDS = table(
|
_NEGO_CARDS = table(
|
||||||
"nego_cards",
|
"nego_cards",
|
||||||
column("nego_card_id"), column("number"), column("deleted"),
|
column("nego_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
||||||
schema="card",
|
schema="card",
|
||||||
)
|
)
|
||||||
_WILD_CARDS = table(
|
_WILD_CARDS = table(
|
||||||
"wild_cards",
|
"wild_cards",
|
||||||
column("wild_card_id"), column("number"), column("deleted"),
|
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
||||||
|
column("available"),
|
||||||
schema="card",
|
schema="card",
|
||||||
)
|
)
|
||||||
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
||||||
@ -72,12 +98,28 @@ _SUPPLIER_ITEMS = table(
|
|||||||
class INegoContextCRUD(ABC):
|
class INegoContextCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
|
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||||
"""세션 행 (qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id). 없으면 None."""
|
"""세션 행 (qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id). 없으면 None."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
|
||||||
"""품목 기준가(items.price). 없으면 0."""
|
"""협상 기준가·그 호칭·회사 용어 사전 (가격, 호칭, labels).
|
||||||
|
|
||||||
|
어느 컬럼을 기준가로 쓰는지는 회사 설정(features.nego_baseline_field)이 정한다.
|
||||||
|
labels 는 companies.settings.labels 원본 — 협상 스크립트의 용어 토큰 치환에 쓴다.
|
||||||
|
값이 없으면 (0, 호칭, {})."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
|
||||||
|
"""협상카드 사용 횟수 상한(quotation_settings.card_count) — 세션의 qt_setting_id 로 조인.
|
||||||
|
설정이 없으면 None(호출부가 상한 미적용). 이 값이 협상 중 실제로 플레이 가능한 협상카드 수를 캡한다."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_item_lowest_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||||
|
"""상품 인터넷 최저가(items.internet_lowest_price — LPS 수집 대표값). 미수집이면 0.
|
||||||
|
카드 스크립트 {internet_lowest_price} 치환용(NGC-008 등 시장가 인용 카드)."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -85,6 +127,16 @@ class INegoContextCRUD(ABC):
|
|||||||
"""협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0."""
|
"""협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_item_name(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Optional[str]]:
|
||||||
|
"""상품명(items.name). 없으면 None — 카드 스크립트 {product_name} 치환용."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[str]]:
|
||||||
|
"""협력사명(suppliers.name). 없으면 None — 카드 스크립트 {partner_name} 치환용."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_supply_type(self, cdb: AsyncSession, supplier_id, item_id) -> Tuple[ErrorType, Optional[int]]:
|
async def get_supply_type(self, cdb: AsyncSession, supplier_id, item_id) -> Tuple[ErrorType, Optional[int]]:
|
||||||
"""이 협력사가 이 상품을 공급하는 방식(supplier_items.supply_type: 0=none/1=유통/2=제조/3=총판).
|
"""이 협력사가 이 상품을 공급하는 방식(supplier_items.supply_type: 0=none/1=유통/2=제조/3=총판).
|
||||||
@ -102,24 +154,9 @@ class INegoContextCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
|
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
|
||||||
"""견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록."""
|
"""견적 version_id 에 연결된 (일반카드 행 목록, 와일드카드 행 목록). 없으면 빈 목록.
|
||||||
pass
|
행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 카드 전술(CardSpec)을 만든다."""
|
||||||
|
|
||||||
@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
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -128,6 +165,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price,
|
select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price,
|
||||||
|
_SESSIONS.c.done_ceiling_price,
|
||||||
_SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id)
|
_SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id)
|
||||||
.where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712
|
.where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@ -140,30 +178,61 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
|
||||||
|
_fallback = (0, _SUPPLIER_PRICE_LABEL, {})
|
||||||
try:
|
try:
|
||||||
|
# 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
|
||||||
query = (
|
query = (
|
||||||
select(_ITEMS.c.price)
|
select(_ITEMS.c.price, _ITEMS.c.purchase_price, _COMPANIES.c.settings)
|
||||||
|
.select_from(_ITEMS.outerjoin(_COMPANIES, _ITEMS.c.company_id == _COMPANIES.c.company_id))
|
||||||
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
|
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_price failed.", raise_error=False)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_baseline failed.", raise_error=False)
|
||||||
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
if err_type != ErrorType.SUCCESS or not rows:
|
||||||
return err_type, 0
|
return err_type, _fallback
|
||||||
|
# 컬럼이 2개 이상이면 execute 가 행 리스트를 준다(1개일 때만 스칼라 리스트).
|
||||||
|
price, purchase_price, settings = rows[0]
|
||||||
|
settings = settings if isinstance(settings, dict) else {}
|
||||||
|
labels = settings.get("labels") or {}
|
||||||
|
field = _resolve_baseline(settings)
|
||||||
|
value = purchase_price if field == _BASELINE_PURCHASE else price
|
||||||
|
return ErrorType.SUCCESS, (int(value or 0), _SUPPLIER_PRICE_LABEL, labels)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, _fallback
|
||||||
|
|
||||||
|
async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(_QUOTATION_SETTINGS.c.card_count)
|
||||||
|
.select_from(
|
||||||
|
_SESSIONS.join(
|
||||||
|
_QUOTATION_SETTINGS,
|
||||||
|
_QUOTATION_SETTINGS.c.qt_setting_id == _SESSIONS.c.qt_setting_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(_SESSIONS.c.session_id == session_id,
|
||||||
|
_SESSIONS.c.deleted == False, # noqa: E712
|
||||||
|
_QUOTATION_SETTINGS.c.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_card_count failed.", raise_error=False)
|
||||||
|
if err_type != ErrorType.SUCCESS or not rows or rows[0] is None:
|
||||||
|
return err_type, None
|
||||||
return ErrorType.SUCCESS, int(rows[0])
|
return ErrorType.SUCCESS, int(rows[0])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_item_internet_lowest(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
async def get_item_lowest_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||||
"""상품의 인터넷최저가(partner.items.internet_lowest_price). 미수집이면 0."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(_ITEMS.c.internet_lowest_price)
|
select(_ITEMS.c.internet_lowest_price)
|
||||||
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
|
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_internet_lowest failed.", raise_error=False)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_lowest_price failed.", raise_error=False)
|
||||||
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||||
return err_type, 0
|
return err_type, 0
|
||||||
return ErrorType.SUCCESS, int(rows[0])
|
return ErrorType.SUCCESS, int(rows[0])
|
||||||
@ -171,51 +240,6 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, 0
|
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]:
|
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
@ -231,6 +255,36 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, 0.0
|
return ErrorType.DB_RUN_FAILED, 0.0
|
||||||
|
|
||||||
|
async def get_item_name(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Optional[str]]:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(_ITEMS.c.name)
|
||||||
|
.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_name failed.", raise_error=False)
|
||||||
|
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||||
|
return err_type, None
|
||||||
|
return ErrorType.SUCCESS, str(rows[0])
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[str]]:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(_SUPPLIERS.c.name)
|
||||||
|
.where(_SUPPLIERS.c.supplier_id == supplier_id, _SUPPLIERS.c.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_supplier_name failed.", raise_error=False)
|
||||||
|
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||||
|
return err_type, None
|
||||||
|
return ErrorType.SUCCESS, str(rows[0])
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_supply_type(self, cdb: AsyncSession, supplier_id, item_id) -> Tuple[ErrorType, Optional[int]]:
|
async def get_supply_type(self, cdb: AsyncSession, supplier_id, item_id) -> Tuple[ErrorType, Optional[int]]:
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
@ -276,7 +330,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
|
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
|
||||||
try:
|
try:
|
||||||
version_q = (
|
version_q = (
|
||||||
select(_QUOTATIONS.c.version_id)
|
select(_QUOTATIONS.c.version_id)
|
||||||
@ -291,7 +345,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
version_id = rows[0]
|
version_id = rows[0]
|
||||||
|
|
||||||
nego_q = (
|
nego_q = (
|
||||||
select(_NEGO_CARDS.c.number)
|
select(_NEGO_CARDS.c.number, _NEGO_CARDS.c.script, _NEGO_CARDS.c.tactic)
|
||||||
.select_from(
|
.select_from(
|
||||||
_VERSION_NEGO_CARDS.join(
|
_VERSION_NEGO_CARDS.join(
|
||||||
_NEGO_CARDS,
|
_NEGO_CARDS,
|
||||||
@ -310,7 +364,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
return n_err, ([], [])
|
return n_err, ([], [])
|
||||||
|
|
||||||
wild_q = (
|
wild_q = (
|
||||||
select(_WILD_CARDS.c.number)
|
select(_WILD_CARDS.c.number, _WILD_CARDS.c.script, _WILD_CARDS.c.tactic)
|
||||||
.select_from(
|
.select_from(
|
||||||
_VERSION_WILD_CARDS.join(
|
_VERSION_WILD_CARDS.join(
|
||||||
_WILD_CARDS,
|
_WILD_CARDS,
|
||||||
@ -321,6 +375,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
_VERSION_WILD_CARDS.c.version_id == version_id,
|
_VERSION_WILD_CARDS.c.version_id == version_id,
|
||||||
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
|
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||||
_WILD_CARDS.c.deleted == False, # noqa: E712
|
_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||||
|
# 협상 적용 여부(카드 설정 '적용 대기(수동)') — 꺼진 카드는 견적에 담겨 있어도 발동 금지
|
||||||
|
_WILD_CARDS.c.available == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.order_by(_VERSION_WILD_CARDS.c.created_at)
|
.order_by(_VERSION_WILD_CARDS.c.created_at)
|
||||||
)
|
)
|
||||||
@ -329,8 +385,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
return w_err, ([], [])
|
return w_err, ([], [])
|
||||||
|
|
||||||
return ErrorType.SUCCESS, (
|
return ErrorType.SUCCESS, (
|
||||||
[str(r) for r in n_rows if r is not None],
|
[(str(r[0]), r[1], r[2]) for r in n_rows if r[0] is not None],
|
||||||
[str(r) for r in w_rows if r is not None],
|
[(str(r[0]), r[1], r[2]) for r in w_rows if r[0] is not None],
|
||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
|
|||||||
@ -10,62 +10,28 @@ import re
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from negotiation.cards.domain.tactics import (
|
||||||
|
OFFER_VARIABLES, Offer, available, compute_offer_detail, is_played, mark_played, playable,
|
||||||
|
record_offer, settle_ceiling, spec_from_context,
|
||||||
|
)
|
||||||
from negotiation.chat.service.script_repository import ScriptRepository
|
from negotiation.chat.service.script_repository import ScriptRepository
|
||||||
|
|
||||||
MAX_ROUNDS = 3
|
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
||||||
|
# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 target/anchor 는 제외).
|
||||||
|
_DERIVED_PRICE_VARIABLES = ("target_mid_price", "middle_price")
|
||||||
|
|
||||||
_PRICE_MODES = ("price",)
|
_PRICE_MODES = ("price",)
|
||||||
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
_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=False(뒤에 협상종료가 옴)라, outcome 을 컨텍스트에 적재했다가
|
||||||
# 실제 종료(chat_end=협상종료) 시점에 확정 보고한다 → backend 가 chat_end 에서 DONE/REJECTED 를 옳게 가른다.
|
# 실제 종료(chat_end=협상종료) 시점에 확정 보고한다 → backend 가 chat_end 에서 DONE/REJECTED 를 옳게 가른다.
|
||||||
_SUCCESS_STEPS = ("협상완료", "결과제출")
|
_SUCCESS_STEPS = ("협상완료", "결과제출")
|
||||||
_FAILURE_STEPS = ("협상실패",)
|
_FAILURE_STEPS = ("협상실패",)
|
||||||
|
|
||||||
|
# 카운터 제안(pending_counter_price) 수락으로 인정하는 선택 입력.
|
||||||
|
_ACCEPT_INPUTS = ("예", "수락")
|
||||||
|
|
||||||
# 프론트는 표시용 문자열로 가격을 보낸다(예: "530,000원"). 천단위 콤마·통화기호("원")·공백 등
|
# 프론트는 표시용 문자열로 가격을 보낸다(예: "530,000원"). 천단위 콤마·통화기호("원")·공백 등
|
||||||
# 숫자 외 문자를 제거하고 파싱한다. (콤마만 지우면 "원" 때문에 float() 가 실패해 가격 입력이
|
# 숫자 외 문자를 제거하고 파싱한다. (콤마만 지우면 "원" 때문에 float() 가 실패해 가격 입력이
|
||||||
# 영영 저장되지 않고 같은 step 에 머무는 버그가 났었다.)
|
# 영영 저장되지 않고 같은 step 에 머무는 버그가 났었다.)
|
||||||
@ -83,6 +49,39 @@ def _parse_price(user_input: Any) -> Optional[float]:
|
|||||||
return price if price > 0 else None
|
return price if price > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
# 협상 스크립트가 쓰는 용어 토큰: {label_*} = 회사 용어(없으면 기본값).
|
||||||
|
# 값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 화면·멘트 표기가 갈리지 않는다.
|
||||||
|
_SCRIPT_LABELS = {
|
||||||
|
"label_supplier": ("supplier", "협력사"),
|
||||||
|
"label_target_price": ("target_price", "목표가"),
|
||||||
|
"label_delivery_type": ("item.delivery_type", "배송 형태"),
|
||||||
|
"label_delivery_type_1": ("delivery_type.1", "협력사배송"),
|
||||||
|
"label_delivery_type_2": ("delivery_type.2", "지정택배배송"),
|
||||||
|
"label_delivery_type_3": ("delivery_type.3", "픽업배송"),
|
||||||
|
"label_product": ("item.name", "상품명"),
|
||||||
|
}
|
||||||
|
# 협상 기준가 호칭 — 공급사 화면 고정 용어. 회사 용어 사전(labels)을 타지 않는다(그건 관리자 화면 전용).
|
||||||
|
# 실제 값은 loader 가 컨텍스트에 박제하고, DB 컨텍스트가 없는 데모/직접호출 경로만 이 폴백을 쓴다.
|
||||||
|
_SUPPLIER_PRICE_LABEL = "공급가"
|
||||||
|
# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다.
|
||||||
|
# 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을").
|
||||||
|
_JOSA = {"은": ("은", "는"), "는": ("은", "는"), "이": ("이", "가"), "가": ("이", "가"),
|
||||||
|
"을": ("을", "를"), "를": ("을", "를"), "과": ("과", "와"), "와": ("과", "와")}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_batchim(word: str) -> bool:
|
||||||
|
last = word[-1] if word else ""
|
||||||
|
return "가" <= last <= "힣" and (ord(last) - 0xAC00) % 28 != 0
|
||||||
|
|
||||||
|
|
||||||
|
def _josa(word: str, form: str) -> str:
|
||||||
|
"""단어 + 받침에 맞는 조사. form 은 대표형('를'·'는'·'가'·'와')."""
|
||||||
|
pair = _JOSA.get(form)
|
||||||
|
if not pair:
|
||||||
|
return word
|
||||||
|
return word + (pair[0] if _has_batchim(word) else pair[1])
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChatSession:
|
class ChatSession:
|
||||||
session_id: str
|
session_id: str
|
||||||
@ -114,11 +113,11 @@ class ChatEngine:
|
|||||||
def __init__(self, scripts_repo: ScriptRepository, rq_type: str = "재협상"):
|
def __init__(self, scripts_repo: ScriptRepository, rq_type: str = "재협상"):
|
||||||
self.repo = scripts_repo
|
self.repo = scripts_repo
|
||||||
self.rq_type = rq_type
|
self.rq_type = rq_type
|
||||||
# 자율 스텝은 병합만 해둔다(repo 캐시 오염 방지 위해 새 dict) — decider 미주입 시 도달 불가.
|
self.scripts = scripts_repo.load_scripts(rq_type)
|
||||||
self.scripts = {**scripts_repo.load_scripts(rq_type), **_AUTONOMY_STEPS}
|
|
||||||
self.step_map = scripts_repo.client_step_mapping()
|
self.step_map = scripts_repo.client_step_mapping()
|
||||||
# 완전 자율 모드: ChatService 가 AutonomyStore 정책을 주입하면 가격협상 판정 룰을 대체한다.
|
# 결정 스택 규칙층(Phase 1): 와일드카드 진입 임계·라운드 상한을 테넌트 config 에서 읽는다.
|
||||||
self.autonomy_decider = None # Callable[[dict], autonomy_actions.Action]
|
# (하드코딩 1.02/1.05/3 을 데이터화 — 고객사별로 튜닝 가능, 코드 수정 불필요)
|
||||||
|
self.rules = scripts_repo.config.negotiation
|
||||||
|
|
||||||
# ---- public --------------------------------------------------------
|
# ---- public --------------------------------------------------------
|
||||||
def start(self, session: ChatSession) -> StepView:
|
def start(self, session: ChatSession) -> StepView:
|
||||||
@ -136,25 +135,29 @@ class ChatEngine:
|
|||||||
if price is None:
|
if price is None:
|
||||||
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
||||||
session.context["input_price"] = price
|
session.context["input_price"] = price
|
||||||
|
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터·그 재료 폐기.
|
||||||
|
session.context.pop("pending_counter_price", None)
|
||||||
|
session.context.pop("pending_offer", None)
|
||||||
|
session.context["prev_partner_price"] = price
|
||||||
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
||||||
session.context.setdefault("first_offer_price", price)
|
session.context.setdefault("first_offer_price", price)
|
||||||
session.context["round"] = session.context.get("round", 0) + 1
|
session.context["round"] = session.context.get("round", 0) + 1
|
||||||
nxt = self._default_next(node)
|
nxt = self._default_next(node)
|
||||||
elif mode in _CHOICE_MODES:
|
elif mode in _CHOICE_MODES:
|
||||||
# 와일드카드 1% 인하 제안을 수락("예")하면 합의가를 제안가(offer_1pct)로 확정한다.
|
|
||||||
# (멘트에만 쓰이던 offer_1pct 가 input_price 에 반영되지 않아, 요약/입찰가가
|
|
||||||
# 직전 제시가로 잡히던 버그 수정 — 수락 시 실제 합의가는 인하가다.)
|
|
||||||
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)
|
nxt = self._choice_next(node, user_input, session)
|
||||||
else:
|
else:
|
||||||
nxt = self._default_next(node)
|
nxt = self._default_next(node)
|
||||||
|
|
||||||
nxt = self._resolve(nxt, session)
|
nxt = self._resolve(nxt, session)
|
||||||
|
# 카운터 수락 일반 메커니즘: 카드/와일드카드가 제시한 카운터가(pending_counter_price)를
|
||||||
|
# 협력사가 수락("예"/"수락")한 채 성공 스텝으로 전이하면 합의가 = 카운터가.
|
||||||
|
# (구 offer_1pct 특수 분기의 일반화. 거절인데 성공 스텝으로 가는 경로 — 1% 거절 시
|
||||||
|
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
|
||||||
|
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
|
||||||
|
pending = session.context.pop("pending_counter_price", None)
|
||||||
|
session.context.pop("pending_offer", None)
|
||||||
|
if pending and user_input in _ACCEPT_INPUTS:
|
||||||
|
session.context["input_price"] = float(pending)
|
||||||
return self._render(session, nxt)
|
return self._render(session, nxt)
|
||||||
|
|
||||||
# ---- transition ----------------------------------------------------
|
# ---- transition ----------------------------------------------------
|
||||||
@ -182,20 +185,12 @@ class ChatEngine:
|
|||||||
return nxt
|
return nxt
|
||||||
|
|
||||||
def _eval_conditions(self, conds: List[dict], session: ChatSession) -> Optional[str]:
|
def _eval_conditions(self, conds: List[dict], session: ChatSession) -> Optional[str]:
|
||||||
"""KT 구매자 관점 조건 평가.
|
"""KT 구매자 관점 조건 평가 (임계값은 config negotiation.* — 규칙층 데이터화).
|
||||||
- 협력사 제시가 ≤ anchor → 우선협상(협상완료).
|
- 협력사 제시가 ≤ anchor → 우선협상(협상완료).
|
||||||
- anchor 살짝 초과(≤ anchor*1.05) + 와일드카드 미사용 → 와일드카드로 인하 압박.
|
- anchor 살짝 초과(≤ anchor×wildcard_entry_ratio) + 와일드카드 미사용 → 와일드카드로 인하 압박.
|
||||||
- 설정 카드(action_space) 모두 소진 → 협상실패.
|
- 설정 카드(action_space) 모두 소진 → 협상실패.
|
||||||
- 그 외 → 가격협상(카드 1장 플레이 후 재제안).
|
- 그 외 → 가격협상(카드 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
|
ctx = session.context
|
||||||
price = ctx.get("input_price", 0)
|
price = ctx.get("input_price", 0)
|
||||||
anchor = ctx.get("anchor_price", 0)
|
anchor = ctx.get("anchor_price", 0)
|
||||||
@ -210,91 +205,99 @@ class ChatEngine:
|
|||||||
and anchor > 0
|
and anchor > 0
|
||||||
and anchor < price
|
and anchor < price
|
||||||
and (
|
and (
|
||||||
price <= anchor * 1.02
|
price <= anchor * self.rules.wildcard_1pct_ratio
|
||||||
or (bool(ctx.get("allow_selected_wildcards", True)) and price <= anchor * 1.05)
|
or (bool(ctx.get("allow_selected_wildcards", True))
|
||||||
|
and price <= anchor * self.rules.wildcard_entry_ratio)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# 구간에 들어와도 실제로 낼 카드가 없으면(전부 종결 전용·사용됨·유효조건 미달) 이 조건은
|
||||||
|
# 불충족으로 두고 다음 조건(우선협상·소진 판정)을 평가한다 — 여기서 매칭돼 버리면
|
||||||
|
# 카드 소진 판정이 영영 돌지 않아, 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상이 된다.
|
||||||
|
if ok:
|
||||||
|
probe = ChatSession(
|
||||||
|
session_id=session.session_id, tenant_id=session.tenant_id,
|
||||||
|
company_id=session.company_id, context=dict(ctx),
|
||||||
|
)
|
||||||
|
ok = self._pick_wildcard(probe) != "가격협상"
|
||||||
elif cond == "check_is_supplier_type_c":
|
elif cond == "check_is_supplier_type_c":
|
||||||
ok = False # 공급사 유형 미보유 (PoC 단순화)
|
ok = False # 공급사 유형 미보유 (PoC 단순화)
|
||||||
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
|
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
|
||||||
ok = anchor > 0 and price <= anchor
|
ok = anchor > 0 and price <= anchor
|
||||||
elif cond == "check_iteration_limit": # 협상 라운드 상한 또는 카드 소진
|
elif cond == "check_iteration_limit": # 협상 라운드 상한 또는 카드 소진 → 종결 국면
|
||||||
# round 는 매 가격입력마다 증가(기존가격제시=1). 그 이후 카운터제안이 MAX_ROUNDS 회를
|
# round 는 매 가격입력마다 증가(기존가격제시=1). 카운터제안이 상한을 넘거나 카드가
|
||||||
# 넘으면 종료한다. 카드선택(RL)이 실패(state ValueError)해도 used_action_ids 가 안 늘어
|
# 소진되면 곧장 실패가 아니라 **종결 국면**으로 처리한다(실제 MD 협상 방식):
|
||||||
# 카드 소진 조건만으로는 종료되지 않으므로, 라운드 상한을 독립적으로 둬 무한 가격입력을 막는다.
|
# ① 아직 종결 전술을 안 썼으면 → 가격협상으로 보내되 force_closing 마킹
|
||||||
# (선행 chat_server 의 `iteration >= 3` 와 동일한 안전장치.)
|
# (ChatService 가 종결 전술 — 중간값 절충/최후통첩 — 을 강제 발동)
|
||||||
|
# ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결,
|
||||||
|
# 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합.
|
||||||
counter_rounds = max(0, ctx.get("round", 0) - 1)
|
counter_rounds = max(0, ctx.get("round", 0) - 1)
|
||||||
ok = counter_rounds >= MAX_ROUNDS or (cards_total > 0 and cards_used >= cards_total)
|
# 담은 협상카드 중 지금 낼 수 있는 게 하나도 없으면(사용됨·발동조건 미달 — 예:
|
||||||
|
# 시장가 인용 카드인데 최저가 결측) 장수와 무관하게 소진으로 본다 — 안 그러면
|
||||||
|
# 선택 마스크가 전부 막힌 채 폴백이 부적합 카드를 억지로 꺼낸다(토큰 노출).
|
||||||
|
selected = ctx.get("selected_nego_card_numbers") or []
|
||||||
|
none_playable = bool(selected) and not any(
|
||||||
|
not is_played(ctx, n) and playable(spec_from_context(ctx, n), ctx)
|
||||||
|
for n in selected
|
||||||
|
)
|
||||||
|
exhausted = (
|
||||||
|
counter_rounds >= self.rules.max_counter_rounds
|
||||||
|
or (cards_total > 0 and cards_used >= cards_total)
|
||||||
|
or none_playable
|
||||||
|
)
|
||||||
|
if exhausted:
|
||||||
|
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
||||||
|
# 상한 이내면 타결한다.
|
||||||
|
ceiling = settle_ceiling(ctx)
|
||||||
|
if not ctx.get("closing_played"):
|
||||||
|
ctx["force_closing"] = True
|
||||||
|
return "가격협상"
|
||||||
|
return "협상완료" if (ceiling > 0 and price <= ceiling) else c.get("next")
|
||||||
|
ok = False
|
||||||
elif cond == "default":
|
elif cond == "default":
|
||||||
ok = True
|
ok = True
|
||||||
if ok:
|
if ok:
|
||||||
return c.get("next")
|
return c.get("next")
|
||||||
return "가격협상"
|
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:
|
def _pick_wildcard(self, session: ChatSession) -> str:
|
||||||
"""앵커가에 아주 근접(≤ anchor*1.02)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
||||||
앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다.
|
앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고,
|
||||||
|
낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다.
|
||||||
과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다 —
|
|
||||||
견적에서 실제 선택한 와일드카드(중간값 절충·목표가 선제안 등)와 매핑되지 않은 채
|
|
||||||
'와일드카드를 하나라도 골랐으면' 조건만으로 발동해, 선택하지도 않은 재원부족 멘트가
|
|
||||||
노출되는 오작동이 있었다.
|
|
||||||
"""
|
"""
|
||||||
ctx = session.context
|
ctx = session.context
|
||||||
price = ctx.get("input_price", 0)
|
price = ctx.get("input_price", 0)
|
||||||
anchor = ctx.get("anchor_price", 0)
|
anchor = ctx.get("anchor_price", 0)
|
||||||
if anchor > 0 and price <= anchor * 1.02:
|
target = ctx.get("target_price", 0)
|
||||||
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
|
||||||
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
|
||||||
ctx["wildcard_used"] = True
|
# 제안가 공통 유효조건(≤목표가 · <제시가 · 직전 당사 제안 이상=역행 금지)은 시스템 1% 카드에도
|
||||||
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가
|
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
|
||||||
return "wild_card_1pct"
|
prev_customer = ctx.get("prev_customer_price") or 0
|
||||||
|
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target) and offer_1pct >= prev_customer:
|
||||||
|
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
||||||
|
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||||
|
ctx["wildcard_used"] = True
|
||||||
|
ctx["offer_1pct"] = offer_1pct
|
||||||
|
record_offer(ctx, Offer(price=offer_1pct, variable="offer_1pct",
|
||||||
|
prev_customer=int(prev_customer or anchor), prev_partner=int(price)))
|
||||||
|
return "wild_card_1pct"
|
||||||
|
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
||||||
|
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
||||||
|
if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio:
|
||||||
|
for number in (ctx.get("selected_wild_card_numbers") or []):
|
||||||
|
number = str(number)
|
||||||
|
spec = spec_from_context(ctx, number)
|
||||||
|
# 종결 전용 카드(최종 통보·중간값 절충)는 여기서 안 꺼낸다 — 종결 국면의 마지막 한 방으로 예약.
|
||||||
|
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
|
||||||
|
if not available(spec, ctx) or is_played(ctx, number):
|
||||||
|
continue
|
||||||
|
offer = compute_offer_detail(spec, ctx)
|
||||||
|
if offer is not None:
|
||||||
|
ctx["wildcard_used"] = True
|
||||||
|
record_offer(ctx, offer)
|
||||||
|
ctx["active_wild_card_number"] = number
|
||||||
|
mark_played(ctx, number)
|
||||||
|
return "wild_card_dynamic"
|
||||||
return "가격협상"
|
return "가격협상"
|
||||||
|
|
||||||
# ---- render --------------------------------------------------------
|
# ---- render --------------------------------------------------------
|
||||||
@ -304,41 +307,109 @@ class ChatEngine:
|
|||||||
out = {}
|
out = {}
|
||||||
if "input_price" in ctx:
|
if "input_price" in ctx:
|
||||||
out["input_price"] = int(ctx["input_price"])
|
out["input_price"] = int(ctx["input_price"])
|
||||||
|
# 목표가/앵커가: 엔진 내부 파일 스크립트는 {target}/{anchor}, negodata 카드 에디터는
|
||||||
|
# {target_price}/{anchor_price}(variables.ts) 를 쓴다 — 양쪽 이름 모두 채워 치환 누락 방지.
|
||||||
if "target_price" in ctx:
|
if "target_price" in ctx:
|
||||||
out["target"] = int(ctx["target_price"])
|
out["target"] = out["target_price"] = 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:
|
if "anchor_price" in ctx:
|
||||||
out["anchor"] = int(ctx["anchor_price"])
|
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
|
||||||
out["anchoring_price"] = int(ctx["anchor_price"]) # DB 카드 정본 변수명(NGC-007 등)
|
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"])
|
||||||
|
# 용어 토큰 — 회사 용어 사전(labels)이 있으면 그 단어, 없으면 카탈로그 기본값.
|
||||||
|
# 조사가 붙는 자리를 위해 {label_supplier_를} 같은 파생 키도 함께 만든다.
|
||||||
|
labels = ctx.get("labels") or {}
|
||||||
|
for token, (label_key, fallback) in _SCRIPT_LABELS.items():
|
||||||
|
word = labels.get(label_key) or fallback
|
||||||
|
out[token] = word
|
||||||
|
for form in ("는", "가", "를", "와"):
|
||||||
|
out[f"{token}_{form}"] = _josa(word, form)
|
||||||
|
# 기준가 호칭은 회사 용어가 아니라 공급사 관점 고정 — loader 박제값(없으면 '공급가').
|
||||||
|
price_word = str(ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL)
|
||||||
|
out["label_item_price"] = price_word
|
||||||
|
for form in ("는", "가", "를", "와"):
|
||||||
|
out[f"label_item_price_{form}"] = _josa(price_word, form)
|
||||||
|
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
|
||||||
|
if ctx.get("partner_name"):
|
||||||
|
out["partner_name"] = str(ctx["partner_name"])
|
||||||
|
if ctx.get("product_name"):
|
||||||
|
out["product_name"] = str(ctx["product_name"])
|
||||||
if "offer_1pct" in ctx:
|
if "offer_1pct" in ctx:
|
||||||
out["offer_1pct"] = int(ctx["offer_1pct"])
|
out["offer_1pct"] = int(ctx["offer_1pct"])
|
||||||
if "autonomy_offer" in ctx:
|
# 인터넷 최저가: LPS 대표값(items.internet_lowest_price). 카드는 {internet_lowest_price},
|
||||||
out["autonomy_offer"] = int(ctx["autonomy_offer"])
|
# 라벨 매핑(variable_mapping.json)은 internet_min_price 를 쓰므로 target/anchor 처럼 양쪽 이름 모두 채운다.
|
||||||
# 인터넷 최저가(NGC-008): 수집값이 컨텍스트에 없으면 앵커가로 폴백 — 원형 토큰 노출 방지.
|
# 미수집(0/없음)이면 키를 만들지 않는다 — 원형 유지 → 허위 시장가 인용 방지(NGC-008 은 값 있을 때만 유효).
|
||||||
# TODO: partner.item_internet_lowest_prices 최신 성공 수집값을 context loader 로 연결.
|
ilp = ctx.get("internet_lowest_price") or 0
|
||||||
if ctx.get("internet_lowest_price"):
|
if ilp > 0:
|
||||||
out["internet_lowest_price"] = int(ctx["internet_lowest_price"])
|
out["internet_lowest_price"] = out["internet_min_price"] = int(ilp)
|
||||||
elif "anchor_price" in ctx:
|
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
|
||||||
out["internet_lowest_price"] = int(ctx["anchor_price"])
|
anchor = ctx.get("anchor_price") or 0
|
||||||
# 고객사 교환·요구 조건(NGC-009/010): 런타임 소스 미구현 — 중립 문구 폴백.
|
target = ctx.get("target_price") or 0
|
||||||
# TODO: 견적/카드 편집 단계에서 입력받아 컨텍스트로 전달.
|
# 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다.
|
||||||
out["customer_condition"] = ctx.get("customer_condition") or "상호 협의된 조건"
|
# prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가
|
||||||
# 인하율 = (기존 공급가 - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
|
# "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다.
|
||||||
|
pending_offer = ctx.get("pending_offer") or {}
|
||||||
|
prev_customer = int(pending_offer.get("prev_customer") or ctx.get("prev_customer_price") or anchor or 0)
|
||||||
|
partner_price = int(pending_offer.get("prev_partner") or ctx.get("prev_partner_price") or ctx.get("input_price") or 0)
|
||||||
|
offer_price = int(pending_offer.get("price") or ctx.get("pending_counter_price") or 0)
|
||||||
|
offer_variable = pending_offer.get("variable") or ""
|
||||||
|
if prev_customer:
|
||||||
|
out["prev_customer_price"] = prev_customer
|
||||||
|
if partner_price:
|
||||||
|
out["prev_partner_price"] = partner_price
|
||||||
|
if offer_price:
|
||||||
|
out["counter_price"] = offer_price
|
||||||
|
# 파생 가격(절충가·중간가) — 제안가로 확정된 변수는 그 금액을 그대로 쓴다(멘트에 보이는 금액과
|
||||||
|
# 수락 시 타결가는 항상 같아야 한다). 나머지는 참고 인용이므로 tactics 산식으로 채운다.
|
||||||
|
# 산식을 여기 복사해 두면 갱신 시점 차이로 표시가와 제안가가 갈라지므로 정의를 호출만 한다.
|
||||||
|
# 어느 변수가 제안가인지 모르는 진행 중 세션(구버전 기록)은 종전대로 전부 제안가로 고정한다.
|
||||||
|
for name in _DERIVED_PRICE_VARIABLES:
|
||||||
|
if offer_price and (name == offer_variable or not offer_variable):
|
||||||
|
out[name] = offer_price
|
||||||
|
continue
|
||||||
|
value = OFFER_VARIABLES[name](target, anchor, partner_price, prev_customer)
|
||||||
|
if value:
|
||||||
|
out[name] = int(value / 10 + 0.5) * 10 # 10원 반올림 — compute_offer 와 동일
|
||||||
|
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
|
||||||
|
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
||||||
|
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
|
||||||
|
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
|
||||||
|
# 기준가 호칭은 공급사 화면 고정 용어('공급가') — loader 가 박제한 값.
|
||||||
base = ctx.get("item_price") or 0
|
base = ctx.get("item_price") or 0
|
||||||
|
label = ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL
|
||||||
if base > 1 and "input_price" in ctx:
|
if base > 1 and "input_price" in ctx:
|
||||||
out["discount_rate"] = f"{((base - ctx['input_price']) / base) * 100:.1f}"
|
rate = ((base - ctx["input_price"]) / base) * 100
|
||||||
|
out["discount_rate"] = f"{max(0.0, rate):.1f}"
|
||||||
|
if rate >= 0.05:
|
||||||
|
out["discount_phrase"] = f"기존 {label} 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
|
||||||
|
elif rate <= -0.05:
|
||||||
|
out["discount_phrase"] = (
|
||||||
|
f"기존 {label}(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
|
||||||
|
else:
|
||||||
|
out["discount_phrase"] = f"기존 {_josa(label, '와')} 동일한 수준의 금액입니다. "
|
||||||
else:
|
else:
|
||||||
out["discount_rate"] = "0.0"
|
out["discount_rate"] = "0.0"
|
||||||
|
out["discount_phrase"] = ""
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _vars(self, session: ChatSession) -> Dict[str, Any]:
|
def _vars(self, session: ChatSession) -> Dict[str, Any]:
|
||||||
return self.vars_for(session)
|
return self.vars_for(session)
|
||||||
|
|
||||||
|
def render_step(self, session: ChatSession, step_key: str) -> StepView:
|
||||||
|
"""지정 스텝으로 전이·렌더 (공개) — ChatService 가 카드 카운터 제시 시
|
||||||
|
가격협상 → 가격협상_카운터로 스텝을 전환할 때 사용한다."""
|
||||||
|
return self._render(session, step_key)
|
||||||
|
|
||||||
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
|
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
|
||||||
if not step_key or step_key not in self.scripts:
|
if not step_key or step_key not in self.scripts:
|
||||||
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
||||||
|
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
|
||||||
|
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
|
||||||
|
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다.
|
||||||
|
# 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
||||||
|
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
|
||||||
|
ctx = session.context
|
||||||
|
ceiling = settle_ceiling(ctx)
|
||||||
|
if ceiling > 0 and ctx.get("input_price", 0) > ceiling:
|
||||||
|
step_key = "협상실패"
|
||||||
node = self.scripts[step_key]
|
node = self.scripts[step_key]
|
||||||
session.step = step_key
|
session.step = step_key
|
||||||
chat_end = bool(node.get("chat_end"))
|
chat_end = bool(node.get("chat_end"))
|
||||||
@ -351,11 +422,14 @@ class ChatEngine:
|
|||||||
elif step_key in _FAILURE_STEPS:
|
elif step_key in _FAILURE_STEPS:
|
||||||
session.context["final_outcome"] = "failure"
|
session.context["final_outcome"] = "failure"
|
||||||
outcome = session.context.get("final_outcome") if chat_end else None
|
outcome = session.context.get("final_outcome") if chat_end else None
|
||||||
|
# 선택지도 스크립트와 같은 변수 치환을 태운다 — 배송형태 보기가 회사 용어({label_delivery_type_1} 등)라
|
||||||
|
# 치환을 건너뛰면 사용자에게 토큰 원문이 그대로 보인다.
|
||||||
|
step_vars = self._vars(session)
|
||||||
return StepView(
|
return StepView(
|
||||||
step=step_key,
|
step=step_key,
|
||||||
script=self.repo.format_script(node.get("script", ""), self._vars(session)),
|
script=self.repo.format_script(node.get("script", ""), step_vars),
|
||||||
input_mode=node.get("next_input_mode", "null"),
|
input_mode=node.get("next_input_mode", "null"),
|
||||||
input_options=node.get("input_options", []),
|
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
|
||||||
chat_end=bool(node.get("chat_end")),
|
chat_end=bool(node.get("chat_end")),
|
||||||
client_step=self.step_map.get(step_key, step_key),
|
client_step=self.step_map.get(step_key, step_key),
|
||||||
needs_card_selection=(step_key == "가격협상"),
|
needs_card_selection=(step_key == "가격협상"),
|
||||||
@ -364,9 +438,13 @@ class ChatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _error(self, session: ChatSession, msg: str) -> StepView:
|
def _error(self, session: ChatSession, msg: str) -> StepView:
|
||||||
|
# 에러 재렌더도 정상 렌더와 같은 변수 치환을 태운다 — 여기만 raw 로 두면
|
||||||
|
# 가격 오입력 시 옵션 버튼에 {label_*} 토큰이 그대로 노출된다.
|
||||||
node = self.scripts.get(session.step, {})
|
node = self.scripts.get(session.step, {})
|
||||||
|
step_vars = self._vars(session)
|
||||||
return StepView(
|
return StepView(
|
||||||
step=session.step, script=node.get("script", ""),
|
step=session.step, script=self.repo.format_script(node.get("script", ""), step_vars),
|
||||||
input_mode=node.get("next_input_mode", "null"), input_options=node.get("input_options", []),
|
input_mode=node.get("next_input_mode", "null"),
|
||||||
|
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
|
||||||
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
|
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
|
||||||
)
|
)
|
||||||
|
|||||||
149
agent/negotiation/chat/service/input_interpreter.py
Normal file
149
agent/negotiation/chat/service/input_interpreter.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"""InputInterpreter — 협력사 자유 발화를 대화 단계 기대 입력으로 구조화 (Phase 3 이해층).
|
||||||
|
|
||||||
|
원칙: "숫자와 결정은 결정론이, 말은 LLM 이" (ScriptNaturalizer 와 동일 철학) —
|
||||||
|
- LLM 의 역할은 ① 의도 분류(choice|price|unknown) ② 가격 '표현의 위치' 찾기까지다.
|
||||||
|
가격 숫자 계산은 LLM 출력이 아니라 결정론 한국어 가격 파서(parse_korean_price)가 수행한다.
|
||||||
|
- 검증: choice 는 허용 선택지 목록에 철자 그대로 있어야 하고, price_text 는 사용자 원문의
|
||||||
|
부분 문자열이어야 한다(환각 차단). 하나라도 어긋나면 None → 호출부가 원문 그대로 폴백
|
||||||
|
(기존 엔진의 재질문 흐름 유지 — 협상은 절대 멈추지 않는다).
|
||||||
|
|
||||||
|
게이트: tenant llm.enabled + 전역 자격증명(ScriptNaturalizer 와 동일). 미설정이면 무동작 —
|
||||||
|
기존 버튼/정형 입력 경로는 그대로 두고, 자유 텍스트일 때만 해석을 시도한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from negotiation.profiling.config import LlmCredentials
|
||||||
|
|
||||||
|
# 정형 가격 입력(버튼/필드) — LLM 없이 기존 결정론 경로로 처리 가능한 형태.
|
||||||
|
SIMPLE_PRICE_RE = re.compile(r"\s*[\d,]+(\.\d+)?\s*원?\s*")
|
||||||
|
|
||||||
|
# 한국어 단위 (큰 단위 → 작은 단위 순서로 등장한다고 가정: "1만 2천 500원")
|
||||||
|
_KOREAN_UNITS = {"억": 100_000_000, "만": 10_000, "천": 1_000, "백": 100}
|
||||||
|
_PRICE_TOKEN_RE = re.compile(r"[\d.억만천백]+")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_korean_price(text: str) -> Optional[float]:
|
||||||
|
"""가격 표현 문자열 → 숫자 (결정론). "10,500원"→10500, "1만 500원"→10500, "만원"→10000,
|
||||||
|
"1.5만"→15000, "3만2천원"→32000. 해석 불가/0 이하 → None.
|
||||||
|
"""
|
||||||
|
t = (text or "").replace(",", "").replace(" ", "").replace("원", "").strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
if re.fullmatch(r"\d+(\.\d+)?", t):
|
||||||
|
v = float(t)
|
||||||
|
return v if v > 0 else None
|
||||||
|
if not re.fullmatch(r"[\d.억만천백]+", t):
|
||||||
|
return None # 단위·숫자 외 문자 포함 → 해석 불가(안전 폴백)
|
||||||
|
total, num = 0.0, ""
|
||||||
|
for ch in t:
|
||||||
|
if ch.isdigit() or ch == ".":
|
||||||
|
num += ch
|
||||||
|
else: # 단위 문자
|
||||||
|
try:
|
||||||
|
n = float(num) if num else 1.0 # "만원" = 1만
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
total += n * _KOREAN_UNITS[ch]
|
||||||
|
num = ""
|
||||||
|
if num:
|
||||||
|
try:
|
||||||
|
total += float(num) # 잔여 숫자: "1만500" 의 500
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return total if total > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InterpretedInput:
|
||||||
|
kind: str # "choice" | "price"
|
||||||
|
value: str # ChatEngine.advance 에 그대로 전달할 문자열 ("예" / "10500")
|
||||||
|
source: str # 판단 근거(선택지 원문 또는 가격 표현 원문) — 로깅/투명성용
|
||||||
|
|
||||||
|
|
||||||
|
_SYSTEM = """너는 B2B 구매 협상 챗봇의 입력 해석기다. 협력사(사용자)의 자유 발화를 현재 대화 단계가 기대하는 입력으로 구조화한다.
|
||||||
|
|
||||||
|
규칙 (하나라도 어기면 출력은 폐기된다):
|
||||||
|
- 출력은 JSON 하나만: {"intent": "choice"|"price"|"unknown", "choice": "<선택지 철자 그대로>"|null, "price_text": "<원문 속 가격 표현 그대로>"|null}
|
||||||
|
- choice 는 발화의 '의미'를 선택지 중 하나에 대응시켜, 그 선택지 문자열을 철자 그대로 복사한다.
|
||||||
|
예) 선택지 ["예","아니오"]: "네 접니다"/"맞습니다"/"진행해주세요"/"동의합니다" → "예",
|
||||||
|
"아닌데요"/"제가 아닙니다"/"어렵습니다"/"거절하겠습니다" → "아니오"
|
||||||
|
- 사용자가 구체적 가격을 제시/역제안하면 intent=price. price_text 는 반드시 사용자 원문에 등장한
|
||||||
|
표현을 그대로 복사한다(예: "10,500원", "1만 500원"). 숫자를 계산하거나 변형하지 마라.
|
||||||
|
- 발화가 어느 선택지의 의미인지 정말 판단할 수 없거나 주제와 무관할 때만 intent=unknown."""
|
||||||
|
|
||||||
|
|
||||||
|
def _default_llm_call(messages: List[dict]) -> dict:
|
||||||
|
"""기본 LLM 호출(동기) — 전역 자격증명으로 chat_json. 테스트에서 주입 대체 지점."""
|
||||||
|
from negotiation.profiling.infra.llm_adapter import chat_json
|
||||||
|
|
||||||
|
return chat_json(messages, temperature=0.0, max_tokens=200)
|
||||||
|
|
||||||
|
|
||||||
|
class InputInterpreter:
|
||||||
|
def __init__(self, llm_call: Optional[Callable[[List[dict]], dict]] = None,
|
||||||
|
timeout_seconds: float = 6.0):
|
||||||
|
self._llm_call = llm_call or _default_llm_call
|
||||||
|
self._timeout = timeout_seconds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available() -> bool:
|
||||||
|
"""전역 LLM 자격증명이 설정돼 있는가 (테넌트 enabled 게이트는 호출부 몫)."""
|
||||||
|
return LlmCredentials.from_config().is_configured()
|
||||||
|
|
||||||
|
async def interpret(self, user_input: str, *, input_mode: str,
|
||||||
|
input_options: Optional[List[str]] = None,
|
||||||
|
step_script: Optional[str] = None) -> Optional[InterpretedInput]:
|
||||||
|
"""자유 발화 → 기대 입력. 검증 불통과/실패/타임아웃 시 None(호출부 원문 폴백).
|
||||||
|
|
||||||
|
step_script: 직전 봇 질문(맥락) — "네 접니다" 같은 발화는 질문 없이는 의도 판단이
|
||||||
|
애매해 unknown 이 되므로, 무엇에 대한 답인지 함께 준다.
|
||||||
|
"""
|
||||||
|
text = (user_input or "").strip()
|
||||||
|
options = [str(o) for o in (input_options or [])]
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
payload = {
|
||||||
|
"현재 단계 기대 입력": "가격(숫자)" if input_mode == "price" else "선택지 중 하나",
|
||||||
|
"선택지": options,
|
||||||
|
"사용자 발화": text,
|
||||||
|
}
|
||||||
|
if step_script:
|
||||||
|
payload["직전 봇 질문"] = step_script[:300]
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _SYSTEM},
|
||||||
|
{"role": "user", "content": json.dumps(payload, ensure_ascii=False) + "\n\nJSON 으로만 답하라."},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(asyncio.to_thread(self._llm_call, messages), self._timeout)
|
||||||
|
except Exception as ex: # 타임아웃 포함 — 원문 폴백
|
||||||
|
LOG.w(f"[InputInterpreter] LLM 호출 실패(원문 폴백): {ex}")
|
||||||
|
return None
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
intent = result.get("intent")
|
||||||
|
if intent == "choice":
|
||||||
|
choice = result.get("choice")
|
||||||
|
# 결정: 선택지 목록에 철자 그대로 있어야만 채택 (LLM 이 지어낸 분기 차단).
|
||||||
|
if isinstance(choice, str) and choice in options:
|
||||||
|
return InterpretedInput(kind="choice", value=choice, source=choice)
|
||||||
|
LOG.w(f"[InputInterpreter] 검증 실패: choice={choice!r} ∉ {options}")
|
||||||
|
return None
|
||||||
|
if intent == "price":
|
||||||
|
span = result.get("price_text")
|
||||||
|
# 환각 차단: 가격 표현은 사용자 원문의 부분 문자열이어야 한다.
|
||||||
|
if not isinstance(span, str) or not span.strip() or span.strip() not in text:
|
||||||
|
LOG.w(f"[InputInterpreter] 검증 실패: price_text={span!r} 가 원문에 없음")
|
||||||
|
return None
|
||||||
|
price = parse_korean_price(span.strip()) # 숫자 계산은 결정론 파서가
|
||||||
|
if price is None:
|
||||||
|
LOG.w(f"[InputInterpreter] 검증 실패: 가격 해석 불가 span={span!r}")
|
||||||
|
return None
|
||||||
|
return InterpretedInput(kind="price", value=str(int(price)), source=span.strip())
|
||||||
|
return None # unknown → 원문 폴백(엔진 재질문)
|
||||||
@ -1,188 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -19,6 +19,7 @@ from typing import Optional
|
|||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.enums import DBType, DBWRType, ErrorType
|
from common.enums import DBType, DBWRType, ErrorType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
from negotiation.cards.domain.tactics import build_card_spec
|
||||||
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
|
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
|
||||||
from negotiation.qtable.domain.model.snapshot import PartnerType
|
from negotiation.qtable.domain.model.snapshot import PartnerType
|
||||||
|
|
||||||
@ -38,29 +39,30 @@ class NegotiationDbContext:
|
|||||||
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
|
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
|
||||||
target_price: int # 목표 매입가(원) — sessions.target_price
|
target_price: int # 목표 매입가(원) — sessions.target_price
|
||||||
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
|
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
|
||||||
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
|
done_ceiling_price: int # 타결 상한가 — sessions.done_ceiling_price(생성 시 박제). 없으면 target
|
||||||
|
item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
|
||||||
|
item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
|
||||||
|
labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
|
||||||
|
internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0
|
||||||
|
partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None
|
||||||
|
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
|
||||||
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
|
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
|
||||||
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
|
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
|
||||||
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None
|
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None
|
||||||
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
|
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
|
||||||
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
|
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
|
||||||
# ---- 자율 에이전트 v3 상태 특징 소스 (없으면 0/None — 특징은 중립 기본값으로 폴백) ----
|
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
|
||||||
internet_lowest_price: int = 0 # items.internet_lowest_price (미수집 0)
|
# 카드번호 → 전술 {offer_variable, min_round, closing}. 스크립트 파싱 + tactic JSONB 로 시작 시 1회 확정 —
|
||||||
deadline_end_ts: Optional[float] = None # 견적 마감(epoch 초) — quotations.end_time
|
# 진행 중 협상은 카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다(세션 컨텍스트에 박제).
|
||||||
deadline_total_s: Optional[float] = None # 협상 전체 기간(초) — end−start
|
card_specs: dict
|
||||||
hist_n: int = 0 # 이 협력사와의 과거 협상 횟수
|
|
||||||
hist_success: Optional[float] = None # 과거 성사율 (이력 없으면 None)
|
|
||||||
hist_settle_ratio: Optional[float] = None # 과거 평균 타결가/목표가 (성사 이력 없으면 None)
|
|
||||||
|
|
||||||
|
|
||||||
class NegotiationContextLoader:
|
class NegotiationContextLoader:
|
||||||
def __init__(self, crud: Optional[INegoContextCRUD] = None):
|
def __init__(self, crud: Optional[INegoContextCRUD] = None):
|
||||||
self.crud: INegoContextCRUD = crud or NegoContextCRUD()
|
self.crud: INegoContextCRUD = crud or NegoContextCRUD()
|
||||||
|
|
||||||
async def load(self, session_id: Optional[str],
|
async def load(self, session_id: Optional[str]) -> Optional[NegotiationDbContext]:
|
||||||
company_id: Optional[str] = None) -> Optional[NegotiationDbContext]:
|
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백)."""
|
||||||
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백).
|
|
||||||
company_id 는 협력사 이력 집계(experience_logs 테넌트 스코프)용 — 없으면 이력 특징 생략."""
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@ -72,8 +74,10 @@ class NegotiationContextLoader:
|
|||||||
err, row = await self.crud.get_session_row(s, sid)
|
err, row = await self.crud.get_session_row(s, sid)
|
||||||
if err != ErrorType.SUCCESS or row is None:
|
if err != ErrorType.SUCCESS or row is None:
|
||||||
return None
|
return None
|
||||||
qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row
|
qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id = row
|
||||||
target = int(target_price or 0)
|
target = int(target_price or 0)
|
||||||
|
# 타결 상한가: 견적 생성 시 박제(목표가×(1+타결상한율)). 옛 세션은 NULL → 목표가로 폴백.
|
||||||
|
ceiling = int(done_ceiling_price or 0) or target
|
||||||
|
|
||||||
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
||||||
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
|
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
|
||||||
@ -90,8 +94,16 @@ class NegotiationContextLoader:
|
|||||||
# 매핑이 없거나 미지정이면 None → 호출부 기본값.
|
# 매핑이 없거나 미지정이면 None → 호출부 기본값.
|
||||||
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
|
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
|
||||||
|
|
||||||
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
# 협상 기준가 + 그 호칭 — 어느 컬럼을 쓸지는 고객사 설정(hidden_fields)이 정한다(crud).
|
||||||
_, item_price = await self.crud.get_item_price(s, item_id)
|
# 없으면 0(인하율 멘트 미표시).
|
||||||
|
_, (item_price, item_price_label, labels) = await self.crud.get_item_baseline(s, item_id)
|
||||||
|
|
||||||
|
# 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환).
|
||||||
|
_, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id)
|
||||||
|
|
||||||
|
# 카드 스크립트 치환용 이름 — 협력사명/상품명. 없으면 None(호출부 기본값 폴백).
|
||||||
|
_, partner_name = await self.crud.get_supplier_name(s, supplier_id)
|
||||||
|
_, product_name = await self.crud.get_item_name(s, item_id)
|
||||||
|
|
||||||
# 파트너사 유형: 상품에 연결된 협력사 수 — supplier_items 매핑(등록 기준) 우선.
|
# 파트너사 유형: 상품에 연결된 협력사 수 — supplier_items 매핑(등록 기준) 우선.
|
||||||
# 매핑이 아직 없으면 협상 세션 이력 기준 폴백(더미보다 항상 낫다). 실패 시 SINGLE.
|
# 매핑이 아직 없으면 협상 세션 이력 기준 폴백(더미보다 항상 낫다). 실패 시 SINGLE.
|
||||||
@ -104,39 +116,41 @@ class NegotiationContextLoader:
|
|||||||
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
|
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
|
||||||
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
|
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
|
||||||
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
|
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
|
||||||
selected_nego_cards, selected_wild_cards = selected_cards
|
nego_rows, wild_rows = selected_cards
|
||||||
|
selected_nego_cards = [number for number, _script, _tactic in nego_rows]
|
||||||
|
selected_wild_cards = [number for number, _script, _tactic in wild_rows]
|
||||||
|
# 카드 전술 확정 — "스크립트에 꽂힌 변수가 곧 전술"(제안가 파싱) + tactic JSONB(min_round·closing).
|
||||||
|
card_specs = {}
|
||||||
|
for number, script, tactic in [*nego_rows, *wild_rows]:
|
||||||
|
spec = build_card_spec(script, tactic if isinstance(tactic, dict) else None)
|
||||||
|
card_specs[number] = {
|
||||||
|
"offer_variable": spec.offer_variable,
|
||||||
|
"min_round": spec.min_round,
|
||||||
|
"closing": spec.closing,
|
||||||
|
"requires": list(spec.requires), # 세션-의존 변수 결측 시 미발동(available)
|
||||||
|
}
|
||||||
|
|
||||||
# ---- 자율 에이전트 v3 특징 소스 (조회 실패는 전부 중립 폴백 — 협상은 계속돼야 한다) ----
|
# 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
|
||||||
_, internet_lowest = await self.crud.get_item_internet_lowest(s, item_id)
|
_, card_count = await self.crud.get_card_count(s, sid)
|
||||||
_, 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(
|
return NegotiationDbContext(
|
||||||
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
|
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
|
||||||
target_price=target,
|
target_price=target,
|
||||||
anchor_price=anchor,
|
anchor_price=anchor,
|
||||||
|
done_ceiling_price=ceiling,
|
||||||
item_price=item_price,
|
item_price=item_price,
|
||||||
|
item_price_label=item_price_label,
|
||||||
|
labels=labels,
|
||||||
|
internet_lowest_price=internet_lowest_price,
|
||||||
|
partner_name=partner_name,
|
||||||
|
product_name=product_name,
|
||||||
partner_type=PartnerType.from_count(supplier_count),
|
partner_type=PartnerType.from_count(supplier_count),
|
||||||
revenue_amount=revenue_amount,
|
revenue_amount=revenue_amount,
|
||||||
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
|
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
|
||||||
selected_nego_card_numbers=selected_nego_cards,
|
selected_nego_card_numbers=selected_nego_cards,
|
||||||
selected_wild_card_numbers=selected_wild_cards,
|
selected_wild_card_numbers=selected_wild_cards,
|
||||||
internet_lowest_price=internet_lowest,
|
card_count=card_count,
|
||||||
deadline_end_ts=deadline_end_ts,
|
card_specs=card_specs,
|
||||||
deadline_total_s=deadline_total_s,
|
|
||||||
hist_n=hist_n,
|
|
||||||
hist_success=hist_success,
|
|
||||||
hist_settle_ratio=hist_settle,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
144
agent/negotiation/chat/service/script_naturalizer.py
Normal file
144
agent/negotiation/chat/service/script_naturalizer.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""ScriptNaturalizer — 협상 카드 멘트를 LLM 으로 상황에 맞게 자연화 (Phase 2 표현층).
|
||||||
|
|
||||||
|
원칙: "숫자와 결정은 결정론이, 말은 LLM 이" —
|
||||||
|
- 입력은 **치환 전 템플릿**({input_price} 등 placeholder 유지 상태). LLM 은 숫자를 절대 만들지 않는다.
|
||||||
|
- 상황(라운드·가격구간·수용률)은 **정성 라벨**로만 전달(수치 미노출 → 숫자 환각 원천 차단).
|
||||||
|
- 검증 실패/타임아웃/미설정 시 None → 호출부가 원본 템플릿 폴백(협상은 절대 안 멈춤).
|
||||||
|
|
||||||
|
검증(ScriptVerifier 철학의 플레인 텍스트판):
|
||||||
|
① {placeholder} 집합이 원본과 정확히 동일(누락·추가 금지)
|
||||||
|
② 원본에 없던 숫자 등장 금지(가격 환각 차단)
|
||||||
|
③ 비어있지 않고 길이 폭주 금지
|
||||||
|
|
||||||
|
게이트: tenant llm.enabled(기본 false) + 전역 LLM 자격증명(config.local.toml [OpenAIConfig]
|
||||||
|
또는 OPENAI_API_KEY env). 호출은 스레드로 넘겨 이벤트루프 비차단 + 타임아웃.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from negotiation.profiling.config import LlmCredentials
|
||||||
|
|
||||||
|
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
|
||||||
|
_DIGITS_RE = re.compile(r"\d+")
|
||||||
|
# 강조 마커(negodata Slate 편집 정본) — 고객사가 지정한 표시라 자연화가 보존해야 한다.
|
||||||
|
# 색 마커 {{강조|..}} {{안내|..}} 는 여는 토큰 개수로, 굵게/밑줄은 구분자 개수(짝수=쌍)로 센다.
|
||||||
|
_COLOR_OPEN_RE = re.compile(r"\{\{(강조|안내)\|")
|
||||||
|
|
||||||
|
# 카드 메타 코드 → 프롬프트 라벨 (init-data.sql CardTone/CardStrategyType 정의와 동일)
|
||||||
|
_TONE_LABEL = {1: "강경", 2: "정중", 3: "우호", 4: "중립", 5: "단호"}
|
||||||
|
_STRATEGY_LABEL = {1: "경쟁", 2: "수용", 3: "고수", 4: "협력", 5: "선점", 6: "종결"}
|
||||||
|
|
||||||
|
_SYSTEM = """너는 B2B 구매 협상 챗봇의 문장 작성기다. 주어진 협상 카드 멘트 '템플릿'을 협상 상황에 맞게 자연스럽게 다시 쓴다.
|
||||||
|
|
||||||
|
규칙 (하나라도 어기면 출력은 폐기된다):
|
||||||
|
- {변수명} 치환자는 철자 그대로 유지한다. 추가/삭제/변경 금지.
|
||||||
|
- 숫자를 직접 쓰지 마라. 가격·비율 등 모든 수치는 치환자로만 표현한다.
|
||||||
|
- 강조 마커(**굵게** __밑줄__ {{강조|...}} {{안내|...}})는 **개수와 종류를 그대로 유지**한다.
|
||||||
|
고객사가 지정한 강조 표시이므로 삭제·추가·종류변경 금지. 감싼 문구는 자연스럽게 바꿔도 되지만
|
||||||
|
강조된 구절 수만큼 같은 마커로 반드시 다시 감싼다(예: **굵게** 2개면 결과도 **…** 2쌍).
|
||||||
|
- 새로운 약속·할인 조건·법적 표현을 만들지 마라. 원 템플릿의 협상 의도(전술)는 유지한다.
|
||||||
|
- 한국어 존댓말, 2~5문장, 채팅 말풍선에 어울리게 간결히.
|
||||||
|
- 출력은 JSON 하나만: {"script": "다시 쓴 멘트"}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _default_llm_call(messages: List[dict]) -> dict:
|
||||||
|
"""기본 LLM 호출(동기) — 전역 자격증명으로 chat_json. 테스트에서 monkeypatch 지점."""
|
||||||
|
from negotiation.profiling.infra.llm_adapter import chat_json
|
||||||
|
|
||||||
|
return chat_json(messages, temperature=0.5, max_tokens=600)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptNaturalizer:
|
||||||
|
def __init__(self, llm_call: Optional[Callable[[List[dict]], dict]] = None,
|
||||||
|
timeout_seconds: float = 8.0):
|
||||||
|
self._llm_call = llm_call or _default_llm_call
|
||||||
|
self._timeout = timeout_seconds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available() -> bool:
|
||||||
|
"""전역 LLM 자격증명이 설정돼 있는가 (테넌트 enabled 게이트는 호출부 몫)."""
|
||||||
|
return LlmCredentials.from_config().is_configured()
|
||||||
|
|
||||||
|
async def naturalize(self, template: str, *, situation: Optional[Dict[str, Any]] = None,
|
||||||
|
tone: Optional[int] = None, strategy: Optional[int] = None) -> Optional[str]:
|
||||||
|
"""템플릿(치환 전)을 상황 맞춤 문장으로 재작성. 실패/검증불통과 시 None(호출부 폴백)."""
|
||||||
|
if not template or not template.strip():
|
||||||
|
return None
|
||||||
|
ctx = dict(situation or {})
|
||||||
|
if tone in _TONE_LABEL:
|
||||||
|
ctx["톤"] = _TONE_LABEL[tone]
|
||||||
|
if strategy in _STRATEGY_LABEL:
|
||||||
|
ctx["전략"] = _STRATEGY_LABEL[strategy]
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _SYSTEM},
|
||||||
|
{"role": "user", "content":
|
||||||
|
"템플릿:\n" + template +
|
||||||
|
"\n\n협상 상황:\n" + json.dumps(ctx, ensure_ascii=False) +
|
||||||
|
'\n\n규칙대로 다시 써서 {"script": "..."} 로만 출력.'},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(asyncio.to_thread(self._llm_call, messages), self._timeout)
|
||||||
|
except Exception as ex: # 타임아웃 포함 — 폴백
|
||||||
|
LOG.w(f"[ScriptNaturalizer] LLM 호출 실패(폴백): {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = result.get("script") if isinstance(result, dict) else None
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
return None
|
||||||
|
return text if self._verify(template, text) else None
|
||||||
|
|
||||||
|
# ---- 검증 ----------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _verify(template: str, rewritten: str) -> bool:
|
||||||
|
orig = set(_PLACEHOLDER_RE.findall(template))
|
||||||
|
new = set(_PLACEHOLDER_RE.findall(rewritten))
|
||||||
|
if orig != new:
|
||||||
|
LOG.w(f"[ScriptNaturalizer] 검증 실패: 치환자 불일치 (누락={orig - new}, 추가={new - orig})")
|
||||||
|
return False
|
||||||
|
# 원본에 없던 숫자 금지 — 가격/비율 환각 차단 (수치는 치환자로만).
|
||||||
|
orig_digits = set(_DIGITS_RE.findall(template))
|
||||||
|
new_digits = set(_DIGITS_RE.findall(rewritten)) - orig_digits
|
||||||
|
if new_digits:
|
||||||
|
LOG.w(f"[ScriptNaturalizer] 검증 실패: 새 숫자 등장 {new_digits}")
|
||||||
|
return False
|
||||||
|
if len(rewritten) > max(600, len(template) * 4):
|
||||||
|
LOG.w("[ScriptNaturalizer] 검증 실패: 길이 폭주")
|
||||||
|
return False
|
||||||
|
# 강조 마커 보존 — 고객사가 지정한 볼드/밑줄/색을 LLM 이 떨어뜨리면 폐기(원본 폴백).
|
||||||
|
# 굵게/밑줄: 구분자 총 개수가 같아야 짝(쌍)이 보존됨. 색: 여는 토큰 개수 동일.
|
||||||
|
if (template.count("**") != rewritten.count("**")
|
||||||
|
or template.count("__") != rewritten.count("__")
|
||||||
|
or len(_COLOR_OPEN_RE.findall(template)) != len(_COLOR_OPEN_RE.findall(rewritten))):
|
||||||
|
LOG.w("[ScriptNaturalizer] 검증 실패: 강조 마커 불일치(볼드/색 소실) → 원본 유지")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_situation(context: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심).
|
||||||
|
|
||||||
|
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 협상 기준가 대비.
|
||||||
|
"""
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
rnd = context.get("round") or 0
|
||||||
|
if rnd:
|
||||||
|
out["라운드"] = "첫 제안" if rnd <= 1 else ("초반 조율" if rnd == 2 else "막바지 조율")
|
||||||
|
price = context.get("input_price") or 0
|
||||||
|
anchor = context.get("anchor_price") or 0
|
||||||
|
target = context.get("target_price") or 0
|
||||||
|
if price and anchor and target:
|
||||||
|
if price <= anchor:
|
||||||
|
out["가격구간"] = "목표 범위 도달(마무리 국면)"
|
||||||
|
elif price <= target:
|
||||||
|
out["가격구간"] = "목표 범위 근접(조율 국면)"
|
||||||
|
else:
|
||||||
|
out["가격구간"] = "목표 상회(추가 인하 필요)"
|
||||||
|
base = context.get("item_price") or 0
|
||||||
|
if base and price:
|
||||||
|
rate = (base - price) / base
|
||||||
|
out["인하 진행"] = "아직 미미" if rate < 0.01 else ("일부 진행" if rate < 0.05 else "상당히 진행")
|
||||||
|
return out
|
||||||
@ -38,6 +38,11 @@ class ScriptRepository:
|
|||||||
# 카드 멘트 DB 소스(backoffice_db). file 모드면 미사용.
|
# 카드 멘트 DB 소스(backoffice_db). file 모드면 미사용.
|
||||||
self._card_repo: ICardScriptRepository = card_repo or CardScriptDbRepository()
|
self._card_repo: ICardScriptRepository = card_repo or CardScriptDbRepository()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> TenantConfig:
|
||||||
|
"""테넌트 config 노출 — ChatEngine 이 협상 규칙(negotiation.*)을 읽는다."""
|
||||||
|
return self._config
|
||||||
|
|
||||||
# ---- 경로 해석 (_base 폴백) ---------------------------------------
|
# ---- 경로 해석 (_base 폴백) ---------------------------------------
|
||||||
def _resource_path(self, filename: str) -> Optional[str]:
|
def _resource_path(self, filename: str) -> Optional[str]:
|
||||||
scripts_dir = self._config.resources.scripts_dir
|
scripts_dir = self._config.resources.scripts_dir
|
||||||
@ -89,25 +94,47 @@ class ScriptRepository:
|
|||||||
return None
|
return None
|
||||||
return self.format_script(text, variables)
|
return self.format_script(text, variables)
|
||||||
|
|
||||||
|
async def resolve_card_template(self, action_id: int, card_id: Optional[str],
|
||||||
|
prefer_db: bool = False) -> tuple:
|
||||||
|
"""카드 멘트의 **치환 전 템플릿**과 메타를 해석 → (template, tone, strategy_type).
|
||||||
|
|
||||||
|
cards.source_type == 'backoffice_db' 면 card.nego_cards(DB, tone/strategy 포함) 우선,
|
||||||
|
없거나 file 모드면 scripts_cards.json(파일, 메타 None) 폴백. 둘 다 없으면 (None, None, None).
|
||||||
|
치환 전 템플릿을 그대로 주는 이유: LLM 표현층이 placeholder 를 유지한 채 재작성한 뒤
|
||||||
|
format_script 로 치환해야 숫자를 LLM 이 절대 만지지 않기 때문.
|
||||||
|
"""
|
||||||
|
if (prefer_db or self._config.cards.source_type == _CARD_SOURCE_DB) and card_id:
|
||||||
|
card = await self._fetch_card_db(card_id)
|
||||||
|
if card:
|
||||||
|
return card # (script, tone, strategy)
|
||||||
|
return self.card_scripts().get(str(action_id)), None, None # 파일 폴백(메타 없음)
|
||||||
|
|
||||||
async def resolve_card_script(self, action_id: int, card_id: Optional[str],
|
async def resolve_card_script(self, action_id: int, card_id: Optional[str],
|
||||||
variables: Optional[Dict[str, Any]] = None,
|
variables: Optional[Dict[str, Any]] = None,
|
||||||
prefer_db: bool = False) -> Optional[str]:
|
prefer_db: bool = False) -> Optional[str]:
|
||||||
"""카드 멘트 해석. cards.source_type == 'backoffice_db' 면 card.nego_cards.script(DB)를
|
"""카드 멘트 해석(템플릿 + 변수 치환). DB(정본) 우선 → 파일 폴백. 마커는 불투명 텍스트."""
|
||||||
우선 조회하고, 없거나 file 모드면 scripts_cards.json(파일) 폴백. 변수 치환 후 반환.
|
template, _, _ = await self.resolve_card_template(action_id, card_id, prefer_db=prefer_db)
|
||||||
|
if not template:
|
||||||
|
return None
|
||||||
|
return self.format_script(template, variables)
|
||||||
|
|
||||||
DB 멘트는 백오피스(negodata)가 편집한 정본이라 파일보다 우선한다. 마커(**굵게** 등)가
|
async def resolve_wild_card_template(self, number: str) -> Optional[str]:
|
||||||
섞여 있어도 agent 는 불투명 텍스트로 취급 — 표현 렌더는 프론트 소유.
|
"""선택형 와일드카드(WC-*) 멘트 템플릿 — card.wild_cards(DB, negodata 편집 정본) 조회.
|
||||||
"""
|
없거나 DB 불가면 None(호출부가 스텝 기본 멘트 폴백)."""
|
||||||
if (prefer_db or self._config.cards.source_type == _CARD_SOURCE_DB) and card_id:
|
|
||||||
db_text = await self._fetch_card_script_db(card_id)
|
|
||||||
if db_text:
|
|
||||||
return self.format_script(db_text, variables)
|
|
||||||
return self.card_script(action_id, variables) # 파일 폴백
|
|
||||||
|
|
||||||
async def _fetch_card_script_db(self, card_id: str) -> Optional[str]:
|
|
||||||
async def _q(s):
|
async def _q(s):
|
||||||
_, text = await self._card_repo.get_script_by_number(s, card_id)
|
_, script = await self._card_repo.get_wild_card_by_number(s, number)
|
||||||
return text
|
return script
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(f"[ScriptRepository] 와일드카드 멘트 DB 조회 실패 number={number}: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_card_db(self, card_id: str) -> Optional[tuple]:
|
||||||
|
async def _q(s):
|
||||||
|
_, card = await self._card_repo.get_card_by_number(s, card_id)
|
||||||
|
return card
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
"""완전 자율 협상 행동 공간 (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()
|
|
||||||
@ -41,6 +41,9 @@ class PolicyContext:
|
|||||||
action_space_size: int
|
action_space_size: int
|
||||||
episode: EpisodeState
|
episode: EpisodeState
|
||||||
available_mask: Optional[np.ndarray] = None # None 이면 used_action_ids 로 산출
|
available_mask: Optional[np.ndarray] = None # None 이면 used_action_ids 로 산출
|
||||||
|
# 의도층 prior(Phase 1): 갑이 견적에서 고른 카드 순서 등 사전 선호. 방문수로 감쇠되어
|
||||||
|
# 콜드 스타트 선택만 편향하고 학습(Q)이 쌓이면 영향이 소멸한다 — Q-table 오염 없음.
|
||||||
|
prior_bonus: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -36,7 +36,8 @@ class UCBQTablePolicy(NegotiationPolicy):
|
|||||||
avail = [a for a in range(ctx.action_space_size) if a not in used]
|
avail = [a for a in range(ctx.action_space_size) if a not in used]
|
||||||
return avail or list(range(ctx.action_space_size)) # 다 썼으면 전체 허용
|
return avail or list(range(ctx.action_space_size)) # 다 썼으면 전체 허용
|
||||||
|
|
||||||
def _ucb_scores(self, state_index: int, available: List[int]) -> np.ndarray:
|
def _ucb_scores(self, state_index: int, available: List[int],
|
||||||
|
prior: "np.ndarray | None" = None) -> np.ndarray:
|
||||||
q = self.qtable.row(state_index)
|
q = self.qtable.row(state_index)
|
||||||
visits = self.qtable.visit_row(state_index)
|
visits = self.qtable.visit_row(state_index)
|
||||||
total = self.qtable.state_visits(state_index)
|
total = self.qtable.state_visits(state_index)
|
||||||
@ -45,11 +46,15 @@ class UCBQTablePolicy(NegotiationPolicy):
|
|||||||
for a in available:
|
for a in available:
|
||||||
bonus = self.c * math.sqrt(ln / (visits[a] + 1e-6))
|
bonus = self.c * math.sqrt(ln / (visits[a] + 1e-6))
|
||||||
scores[a] = q[a] + bonus
|
scores[a] = q[a] + bonus
|
||||||
|
if prior is not None:
|
||||||
|
# 의도층 prior — 방문수 감쇠: 콜드 스타트 동점(전부 Q=0·bonus=0)일 때만 순서를
|
||||||
|
# 결정하고, 학습이 쌓이면 1/(1+visits) 로 사라진다.
|
||||||
|
scores[a] += float(prior[a]) / (1.0 + visits[a])
|
||||||
return scores
|
return scores
|
||||||
|
|
||||||
def select(self, ctx: PolicyContext) -> ActionDecision:
|
def select(self, ctx: PolicyContext) -> ActionDecision:
|
||||||
available = self._available(ctx)
|
available = self._available(ctx)
|
||||||
scores = self._ucb_scores(ctx.state_index, available)
|
scores = self._ucb_scores(ctx.state_index, available, prior=ctx.prior_bonus)
|
||||||
action_id = int(np.argmax(scores))
|
action_id = int(np.argmax(scores))
|
||||||
n = len(available)
|
n = len(available)
|
||||||
# ε-greedy 근사 propensity (greedy 액션)
|
# ε-greedy 근사 propensity (greedy 액션)
|
||||||
|
|||||||
@ -1,152 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -53,6 +53,9 @@ class Res_Chat(Res_WebPacketProtocol):
|
|||||||
# 성공 확정 이후 턴(협상완료 요약·협상종료)에 내려주는 합의가. 와일드카드 1% 인하 수락 등
|
# 성공 확정 이후 턴(협상완료 요약·협상종료)에 내려주는 합의가. 와일드카드 1% 인하 수락 등
|
||||||
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어, backend 요약/입찰가는 이 값을 최우선 사용한다.
|
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어, backend 요약/입찰가는 이 값을 최우선 사용한다.
|
||||||
settled_price: Optional[int] = None
|
settled_price: Optional[int] = None
|
||||||
|
# Phase 3 이해층: 자유 발화를 NLU 로 해석해 진행한 경우, 엔진에 실제 전달된 입력.
|
||||||
|
# (예: "만원까지는 어렵고 10,500원이면 가능합니다" → "10500") 미해석/정형 입력이면 None.
|
||||||
|
interpreted_input: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Res_ChatSession(Res_WebPacketProtocol):
|
class Res_ChatSession(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -13,16 +13,19 @@ from common.enums import DBType, ErrorType
|
|||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from config.server_configs import agent_config
|
from config.server_configs import agent_config
|
||||||
from negotiation.chat.service import ment_generator
|
from negotiation.cards.domain.tactics import (
|
||||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView
|
Offer, available, compute_offer_detail, is_played, mark_played, playable, record_offer, spec_from_context,
|
||||||
|
)
|
||||||
|
from negotiation.chat.service.chat_engine import (
|
||||||
|
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
|
||||||
|
)
|
||||||
from negotiation.chat.service.indicator import compute_indicator
|
from negotiation.chat.service.indicator import compute_indicator
|
||||||
|
from negotiation.chat.service.input_interpreter import SIMPLE_PRICE_RE, InputInterpreter
|
||||||
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
|
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
|
||||||
from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader
|
from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader
|
||||||
|
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer, build_situation
|
||||||
from negotiation.chat.service.script_repository import ScriptRepository
|
from negotiation.chat.service.script_repository import ScriptRepository
|
||||||
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
|
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.policy.model_store import QTablePolicyStore
|
||||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot, PartnerType
|
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot, PartnerType
|
||||||
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||||
@ -38,9 +41,19 @@ _DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가
|
|||||||
_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
|
_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
|
||||||
_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백
|
_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백
|
||||||
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
|
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
|
||||||
|
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
|
||||||
|
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
|
||||||
|
_DEFAULT_ITEM_PRICE_LABEL = "공급가" # 협상 기준가 호칭(공급사 화면 고정 용어) — DB 컨텍스트 없는 데모/직접호출 경로 폴백
|
||||||
|
# (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다)
|
||||||
|
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
|
# Phase 2 표현층 / Phase 3 이해층 — 테넌트 llm.enabled + 전역 자격증명일 때만 사용(기본 무동작).
|
||||||
|
# 클래스 속성인 이유: FastAPI 가 ChatService 를 Depends 로 쓰므로 __init__ 파라미터를 두면
|
||||||
|
# 쿼리 파라미터로 해석된다. 테스트는 인스턴스 속성으로 덮어 주입한다.
|
||||||
|
_naturalizer = ScriptNaturalizer()
|
||||||
|
_interpreter = InputInterpreter()
|
||||||
|
|
||||||
async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat:
|
async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat:
|
||||||
res = Res_Chat()
|
res = Res_Chat()
|
||||||
repo = ScriptRepository(engine.config, agent_config.tenants_dir)
|
repo = ScriptRepository(engine.config, agent_config.tenants_dir)
|
||||||
@ -50,19 +63,9 @@ class ChatService:
|
|||||||
session = await sess_repo.get(req.session_id) if req.session_id else None
|
session = await sess_repo.get(req.session_id) if req.session_id else None
|
||||||
# 새 세션 컨텍스트: 요청 페이로드 대신 DB(negotiation.sessions 등)에서 1회 조회.
|
# 새 세션 컨텍스트: 요청 페이로드 대신 DB(negotiation.sessions 등)에서 1회 조회.
|
||||||
# 행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
|
# 행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
|
||||||
db_ctx = None if session else await NegotiationContextLoader().load(req.session_id, engine.company_id)
|
db_ctx = None if session else await NegotiationContextLoader().load(req.session_id)
|
||||||
rq_type = session.rq_type if session else (db_ctx.rq_type if db_ctx else _DEFAULT_RQ_TYPE)
|
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)
|
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 과 다르면 경고.
|
# ① step desync 감지: backend 가 본 직전 봇 step(client_step)이 agent 세션 step 과 다르면 경고.
|
||||||
# agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴),
|
# agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴),
|
||||||
@ -83,8 +86,12 @@ class ChatService:
|
|||||||
selected_wild_cards = db_ctx.selected_wild_card_numbers if db_ctx else []
|
selected_wild_cards = db_ctx.selected_wild_card_numbers if db_ctx else []
|
||||||
# 운영 DB 세션은 견적 version_id 에 묶인 카드만 사용한다. 직접 호출/데모(DB context 없음)는
|
# 운영 DB 세션은 견적 version_id 에 묶인 카드만 사용한다. 직접 호출/데모(DB context 없음)는
|
||||||
# 기존 테넌트 기본 action mapping 으로 폴백해 로컬 테스트와 콘솔 데모를 유지한다.
|
# 기존 테넌트 기본 action mapping 으로 폴백해 로컬 테스트와 콘솔 데모를 유지한다.
|
||||||
|
# 협상카드 사용 횟수 상한(quotation_settings.card_count)으로 실제 플레이 가능한 카드 수를 캡한다 —
|
||||||
|
# session.action_space_size 는 카드 소진 판정(cards_total) 전용이라 여기서 줄여도 Q-table 은
|
||||||
|
# engine.action_space_size(카탈로그 전체)로 별도 고정된다. card_count 미설정(None)이면 선택 수 그대로.
|
||||||
|
card_cap = db_ctx.card_count if (db_ctx and db_ctx.card_count and db_ctx.card_count > 0) else None
|
||||||
action_space_size = (
|
action_space_size = (
|
||||||
min(len(selected_nego_cards), engine.action_space_size)
|
min(len(selected_nego_cards), engine.action_space_size, *( [card_cap] if card_cap else [] ))
|
||||||
if db_ctx is not None
|
if db_ctx is not None
|
||||||
else engine.action_space_size
|
else engine.action_space_size
|
||||||
)
|
)
|
||||||
@ -105,28 +112,40 @@ class ChatService:
|
|||||||
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
|
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
|
||||||
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE,
|
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE,
|
||||||
"target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
|
"target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
|
||||||
|
# 타결 상한가(sessions.done_ceiling_price 박제) — 타결 판정선이자 카드 제안가 상한.
|
||||||
|
# 목표가를 조금 넘어도 이 이하면 타결한다. 미박제/데모는 목표가와 같다.
|
||||||
|
"done_ceiling_price": db_ctx.done_ceiling_price if db_ctx else _DEFAULT_TARGET_PRICE,
|
||||||
|
# 협력사명/상품명 — 카드 스크립트 {partner_name}·{product_name} 치환용(loader). 없으면 폴백.
|
||||||
|
"partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME),
|
||||||
|
"product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME),
|
||||||
"round": 0,
|
"round": 0,
|
||||||
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
|
# 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 가격협상_확인 인하율 산출용.
|
||||||
|
# 호칭은 회사 설정 라벨을 따른다("기존 {label} 대비 …" 멘트).
|
||||||
"item_price": db_ctx.item_price if db_ctx else 0,
|
"item_price": db_ctx.item_price if db_ctx else 0,
|
||||||
|
"item_price_label": db_ctx.item_price_label if db_ctx else _DEFAULT_ITEM_PRICE_LABEL,
|
||||||
|
# 회사 용어 사전 — 스크립트의 {label_*} 토큰(협력사·목표가·배송형태 등) 치환용.
|
||||||
|
"labels": (db_ctx.labels if db_ctx else {}),
|
||||||
|
# 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용.
|
||||||
|
# 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지).
|
||||||
|
"internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0,
|
||||||
# 견적 생성 시 선택한 카드. 일반카드는 action_id 0..N-1 에 그대로 매핑한다.
|
# 견적 생성 시 선택한 카드. 일반카드는 action_id 0..N-1 에 그대로 매핑한다.
|
||||||
# 1% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는
|
# 1% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는
|
||||||
# 선택된 와일드카드가 있을 때만 허용한다.
|
# 선택된 와일드카드가 있을 때만 허용한다.
|
||||||
"db_context_loaded": db_ctx is not None,
|
"db_context_loaded": db_ctx is not None,
|
||||||
"selected_nego_card_numbers": selected_nego_cards,
|
"selected_nego_card_numbers": selected_nego_cards,
|
||||||
"selected_wild_card_numbers": selected_wild_cards,
|
"selected_wild_card_numbers": selected_wild_cards,
|
||||||
|
# 카드번호 → 전술 {offer_variable, min_round, closing}. 시작 시 1회 박제(loader) —
|
||||||
|
# 이후 카드 멘트가 바뀌어도 이 협상은 시작 시점 전술로 끝까지 간다.
|
||||||
|
"card_specs": (db_ctx.card_specs if db_ctx else {}),
|
||||||
"allow_selected_wildcards": True if db_ctx is None else bool(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)
|
view = chat_engine.start(session)
|
||||||
else:
|
else:
|
||||||
view = chat_engine.advance(session, req.user_input)
|
# Phase 3 이해층: 자유 발화(버튼/정형 입력이 아닌 텍스트)를 기대 입력으로 해석.
|
||||||
|
# 해석 실패/미설정 시 원문 그대로 → 기존 엔진 재질문 흐름 유지.
|
||||||
|
user_input = await self._interpret_input(engine, chat_engine, session, req.user_input, res)
|
||||||
|
view = chat_engine.advance(session, user_input)
|
||||||
|
|
||||||
# 2) 응답 기본 채움 (학습 블록이 가격협상 턴에서 script/indicator 를 덮어쓸 수 있어 먼저 채운다)
|
# 2) 응답 기본 채움 (학습 블록이 가격협상 턴에서 script/indicator 를 덮어쓸 수 있어 먼저 채운다)
|
||||||
res.session_id = session.session_id
|
res.session_id = session.session_id
|
||||||
@ -143,6 +162,18 @@ class ChatService:
|
|||||||
if session.context.get("final_outcome") == "success" and session.context.get("input_price"):
|
if session.context.get("final_outcome") == "success" and session.context.get("input_price"):
|
||||||
res.settled_price = int(session.context["input_price"])
|
res.settled_price = int(session.context["input_price"])
|
||||||
|
|
||||||
|
# 선택형 와일드카드 턴(wild_card_dynamic): 멘트 정본은 card.wild_cards(negodata 편집).
|
||||||
|
# DB 멘트가 있으면 스텝 기본 멘트를 대체하고, 없으면 기본 멘트(counter_price 치환)로 진행.
|
||||||
|
if view.step == "wild_card_dynamic" and session.context.get("active_wild_card_number"):
|
||||||
|
number = session.context["active_wild_card_number"]
|
||||||
|
template = await repo.resolve_wild_card_template(number)
|
||||||
|
if template:
|
||||||
|
if engine.config.llm.enabled and ScriptNaturalizer.available():
|
||||||
|
template = (await self._naturalizer.naturalize(
|
||||||
|
template, situation=build_situation(session.context))) or template
|
||||||
|
res.script = repo.format_script(template, chat_engine.vars_for(session))
|
||||||
|
res.card_id = number
|
||||||
|
|
||||||
# 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
|
# 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
|
||||||
if view.error is None and session.action_space_size > 0:
|
if view.error is None and session.action_space_size > 0:
|
||||||
if view.needs_card_selection:
|
if view.needs_card_selection:
|
||||||
@ -150,17 +181,6 @@ class ChatService:
|
|||||||
elif view.outcome is not None:
|
elif view.outcome is not None:
|
||||||
await self._terminal_learn(engine, session, view.outcome, res)
|
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:
|
if view.error:
|
||||||
res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
|
res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
|
||||||
res.msg = view.error
|
res.msg = view.error
|
||||||
@ -186,6 +206,53 @@ class ChatService:
|
|||||||
res.found = True
|
res.found = True
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# ---- Phase 3 이해층 (자유 발화 NLU) ---------------------------------
|
||||||
|
async def _interpret_input(self, engine: TenantEngine, chat_engine: ChatEngine,
|
||||||
|
session: ChatSession, user_input: Optional[str], res: Res_Chat) -> Optional[str]:
|
||||||
|
"""자유 발화를 현재 step 의 기대 입력으로 해석해 엔진에 넘길 문자열을 돌려준다.
|
||||||
|
|
||||||
|
결정론 fast path 우선: 정형 가격([\\d,]+원?)·버튼 값 그대로면 LLM 을 부르지 않는다.
|
||||||
|
자유 텍스트 + llm.enabled + 자격증명일 때만 InputInterpreter 호출. 해석 실패/미설정이면
|
||||||
|
원문 그대로 반환 → 기존 엔진의 재질문/기본분기 흐름이 그대로 동작(협상 불중단).
|
||||||
|
"""
|
||||||
|
if user_input is None:
|
||||||
|
return user_input
|
||||||
|
raw = str(user_input).strip()
|
||||||
|
if not raw:
|
||||||
|
return user_input
|
||||||
|
node = chat_engine.scripts.get(session.step, {})
|
||||||
|
mode = node.get("next_input_mode", "null")
|
||||||
|
options: list = []
|
||||||
|
if mode in _PRICE_MODES:
|
||||||
|
if SIMPLE_PRICE_RE.fullmatch(raw):
|
||||||
|
return user_input # 정형 가격 — 기존 결정론 파서 경로
|
||||||
|
elif mode in _CHOICE_MODES:
|
||||||
|
options = self._step_options(node)
|
||||||
|
if raw in options:
|
||||||
|
return user_input # 버튼 값 그대로 — 결정론 경로
|
||||||
|
else:
|
||||||
|
return user_input # 입력을 받지 않는 스텝
|
||||||
|
if not (engine.config.llm.enabled and InputInterpreter.available()):
|
||||||
|
return user_input
|
||||||
|
out = await self._interpreter.interpret(raw, input_mode=mode, input_options=options,
|
||||||
|
step_script=node.get("script"))
|
||||||
|
if out is None:
|
||||||
|
return user_input
|
||||||
|
LOG.i(f"[ChatService] NLU: {raw!r} → {out.kind}={out.value!r} (근거={out.source!r}) session={session.session_id}")
|
||||||
|
res.interpreted_input = out.value # 투명성: backend/front 가 해석 결과를 표시할 수 있게
|
||||||
|
return out.value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _step_options(node: dict) -> list:
|
||||||
|
"""현재 step 이 허용하는 선택지 — input_options 우선, next_step 분기 키 보강."""
|
||||||
|
opts = [str(o) for o in (node.get("input_options") or [])]
|
||||||
|
ns = node.get("next_step")
|
||||||
|
if isinstance(ns, dict):
|
||||||
|
for k in ns.keys():
|
||||||
|
if k != "default" and str(k) not in opts:
|
||||||
|
opts.append(str(k))
|
||||||
|
return opts
|
||||||
|
|
||||||
# ---- 학습 ----------------------------------------------------------
|
# ---- 학습 ----------------------------------------------------------
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _acceptance_ratio(context: dict) -> float:
|
def _acceptance_ratio(context: dict) -> float:
|
||||||
@ -215,6 +282,11 @@ class ChatService:
|
|||||||
|
|
||||||
async def _select_and_learn(self, engine: TenantEngine, chat_engine: ChatEngine,
|
async def _select_and_learn(self, engine: TenantEngine, chat_engine: ChatEngine,
|
||||||
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
||||||
|
# 종결 국면(라운드 만료·카드 소진, 엔진 check_iteration_limit 이 마킹): 규칙층이
|
||||||
|
# 종결 전술(중간값 절충/최후통첩)을 강제 발동한다 — RL 선택·학습 대상이 아니다.
|
||||||
|
if session.context.pop("force_closing", False):
|
||||||
|
await self._play_closing_tactic(engine, chat_engine, scripts, session, res)
|
||||||
|
return
|
||||||
snap = self._snapshot(session, NegotiationOutcome.ONGOING)
|
snap = self._snapshot(session, NegotiationOutcome.ONGOING)
|
||||||
try:
|
try:
|
||||||
idx = state_index(snap, engine.config.state)
|
idx = state_index(snap, engine.config.state)
|
||||||
@ -224,28 +296,35 @@ class ChatService:
|
|||||||
policy, version_id, repo = await QTablePolicyStore.load(engine)
|
policy, version_id, repo = await QTablePolicyStore.load(engine)
|
||||||
# action space 는 카탈로그 전체(engine.action_space_size)로 고정 — action_id↔카드 대응을
|
# action space 는 카탈로그 전체(engine.action_space_size)로 고정 — action_id↔카드 대응을
|
||||||
# 견적마다 일정하게 유지해 Q-table 학습 일관성을 지킨다. 견적 선택은 축소가 아니라
|
# 견적마다 일정하게 유지해 Q-table 학습 일관성을 지킨다. 견적 선택은 축소가 아니라
|
||||||
# available_mask 로 처리한다(선택 카드만 pickable, 사용분 제외).
|
# available_mask 로 처리한다(선택 카드만 pickable, 사용분 제외 + 전술 발동조건 AND).
|
||||||
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size,
|
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size,
|
||||||
available_mask=self._selection_mask(engine, session),
|
available_mask=self._combined_mask(engine, session),
|
||||||
|
prior_bonus=self._selection_prior(engine, session),
|
||||||
episode=EpisodeState(used_action_ids=set(session.used_action_ids)))
|
episode=EpisodeState(used_action_ids=set(session.used_action_ids)))
|
||||||
# 카드 '선택'은 DQN 서빙(활성 시), 학습/영속은 아래 Q-table 경로 그대로(오프폴리시 갱신).
|
decision = policy.select(ctx)
|
||||||
# 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)
|
session.used_action_ids.add(decision.action_id)
|
||||||
card_id = self._card_id_for_action(engine, session, decision.action_id)
|
card_id = self._card_id_for_action(engine, session, decision.action_id)
|
||||||
|
# 카드번호 공용 이력 — 와일드/종결 경로와 같은 목록을 본다("한 협상 한 카드 1회" 단일 판정).
|
||||||
|
mark_played(session.context, card_id)
|
||||||
|
# 전술 실행: 카드가 제시할 금액(스크립트 파싱 결과)을 계산해 세션에 적재한다.
|
||||||
|
# pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고,
|
||||||
|
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
|
||||||
|
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
|
||||||
|
spec = spec_from_context(session.context, card_id)
|
||||||
|
offer = compute_offer_detail(spec, session.context) if available(spec, session.context) else None
|
||||||
|
counter = offer.price if offer else None
|
||||||
|
if offer is not None:
|
||||||
|
record_offer(session.context, offer)
|
||||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
||||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||||
session.context["last_state"] = idx
|
session.context["last_state"] = idx
|
||||||
session.context["last_action"] = decision.action_id
|
session.context["last_action"] = decision.action_id
|
||||||
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
|
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, done=False,
|
||||||
|
decision=decision, policy=policy)
|
||||||
|
|
||||||
res.card_id = card_id
|
res.card_id = card_id
|
||||||
res.policy = selector_name
|
res.policy = policy.name
|
||||||
res.q_value = decision.q_value
|
res.q_value = decision.q_value
|
||||||
res.updated_q = float(policy.qtable.q[idx, decision.action_id])
|
res.updated_q = float(policy.qtable.q[idx, decision.action_id])
|
||||||
res.visit_count = int(policy.qtable.visits[idx, decision.action_id])
|
res.visit_count = int(policy.qtable.visits[idx, decision.action_id])
|
||||||
@ -255,12 +334,17 @@ class ChatService:
|
|||||||
# ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉.
|
# ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉.
|
||||||
# backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다.
|
# backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다.
|
||||||
# 카드 멘트: backoffice_db 모드면 card.nego_cards.script(negodata 편집 정본), 아니면 파일 폴백.
|
# 카드 멘트: backoffice_db 모드면 card.nego_cards.script(negodata 편집 정본), 아니면 파일 폴백.
|
||||||
card_script = await scripts.resolve_card_script(
|
template, tone, strategy = await scripts.resolve_card_template(
|
||||||
decision.action_id,
|
decision.action_id, card_id,
|
||||||
card_id,
|
|
||||||
chat_engine.vars_for(session),
|
|
||||||
prefer_db=bool(session.context.get("selected_nego_card_numbers")),
|
prefer_db=bool(session.context.get("selected_nego_card_numbers")),
|
||||||
)
|
)
|
||||||
|
# Phase 2 표현층: llm.enabled(테넌트) + 자격증명 있으면 템플릿을 상황 맞춤 자연화.
|
||||||
|
# placeholder 유지 상태로 재작성 → 검증(치환자/숫자) → 실패·타임아웃 시 원본 폴백.
|
||||||
|
if template and engine.config.llm.enabled and ScriptNaturalizer.available():
|
||||||
|
naturalized = await self._naturalizer.naturalize(
|
||||||
|
template, situation=build_situation(session.context), tone=tone, strategy=strategy)
|
||||||
|
template = naturalized or template
|
||||||
|
card_script = scripts.format_script(template, chat_engine.vars_for(session)) if template else None
|
||||||
if card_script:
|
if card_script:
|
||||||
res.script = card_script
|
res.script = card_script
|
||||||
c = session.context
|
c = session.context
|
||||||
@ -270,48 +354,59 @@ class ChatService:
|
|||||||
res.indicator_range = ind[1]
|
res.indicator_range = ind[1]
|
||||||
res.bot_chat_type = "indicator"
|
res.bot_chat_type = "indicator"
|
||||||
|
|
||||||
async def _autonomy_learn(self, engine: TenantEngine, session: ChatSession, view: StepView, res: Res_Chat):
|
# 카운터 제시 카드면 수락/거절 스텝(가격협상_카운터)으로 전환 — 카드 멘트({target_price} 등
|
||||||
"""완전 자율 행동 로깅 — Q-table 은 건드리지 않고 experience_logs 만 적재한다.
|
# 카운터가 포함)는 그대로 두고, 입력만 [수락|다른 가격 제시] 버튼으로 바꾼다.
|
||||||
|
if counter is not None:
|
||||||
action_id = autonomy_actions.ACTIONS 인덱스, card_id = "AUT|종류|위치|전략" (카드 재학습
|
view2 = chat_engine.render_step(session, "가격협상_카운터")
|
||||||
파이프라인이 임베딩 매칭에서 자동 제외하도록 프리픽스로 구분). 종료 시 최종 보상 행(done=True)을
|
res.step, res.client_step = view2.step, view2.client_step
|
||||||
남겨 retrain 의 에피소드 재구성 규약(카드턴 N + 종료 1)과 정합을 맞춘다.
|
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||||
"""
|
if not card_script:
|
||||||
def _card_id(d) -> str:
|
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
|
||||||
return f"AUT|{d['kind']}|{d['q']:g}|{d['s']}"[:40]
|
|
||||||
|
|
||||||
|
async def _play_closing_tactic(self, engine: TenantEngine, chat_engine: ChatEngine,
|
||||||
|
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
||||||
|
"""종결 국면 강제 전술 — 견적에서 선택한 종결 와일드카드(WC-05 중간값 절충 등) 우선,
|
||||||
|
없으면 목표가 최후통첩. 최종 카운터를 제시하고 수락/거절 스텝으로 전환한다.
|
||||||
|
규칙층의 강제 결정이므로 RL 선택/학습을 우회한다."""
|
||||||
ctx = session.context
|
ctx = session.context
|
||||||
lrepo = LearningRepository(engine.company_id)
|
ctx["closing_played"] = True
|
||||||
pending = ctx.pop("autonomy_pending", None)
|
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
||||||
if pending is not None:
|
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
||||||
snap = self._snapshot(session, NegotiationOutcome.ONGOING)
|
closing_number, closing_offer = None, None
|
||||||
try:
|
for n in (ctx.get("selected_wild_card_numbers") or []):
|
||||||
idx = state_index(snap, engine.config.state) # 로깅 호환용 이산 인덱스
|
n = str(n)
|
||||||
except ValueError:
|
spec = spec_from_context(ctx, n)
|
||||||
idx = 0 # 자율 모드는 이산 상태를 쓰지 않으므로 폴백해도 학습 오염 없음
|
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
|
||||||
if ctx.get("autonomy_last"):
|
continue
|
||||||
ctx["autonomy_prev"] = ctx["autonomy_last"] # 직전 결정 보존 — 멘트 생성 힌트(양보 언급)용
|
offer = compute_offer_detail(spec, ctx)
|
||||||
ctx["autonomy_last"] = dict(pending, state_index=idx)
|
if offer is not None:
|
||||||
if pending.get("kind") == "counter":
|
closing_number, closing_offer = n, offer
|
||||||
# 역제안 기억은 별도 키로 보존 — autonomy_last 는 '마지막 행동'이라 사이에 낀
|
break
|
||||||
# 설득이 덮어쓴다. 단조 봉투·탄약소진 판정이 이 기억을 기준으로 해야
|
if closing_offer is None:
|
||||||
# counter→press→counter 에서 제안 철회가 새지 않는다 (게이트가 잡은 실버그).
|
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
|
||||||
ctx["autonomy_last_counter"] = dict(pending)
|
closing_number = None
|
||||||
if pending.get("kind") == "press":
|
target = int(ctx.get("target_price") or 0)
|
||||||
# 설득 횟수 누적 — 역제시 해금 조건(autonomy_store ③)의 카운터.
|
price = int(ctx.get("input_price") or 0)
|
||||||
ctx["autonomy_press_n"] = int(ctx.get("autonomy_press_n") or 0) + 1
|
if 0 < target < price:
|
||||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
closing_offer = Offer(price=target, variable="target_price",
|
||||||
await self._log(lrepo, session, idx, pending["idx"], _card_id(pending), snap,
|
prev_customer=int(ctx.get("prev_customer_price") or ctx.get("anchor_price") or 0),
|
||||||
reward, (1.0 - 0.1) + 0.1 / len(AUTONOMY_ACTIONS), done=False)
|
prev_partner=price)
|
||||||
res.policy = "full_autonomy"
|
if closing_offer is None:
|
||||||
last = ctx.get("autonomy_last")
|
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
||||||
if view.outcome is not None and last is not None:
|
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
||||||
oc = NegotiationOutcome.SUCCESS if view.outcome == "success" else NegotiationOutcome.FAILURE
|
record_offer(ctx, closing_offer)
|
||||||
snap = self._snapshot(session, oc)
|
|
||||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
template = None
|
||||||
res.reward_total = reward.total
|
if closing_number:
|
||||||
await self._log(lrepo, session, last["state_index"], last["idx"], _card_id(last), snap,
|
template = await scripts.resolve_wild_card_template(closing_number)
|
||||||
reward, None, done=True)
|
if template and engine.config.llm.enabled and ScriptNaturalizer.available():
|
||||||
|
template = (await self._naturalizer.naturalize(
|
||||||
|
template, situation=build_situation(ctx))) or template
|
||||||
|
view2 = chat_engine.render_step(session, "가격협상_카운터")
|
||||||
|
res.step, res.client_step = view2.step, view2.client_step
|
||||||
|
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||||
|
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
|
||||||
|
res.card_id = closing_number
|
||||||
|
|
||||||
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
||||||
oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE
|
oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE
|
||||||
@ -326,7 +421,7 @@ class ChatService:
|
|||||||
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
|
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
|
||||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
|
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
|
||||||
await self._log(repo, session, last_state, last_action,
|
await self._log(repo, session, last_state, last_action,
|
||||||
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True)
|
self._card_id_for_action(engine, session, last_action), snap, reward, done=True)
|
||||||
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -357,13 +452,74 @@ class ChatService:
|
|||||||
dtype=bool,
|
dtype=bool,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
|
@classmethod
|
||||||
|
def _combined_mask(cls, engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
|
||||||
|
"""견적 선택 마스크 AND 전술 발동조건 마스크.
|
||||||
|
|
||||||
|
결합 결과가 전부 False 면(선택 카드가 모두 발동 불가) 선택 마스크 단독으로 폴백 —
|
||||||
|
협상은 멈추지 않고(HOLD 설득으로라도 진행), 종결은 라운드 규칙이 처리한다.
|
||||||
|
"""
|
||||||
|
sel = cls._selection_mask(engine, session)
|
||||||
|
tac = cls._tactic_mask(engine, session)
|
||||||
|
if tac is None:
|
||||||
|
return sel
|
||||||
|
if sel is None:
|
||||||
|
return tac if tac.any() else None
|
||||||
|
both = sel & tac
|
||||||
|
return both if both.any() else sel
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
|
||||||
|
"""지금 플레이 가능한 action 만 True. HOLD(설득)는 발동조건만, 금액 카드는 제안가 유효까지
|
||||||
|
본다(playable) — 무효 금액(역행·목표가 초과 등)이 멘트 글자로 나가는 것 자체를 막는다.
|
||||||
|
전부 True 면 None(마스크 불필요)."""
|
||||||
|
ctx = session.context
|
||||||
|
mask = np.array(
|
||||||
|
[playable(spec_from_context(ctx, engine.mapper.get_card_id(a)), ctx)
|
||||||
|
for a in range(engine.action_space_size)],
|
||||||
|
dtype=bool,
|
||||||
|
)
|
||||||
|
return None if mask.all() else mask
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _selection_prior(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
|
||||||
|
"""의도층 prior(Phase 1) — 견적에서 고른 카드 순서를 콜드 스타트 선호로 반영.
|
||||||
|
|
||||||
|
갑이 먼저 고른 카드일수록 높은 보너스(최대 0.3, 순위 선형 감소). UCB 점수에
|
||||||
|
1/(1+visits) 감쇠로 더해지므로 학습이 쌓이면 Q 가 지배한다(오염 없음).
|
||||||
|
선택이 2장 미만이면 순서 정보가 무의미 → None.
|
||||||
|
"""
|
||||||
|
selected = session.context.get("selected_nego_card_numbers") or []
|
||||||
|
if len(selected) < 2:
|
||||||
|
return None
|
||||||
|
prior = np.zeros(engine.action_space_size)
|
||||||
|
n = len(selected)
|
||||||
|
for rank, num in enumerate(selected):
|
||||||
|
a = engine.mapper.get_action_id(str(num))
|
||||||
|
if a is not None and a < engine.action_space_size:
|
||||||
|
prior[a] = 0.3 * (n - rank) / n
|
||||||
|
return prior if prior.any() else None
|
||||||
|
|
||||||
|
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, done,
|
||||||
|
decision=None, policy=None):
|
||||||
data = {
|
data = {
|
||||||
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
||||||
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
|
"card_id": card_id, "snapshot": snap.to_dict(),
|
||||||
"turn": snap.round_number, "reward": reward.total, "done": done,
|
"turn": snap.round_number, "reward": reward.total, "done": done,
|
||||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||||
}
|
}
|
||||||
|
if decision is not None and policy is not None:
|
||||||
|
# 선택 근거(Q값·UCB·방문수)를 그 턴 값 그대로 박제한다 — 사후에 q_values 를 읽으면 이미 갱신된 뒤라
|
||||||
|
# "그때 왜 이 카드였나"를 복원할 수 없다. negodata 협상 학습 화면이 이 컬럼들을 읽는다.
|
||||||
|
# 종료 로그(카드 선택 없는 done 행)는 decision 이 없어 NULL — 화면 집계(avg/max)가 무시한다.
|
||||||
|
data.update({
|
||||||
|
"propensity": decision.propensity,
|
||||||
|
"available_actions": decision.available_actions,
|
||||||
|
"q_value_at_selection": decision.q_value,
|
||||||
|
"ucb_score_at_selection": decision.ucb_score,
|
||||||
|
"visit_count_at_selection": int(policy.qtable.visits[state_index, action_id]),
|
||||||
|
"total_visits_at_selection": int(policy.qtable.state_visits(state_index)),
|
||||||
|
})
|
||||||
try:
|
try:
|
||||||
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@ -97,10 +97,10 @@ class NegotiationService:
|
|||||||
|
|
||||||
# 7) experience_logs 기록
|
# 7) experience_logs 기록
|
||||||
if req.log:
|
if req.log:
|
||||||
res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
|
res.logged = await self._log(engine, session_id, idx, decision, snap, reward, policy)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool:
|
async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
|
||||||
repo = LearningRepository(engine.company_id)
|
repo = LearningRepository(engine.company_id)
|
||||||
data = {
|
data = {
|
||||||
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
||||||
@ -108,6 +108,8 @@ class NegotiationService:
|
|||||||
"turn": snap.round_number, "available_actions": decision.available_actions,
|
"turn": snap.round_number, "available_actions": decision.available_actions,
|
||||||
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
||||||
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
|
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
|
||||||
|
"visit_count_at_selection": int(policy.qtable.visits[idx, decision.action_id]),
|
||||||
|
"total_visits_at_selection": int(policy.qtable.state_visits(idx)),
|
||||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -51,7 +51,7 @@ async def resolve_company_name(repo: ICompanyProfileRepository, tenant_key: str)
|
|||||||
try:
|
try:
|
||||||
cid = uuid.UUID(tenant_key)
|
cid = uuid.UUID(tenant_key)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None # ktcommerce 등 데모 테넌트명 → 파일 브랜드 유지
|
return None # imarketkorea 등 데모 테넌트명 → 파일 브랜드 유지
|
||||||
|
|
||||||
async def _q(s):
|
async def _q(s):
|
||||||
_, name = await repo.get_company_name(s, cid)
|
_, name = await repo.get_company_name(s, cid)
|
||||||
|
|||||||
@ -148,6 +148,11 @@ class NegotiationConfig(BaseModel):
|
|||||||
anchor_rate: float = 0.01 # 목표가 대비 앵커링 인하율 (기본 1%)
|
anchor_rate: float = 0.01 # 목표가 대비 앵커링 인하율 (기본 1%)
|
||||||
max_rounds: int = 5 # 라운드 상한(보조). 실제 종료는 '카드 소진' 기준.
|
max_rounds: int = 5 # 라운드 상한(보조). 실제 종료는 '카드 소진' 기준.
|
||||||
|
|
||||||
|
# 결정 스택 규칙층(Phase 1) — ChatEngine 하드코딩을 테넌트별 데이터로.
|
||||||
|
wildcard_1pct_ratio: float = 1.02 # 제시가 ≤ anchor×비율 → 1% 인하 와일드카드로 마무리 유도
|
||||||
|
wildcard_entry_ratio: float = 1.05 # 선택 와일드카드 허용 시 와일드카드 진입 상한(anchor×비율)
|
||||||
|
max_counter_rounds: int = 3 # 에이전트 카운터 제안 상한(초과 시 협상실패 종료)
|
||||||
|
|
||||||
def anchor_for(self, target_price: float) -> float:
|
def anchor_for(self, target_price: float) -> float:
|
||||||
return round(target_price * (1.0 - self.anchor_rate))
|
return round(target_price * (1.0 - self.anchor_rate))
|
||||||
|
|
||||||
|
|||||||
@ -15,5 +15,7 @@
|
|||||||
"가격협상_와일드": "가격협상",
|
"가격협상_와일드": "가격협상",
|
||||||
"협상완료": "협상종료",
|
"협상완료": "협상종료",
|
||||||
"협상실패": "협상종료",
|
"협상실패": "협상종료",
|
||||||
"협상종료": "협상종료"
|
"협상종료": "협상종료",
|
||||||
}
|
"가격협상_카운터": "가격협상",
|
||||||
|
"wild_card_dynamic": "가격협상"
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
|
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
|
||||||
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
|
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
|
||||||
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 {label_target_price}(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||||
"2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
"2": "좋은 제안 감사합니다. 다른 {label_supplier}들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
||||||
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
|
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
|
||||||
"4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
"4": "제시해 주신 조건은 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
||||||
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
|
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
|
||||||
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
|
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
|
||||||
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.",
|
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. {label_target_price}({target}원)에 가까운 금액을 제안해 주세요.",
|
||||||
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,25 +5,37 @@
|
|||||||
"editor_script_id": "시작",
|
"editor_script_id": "시작",
|
||||||
"next_input_mode": "null",
|
"next_input_mode": "null",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": { "default": "서비스안내" },
|
"next_step": {
|
||||||
|
"default": "서비스안내"
|
||||||
|
},
|
||||||
"type": "null",
|
"type": "null",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"서비스안내": {
|
"서비스안내": {
|
||||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
||||||
"editor_script_id": "서비스안내",
|
"editor_script_id": "서비스안내",
|
||||||
"next_input_mode": "confirm",
|
"next_input_mode": "confirm",
|
||||||
"input_options": ["확인"],
|
"input_options": [
|
||||||
"next_step": { "default": "담당자확인" },
|
"확인"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"default": "담당자확인"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"담당자확인": {
|
"담당자확인": {
|
||||||
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
"script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||||
"editor_script_id": "담당자확인",
|
"editor_script_id": "담당자확인",
|
||||||
"next_input_mode": "yes_no",
|
"next_input_mode": "yes_no",
|
||||||
"input_options": ["예", "아니오"],
|
"input_options": [
|
||||||
"next_step": { "예": "협상품목안내", "아니오": "담당자확인_아니오" },
|
"예",
|
||||||
|
"아니오"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"예": "협상품목안내",
|
||||||
|
"아니오": "담당자확인_아니오"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -31,13 +43,19 @@
|
|||||||
"script": "[아니오]를 선택하셨습니다. 담당자가 변경되어 정보를 수정하시려면 [정보변경]을, 실수로 선택하신 경우 다시 진행하려면 [돌아가기]를 선택해 주세요.",
|
"script": "[아니오]를 선택하셨습니다. 담당자가 변경되어 정보를 수정하시려면 [정보변경]을, 실수로 선택하신 경우 다시 진행하려면 [돌아가기]를 선택해 주세요.",
|
||||||
"editor_script_id": "담당자확인_아니오",
|
"editor_script_id": "담당자확인_아니오",
|
||||||
"next_input_mode": "yes_no",
|
"next_input_mode": "yes_no",
|
||||||
"input_options": ["돌아가기", "정보변경"],
|
"input_options": [
|
||||||
"next_step": { "돌아가기": "담당자확인", "정보변경": "정보변경_완료" },
|
"돌아가기",
|
||||||
|
"정보변경"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"돌아가기": "담당자확인",
|
||||||
|
"정보변경": "정보변경_완료"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"정보변경_완료": {
|
"정보변경_완료": {
|
||||||
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
"script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
||||||
"editor_script_id": "정보변경_완료",
|
"editor_script_id": "정보변경_완료",
|
||||||
"next_input_mode": "null",
|
"next_input_mode": "null",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
@ -49,8 +67,12 @@
|
|||||||
"script": "{company_name}는 귀사의 협력에 진심으로 감사드립니다. 이번 가격 협상 품목과 기본 정보를 안내드립니다. 좌측의 상품 정보를 확인해 주세요. 협상이 원만히 마무리되면 더 많은 협력 기회가 마련될 수 있습니다.",
|
"script": "{company_name}는 귀사의 협력에 진심으로 감사드립니다. 이번 가격 협상 품목과 기본 정보를 안내드립니다. 좌측의 상품 정보를 확인해 주세요. 협상이 원만히 마무리되면 더 많은 협력 기회가 마련될 수 있습니다.",
|
||||||
"editor_script_id": "협상품목안내",
|
"editor_script_id": "협상품목안내",
|
||||||
"next_input_mode": "confirm",
|
"next_input_mode": "confirm",
|
||||||
"input_options": ["확인"],
|
"input_options": [
|
||||||
"next_step": { "확인": "기존가격제시" },
|
"확인"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"확인": "기존가격제시"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -59,7 +81,9 @@
|
|||||||
"editor_script_id": "기존가격제시",
|
"editor_script_id": "기존가격제시",
|
||||||
"next_input_mode": "price",
|
"next_input_mode": "price",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": { "default": "가격협상_확인" },
|
"next_step": {
|
||||||
|
"default": "가격협상_확인"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -68,22 +92,42 @@
|
|||||||
"editor_script_id": "가격협상_재입력",
|
"editor_script_id": "가격협상_재입력",
|
||||||
"next_input_mode": "price",
|
"next_input_mode": "price",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": { "default": "가격협상_확인" },
|
"next_step": {
|
||||||
|
"default": "가격협상_확인"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"가격협상_확인": {
|
"가격협상_확인": {
|
||||||
"script": "제시하신 가격은 **{input_price}원**으로, 기존 공급가 대비 약 **{discount_rate}%** 인하된 금액입니다. 이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
"script": "제시하신 가격은 **{input_price}원**입니다. {discount_phrase}이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
||||||
"editor_script_id": "가격협상_확인",
|
"editor_script_id": "가격협상_확인",
|
||||||
"next_input_mode": "yes_no",
|
"next_input_mode": "yes_no",
|
||||||
"input_options": ["예", "아니오"],
|
"input_options": [
|
||||||
|
"예",
|
||||||
|
"아니오"
|
||||||
|
],
|
||||||
"next_step": {
|
"next_step": {
|
||||||
"예": [
|
"예": [
|
||||||
{ "condition": "check_wildcard_entry", "next": "가격협상_와일드" },
|
{
|
||||||
{ "condition": "check_is_supplier_type_c", "next": "협상완료" },
|
"condition": "check_wildcard_entry",
|
||||||
{ "condition": "check_price_match", "next": "협상완료" },
|
"next": "가격협상_와일드"
|
||||||
{ "condition": "check_iteration_limit", "next": "협상실패" },
|
},
|
||||||
{ "condition": "default", "next": "가격협상" }
|
{
|
||||||
|
"condition": "check_is_supplier_type_c",
|
||||||
|
"next": "협상완료"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"condition": "check_price_match",
|
||||||
|
"next": "협상완료"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"condition": "check_iteration_limit",
|
||||||
|
"next": "협상실패"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"condition": "default",
|
||||||
|
"next": "가격협상"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"아니오": "가격협상_재입력"
|
"아니오": "가격협상_재입력"
|
||||||
},
|
},
|
||||||
@ -94,8 +138,14 @@
|
|||||||
"script": "제시하신 금액은 **{input_price}원**입니다. 이 금액으로 견적을 제출하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
"script": "제시하신 금액은 **{input_price}원**입니다. 이 금액으로 견적을 제출하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
||||||
"editor_script_id": "가격협상_확인_버짓",
|
"editor_script_id": "가격협상_확인_버짓",
|
||||||
"next_input_mode": "yes_no",
|
"next_input_mode": "yes_no",
|
||||||
"input_options": ["예", "아니오"],
|
"input_options": [
|
||||||
"next_step": { "예": "협상완료", "아니오": "가격협상_재입력" },
|
"예",
|
||||||
|
"아니오"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"예": "협상완료",
|
||||||
|
"아니오": "가격협상_재입력"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -104,7 +154,9 @@
|
|||||||
"editor_script_id": "가격협상",
|
"editor_script_id": "가격협상",
|
||||||
"next_input_mode": "price",
|
"next_input_mode": "price",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": { "default": "가격협상_확인" },
|
"next_step": {
|
||||||
|
"default": "가격협상_확인"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -112,8 +164,12 @@
|
|||||||
"script": "협조해 주신 덕분에 원만히 협상이 완료되었습니다. 협상 결과를 확인하신 뒤 동의해 주세요. 거래 약정에 따라 일부 조건이 조정될 수 있는 점 참고 부탁드립니다. 성실히 응해 주셔서 감사합니다.",
|
"script": "협조해 주신 덕분에 원만히 협상이 완료되었습니다. 협상 결과를 확인하신 뒤 동의해 주세요. 거래 약정에 따라 일부 조건이 조정될 수 있는 점 참고 부탁드립니다. 성실히 응해 주셔서 감사합니다.",
|
||||||
"editor_script_id": "협상완료",
|
"editor_script_id": "협상완료",
|
||||||
"next_input_mode": "confirm",
|
"next_input_mode": "confirm",
|
||||||
"input_options": ["협상 내용을 확인했으며, 이의가 없음에 동의합니다."],
|
"input_options": [
|
||||||
"next_step": { "default": "협상종료" },
|
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"default": "협상종료"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -122,7 +178,9 @@
|
|||||||
"editor_script_id": "협상실패",
|
"editor_script_id": "협상실패",
|
||||||
"next_input_mode": "null",
|
"next_input_mode": "null",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": { "default": "협상종료" },
|
"next_step": {
|
||||||
|
"default": "협상종료"
|
||||||
|
},
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -134,5 +192,21 @@
|
|||||||
"next_step": null,
|
"next_step": null,
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": true
|
"chat_end": true
|
||||||
|
},
|
||||||
|
"가격협상_카운터": {
|
||||||
|
"script": "제시해 주신 **{input_price}원** 검토했습니다. 저희는 **{counter_price}원**을 제안드립니다. 이 가격으로 진행 가능하시면 '수락'을, 어려우시면 '다른 가격 제시'를 선택해 주세요.",
|
||||||
|
"editor_script_id": "가격협상_카운터",
|
||||||
|
"next_input_mode": "yes_no",
|
||||||
|
"input_options": [
|
||||||
|
"수락",
|
||||||
|
"다른 가격 제시"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"수락": "협상완료",
|
||||||
|
"다른 가격 제시": "가격협상_재입력",
|
||||||
|
"default": "가격협상_재입력"
|
||||||
|
},
|
||||||
|
"type": "text",
|
||||||
|
"chat_end": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10,7 +10,7 @@
|
|||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"서비스안내": {
|
"서비스안내": {
|
||||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
||||||
"editor_script_id": "서비스안내",
|
"editor_script_id": "서비스안내",
|
||||||
"next_input_mode": "confirm",
|
"next_input_mode": "confirm",
|
||||||
"input_options": ["확인"],
|
"input_options": ["확인"],
|
||||||
@ -19,7 +19,7 @@
|
|||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"담당자확인": {
|
"담당자확인": {
|
||||||
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
"script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||||
"editor_script_id": "담당자확인",
|
"editor_script_id": "담당자확인",
|
||||||
"next_input_mode": "yes_no",
|
"next_input_mode": "yes_no",
|
||||||
"input_options": ["예", "아니오"],
|
"input_options": ["예", "아니오"],
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"정보변경_완료": {
|
"정보변경_완료": {
|
||||||
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
|
"script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
|
||||||
"editor_script_id": "정보변경_완료",
|
"editor_script_id": "정보변경_완료",
|
||||||
"next_input_mode": "null",
|
"next_input_mode": "null",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
@ -46,7 +46,7 @@
|
|||||||
"chat_end": true
|
"chat_end": true
|
||||||
},
|
},
|
||||||
"협상품목안내": {
|
"협상품목안내": {
|
||||||
"script": "{company_name}는 아래 상품에 대해 신규 공급사를 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 공급사 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
"script": "{company_name}는 아래 상품에 대해 신규 {label_supplier_를} 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 {label_supplier} 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
||||||
"editor_script_id": "협상품목안내",
|
"editor_script_id": "협상품목안내",
|
||||||
"next_input_mode": "confirm",
|
"next_input_mode": "confirm",
|
||||||
"input_options": ["네, 알겠습니다."],
|
"input_options": ["네, 알겠습니다."],
|
||||||
@ -73,10 +73,10 @@
|
|||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"배송형태선택": {
|
"배송형태선택": {
|
||||||
"script": "배송 형태를 선택해 주세요.",
|
"script": "{label_delivery_type_를} 선택해 주세요.",
|
||||||
"editor_script_id": "배송형태선택",
|
"editor_script_id": "배송형태선택",
|
||||||
"next_input_mode": "delivery_type",
|
"next_input_mode": "delivery_type",
|
||||||
"input_options": ["협력사배송", "지정택배배송", "픽업배송"],
|
"input_options": ["{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}"],
|
||||||
"next_step": { "default": "가격협상_입력" },
|
"next_step": { "default": "가격협상_입력" },
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
|
|||||||
@ -5,17 +5,39 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false,
|
"chat_end": false,
|
||||||
"next_input_mode": "yes_no",
|
"next_input_mode": "yes_no",
|
||||||
"input_options": ["예", "아니오"],
|
"input_options": [
|
||||||
"next_step": { "default": "협상완료" },
|
"예",
|
||||||
|
"아니오"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"default": "협상완료"
|
||||||
|
},
|
||||||
"editor_script_id": "wild_card_1pct"
|
"editor_script_id": "wild_card_1pct"
|
||||||
},
|
},
|
||||||
"wild_card_budget": {
|
"wild_card_budget": {
|
||||||
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 당사 {label_target_price}는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false,
|
"chat_end": false,
|
||||||
"next_input_mode": "price",
|
"next_input_mode": "price",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": { "default": "가격협상_확인_버짓" },
|
"next_step": {
|
||||||
|
"default": "가격협상_확인_버짓"
|
||||||
|
},
|
||||||
"editor_script_id": "wild_card_budget"
|
"editor_script_id": "wild_card_budget"
|
||||||
|
},
|
||||||
|
"wild_card_dynamic": {
|
||||||
|
"script": "저희는 **{counter_price}원**이면 즉시 진행이 가능합니다. 이 가격으로 진행 가능하시면 '수락'을, 어려우시면 '다른 가격 제시'를 선택해 주세요.",
|
||||||
|
"next_input_mode": "yes_no",
|
||||||
|
"input_options": [
|
||||||
|
"수락",
|
||||||
|
"다른 가격 제시"
|
||||||
|
],
|
||||||
|
"next_step": {
|
||||||
|
"수락": "협상완료",
|
||||||
|
"다른 가격 제시": "가격협상_재입력",
|
||||||
|
"default": "가격협상_재입력"
|
||||||
|
},
|
||||||
|
"type": "text",
|
||||||
|
"chat_end": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,7 +78,7 @@ cards:
|
|||||||
connection: {}
|
connection: {}
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
enabled: false
|
enabled: true
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
language: ko
|
language: ko
|
||||||
|
|||||||
@ -37,7 +37,9 @@ action_mapping:
|
|||||||
"10": "NGC-B011"
|
"10": "NGC-B011"
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
enabled: false
|
enabled: true
|
||||||
|
# api_key_ref 는 미구현(dead) — 현재 LLM 키는 전역 config.local.toml [OpenAIConfig].api_key
|
||||||
|
# (또는 OPENAI_API_KEY env) 를 사용한다. 테넌트별 키 분리는 표현층(Phase 2) 본작업에서 구현.
|
||||||
api_key_ref: TENANT_B_OPENAI_API_KEY
|
api_key_ref: TENANT_B_OPENAI_API_KEY
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
# 데모 테넌트 프로파일 (합성/중립값 — CLEANROOM.md).
|
|
||||||
# tenant_id 는 라우팅 키일 뿐이며, 아래 값은 해당 회사의 실제 운영값이 아니다.
|
|
||||||
# 실제 운영 시 카드 카탈로그(card.nego_cards)·튜닝값은 테넌트 비공개 소스에서 주입한다.
|
|
||||||
tenant_id: ktcommerce
|
|
||||||
inherits_base: true
|
|
||||||
name: "Demo Tenant A"
|
|
||||||
company_id: null # P3 시드 시 company.companies.company_id(uuid) 로 채움
|
|
||||||
|
|
||||||
action_mapping:
|
|
||||||
type: file
|
|
||||||
action_to_card: # 우리 중립 데모 카드 코드(합성). 실제 카드 코드 아님. 카탈로그 11장(_base 와 정합).
|
|
||||||
"0": "NGC-A001"
|
|
||||||
"1": "NGC-A002"
|
|
||||||
"2": "NGC-A003"
|
|
||||||
"3": "NGC-A004"
|
|
||||||
"4": "NGC-A005"
|
|
||||||
"5": "NGC-A006"
|
|
||||||
"6": "NGC-A007"
|
|
||||||
"7": "NGC-A008"
|
|
||||||
"8": "NGC-A009"
|
|
||||||
"9": "NGC-A010"
|
|
||||||
"10": "NGC-A011"
|
|
||||||
|
|
||||||
llm:
|
|
||||||
enabled: false # P7 에서 테넌트별 자격증명 주입
|
|
||||||
api_key_ref: TENANT_A_OPENAI_API_KEY
|
|
||||||
|
|
||||||
resources:
|
|
||||||
language: ko
|
|
||||||
scripts_dir: resources # 없으면 _base/resources 폴백
|
|
||||||
company_name: "데모상사 A" # 스크립트 {company_name} 치환값 (합성)
|
|
||||||
service_name: "Negosium"
|
|
||||||
157
agent/tests/fuzz_negotiation.py
Normal file
157
agent/tests/fuzz_negotiation.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 N회 완주시키고 불변식 위반을 수집한다.
|
||||||
|
시드 고정(재현 가능). test_ 접두사 없음 — pytest 수집 대상 아님, 수동 실행 전용:
|
||||||
|
docker run --rm -v $PWD/agent:/work -w /work -e APP_ENV=local -e DB_HOST=host.docker.internal \
|
||||||
|
o2o-negosium-agent sh -lc "pip install -q pytest pytest-asyncio httpx; python tests/fuzz_negotiation.py"
|
||||||
|
|
||||||
|
케이스마다 검사하는 불변식:
|
||||||
|
1. 전 턴 success
|
||||||
|
2. 같은 카드 2회 발동 금지
|
||||||
|
3. 종결 전용(WC-03·05)은 가격협상_카운터에서만 / 비종결 와일드는 wild_card_dynamic 에서만
|
||||||
|
4. 타결 시 타결가 ≤ 목표가
|
||||||
|
5. 카운터/1% 수락으로 타결하면 그 멘트에 타결가 표기
|
||||||
|
6. 멘트·버튼에 미치환 토큰({xxx}) 잔존 금지
|
||||||
|
7. 턴 상한(60) 안에 반드시 종료
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
sys.path.insert(0, "/work")
|
||||||
|
|
||||||
|
from router.v1.chat.protocol import Req_Chat # noqa: E402
|
||||||
|
from services.chat_service import ChatService, reset_sessions # noqa: E402
|
||||||
|
from tenancy.config_loader import TenantConfigLoader # noqa: E402
|
||||||
|
from tenancy.registry import TenantEngineRegistry # noqa: E402
|
||||||
|
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session # noqa: E402
|
||||||
|
|
||||||
|
N = 100
|
||||||
|
SEED = 20260805
|
||||||
|
TARGET = 10_000
|
||||||
|
NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005",
|
||||||
|
"NGC-007", "NGC-008", "NGC-010", "NGC-011"]
|
||||||
|
WILD_POOL = ["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]
|
||||||
|
CLOSING = {"WC-03", "WC-05"}
|
||||||
|
TOKEN_RE = re.compile(r"(?<!\{)\{([a-z_0-9]+)\}(?!\})")
|
||||||
|
|
||||||
|
|
||||||
|
class Supplier:
|
||||||
|
"""랜덤 협력사 — 높은 시작가에서 점진 양보, 카운터는 확률적으로 수락/거절."""
|
||||||
|
|
||||||
|
def __init__(self, rng, anchor):
|
||||||
|
self.rng = rng
|
||||||
|
self.anchor = anchor
|
||||||
|
self.price = TARGET * rng.uniform(1.02, 1.30)
|
||||||
|
self.accept_p = rng.uniform(0.15, 0.5)
|
||||||
|
|
||||||
|
def next_price(self):
|
||||||
|
p = int(self.price)
|
||||||
|
# 다음 라운드를 위해 양보 — 가끔 앵커 밑까지 다이브(우선협상 유도).
|
||||||
|
self.price *= self.rng.uniform(0.90, 0.99)
|
||||||
|
if self.rng.random() < 0.15:
|
||||||
|
self.price = self.anchor * self.rng.uniform(0.95, 1.04)
|
||||||
|
return str(max(p, 100))
|
||||||
|
|
||||||
|
def choose(self, options):
|
||||||
|
if "수락" in options:
|
||||||
|
return "수락" if self.rng.random() < self.accept_p else "다른 가격 제시"
|
||||||
|
if set(options) >= {"예", "아니오"}:
|
||||||
|
return "예" if self.rng.random() < max(self.accept_p, 0.5) else "아니오"
|
||||||
|
return options[0] if options else "확인"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_case(idx, rng):
|
||||||
|
anchor = int(TARGET * rng.choice([0.99, 0.99, 0.97, 0.95, 1.0]))
|
||||||
|
nego = rng.sample(NEGO_POOL, rng.randint(1, 5))
|
||||||
|
wild = rng.sample(WILD_POOL, rng.randint(0, 5))
|
||||||
|
sup = Supplier(rng, anchor)
|
||||||
|
|
||||||
|
reset_sessions()
|
||||||
|
sid = uuid.uuid4()
|
||||||
|
qid, ver = await _seed_quote_session(sid, nego, wild_numbers=wild, target=TARGET, anchor=anchor)
|
||||||
|
violations, fired, settled, outcome, ended = [], [], None, None, False
|
||||||
|
try:
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine(str(uuid.uuid4()))
|
||||||
|
svc = ChatService()
|
||||||
|
ui, last_input = None, None
|
||||||
|
for _turn in range(60):
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||||
|
if r.result.success is not True:
|
||||||
|
violations.append(f"턴 실패 input={ui} msg={r.msg}")
|
||||||
|
break
|
||||||
|
script, opts = r.script or "", list(r.input_options or [])
|
||||||
|
if TOKEN_RE.search(script):
|
||||||
|
violations.append(f"미치환 토큰(script): {TOKEN_RE.findall(script)} @ {r.step}")
|
||||||
|
for o in opts:
|
||||||
|
if TOKEN_RE.search(o):
|
||||||
|
violations.append(f"미치환 토큰(option): {o} @ {r.step}")
|
||||||
|
if r.card_id:
|
||||||
|
fired.append((r.step, r.card_id))
|
||||||
|
if r.settled_price is not None:
|
||||||
|
settled = r.settled_price
|
||||||
|
# 카운터/1% '수락' 타결이면 마지막 카운터 멘트에 타결가가 보였어야 한다.
|
||||||
|
if last_input in ("수락",) and str(settled) not in (last_counter or ""):
|
||||||
|
violations.append(f"표시가≠타결가: {settled} not in counter script")
|
||||||
|
if r.step in ("가격협상_카운터", "wild_card_dynamic", "wild_card_1pct"):
|
||||||
|
last_counter = script
|
||||||
|
if r.chat_end:
|
||||||
|
outcome, ended = r.outcome, True
|
||||||
|
break
|
||||||
|
# 다음 입력 결정
|
||||||
|
last_input = None
|
||||||
|
if r.input_mode == "price":
|
||||||
|
ui = sup.next_price()
|
||||||
|
elif opts:
|
||||||
|
ui = sup.choose(opts)
|
||||||
|
last_input = ui
|
||||||
|
else:
|
||||||
|
ui = "확인"
|
||||||
|
if not ended:
|
||||||
|
violations.append("60턴 내 미종료")
|
||||||
|
|
||||||
|
# 카드 불변식
|
||||||
|
ids = [c for _, c in fired]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
violations.append(f"카드 중복: {ids}")
|
||||||
|
for step, c in fired:
|
||||||
|
if c in CLOSING and step != "가격협상_카운터":
|
||||||
|
violations.append(f"종결 카드 {c} 가 {step} 에서 발동")
|
||||||
|
if c.startswith("WC") and c not in CLOSING and step != "wild_card_dynamic":
|
||||||
|
violations.append(f"비종결 와일드 {c} 가 {step} 에서 발동")
|
||||||
|
if outcome == "success":
|
||||||
|
if settled is None:
|
||||||
|
violations.append("성공인데 settled 없음")
|
||||||
|
elif settled > TARGET:
|
||||||
|
violations.append(f"목표가 초과 타결: {settled}")
|
||||||
|
finally:
|
||||||
|
await _cleanup(sid, qid, ver)
|
||||||
|
return {"idx": idx, "anchor": anchor, "nego": nego, "wild": wild,
|
||||||
|
"fired": fired, "settled": settled, "outcome": outcome, "violations": violations}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
rng = random.Random(SEED)
|
||||||
|
results, bad = [], []
|
||||||
|
for i in range(N):
|
||||||
|
res = await run_case(i, random.Random(rng.random()))
|
||||||
|
results.append(res)
|
||||||
|
if res["violations"]:
|
||||||
|
bad.append(res)
|
||||||
|
tag = "OK " if not res["violations"] else "BAD"
|
||||||
|
print(f"[{tag}] #{i:02d} anchor={res['anchor']} nego={len(res['nego'])} wild={len(res['wild'])} "
|
||||||
|
f"fired={'→'.join(c for _, c in res['fired']) or '-'} settled={res['settled']} {res['outcome']}")
|
||||||
|
ok = sum(1 for r in results if not r["violations"])
|
||||||
|
succ = sum(1 for r in results if r["outcome"] == "success")
|
||||||
|
print(f"\n===== {ok}/{N} clean · 타결 {succ} / 결렬 {N - succ} =====")
|
||||||
|
for r in bad:
|
||||||
|
print(f"\n#{r['idx']} 위반: nego={r['nego']} wild={r['wild']} anchor={r['anchor']}")
|
||||||
|
for v in r["violations"]:
|
||||||
|
print(" -", v)
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
await DB_SESSION_MNG.dispose_all()
|
||||||
|
sys.exit(0 if not bad else 1)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@ -29,7 +29,7 @@ def _reg():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_4_1_honors_backend_session_id(db_engine):
|
async def test_4_1_honors_backend_session_id(db_engine):
|
||||||
reset_sessions()
|
reset_sessions()
|
||||||
eng = await _reg().get_engine("ktcommerce")
|
eng = await _reg().get_engine("imarketkorea")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
|
|
||||||
# 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X)
|
# 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X)
|
||||||
@ -53,7 +53,7 @@ async def test_4_4_company_id_auto_onboard():
|
|||||||
eng = await _reg().get_engine(COMPANY_ID)
|
eng = await _reg().get_engine(COMPANY_ID)
|
||||||
assert eng.tenant_id == COMPANY_ID
|
assert eng.tenant_id == COMPANY_ID
|
||||||
assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리
|
assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리
|
||||||
assert eng.action_space_size == 11 # base 기본 카드(162×11 정합)
|
assert eng.action_space_size == 9 # DB 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||||
assert eng.state_space_size == 162
|
assert eng.state_space_size == 162
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
154
agent/tests/test_card_selection_e2e.py
Normal file
154
agent/tests/test_card_selection_e2e.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""협상카드 선택 E2E — 실 DB 왕복으로 "견적에서 고른 카드만 뽑히는지" 검증.
|
||||||
|
|
||||||
|
시나리오: 견적 생성 시 협상카드 2장(NGC-003, NGC-007)만 선택 →
|
||||||
|
version_nego_cards 로 연결 → 협상 세션 시작 → 가격협상 턴 2회 진행.
|
||||||
|
검증: ① 뽑힌 카드가 선택 2장 안에서만 나옴(선택 마스크) ② 세션 내 중복 없음(사용 마스크)
|
||||||
|
③ 카탈로그(DB, NGC-001~011) 기준 action space ④ 선택 없으면 전체 카탈로그 허용(폴백).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import column, delete, insert, select, table
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.enums import DBType, DBWRType, ErrorType
|
||||||
|
from router.v1.chat.protocol import Req_Chat
|
||||||
|
from services.chat_service import ChatService, reset_sessions
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tenancy.registry import TenantEngineRegistry
|
||||||
|
|
||||||
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||||
|
|
||||||
|
_T_SESSIONS = table(
|
||||||
|
"sessions",
|
||||||
|
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
|
||||||
|
column("qt_number"), column("qt_round"), column("qt_type"), column("target_price"),
|
||||||
|
column("anchoring_price"), column("status"), column("end_time"),
|
||||||
|
schema="negotiation",
|
||||||
|
)
|
||||||
|
_T_QUOTATIONS = table(
|
||||||
|
"quotations",
|
||||||
|
column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"),
|
||||||
|
column("name"), column("number"), column("type"), column("status"),
|
||||||
|
column("start_time"), column("end_time"),
|
||||||
|
schema="quotation",
|
||||||
|
)
|
||||||
|
_T_VNC = table(
|
||||||
|
"version_nego_cards",
|
||||||
|
column("vnc_id"), column("version_id"), column("nego_card_id"),
|
||||||
|
schema="card",
|
||||||
|
)
|
||||||
|
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
|
||||||
|
|
||||||
|
|
||||||
|
async def _card_uuid(number: str):
|
||||||
|
def _q(s):
|
||||||
|
return DB_SESSION_MNG.execute(
|
||||||
|
s, select(_T_NEGO.c.nego_card_id).where(
|
||||||
|
_T_NEGO.c.number == number, _T_NEGO.c.deleted == False).limit(1)) # noqa: E712
|
||||||
|
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selected_cards_only_are_played(db_engine):
|
||||||
|
reset_sessions()
|
||||||
|
sid, qid, ver_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
||||||
|
iid, sup = _uuid.uuid4(), _uuid.uuid4()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
selected = ["NGC-003", "NGC-007"]
|
||||||
|
card_ids = {}
|
||||||
|
for n in selected:
|
||||||
|
card_ids[n] = await _card_uuid(n)
|
||||||
|
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||||
|
|
||||||
|
def _seed(s_):
|
||||||
|
async def run(s):
|
||||||
|
e = await DB_SESSION_MNG.add(s, insert(_T_QUOTATIONS).values(
|
||||||
|
qt_id=qid, user_id=_uuid.uuid4(), qt_setting_id=_uuid.uuid4(), version_id=ver_id,
|
||||||
|
name="카드선택E2E", number="QT-CARDSEL-E2E", type=1, status=2,
|
||||||
|
start_time=now, end_time=now + timedelta(days=1)))
|
||||||
|
if e != ErrorType.SUCCESS:
|
||||||
|
return e
|
||||||
|
for n in selected: # 견적 생성 시 선택한 카드 2장
|
||||||
|
e = await DB_SESSION_MNG.add(s, insert(_T_VNC).values(
|
||||||
|
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
|
||||||
|
if e != ErrorType.SUCCESS:
|
||||||
|
return e
|
||||||
|
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
|
||||||
|
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
|
||||||
|
qt_number="QT-CARDSEL-E2E", qt_round=1, qt_type=1,
|
||||||
|
target_price=10000, anchoring_price=9900, status=2,
|
||||||
|
end_time=now + timedelta(days=1)))
|
||||||
|
return run(s_)
|
||||||
|
|
||||||
|
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_seed])
|
||||||
|
assert err == ErrorType.SUCCESS
|
||||||
|
|
||||||
|
try:
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine(str(_uuid.uuid4())) # 자동 온보딩(_base type:db → 실 DB 카탈로그)
|
||||||
|
assert eng.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||||
|
|
||||||
|
svc = ChatService()
|
||||||
|
played = []
|
||||||
|
session_id = str(sid)
|
||||||
|
# 적응형 진행: 카드 전술 재설계 후 카운터 제시 카드(NGC-007 등)는 수락/거절 스텝
|
||||||
|
# (가격협상_카운터)으로 전환된다 — 거절하고 새 가격을 제시하며 카드 2턴을 유도한다.
|
||||||
|
prices = iter(["11000", "10600", "10400"])
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id))
|
||||||
|
for _ in range(14):
|
||||||
|
if r.step in ("가격협상", "가격협상_카운터") and r.card_id:
|
||||||
|
played.append(r.card_id)
|
||||||
|
if len(played) == 2:
|
||||||
|
break
|
||||||
|
if r.chat_end:
|
||||||
|
break
|
||||||
|
if r.input_mode == "price":
|
||||||
|
ui = next(prices)
|
||||||
|
elif r.step == "가격협상_카운터":
|
||||||
|
ui = "다른 가격 제시"
|
||||||
|
elif r.input_options:
|
||||||
|
ui = "예" if "예" in r.input_options else r.input_options[0]
|
||||||
|
else:
|
||||||
|
ui = "확인"
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||||
|
|
||||||
|
assert len(played) == 2, f"가격협상 카드 턴 2회 기대, 실제 {played}"
|
||||||
|
# ① 선택한 카드 안에서만 뽑힘 ② 세션 내 중복 없음
|
||||||
|
assert set(played) <= set(selected), f"선택 밖 카드 발동: {played}"
|
||||||
|
assert len(set(played)) == 2, f"카드 중복 사용: {played}"
|
||||||
|
finally:
|
||||||
|
await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[DBType.MAIN.value],
|
||||||
|
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)),
|
||||||
|
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
|
||||||
|
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_selection_allows_full_catalog(db_engine):
|
||||||
|
"""선택 카드가 없으면(직접호출/데모) 전체 카탈로그가 허용된다 — 카드가 정상적으로 뽑히는지 기본 검증."""
|
||||||
|
reset_sessions()
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine(str(_uuid.uuid4())) # _base type:db → DB 카탈로그
|
||||||
|
catalog = {eng.mapper.get_card_id(a) for a in range(eng.action_space_size)}
|
||||||
|
|
||||||
|
svc = ChatService()
|
||||||
|
played = []
|
||||||
|
sid = None
|
||||||
|
for ui in [None, "확인", "예", "확인", "11000", "예", "10600", "예", "10600", "예"]:
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
|
||||||
|
sid = r.session_id
|
||||||
|
if r.step == "가격협상" and r.card_id:
|
||||||
|
played.append(r.card_id)
|
||||||
|
if r.chat_end:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert played, "가격협상 카드 턴이 발생해야 함"
|
||||||
|
assert set(played) <= catalog # 카탈로그(NGC-001~011) 내에서만
|
||||||
|
assert len(played) == len(set(played)) # 세션 내 중복 없음
|
||||||
499
agent/tests/test_card_tactics.py
Normal file
499
agent/tests/test_card_tactics.py
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
"""카드 전술 검증 — "스크립트에 꽂힌 변수가 곧 전술" (파싱 + 변수별 유효조건 + tactic JSONB).
|
||||||
|
|
||||||
|
① 제안가 파싱(마지막 제안가 변수) + 변수별 계산식 결정론
|
||||||
|
② 변수 공통 유효조건 — 목표가 초과·제시가 이상이면 미발동(클램프 아님 — IMK 8AB0 회귀)
|
||||||
|
③ 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
|
||||||
|
④ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
|
||||||
|
⑤ 와일드 진입 — 종결 전용 카드 예약(중반 미발동) + 카드 이력 공유(중복 발동 차단, IMK BB9A 회귀)
|
||||||
|
⑥ E2E: 견적 선택 카드(NGC-010 목표가 제안)의 카운터를 수락하면 settled=target
|
||||||
|
⑦ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from negotiation.cards.domain.tactics import (
|
||||||
|
CardSpec, HOLD, available, build_card_spec, compute_offer,
|
||||||
|
is_played, mark_played, parse_offer_variable, playable, spec_from_context,
|
||||||
|
)
|
||||||
|
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||||
|
from negotiation.chat.service.script_repository import ScriptRepository
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
|
||||||
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||||
|
|
||||||
|
# 엔진 단위 테스트용 카드 스펙(로더가 DB 스크립트 파싱으로 만드는 것과 같은 형태).
|
||||||
|
_SPECS = {
|
||||||
|
"WC-02": {"offer_variable": "target_mid_price", "min_round": 1, "closing": False},
|
||||||
|
"WC-05": {"offer_variable": "middle_price", "min_round": 1, "closing": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _engine() -> ChatEngine:
|
||||||
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
|
return ChatEngine(ScriptRepository(cfg, _TENANTS_DIR), rq_type="재협상")
|
||||||
|
|
||||||
|
|
||||||
|
def _session(step="가격협상_확인", **ctx_over):
|
||||||
|
ctx = {"input_price": 10300, "anchor_price": 10000, "target_price": 10100,
|
||||||
|
"round": 1, "allow_selected_wildcards": False, "card_specs": dict(_SPECS)}
|
||||||
|
ctx.update(ctx_over)
|
||||||
|
return ChatSession(session_id="00000000-0000-0000-0000-00000000e001", tenant_id="imarketkorea",
|
||||||
|
company_id="imarketkorea", step=step, action_space_size=0, context=ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ① 제안가 파싱 + 계산식 ---------------------------------------------------
|
||||||
|
def test_parse_offer_variable_last_offer_wins():
|
||||||
|
"""제안가 변수가 여럿이면 마지막 것 — 카드 문장은 배경을 먼저, 제안을 마지막에 한다(WC-04)."""
|
||||||
|
assert parse_offer_variable("적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안") == "target_price"
|
||||||
|
assert parse_offer_variable("{target_price}원을 제안 드립니다") == "target_price"
|
||||||
|
# 읽어주기 변수만 있으면 설득 카드 — 제안가 없음
|
||||||
|
assert parse_offer_variable("시장가 {internet_lowest_price}원 안팎, 제시가 {prev_partner_price}원") is None
|
||||||
|
assert parse_offer_variable("가격 변수 없는 설득 멘트") is None
|
||||||
|
assert parse_offer_variable(None) is None
|
||||||
|
# WC-05 정본: 읽어주기(직전 제안·제시가) 뒤 절충가 제안
|
||||||
|
assert parse_offer_variable("당사 제안 {prev_customer_price}원과 귀사 제안 {prev_partner_price}원을 절반씩, {middle_price}원으로") == "middle_price"
|
||||||
|
# negodata 에디터 칩 표기(anchor_price)도 앵커가 제안으로 인식 — DB 시드 표기(anchoring_price)의 별칭
|
||||||
|
assert parse_offer_variable("예산 한도는 {anchor_price}원입니다") == "anchor_price"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_card_spec_merges_script_and_tactic():
|
||||||
|
spec = build_card_spec("{target_price}원으로 제안", {"min_round": 2, "closing": True})
|
||||||
|
assert spec == CardSpec(offer_variable="target_price", min_round=2, closing=True)
|
||||||
|
# tactic 없음 → 기본값. offer_variable override 는 파싱보다 우선.
|
||||||
|
assert build_card_spec("설득 멘트", None) == HOLD
|
||||||
|
assert build_card_spec("멘트", {"offer_variable": "anchoring_price"}).offer_variable == "anchoring_price"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offer_formulas():
|
||||||
|
ctx = {"input_price": 11000, "anchor_price": 9900, "target_price": 10000}
|
||||||
|
offer = lambda var, c=None: compute_offer(CardSpec(offer_variable=var), c or ctx) # noqa: E731
|
||||||
|
assert offer("target_price") == 10000
|
||||||
|
assert offer("anchoring_price") == 9900
|
||||||
|
assert offer("target_mid_price") == 9950 # (anchor+target)/2
|
||||||
|
# 절충가: 갑 직전 포지션 폴백 = anchor → (8900+9500)/2 = 9200
|
||||||
|
assert offer("middle_price", dict(ctx, input_price=9500, anchor_price=8900)) == 9200
|
||||||
|
# 갑 직전 포지션이 있으면 그 기준: (9000+9500)/2 = 9250
|
||||||
|
assert offer("middle_price", dict(ctx, input_price=9500, prev_customer_price=9000)) == 9250
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ② 변수 공통 유효조건 — 미발동(클램프 아님) --------------------------------
|
||||||
|
def test_offer_over_target_does_not_fire_imk_8ab0():
|
||||||
|
"""IMK 8AB0 회귀: 목표가 9,000 / 제시가 9,500 → 절충가 (8,910+9,500)/2 = 9,205 > 목표가.
|
||||||
|
구현이 목표가로 깎아 부르면 '중간에서 만나자며 목표가를 부르는' 모순 — 클램프가 아니라 미발동이 정답."""
|
||||||
|
ctx = {"input_price": 9500, "anchor_price": 8910, "target_price": 9000}
|
||||||
|
assert compute_offer(CardSpec(offer_variable="middle_price"), ctx) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_offer_at_or_above_input_price_does_not_fire():
|
||||||
|
"""협력사 제시가가 이미 제안가 이하면 부를 이유가 없다 → 미발동."""
|
||||||
|
ctx = {"input_price": 9950, "anchor_price": 9900, "target_price": 10000}
|
||||||
|
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) is None # target ≥ 제시가
|
||||||
|
assert compute_offer(CardSpec(offer_variable="anchoring_price"), dict(ctx, input_price=9900)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_offer_without_materials_does_not_fire():
|
||||||
|
"""재료 결측(목표가·제시가·앵커) — 어떤 변수도 미발동."""
|
||||||
|
assert compute_offer(CardSpec(offer_variable="target_price"), {"input_price": 11000}) is None # 목표가 없음
|
||||||
|
assert compute_offer(CardSpec(offer_variable="anchoring_price"),
|
||||||
|
{"input_price": 11000, "target_price": 10000}) is None # 앵커 없음
|
||||||
|
assert compute_offer(HOLD, {"input_price": 11000, "target_price": 10000}) is None # 설득 카드
|
||||||
|
assert compute_offer(CardSpec(offer_variable="없는변수"), {"input_price": 11000, "target_price": 10000}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_min_round_and_closing_phase():
|
||||||
|
spec2 = CardSpec(offer_variable="target_price", min_round=2)
|
||||||
|
assert available(spec2, {"round": 1}) is False # min_round 미만
|
||||||
|
assert available(spec2, {"round": 2}) is True
|
||||||
|
closing = CardSpec(offer_variable="middle_price", closing=True)
|
||||||
|
assert available(closing, {"round": 1}) is False # 종결 전용 — 중반 미발동(예약)
|
||||||
|
assert available(closing, {"round": 1}, closing_phase=True) is True
|
||||||
|
assert available(spec2, {"round": 3}, closing_phase=True) is False # 종결 국면엔 종결 카드만
|
||||||
|
|
||||||
|
|
||||||
|
def test_tactic_offer_variable_overrides_parse():
|
||||||
|
"""검증: tactic.offer_variable 명시 지정(negodata 셀렉트) — 파싱(마지막 변수) 대신 지정 변수 사용.
|
||||||
|
기대결과: 멘트 마지막이 target_price 여도 지정한 anchoring_price 가 제안가 변수가 된다."""
|
||||||
|
script = "적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안 드립니다."
|
||||||
|
assert build_card_spec(script).offer_variable == "target_price" # 자동: 마지막 변수
|
||||||
|
spec = build_card_spec(script, {"offer_variable": "anchoring_price"})
|
||||||
|
assert spec.offer_variable == "anchoring_price" # 명시 지정이 우선
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_requires_context_value():
|
||||||
|
"""검증: 시장가 인용 카드(NGC-008류)의 requires 게이트 — build_card_spec 이 스크립트에서 잡아내고,
|
||||||
|
기대결과: 컨텍스트에 인터넷 최저가가 없으면(0/결측) 미발동, 있으면 발동(퍼즈 #3·13·23·40 회귀)."""
|
||||||
|
spec = build_card_spec("유사 거래는 {internet_lowest_price}원 안팎에서 합의되고 있습니다.")
|
||||||
|
assert spec.requires == ("internet_lowest_price",)
|
||||||
|
assert available(spec, {"round": 1}) is False # 결측
|
||||||
|
assert available(spec, {"round": 1, "internet_lowest_price": 0}) is False # 미수집(0)
|
||||||
|
assert available(spec, {"round": 1, "internet_lowest_price": 6300}) is True
|
||||||
|
# 일반 카드는 requires 없음 — 기존 동작 그대로.
|
||||||
|
assert build_card_spec("귀사와의 협력을 소중히 생각합니다.").requires == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_offer_monotonic_no_regression():
|
||||||
|
"""검증: 역행 금지(IMK 논의 — 절충 16,980 후 예산 상한 16,810 제시) 재현.
|
||||||
|
기대결과: 직전 당사 제안보다 낮은 제안가 카드는 미발동(설득 폴백으로도 안 나감).
|
||||||
|
직전 제안이 없으면 앵커 제시 허용, 같은 금액 재제시 허용, 더 높은 제안은 정상."""
|
||||||
|
anchor_card = CardSpec(offer_variable="anchoring_price")
|
||||||
|
ctx = {"round": 2, "target_price": 17_300, "anchor_price": 16_810, "input_price": 17_500}
|
||||||
|
assert compute_offer(anchor_card, ctx) == 16_810 # 첫 카운터 전(포지션=앵커): 같은 금액 → 허용
|
||||||
|
ctx["prev_customer_price"] = 16_980 # 절충 카드가 이미 16,980 을 부른 상태
|
||||||
|
assert compute_offer(anchor_card, ctx) is None # 앵커 16,810 은 역행 → 미발동
|
||||||
|
assert playable(anchor_card, ctx) is False # 멘트에 금액이 박히므로 설득 폴백도 금지
|
||||||
|
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) == 17_300 # 상향 제안은 정상
|
||||||
|
|
||||||
|
|
||||||
|
def test_played_history_is_shared_by_number():
|
||||||
|
ctx = {}
|
||||||
|
assert is_played(ctx, "WC-05") is False
|
||||||
|
mark_played(ctx, "WC-05")
|
||||||
|
assert is_played(ctx, "WC-05") is True
|
||||||
|
mark_played(ctx, "WC-05") # 재기록해도 1건 유지
|
||||||
|
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||||
|
mark_played(ctx, None) # no-op(폴백 최후통첩)
|
||||||
|
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_from_context_reads_snapshot_and_falls_back_to_hold():
|
||||||
|
ctx = {"card_specs": dict(_SPECS)}
|
||||||
|
assert spec_from_context(ctx, "WC-05") == CardSpec(offer_variable="middle_price", min_round=1, closing=True)
|
||||||
|
assert spec_from_context(ctx, "NGC-B003") == HOLD # 미등록 카드(데모) 폴백
|
||||||
|
assert spec_from_context({}, "WC-05") == HOLD # 스펙 미적재(구세션·데모) 폴백
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ③ 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
|
||||||
|
def test_accept_counter_settles_at_counter_price():
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(step="가격협상_카운터", pending_counter_price=10000)
|
||||||
|
view = eng.advance(s, "수락")
|
||||||
|
assert view.step == "협상완료"
|
||||||
|
assert s.context["input_price"] == 10000 # 합의가 = 카운터가
|
||||||
|
assert "pending_counter_price" not in s.context
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_counter_reenters_price_and_discards_pending():
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(step="가격협상_카운터", pending_counter_price=10000)
|
||||||
|
view = eng.advance(s, "다른 가격 제시")
|
||||||
|
assert view.step == "가격협상_재입력"
|
||||||
|
# 새 가격 입력이 pending 을 폐기한다 — 이후 우선협상 타결이 옛 카운터로 오염되지 않음
|
||||||
|
view = eng.advance(s, "9900")
|
||||||
|
assert "pending_counter_price" not in s.context
|
||||||
|
assert s.context["input_price"] == 9900
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_1pct_decline_keeps_original_price():
|
||||||
|
"""1% 인하 거절('아니오')도 협상완료로 가지만 합의가는 원 제시가 — pending 미적용 회귀."""
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(step="wild_card_1pct", input_price=10000,
|
||||||
|
offer_1pct=9900, pending_counter_price=9900)
|
||||||
|
view = eng.advance(s, "아니오")
|
||||||
|
assert view.step == "협상완료"
|
||||||
|
assert s.context["input_price"] == 10000 # 거절 → 카운터 미적용
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ④ 목표가 초과 타결 금지 가드 --------------------------------------------
|
||||||
|
def test_success_step_guard_rejects_over_target():
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(input_price=10800, target_price=10000)
|
||||||
|
view = eng.render_step(s, "협상완료")
|
||||||
|
assert view.step == "협상실패" # 초과가 성공 진입 → 결렬 강제
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ⑤ 와일드 진입 — 종결 예약 + 중복 차단 (IMK BB9A 회귀) ---------------------
|
||||||
|
def test_selected_wildcard_fires_in_entry_zone_and_records_position():
|
||||||
|
eng = _engine()
|
||||||
|
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + 비종결 WC-02 선택
|
||||||
|
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||||
|
selected_wild_card_numbers=["WC-02"])
|
||||||
|
view = eng.advance(s, "예")
|
||||||
|
assert view.step == "wild_card_dynamic"
|
||||||
|
# 제안가 = (anchor 10000 + target 10100)/2 = 10050 ≤ target — 그대로 제시(클램프 없음)
|
||||||
|
assert s.context["pending_counter_price"] == 10050
|
||||||
|
assert s.context["prev_customer_price"] == 10050 # 갑 포지션 기록 — "당사 제안" 멘트 정합(BB9A ③)
|
||||||
|
assert s.context["active_wild_card_number"] == "WC-02"
|
||||||
|
assert is_played(s.context, "WC-02") # 카드 이력 기록
|
||||||
|
# 수락 → 그 가격으로 타결
|
||||||
|
view = eng.advance(s, "수락")
|
||||||
|
assert view.step == "협상완료" and s.context["input_price"] == 10050
|
||||||
|
|
||||||
|
|
||||||
|
def test_closing_card_is_reserved_never_fires_mid_negotiation():
|
||||||
|
"""종결 전용 카드(WC-05)는 entry 존이라도 중반에 안 나간다 — 종결 국면의 마지막 한 방으로 예약.
|
||||||
|
(BB9A 중복의 절반: 중반에 당겨 쓴 카드를 종결에서 또 쓰던 경로 차단.)"""
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||||
|
selected_wild_card_numbers=["WC-05"])
|
||||||
|
view = eng.advance(s, "예")
|
||||||
|
assert view.step == "가격협상" # 종결 카드뿐 → 일반 카드 플레이로
|
||||||
|
assert "active_wild_card_number" not in s.context
|
||||||
|
assert not is_played(s.context, "WC-05") # 안 나갔으니 이력도 없음
|
||||||
|
# 종결 국면에선 발동 가능 + 이력 없음 — 서비스 종결 루프가 이 카드를 쓴다
|
||||||
|
spec = spec_from_context(s.context, "WC-05")
|
||||||
|
assert available(spec, s.context, closing_phase=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_played_wildcard_is_skipped_on_reentry():
|
||||||
|
"""이미 쓴 카드는 같은 협상에서 다시 안 나간다 — 다음 후보로 넘어간다."""
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(input_price=10300, allow_selected_wildcards=True, wildcard_used=False,
|
||||||
|
selected_wild_card_numbers=["WC-02"], played_card_numbers=["WC-02"])
|
||||||
|
view = eng.advance(s, "예")
|
||||||
|
assert view.step == "가격협상" # 유일 후보가 사용됨 → 발동 없음
|
||||||
|
|
||||||
|
|
||||||
|
def test_unselected_wildcard_zone_still_falls_to_nego():
|
||||||
|
"""와일드카드 미선택이면 entry 존이라도 일반 가격협상 — 기존 동작 보존."""
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(input_price=10300, allow_selected_wildcards=True, selected_wild_card_numbers=[])
|
||||||
|
view = eng.advance(s, "예")
|
||||||
|
assert view.step == "가격협상"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 인하율 표기 (회귀: 인상 제시가 "-1.3% 인하"로 표기되던 버그) -----------------
|
||||||
|
def test_discount_never_negative_and_phrase_matches_direction():
|
||||||
|
eng = _engine()
|
||||||
|
# 인상 제시(기존 공급가 78000 < 제시 79000): 음수 인하율 금지 + "높은 금액" 문구
|
||||||
|
s = _session(item_price=78000, input_price=79000)
|
||||||
|
v = eng.vars_for(s)
|
||||||
|
assert v["discount_rate"] == "0.0" # 마이너스 인하 표기 금지
|
||||||
|
assert "높은 금액" in v["discount_phrase"] and "78000원" in v["discount_phrase"]
|
||||||
|
assert "-" not in v["discount_phrase"]
|
||||||
|
# 인하 제시: 상품단가(item_price) 기준 인하율
|
||||||
|
v = eng.vars_for(_session(item_price=78000, input_price=77000))
|
||||||
|
assert v["discount_rate"] == "1.3"
|
||||||
|
assert "인하된 금액" in v["discount_phrase"]
|
||||||
|
# 동일가
|
||||||
|
v = eng.vars_for(_session(item_price=78000, input_price=78000))
|
||||||
|
assert "동일한 수준" in v["discount_phrase"]
|
||||||
|
# 기존가 미보유(신규 협상) → 문구 생략
|
||||||
|
v = eng.vars_for(_session(item_price=0, input_price=79000))
|
||||||
|
assert v["discount_phrase"] == "" and v["discount_rate"] == "0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_confirm_script_renders_raise_correctly():
|
||||||
|
"""가격협상_확인 멘트 E2E — 인상 제시에 '인하' 표현이 나오지 않는다."""
|
||||||
|
eng = _engine()
|
||||||
|
s = _session(step="기존가격제시", item_price=78000, input_price=None, round=0)
|
||||||
|
s.context.pop("input_price")
|
||||||
|
view = eng.advance(s, "79000")
|
||||||
|
assert view.step == "가격협상_확인"
|
||||||
|
assert "인하" not in view.script # 인상인데 '인하' 금지
|
||||||
|
assert "높은 금액" in view.script and "79000원" in view.script
|
||||||
|
|
||||||
|
|
||||||
|
# ---- vars_for 전술 변수 치환 ---------------------------------------------------
|
||||||
|
def test_vars_for_supplies_tactic_variables():
|
||||||
|
eng = _engine()
|
||||||
|
# 카운터 미제시(정보성): 절충/중간 변수는 원 계산값.
|
||||||
|
s0 = _session(input_price=10300, prev_customer_price=10000) # anchor=10000, target=10100 (기본)
|
||||||
|
v0 = eng.vars_for(s0)
|
||||||
|
assert v0["prev_partner_price"] == 10300
|
||||||
|
assert v0["prev_customer_price"] == 10000
|
||||||
|
assert v0["target_mid_price"] == 10050 # (anchor 10000 + target 10100)/2
|
||||||
|
assert v0["middle_price"] == 10150 # (prev_customer 10000 + input 10300)/2
|
||||||
|
|
||||||
|
# 카운터 제시 중: 표시 제시가(middle/target_mid/counter) == 타결가(pending) 로 고정.
|
||||||
|
# 회귀(표시가≠투찰가): 예전엔 middle_price 가 재계산값 10150 을 표시하면서 10100 으로 타결됐다.
|
||||||
|
s1 = _session(input_price=10300, prev_customer_price=10000, pending_counter_price=10100)
|
||||||
|
v1 = eng.vars_for(s1)
|
||||||
|
assert v1["counter_price"] == 10100
|
||||||
|
assert v1["middle_price"] == 10100 # 재계산 10150 이 아니라 pending
|
||||||
|
assert v1["target_mid_price"] == 10100
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ⑥⑦ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
|
||||||
|
from sqlalchemy import column, delete, insert, select, table # noqa: E402
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
||||||
|
from common.enums import DBType, DBWRType, ErrorType # noqa: E402
|
||||||
|
from router.v1.chat.protocol import Req_Chat # noqa: E402
|
||||||
|
from services.chat_service import ChatService, reset_sessions # noqa: E402
|
||||||
|
from tenancy.registry import TenantEngineRegistry # noqa: E402
|
||||||
|
|
||||||
|
_T_SESSIONS = table(
|
||||||
|
"sessions",
|
||||||
|
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
|
||||||
|
column("qt_number"), column("qt_round"), column("qt_type"), column("target_price"),
|
||||||
|
column("anchoring_price"), column("status"), column("end_time"),
|
||||||
|
schema="negotiation",
|
||||||
|
)
|
||||||
|
_T_QUOTATIONS = table(
|
||||||
|
"quotations",
|
||||||
|
column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"),
|
||||||
|
column("name"), column("number"), column("type"), column("status"),
|
||||||
|
column("start_time"), column("end_time"),
|
||||||
|
schema="quotation",
|
||||||
|
)
|
||||||
|
_T_VNC = table("version_nego_cards", column("vnc_id"), column("version_id"), column("nego_card_id"), schema="card")
|
||||||
|
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
|
||||||
|
_T_VWC = table("version_wild_cards", column("vwc_id"), column("version_id"), column("wild_card_id"), schema="card")
|
||||||
|
_T_WILD = table("wild_cards", column("wild_card_id"), column("number"), column("deleted"), schema="card")
|
||||||
|
|
||||||
|
|
||||||
|
async def _card_uuid(number: str, *, wild=False):
|
||||||
|
tbl, pk = (_T_WILD, _T_WILD.c.wild_card_id) if wild else (_T_NEGO, _T_NEGO.c.nego_card_id)
|
||||||
|
|
||||||
|
def _q(s):
|
||||||
|
return DB_SESSION_MNG.execute(
|
||||||
|
s, select(pk).where(tbl.c.number == number, tbl.c.deleted == False).limit(1)) # noqa: E712
|
||||||
|
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_quote_session(sid, selected_numbers, wild_numbers=(), target=10000, anchor=9900):
|
||||||
|
qid, ver_id, iid, sup = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
card_ids, wild_ids = {}, {}
|
||||||
|
for n in selected_numbers:
|
||||||
|
card_ids[n] = await _card_uuid(n)
|
||||||
|
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||||
|
for n in wild_numbers:
|
||||||
|
wild_ids[n] = await _card_uuid(n, wild=True)
|
||||||
|
assert wild_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||||
|
|
||||||
|
def _seed(s_):
|
||||||
|
async def run(s):
|
||||||
|
e = await DB_SESSION_MNG.add(s, insert(_T_QUOTATIONS).values(
|
||||||
|
qt_id=qid, user_id=_uuid.uuid4(), qt_setting_id=_uuid.uuid4(), version_id=ver_id,
|
||||||
|
name="전술E2E", number=f"QT-TACTIC-{str(sid)[:8]}", type=1, status=2,
|
||||||
|
start_time=now, end_time=now + timedelta(days=1)))
|
||||||
|
if e != ErrorType.SUCCESS:
|
||||||
|
return e
|
||||||
|
for n in selected_numbers:
|
||||||
|
e = await DB_SESSION_MNG.add(s, insert(_T_VNC).values(
|
||||||
|
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
|
||||||
|
if e != ErrorType.SUCCESS:
|
||||||
|
return e
|
||||||
|
for n in wild_numbers:
|
||||||
|
e = await DB_SESSION_MNG.add(s, insert(_T_VWC).values(
|
||||||
|
vwc_id=_uuid.uuid4(), version_id=ver_id, wild_card_id=wild_ids[n]))
|
||||||
|
if e != ErrorType.SUCCESS:
|
||||||
|
return e
|
||||||
|
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
|
||||||
|
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
|
||||||
|
qt_number=f"QT-TACTIC-{str(sid)[:8]}", qt_round=1, qt_type=1,
|
||||||
|
target_price=target, anchoring_price=anchor, status=2,
|
||||||
|
end_time=now + timedelta(days=1)))
|
||||||
|
return run(s_)
|
||||||
|
|
||||||
|
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_seed])
|
||||||
|
assert err == ErrorType.SUCCESS
|
||||||
|
return qid, ver_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup(sid, qid, ver_id):
|
||||||
|
await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[DBType.MAIN.value],
|
||||||
|
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)),
|
||||||
|
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
|
||||||
|
lambda s: DB_SESSION_MNG.add(s, delete(_T_VWC).where(_T_VWC.c.version_id == ver_id)),
|
||||||
|
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_e2e_counter_accept_settles_at_target(db_engine):
|
||||||
|
"""견적 선택 카드 NGC-010(향후 거래 연계 — 스크립트 {target_price} 파싱 → 목표가 제안)의
|
||||||
|
카운터를 수락하면 합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
|
||||||
|
reset_sessions()
|
||||||
|
sid = _uuid.uuid4()
|
||||||
|
qid, ver_id = await _seed_quote_session(sid, ["NGC-010"])
|
||||||
|
try:
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||||
|
svc = ChatService()
|
||||||
|
session_id = str(sid)
|
||||||
|
r = None
|
||||||
|
for ui in [None, "확인", "예", "확인", "11000", "예"]:
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||||
|
# 가격협상 카드 턴 → NGC-010 카운터(target) 제시 스텝
|
||||||
|
assert r.step == "가격협상_카운터", f"카운터 스텝 기대, 실제 {r.step}"
|
||||||
|
assert r.card_id == "NGC-010"
|
||||||
|
assert r.input_options == ["수락", "다른 가격 제시"]
|
||||||
|
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||||
|
assert r.step == "협상완료"
|
||||||
|
assert r.settled_price == 10000 # 합의가 = 목표가 (고객사 이득)
|
||||||
|
finally:
|
||||||
|
await _cleanup(sid, qid, ver_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
|
||||||
|
"""협력사가 목표가 초과(11000)를 고수하면: 카드 소진 → 종결 전술(목표가 최후통첩) →
|
||||||
|
그래도 거절 → 결렬(협상실패). 목표가 초과로는 절대 타결되지 않는다."""
|
||||||
|
reset_sessions()
|
||||||
|
sid = _uuid.uuid4()
|
||||||
|
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # 설득 카드 1장 → 빠른 소진
|
||||||
|
try:
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||||
|
svc = ChatService()
|
||||||
|
session_id = str(sid)
|
||||||
|
steps, r = [], None
|
||||||
|
# 고수 시나리오: 가격은 항상 11000, 카운터는 전부 거절
|
||||||
|
for ui in [None, "확인", "예", "확인", "11000", "예", "11000", "예"]:
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||||
|
steps.append(r.step)
|
||||||
|
# 카드(NGC-003) 소진 → 종결 국면: 목표가 최후통첩 카운터 스텝
|
||||||
|
assert r.step == "가격협상_카운터", f"종결 카운터 기대, 실제 {steps}"
|
||||||
|
assert "10000" in r.script # 최후통첩 = 목표가 제시
|
||||||
|
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="다른 가격 제시"))
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="11000"))
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||||
|
assert r.step == "협상실패" # target 초과 고수 → 결렬
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="확인"))
|
||||||
|
assert r.chat_end and r.outcome == "failure" # backend REJECTED → 개찰 이관
|
||||||
|
assert r.settled_price is None # 초과가 타결 없음
|
||||||
|
finally:
|
||||||
|
await _cleanup(sid, qid, ver_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_e2e_bb9a_no_duplicate_wildcard_and_real_middle(db_engine):
|
||||||
|
"""IMK BB9A 재현 E2E — 와일드카드 2장(WC-02·WC-05) + 설득 카드 1장.
|
||||||
|
|
||||||
|
기대 흐름(수정 후):
|
||||||
|
· 중반 와일드 진입 = 비종결 WC-02 (종결 전용 WC-05 는 예약 — 구현 전엔 WC-05 가 먼저 나갔다)
|
||||||
|
· 종결 국면 = WC-05, 절충가 = (당사 직전 제안 + 협력사 제시가)/2 실계산 (구현 전엔 목표가로 클램프)
|
||||||
|
· 같은 카드 2회 발동 없음 + 종결 발동도 card_id 기록
|
||||||
|
"""
|
||||||
|
reset_sessions()
|
||||||
|
sid = _uuid.uuid4()
|
||||||
|
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"], wild_numbers=["WC-02", "WC-05"])
|
||||||
|
try:
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||||
|
svc = ChatService()
|
||||||
|
session_id = str(sid)
|
||||||
|
r = None
|
||||||
|
# 10300: 1pct 존(≤ 9900×1.02=10098) 밖, entry 존(≤ 10395) 안 → 선택형 와일드 발동 구간
|
||||||
|
for ui in [None, "확인", "예", "확인", "10300", "예"]:
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||||
|
assert r.step == "wild_card_dynamic"
|
||||||
|
assert r.card_id == "WC-02" # 종결 전용 WC-05 가 아니라 비종결 카드
|
||||||
|
# WC-02 제안가 = (anchor 9900 + target 10000)/2 = 9950
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="다른 가격 제시"))
|
||||||
|
# 10010 재제시 → 설득 카드(NGC-003) 1장 소진
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10010"))
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||||
|
assert r.step == "가격협상" and r.card_id == "NGC-003"
|
||||||
|
# 10005 재제시 → 카드 소진 → 종결 국면: WC-05 절충가 = (9950 + 10005)/2 = 9980 (≤ target)
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10005"))
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||||
|
assert r.step == "가격협상_카운터"
|
||||||
|
assert r.card_id == "WC-05" # 종결 발동도 카드 기록(구현 전 null)
|
||||||
|
assert "9980" in r.script # 실제 절충가 — 목표가(10000) 클램프 아님
|
||||||
|
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||||
|
assert r.step == "협상완료"
|
||||||
|
assert r.settled_price == 9980 # 표시가 = 타결가
|
||||||
|
finally:
|
||||||
|
await _cleanup(sid, qid, ver_id)
|
||||||
@ -109,7 +109,7 @@ async def test_context_loaded_from_db(db_engine):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
eng = await reg.get_engine("ktcommerce")
|
eng = await reg.get_engine("imarketkorea")
|
||||||
r = await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
|
r = await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
|
||||||
assert r.session_id == str(sid) and r.step == "서비스안내"
|
assert r.session_id == str(sid) and r.step == "서비스안내"
|
||||||
|
|
||||||
@ -164,7 +164,7 @@ async def test_null_anchoring_falls_back_to_target(db_engine):
|
|||||||
assert err == ErrorType.SUCCESS
|
assert err == ErrorType.SUCCESS
|
||||||
try:
|
try:
|
||||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
eng = await reg.get_engine("ktcommerce")
|
eng = await reg.get_engine("imarketkorea")
|
||||||
await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
|
await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
|
||||||
saved = await ChatSessionRepository(eng.company_id).get(str(sid))
|
saved = await ChatSessionRepository(eng.company_id).get(str(sid))
|
||||||
assert saved is not None
|
assert saved is not None
|
||||||
@ -186,15 +186,30 @@ async def test_loader_with_crud_double(db_engine):
|
|||||||
|
|
||||||
class _FakeCRUD(INegoContextCRUD):
|
class _FakeCRUD(INegoContextCRUD):
|
||||||
async def get_session_row(self, cdb, session_id):
|
async def get_session_row(self, cdb, session_id):
|
||||||
# (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제
|
# (qt_type, target, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id)
|
||||||
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
|
# — 재견적(2)·앵커 미박제·타결상한 52,500(목표가 +5%)
|
||||||
|
return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
|
||||||
|
|
||||||
async def get_item_price(self, cdb, item_id):
|
async def get_item_baseline(self, cdb, item_id):
|
||||||
return ErrorType.SUCCESS, 7000
|
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전.
|
||||||
|
# 호칭은 crud 가 어떤 회사든 '공급가'(공급사 화면 고정 용어)로 내려준다.
|
||||||
|
return ErrorType.SUCCESS, (7000, "공급가", {"supplier": "공급업체"})
|
||||||
|
|
||||||
|
async def get_item_lowest_price(self, cdb, item_id):
|
||||||
|
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
|
||||||
|
|
||||||
|
async def get_card_count(self, cdb, session_id):
|
||||||
|
return ErrorType.SUCCESS, 3 # 협상카드 사용 횟수 상한(quotation_settings.card_count)
|
||||||
|
|
||||||
async def get_supplier_total_revenue(self, cdb, supplier_id):
|
async def get_supplier_total_revenue(self, cdb, supplier_id):
|
||||||
return ErrorType.SUCCESS, 12_000_000.0
|
return ErrorType.SUCCESS, 12_000_000.0
|
||||||
|
|
||||||
|
async def get_item_name(self, cdb, item_id):
|
||||||
|
return ErrorType.SUCCESS, "테스트상품"
|
||||||
|
|
||||||
|
async def get_supplier_name(self, cdb, supplier_id):
|
||||||
|
return ErrorType.SUCCESS, "테스트협력사"
|
||||||
|
|
||||||
async def get_supply_type(self, cdb, supplier_id, item_id):
|
async def get_supply_type(self, cdb, supplier_id, item_id):
|
||||||
return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B"
|
return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B"
|
||||||
|
|
||||||
@ -205,19 +220,39 @@ async def test_loader_with_crud_double(db_engine):
|
|||||||
return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE
|
return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE
|
||||||
|
|
||||||
async def get_quotation_card_numbers(self, cdb, quotation_id):
|
async def get_quotation_card_numbers(self, cdb, quotation_id):
|
||||||
return ErrorType.SUCCESS, (["NGC-003", "NGC-008"], ["WC-02"]) # 견적 선택 카드
|
# 행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 card_specs 를 만든다
|
||||||
|
return ErrorType.SUCCESS, (
|
||||||
|
[("NGC-003", "설득 멘트(가격 변수 없음)", None),
|
||||||
|
("NGC-008", "시장가 {internet_lowest_price}원 인용(읽기 전용 변수)", None)],
|
||||||
|
[("WC-02", "이에 당사는 {target_mid_price}원을 역으로 제안 드립니다.", None)],
|
||||||
|
)
|
||||||
|
|
||||||
ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
|
ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
|
||||||
assert ctx is not None
|
assert ctx is not None
|
||||||
assert ctx.rq_type == "재견적" # qt_type=2(1:N)
|
assert ctx.rq_type == "재견적" # qt_type=2(1:N)
|
||||||
assert ctx.target_price == 50000
|
assert ctx.target_price == 50000
|
||||||
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
|
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
|
||||||
|
assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%)
|
||||||
assert ctx.item_price == 7000
|
assert ctx.item_price == 7000
|
||||||
|
assert ctx.item_price_label == "공급가" # 기준가 호칭이 멘트까지 전달되는지
|
||||||
|
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
|
||||||
|
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
|
||||||
|
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인
|
||||||
|
assert ctx.partner_name == "테스트협력사"
|
||||||
|
assert ctx.product_name == "테스트상품"
|
||||||
assert ctx.revenue_amount == 12_000_000.0
|
assert ctx.revenue_amount == 12_000_000.0
|
||||||
assert ctx.distribution_code == "B" # supply_type=3(총판) → B
|
assert ctx.distribution_code == "B" # supply_type=3(총판) → B
|
||||||
assert ctx.partner_type is PartnerType.NONE
|
assert ctx.partner_type is PartnerType.NONE
|
||||||
assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"]
|
assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"]
|
||||||
assert ctx.selected_wild_card_numbers == ["WC-02"]
|
assert ctx.selected_wild_card_numbers == ["WC-02"]
|
||||||
|
# 카드 전술 확정 — 설득 카드/읽기 전용 변수는 제안가 없음, WC-02 는 스크립트 파싱으로 중간가.
|
||||||
|
assert ctx.card_specs["NGC-003"]["offer_variable"] is None
|
||||||
|
assert ctx.card_specs["NGC-008"]["offer_variable"] is None # 인터넷 최저가는 읽어주기 변수 — 제안가 아님
|
||||||
|
# 시장가 인용 카드는 최저가 결측 세션에서 미발동하도록 requires 로 표시된다(토큰 노출 방지).
|
||||||
|
assert ctx.card_specs["NGC-008"]["requires"] == ["internet_lowest_price"]
|
||||||
|
assert ctx.card_specs["WC-02"] == {
|
||||||
|
"offer_variable": "target_mid_price", "min_round": 1, "closing": False, "requires": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -225,7 +260,7 @@ async def test_context_falls_back_without_db_row(db_engine):
|
|||||||
"""DB 에 세션 행이 없으면(데모/직접 호출) 기본 컨텍스트로 폴백한다."""
|
"""DB 에 세션 행이 없으면(데모/직접 호출) 기본 컨텍스트로 폴백한다."""
|
||||||
reset_sessions()
|
reset_sessions()
|
||||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
eng = await reg.get_engine("ktcommerce")
|
eng = await reg.get_engine("imarketkorea")
|
||||||
r = await ChatService().chat(eng, Req_Chat())
|
r = await ChatService().chat(eng, Req_Chat())
|
||||||
saved = await ChatSessionRepository(eng.company_id).get(r.session_id)
|
saved = await ChatSessionRepository(eng.company_id).get(r.session_id)
|
||||||
assert saved is not None
|
assert saved is not None
|
||||||
|
|||||||
145
agent/tests/test_decision_rules.py
Normal file
145
agent/tests/test_decision_rules.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""Phase 1 결정 스택 잔여분 검증 — 규칙 데이터화 + 선택카드 우선순위 prior.
|
||||||
|
|
||||||
|
1. 와일드카드 진입 임계(wildcard_1pct_ratio/entry_ratio)·카운터 라운드 상한(max_counter_rounds)이
|
||||||
|
하드코딩이 아니라 테넌트 config(negotiation.*)로 주입된다.
|
||||||
|
2. 의도층 prior: 갑이 견적에서 고른 카드 순서가 콜드 스타트 선택을 결정하고,
|
||||||
|
학습(Q·방문수)이 쌓이면 영향이 소멸한다 — Q-table 오염 없음.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||||
|
from negotiation.chat.service.script_repository import ScriptRepository
|
||||||
|
from negotiation.policies.base import EpisodeState, PolicyContext
|
||||||
|
from negotiation.policies.qtable_policy import UCBQTablePolicy
|
||||||
|
from negotiation.qtable.domain.model.q_table import QTable
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
|
||||||
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(**rule_overrides) -> ChatEngine:
|
||||||
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
|
for k, v in rule_overrides.items():
|
||||||
|
setattr(cfg.negotiation, k, v)
|
||||||
|
return ChatEngine(ScriptRepository(cfg, _TENANTS_DIR), rq_type="재협상")
|
||||||
|
|
||||||
|
|
||||||
|
def _session(price, anchor=10000, rnd=1, **ctx_over):
|
||||||
|
ctx = {"input_price": price, "anchor_price": anchor, "target_price": anchor + 100,
|
||||||
|
"round": rnd, "allow_selected_wildcards": False}
|
||||||
|
ctx.update(ctx_over)
|
||||||
|
return ChatSession(session_id="00000000-0000-0000-0000-00000000d001", tenant_id="imarketkorea",
|
||||||
|
company_id="imarketkorea", step="가격협상_확인", action_space_size=0, context=ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 규칙 데이터화 -----------------------------------------------------------
|
||||||
|
def test_default_rules_loaded_from_config():
|
||||||
|
eng = _engine()
|
||||||
|
assert eng.rules.wildcard_1pct_ratio == 1.02
|
||||||
|
assert eng.rules.wildcard_entry_ratio == 1.05
|
||||||
|
assert eng.rules.max_counter_rounds == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_threshold_is_config_driven():
|
||||||
|
# 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상
|
||||||
|
view = _engine().advance(_session(10800), "예")
|
||||||
|
assert view.step == "가격협상"
|
||||||
|
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동.
|
||||||
|
# 1%가(10800×0.99=10692)도 제안가 공통 유효조건(≤목표가)을 타므로 목표가를 그 위로 둔다 —
|
||||||
|
# 기본 target(10100)이면 초과 제시 금지 규칙에 걸려 발동하지 않는 게 새 정답.
|
||||||
|
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800, target_price=11000), "예")
|
||||||
|
assert view.step == "wild_card_1pct"
|
||||||
|
# 목표가가 1%가 아래면(초과 제시 금지) 완화 임계라도 미발동 — 수락해도 결렬되는 모순 제안 차단.
|
||||||
|
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
|
||||||
|
assert view.step == "가격협상"
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_counter_rounds_is_config_driven():
|
||||||
|
# round=3(카운터 2회 경과), 제시 11000: 기본 상한 3 → 아직 협상 지속
|
||||||
|
view = _engine().advance(_session(11000, rnd=3), "예")
|
||||||
|
assert view.step == "가격협상"
|
||||||
|
# 상한 1 → 종결 국면 진입: 곧장 실패가 아니라 종결 전술 발동 지점(force_closing)으로
|
||||||
|
s = _engine(max_counter_rounds=1), _session(11000, rnd=3)
|
||||||
|
view = s[0].advance(s[1], "예")
|
||||||
|
assert view.step == "가격협상" and s[1].context.get("force_closing") is True
|
||||||
|
# 종결 전술까지 소진(closing_played) 후에도 target(10100) 초과 → 결렬
|
||||||
|
view = _engine(max_counter_rounds=1).advance(_session(11000, rnd=3, closing_played=True), "예")
|
||||||
|
assert view.step == "협상실패"
|
||||||
|
# 종결 후 제시가가 target 이하로 내려오면 결렬이 아니라 타결 (새 규칙 — 구현 전엔 무조건 실패)
|
||||||
|
view = _engine(max_counter_rounds=1).advance(
|
||||||
|
_session(10050, rnd=3, closing_played=True, wildcard_used=True), "예")
|
||||||
|
assert view.step == "협상완료"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 선택카드 우선순위 prior ---------------------------------------------------
|
||||||
|
def _snap():
|
||||||
|
return NegotiationSnapshot(revenue_amount=1, distribution_code="A", partner_count=1,
|
||||||
|
acceptance_ratio=0.1, input_price=900, anchor_price=800, target_price=1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(prior=None, mask=None, n=11):
|
||||||
|
return PolicyContext(state_index=0, snapshot=_snap(), action_space_size=n,
|
||||||
|
available_mask=mask, prior_bonus=prior, episode=EpisodeState())
|
||||||
|
|
||||||
|
|
||||||
|
def test_prior_decides_cold_start_order():
|
||||||
|
"""콜드 스타트(Q=0·방문 0)에서는 갑이 먼저 고른 카드(높은 prior)가 먼저 나간다."""
|
||||||
|
qt = QTable(2, 11)
|
||||||
|
prior = np.zeros(11)
|
||||||
|
prior[7], prior[2] = 0.3, 0.15 # 선택 순서: action7 → action2
|
||||||
|
mask = np.zeros(11, dtype=bool)
|
||||||
|
mask[2] = mask[7] = True
|
||||||
|
p = UCBQTablePolicy(qt, mark_visits=False)
|
||||||
|
assert p.select(_ctx(prior=prior, mask=mask)).action_id == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_prior_decays_as_learning_accumulates():
|
||||||
|
"""학습이 쌓이면(Q·방문수) prior 는 1/(1+visits) 로 감쇠 — Q 가 지배한다."""
|
||||||
|
qt = QTable(2, 11)
|
||||||
|
qt.q[0, 2] = 1.0 # action2 가 학습상 우월
|
||||||
|
qt.visits[0, 2] = 5
|
||||||
|
qt.visits[0, 7] = 5 # 탐색 보너스 동률
|
||||||
|
prior = np.zeros(11)
|
||||||
|
prior[7] = 0.3 # 갑 선호는 action7
|
||||||
|
mask = np.zeros(11, dtype=bool)
|
||||||
|
mask[2] = mask[7] = True
|
||||||
|
p = UCBQTablePolicy(qt, mark_visits=False)
|
||||||
|
assert p.select(_ctx(prior=prior, mask=mask)).action_id == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_prior_keeps_existing_behavior():
|
||||||
|
"""prior 미주입(None) 시 기존 UCB 동작 그대로 — 회귀 없음."""
|
||||||
|
qt = QTable(2, 4)
|
||||||
|
qt.q[0] = np.array([1.0, 9.0, 2.0, 0.0])
|
||||||
|
qt.visits[0] = np.array([5, 5, 5, 5])
|
||||||
|
p = UCBQTablePolicy(qt, exploration_constant=0.1, mark_visits=False)
|
||||||
|
assert p.select(_ctx(n=4)).action_id == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_prior_built_from_quotation_order():
|
||||||
|
"""ChatService._selection_prior — 견적 선택 순서 → prior 배열 (앞선 선택일수록 큼)."""
|
||||||
|
from services.chat_service import ChatService
|
||||||
|
|
||||||
|
class _Mapper:
|
||||||
|
_m = {i: f"NGC-B{i + 1:03d}" for i in range(11)}
|
||||||
|
def get_action_id(self, num):
|
||||||
|
return next((a for a, c in self._m.items() if c == num), None)
|
||||||
|
class _Engine:
|
||||||
|
mapper = _Mapper()
|
||||||
|
action_space_size = 11
|
||||||
|
|
||||||
|
session = ChatSession(session_id="00000000-0000-0000-0000-00000000d002", tenant_id="t", company_id="t",
|
||||||
|
context={"selected_nego_card_numbers": ["NGC-B008", "NGC-B003"]})
|
||||||
|
prior = ChatService._selection_prior(_Engine(), session)
|
||||||
|
assert prior is not None
|
||||||
|
assert prior[7] > prior[2] > 0 # 먼저 고른 NGC-B008(action7) 이 더 큼
|
||||||
|
assert prior[[0, 1, 4, 10]].sum() == 0 # 미선택 카드는 0
|
||||||
|
|
||||||
|
# 선택이 1장이면 순서 정보가 없어 None
|
||||||
|
session.context["selected_nego_card_numbers"] = ["NGC-B008"]
|
||||||
|
assert ChatService._selection_prior(_Engine(), session) is None
|
||||||
@ -110,7 +110,7 @@ async def test_version_and_cell_persistence(db_engine):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_service_step_learns_and_isolates(db_engine):
|
async def test_service_step_learns_and_isolates(db_engine):
|
||||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
eng = await reg.get_engine("ktcommerce")
|
eng = await reg.get_engine("imarketkorea")
|
||||||
svc = NegotiationService()
|
svc = NegotiationService()
|
||||||
|
|
||||||
def req():
|
def req():
|
||||||
@ -126,20 +126,21 @@ async def test_service_step_learns_and_isolates(db_engine):
|
|||||||
assert r1.learned is True and r1.policy == "qtable_ucb"
|
assert r1.learned is True and r1.policy == "qtable_ucb"
|
||||||
assert r1.updated_q > 0.0 # 성공 보상으로 Q 상승
|
assert r1.updated_q > 0.0 # 성공 보상으로 Q 상승
|
||||||
|
|
||||||
# DB 에서 state 전체 방문 누적 확인 (state 58 = ktcommerce 의 이 snapshot)
|
# DB 에서 state 전체 방문 누적 확인 (state index 는 imarketkorea config 로 동적 계산)
|
||||||
from negotiation.qtable.domain.service.state_calculator import state_index
|
from negotiation.qtable.domain.service.state_calculator import state_index
|
||||||
sidx = state_index(_snap(revenue_amount=20_000_000, acceptance_ratio=0.11, input_price=990, round_number=3), eng.config.state)
|
sidx = state_index(_snap(revenue_amount=20_000_000, acceptance_ratio=0.11, input_price=990, round_number=3), eng.config.state)
|
||||||
repo_kt = LearningRepository("ktcommerce")
|
repo_a = LearningRepository("imarketkorea")
|
||||||
vid = await repo_kt.get_or_create_active_version(state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95)
|
vid = await repo_a.get_or_create_active_version(state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95)
|
||||||
_, vcells = await repo_kt.load_cells(vid)
|
_, vcells = await repo_a.load_cells(vid)
|
||||||
state_total = sum(c for s, a, c in vcells if s == sidx)
|
state_total = sum(c for s, a, c in vcells if s == sidx)
|
||||||
assert state_total == 3 # 3회 호출 → state 누적 방문 3
|
assert state_total == 3 # 3회 호출 → state 누적 방문 3
|
||||||
|
|
||||||
# 테넌트 격리: imarketkorea 는 별도 학습/별도 state
|
# 테넌트 격리: 자동 온보딩 고객사(UUID)는 별도 학습/별도 state
|
||||||
eng2 = await reg.get_engine("imarketkorea")
|
other = "00000000-0000-0000-0000-0000000000c1"
|
||||||
|
eng2 = await reg.get_engine(other)
|
||||||
ri = await svc.step(eng2, req())
|
ri = await svc.step(eng2, req())
|
||||||
assert ri.visit_count == 1
|
assert ri.visit_count == 1
|
||||||
|
|
||||||
err, ck = await repo_kt.read(lambda s: repo_kt.count_experience(s))
|
err, ck = await repo_a.read(lambda s: repo_a.count_experience(s))
|
||||||
err, ci = await LearningRepository("imarketkorea").read(lambda s: LearningRepository("imarketkorea").count_experience(s))
|
err, ci = await LearningRepository(other).read(lambda s: LearningRepository(other).count_experience(s))
|
||||||
assert ck == 3 and ci == 1 # experience 격리
|
assert ck == 3 and ci == 1 # experience 격리
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def test_card_effectiveness_has_good_cards():
|
|||||||
assert len(good) >= 3 # 효과 좋은 카드 존재 → 학습 대상 신호
|
assert len(good) >= 3 # 효과 좋은 카드 존재 → 학습 대상 신호
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("tenant", ["ktcommerce", "imarketkorea"])
|
@pytest.mark.parametrize("tenant", ["_base", "imarketkorea"])
|
||||||
def test_learning_beats_baseline(tenant):
|
def test_learning_beats_baseline(tenant):
|
||||||
report = run("configs/exp_default.yaml", tenant)
|
report = run("configs/exp_default.yaml", tenant)
|
||||||
pols = report["policies"]
|
pols = report["policies"]
|
||||||
@ -53,7 +53,7 @@ def test_learning_beats_baseline(tenant):
|
|||||||
|
|
||||||
|
|
||||||
def test_static_does_not_learn():
|
def test_static_does_not_learn():
|
||||||
report = run("configs/exp_default.yaml", "ktcommerce")
|
report = run("configs/exp_default.yaml", "imarketkorea")
|
||||||
static = report["policies"]["static"]
|
static = report["policies"]["static"]
|
||||||
# 정적 정책은 항상 고정 카드 → 좋은카드 적중 학습 없음(우연 일치만)
|
# 정적 정책은 항상 고정 카드 → 좋은카드 적중 학습 없음(우연 일치만)
|
||||||
assert static["good_card_hit_rate"] <= report["policies"]["qtable_ucb"]["good_card_hit_rate"]
|
assert static["good_card_hit_rate"] <= report["policies"]["qtable_ucb"]["good_card_hit_rate"]
|
||||||
|
|||||||
161
agent/tests/test_input_interpreter.py
Normal file
161
agent/tests/test_input_interpreter.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
"""Phase 3 이해층 검증 — InputInterpreter (자유 발화 NLU → 기대 입력 구조화).
|
||||||
|
|
||||||
|
원칙 검증: LLM 은 의도 분류·가격 표현 위치만 찾고, 숫자 계산은 결정론 파서가 한다.
|
||||||
|
① 한국어 가격 파서 결정론 ② choice 는 선택지 목록 검증 ③ price_text 는 원문 부분문자열 검증
|
||||||
|
④ 실패/타임아웃 → None(원문 폴백) ⑤ 챗 플로우: 자유 발화로 분기·가격 입력이 진행된다
|
||||||
|
⑥ LLM 미설정 시 기존(정형 입력) 동작 그대로 — 회귀 없음.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from negotiation.chat.service.input_interpreter import (
|
||||||
|
InputInterpreter, InterpretedInput, parse_korean_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 결정론 한국어 가격 파서 -------------------------------------------------
|
||||||
|
@pytest.mark.parametrize("text,expected", [
|
||||||
|
("10,500원", 10500),
|
||||||
|
("10500", 10500),
|
||||||
|
("1만 500원", 10500),
|
||||||
|
("1만500원", 10500),
|
||||||
|
("만원", 10000),
|
||||||
|
("1.5만", 15000),
|
||||||
|
("3만2천원", 32000),
|
||||||
|
("2억", 200_000_000),
|
||||||
|
("0", None), # 0 이하 무효
|
||||||
|
("그건 어렵습니다", None), # 가격 아님
|
||||||
|
("만원에 3개", None), # 잡문자 혼입 → 해석 불가(안전 폴백)
|
||||||
|
])
|
||||||
|
def test_parse_korean_price(text, expected):
|
||||||
|
got = parse_korean_price(text)
|
||||||
|
assert (got == expected) if expected is not None else (got is None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- LLM 출력 검증 (환각 차단) ----------------------------------------------
|
||||||
|
def _fake(reply: dict):
|
||||||
|
def call(messages):
|
||||||
|
return reply
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_choice_mapped_to_option():
|
||||||
|
nat = InputInterpreter(llm_call=_fake({"intent": "choice", "choice": "예", "price_text": None}))
|
||||||
|
out = await nat.interpret("네 접니다, 말씀하세요", input_mode="yes_no", input_options=["예", "아니오"])
|
||||||
|
assert out == InterpretedInput(kind="choice", value="예", source="예")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_choice_outside_options_rejected():
|
||||||
|
nat = InputInterpreter(llm_call=_fake({"intent": "choice", "choice": "글쎄요", "price_text": None}))
|
||||||
|
assert await nat.interpret("음...", input_mode="yes_no", input_options=["예", "아니오"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_span_verified_and_parsed_deterministically():
|
||||||
|
nat = InputInterpreter(llm_call=_fake({"intent": "price", "choice": None, "price_text": "1만 500원"}))
|
||||||
|
out = await nat.interpret("저희 마진상 1만 500원까지는 맞춰드릴 수 있습니다", input_mode="price")
|
||||||
|
assert out is not None and out.kind == "price"
|
||||||
|
assert out.value == "10500" # 숫자는 결정론 파서 산출(LLM 계산 아님)
|
||||||
|
assert out.source == "1만 500원"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_span_not_in_text_rejected():
|
||||||
|
"""LLM 이 원문에 없는 가격 표현을 지어내면 폐기(환각 차단)."""
|
||||||
|
nat = InputInterpreter(llm_call=_fake({"intent": "price", "choice": None, "price_text": "9,000원"}))
|
||||||
|
assert await nat.interpret("만원이면 가능합니다", input_mode="price") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_and_failures_fall_back():
|
||||||
|
assert await InputInterpreter(llm_call=_fake({"intent": "unknown"})).interpret(
|
||||||
|
"글쎄요 검토해 볼게요", input_mode="yes_no", input_options=["예", "아니오"]) is None
|
||||||
|
|
||||||
|
def boom(messages):
|
||||||
|
raise RuntimeError("LLM down")
|
||||||
|
assert await InputInterpreter(llm_call=boom).interpret("네", input_mode="yes_no", input_options=["예"]) is None
|
||||||
|
|
||||||
|
def slow(messages):
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
return {"intent": "choice", "choice": "예"}
|
||||||
|
nat = InputInterpreter(llm_call=slow, timeout_seconds=0.05)
|
||||||
|
assert await nat.interpret("네", input_mode="yes_no", input_options=["예"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 챗 플로우 E2E (fake LLM) ------------------------------------------------
|
||||||
|
def _routing_fake(messages):
|
||||||
|
"""단계별 fake — 기대 입력이 가격이면 price, 아니면 '예' choice 로 응답."""
|
||||||
|
user = messages[-1]["content"]
|
||||||
|
if "가격(숫자)" in user:
|
||||||
|
return {"intent": "price", "choice": None, "price_text": "1만 500원"}
|
||||||
|
return {"intent": "choice", "choice": "예", "price_text": None}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_flow_free_text_negotiation(db_engine):
|
||||||
|
"""자유 발화만으로 담당자확인 분기 + 가격 입력이 진행된다 (버튼 없는 '진짜 대화')."""
|
||||||
|
from router.v1.chat.protocol import Req_Chat
|
||||||
|
from services.chat_service import ChatService, reset_sessions
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tenancy.registry import TenantEngineRegistry
|
||||||
|
|
||||||
|
reset_sessions()
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine("imarketkorea")
|
||||||
|
eng.config.llm.enabled = True
|
||||||
|
|
||||||
|
svc = ChatService()
|
||||||
|
svc._interpreter = InputInterpreter(llm_call=_routing_fake)
|
||||||
|
orig = InputInterpreter.available
|
||||||
|
InputInterpreter.available = staticmethod(lambda: True)
|
||||||
|
try:
|
||||||
|
sid = None
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid)) # 서비스안내
|
||||||
|
sid = r.session_id
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인")) # 담당자확인 (fast path)
|
||||||
|
assert r.step == "담당자확인" and r.interpreted_input is None
|
||||||
|
|
||||||
|
# 자유 발화 → NLU 가 "예" 로 매핑 → 협상품목안내
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="네 접니다, 말씀하세요"))
|
||||||
|
assert r.step == "협상품목안내"
|
||||||
|
assert r.interpreted_input == "예"
|
||||||
|
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인")) # 기존가격제시(price)
|
||||||
|
# 자유 발화 가격 → span "1만 500원" → 결정론 파서 10500 → 가격 저장 후 확인 단계
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="저희 마진상 1만 500원까지는 맞춰드릴 수 있습니다"))
|
||||||
|
assert r.step == "가격협상_확인"
|
||||||
|
assert r.interpreted_input == "10500"
|
||||||
|
assert "10500" in r.script # 멘트 치환도 해석된 가격으로
|
||||||
|
finally:
|
||||||
|
InputInterpreter.available = orig
|
||||||
|
eng.config.llm.enabled = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_flow_without_llm_keeps_legacy_behavior(db_engine):
|
||||||
|
"""LLM 미설정(available=False)이면 자유 발화는 원문 그대로 엔진에 전달 — 기존 동작 회귀 없음."""
|
||||||
|
from router.v1.chat.protocol import Req_Chat
|
||||||
|
from services.chat_service import ChatService, reset_sessions
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tenancy.registry import TenantEngineRegistry
|
||||||
|
|
||||||
|
reset_sessions()
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine("imarketkorea")
|
||||||
|
|
||||||
|
svc = ChatService()
|
||||||
|
sid = None
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid))
|
||||||
|
sid = r.session_id
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인"))
|
||||||
|
assert r.step == "담당자확인"
|
||||||
|
# conftest 가드로 available=False → NLU 미동작, interpreted_input 없음
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="네 접니다"))
|
||||||
|
assert r.interpreted_input is None
|
||||||
190
agent/tests/test_negotiation_invariants.py
Normal file
190
agent/tests/test_negotiation_invariants.py
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"""협상 불변식 시나리오 하네스 — 실서비스 스택(ChatService + 실 DB 카드)으로 13개 협상을 완주시키고,
|
||||||
|
IMK 가 잡은 두 부류의 사고(같은 카드 반복 · 이상한 금액)가 어떤 흐름에서도 안 나는지 검사한다.
|
||||||
|
|
||||||
|
시나리오별 기대 이벤트(카드가 나간 턴의 step·카드·금액)를 정확히 못박고, 공통 불변식을 전 턴에 건다:
|
||||||
|
· 카드 중복 없음 — 한 협상에서 같은 card_id 2회 발동 금지
|
||||||
|
· 카드 자리 규칙 — 종결 전용(WC-03·05)은 가격협상_카운터에서만, 비종결 와일드는 wild_card_dynamic 에서만
|
||||||
|
· 타결가 ≤ 목표가 — 어떤 성공 경로도 목표가 초과로 안 끝남
|
||||||
|
· 카운터 멘트의 금액 = 수락 시 타결가 (표시가=타결가)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from router.v1.chat.protocol import Req_Chat
|
||||||
|
from services.chat_service import ChatService, reset_sessions
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tenancy.registry import TenantEngineRegistry
|
||||||
|
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session
|
||||||
|
|
||||||
|
# 종결 전용 와일드카드(DB tactic 시드와 동일) — 자리 규칙 검사용.
|
||||||
|
_CLOSING_WILDS = {"WC-03", "WC-05"}
|
||||||
|
_NONCLOSING_WILDS = {"WC-01", "WC-02", "WC-04"}
|
||||||
|
# 카드가 나갈 수 있는 스텝(이벤트로 수집).
|
||||||
|
_CARD_STEPS = {"가격협상", "wild_card_dynamic", "wild_card_1pct", "가격협상_카운터"}
|
||||||
|
_BOILERPLATE = [None, "확인", "예", "확인"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scenario:
|
||||||
|
name: str
|
||||||
|
inputs: list # 서두(안내~기존가격제시) 이후의 협력사 입력 시퀀스
|
||||||
|
# 기대 이벤트: (step, card, offer_substring). card="NGC-*" 는 임의 협상카드(중복만 검사).
|
||||||
|
events: list
|
||||||
|
settled: Optional[int] # 기대 타결가(원). None=결렬
|
||||||
|
nego: list = field(default_factory=lambda: ["NGC-001"])
|
||||||
|
wild: list = field(default_factory=list)
|
||||||
|
target: int = 10_000
|
||||||
|
anchor: int = 9_900
|
||||||
|
|
||||||
|
|
||||||
|
# 밴드(기본 target 10000·anchor 9900): 1% 존 ≤ 10,098 · 진입 존 ≤ 10,395.
|
||||||
|
SCENARIOS = [
|
||||||
|
# S01 BB9A 재현 — 중반 비종결 WC-02, 종결 WC-05 실절충가. 같은 카드 2회 없음.
|
||||||
|
Scenario("S01_bb9a_mid_wc02_close_wc05",
|
||||||
|
["10300", "예", "다른 가격 제시", "10010", "예", "10005", "예", "수락", "확인"],
|
||||||
|
[("wild_card_dynamic", "WC-02", "9950"),
|
||||||
|
("가격협상", "NGC-001", None),
|
||||||
|
("가격협상_카운터", "WC-05", "9980")],
|
||||||
|
settled=9980, wild=["WC-02", "WC-05"]),
|
||||||
|
# S02 8AB0 재현 — 절충가(9,205)가 목표가(9,000) 초과 → WC-05 미발동, 목표가 최후통첩(카드 없음).
|
||||||
|
Scenario("S02_8ab0_middle_over_target_skips",
|
||||||
|
["9500", "예", "9500", "예", "다른 가격 제시", "9500", "예", "확인"],
|
||||||
|
[("가격협상", "NGC-001", None),
|
||||||
|
("가격협상_카운터", None, "9000")],
|
||||||
|
settled=None, wild=["WC-05"], target=9_000, anchor=8_910),
|
||||||
|
# S03 와일드 5장 전부 + 협상카드 2장 — 중반 1장(WC-01)·종결 1장(WC-03)만, 협상카드는 서로 다른 2장.
|
||||||
|
Scenario("S03_five_wilds_full_run",
|
||||||
|
["10300", "예", "다른 가격 제시", "10200", "예", "10150", "예", "10100", "예", "수락", "확인"],
|
||||||
|
[("wild_card_dynamic", "WC-01", "10000"),
|
||||||
|
("가격협상", "NGC-*", None),
|
||||||
|
("가격협상", "NGC-*", None),
|
||||||
|
("가격협상_카운터", "WC-03", "10000")],
|
||||||
|
settled=10_000, nego=["NGC-001", "NGC-003"],
|
||||||
|
wild=["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]),
|
||||||
|
# S04 종결 전용 와일드만 담김 + 제시가가 진입 존에 머무름 — 소진 판정이 막히지 않고
|
||||||
|
# 종결로 넘어간다(프로브 픽스 회귀: 픽스 전엔 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상).
|
||||||
|
Scenario("S04_closing_only_wild_no_deadlock",
|
||||||
|
["10300", "예", "10250", "예", "수락", "확인"],
|
||||||
|
[("가격협상", "NGC-001", None),
|
||||||
|
("가격협상_카운터", None, "10000")], # WC-05 절충 10,075>목표가 → 미발동 → 최후통첩
|
||||||
|
settled=10_000, wild=["WC-05"]),
|
||||||
|
# S05 1% 존 — 시스템 1% 카드, 수락 시 표시 금액 그대로 타결.
|
||||||
|
Scenario("S05_one_pct_zone_accept",
|
||||||
|
["10050", "예", "예", "확인"],
|
||||||
|
[("wild_card_1pct", None, "9950")],
|
||||||
|
settled=9_950),
|
||||||
|
# S06 앵커 이하 즉시 타결 — 카드 0장.
|
||||||
|
Scenario("S06_priority_match_no_cards",
|
||||||
|
["9800", "예", "확인"],
|
||||||
|
[],
|
||||||
|
settled=9_800),
|
||||||
|
# S07 목표가 초과 고수 → 설득 1장 → 최후통첩 → 결렬.
|
||||||
|
Scenario("S07_hold_high_fails",
|
||||||
|
["11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||||
|
[("가격협상", "NGC-003", None),
|
||||||
|
("가격협상_카운터", None, "10000")],
|
||||||
|
settled=None, nego=["NGC-003"]),
|
||||||
|
# S08 협상카드 카운터(NGC-010 목표가 제안) 수락 — 협상카드도 카운터 스텝을 쓴다.
|
||||||
|
Scenario("S08_nego_counter_accept",
|
||||||
|
["11000", "예", "수락", "확인"],
|
||||||
|
[("가격협상_카운터", "NGC-010", "10000")],
|
||||||
|
settled=10_000, nego=["NGC-010"]),
|
||||||
|
# S09 min_round=2 — WC-04 는 1라운드 진입 존에서 안 나가고 2라운드에 나간다.
|
||||||
|
Scenario("S09_min_round_two_defers_wc04",
|
||||||
|
["10300", "예", "10200", "예", "수락", "확인"],
|
||||||
|
[("가격협상", "NGC-001", None),
|
||||||
|
("wild_card_dynamic", "WC-04", "10000")],
|
||||||
|
settled=10_000, wild=["WC-04"]),
|
||||||
|
# S10 종결 체인 폴백 — WC-05 무효(절충 10,175>목표) → 다음 종결 WC-03 발동.
|
||||||
|
Scenario("S10_closing_chain_falls_to_wc03",
|
||||||
|
["10500", "예", "10450", "예", "다른 가격 제시", "10450", "예", "확인"],
|
||||||
|
[("가격협상", "NGC-001", None),
|
||||||
|
("가격협상_카운터", "WC-03", "10000")],
|
||||||
|
settled=None, wild=["WC-05", "WC-03"]),
|
||||||
|
# S11 중반+종결 콤보 — WC-02 중반, 종결은 WC-05 무효 건너뛰고 WC-03. 전 카드 1회씩.
|
||||||
|
Scenario("S11_mid_and_closing_combo",
|
||||||
|
["10300", "예", "다른 가격 제시", "10400", "예", "10350", "예", "수락", "확인"],
|
||||||
|
[("wild_card_dynamic", "WC-02", "9950"),
|
||||||
|
("가격협상", "NGC-001", None),
|
||||||
|
("가격협상_카운터", "WC-03", "10000")],
|
||||||
|
settled=10_000, wild=["WC-02", "WC-05", "WC-03"]),
|
||||||
|
# S12 라운드 상한 — 협상카드 3장 각 1회(중복 없음) 후 상한 도달 → 최후통첩 → 결렬.
|
||||||
|
Scenario("S12_round_cap_distinct_nego_cards",
|
||||||
|
["11000", "예", "11000", "예", "11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||||
|
[("가격협상", "NGC-*", None),
|
||||||
|
("가격협상", "NGC-*", None),
|
||||||
|
("가격협상", "NGC-*", None),
|
||||||
|
("가격협상_카운터", None, "10000")],
|
||||||
|
settled=None, nego=["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005"]),
|
||||||
|
# S13 재생성 아님·재료 극단 — 앵커 미박제 세션(anchor=target 폴백)에서도 초과 제시·중복 없음.
|
||||||
|
Scenario("S13_anchor_equals_target_fallback",
|
||||||
|
["10300", "예", "10200", "예", "수락", "확인"],
|
||||||
|
[("가격협상", "NGC-001", None),
|
||||||
|
("가격협상_카운터", "WC-03", "10000")], # WC-05 절충 (10000+10200)/2=10100>목표 → 스킵
|
||||||
|
settled=10_000, wild=["WC-05", "WC-03"], anchor=10_000),
|
||||||
|
# S14 역행 금지(IMK 논의 재현) — 절충 카드(9,950) 뒤에 예산 상한 카드(NGC-007, 앵커 9,900)가
|
||||||
|
# 선택돼 있어도 발동하지 않는다(설득 폴백으로도 안 나감). 낼 카드가 없어져 종결(목표가 최후통첩)로.
|
||||||
|
Scenario("S14_no_offer_regression",
|
||||||
|
["10300", "예", "다른 가격 제시", "10200", "예", "수락", "확인"],
|
||||||
|
[("wild_card_dynamic", "WC-02", "9950"),
|
||||||
|
("가격협상_카운터", None, "10000")], # NGC-007 이벤트가 없어야 함(역행 차단)
|
||||||
|
settled=10_000, nego=["NGC-007"], wild=["WC-02"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("sc", SCENARIOS, ids=[s.name for s in SCENARIOS])
|
||||||
|
async def test_negotiation_invariants(db_engine, sc: Scenario):
|
||||||
|
reset_sessions()
|
||||||
|
sid = _uuid.uuid4()
|
||||||
|
qid, ver_id = await _seed_quote_session(sid, sc.nego, wild_numbers=sc.wild,
|
||||||
|
target=sc.target, anchor=sc.anchor)
|
||||||
|
try:
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||||
|
svc = ChatService()
|
||||||
|
trace, settled, outcome = [], None, None
|
||||||
|
for ui in [*_BOILERPLATE, *sc.inputs]:
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||||
|
assert r.result.success is True, f"{sc.name}: 턴 실패 input={ui} msg={r.msg}"
|
||||||
|
trace.append(r)
|
||||||
|
if r.settled_price is not None:
|
||||||
|
settled = r.settled_price
|
||||||
|
if r.chat_end:
|
||||||
|
outcome = r.outcome
|
||||||
|
|
||||||
|
# ── 기대 이벤트(카드/카운터 턴) 정확 일치 ──
|
||||||
|
events = [r for r in trace if r.step in _CARD_STEPS]
|
||||||
|
got = [(r.step, r.card_id) for r in events]
|
||||||
|
assert len(events) == len(sc.events), f"{sc.name}: 이벤트 수 {got} ≠ 기대 {sc.events}"
|
||||||
|
for r, (step, card, offer) in zip(events, sc.events):
|
||||||
|
assert r.step == step, f"{sc.name}: step {r.step} ≠ {step} (전체 {got})"
|
||||||
|
if card == "NGC-*":
|
||||||
|
assert r.card_id and r.card_id.startswith("NGC-"), f"{sc.name}: 협상카드 기대, 실제 {r.card_id}"
|
||||||
|
else:
|
||||||
|
assert r.card_id == card, f"{sc.name}: card {r.card_id} ≠ {card} (전체 {got})"
|
||||||
|
if offer is not None:
|
||||||
|
assert offer in (r.script or ""), f"{sc.name}: 멘트에 금액 {offer} 없음 — {r.script[:80]}"
|
||||||
|
|
||||||
|
# ── 공통 불변식 ──
|
||||||
|
played = [r.card_id for r in events if r.card_id]
|
||||||
|
assert len(played) == len(set(played)), f"{sc.name}: 카드 중복 발동 {played}"
|
||||||
|
for r in events:
|
||||||
|
if r.card_id in _CLOSING_WILDS:
|
||||||
|
assert r.step == "가격협상_카운터", f"{sc.name}: 종결 카드 {r.card_id}가 중반({r.step})에 발동"
|
||||||
|
if r.card_id in _NONCLOSING_WILDS:
|
||||||
|
assert r.step == "wild_card_dynamic", f"{sc.name}: 비종결 와일드 {r.card_id}가 {r.step}에서 발동"
|
||||||
|
|
||||||
|
# ── 결말 ──
|
||||||
|
if sc.settled is None:
|
||||||
|
assert outcome == "failure" and settled is None, f"{sc.name}: 결렬 기대, settled={settled} outcome={outcome}"
|
||||||
|
else:
|
||||||
|
assert outcome == "success", f"{sc.name}: 타결 기대, outcome={outcome}"
|
||||||
|
assert settled == sc.settled, f"{sc.name}: 타결가 {settled} ≠ 기대 {sc.settled}"
|
||||||
|
assert settled <= sc.target, f"{sc.name}: 목표가 초과 타결 {settled} > {sc.target}"
|
||||||
|
finally:
|
||||||
|
await _cleanup(sid, qid, ver_id)
|
||||||
@ -29,12 +29,12 @@ async def test_step_requires_tenant_header(client):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_step_tenant_divergence(client):
|
async def test_step_tenant_divergence(client):
|
||||||
rk = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=_BODY)
|
rk = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "_base"}, json=_BODY)
|
||||||
ri = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=_BODY)
|
ri = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=_BODY)
|
||||||
assert rk.status_code == 200 and ri.status_code == 200
|
assert rk.status_code == 200 and ri.status_code == 200
|
||||||
dk, di = rk.json(), ri.json()
|
dk, di = rk.json(), ri.json()
|
||||||
# 같은 입력이 테넌트 config 에 따라 다른 상태/카드로 갈린다
|
# 같은 입력이 테넌트 config 에 따라 다른 상태/카드로 갈린다 (_base=공용 NGC-0xx, imk=NGC-Bxxx)
|
||||||
assert dk["card_id"].startswith("NGC-A")
|
assert dk["card_id"].startswith("NGC-0")
|
||||||
assert di["card_id"].startswith("NGC-B")
|
assert di["card_id"].startswith("NGC-B")
|
||||||
assert dk["state_index"] != di["state_index"]
|
assert dk["state_index"] != di["state_index"]
|
||||||
# 응답 형태
|
# 응답 형태
|
||||||
@ -49,7 +49,7 @@ async def test_step_tenant_divergence(client):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_step_invalid_distribution_code_is_domain_error(client):
|
async def test_step_invalid_distribution_code_is_domain_error(client):
|
||||||
body = dict(_BODY, distribution_code="Z")
|
body = dict(_BODY, distribution_code="Z")
|
||||||
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=body)
|
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=body)
|
||||||
assert r.status_code == 200 # HTTP 는 200, 결과코드로 에러 전달(backend 규약)
|
assert r.status_code == 200 # HTTP 는 200, 결과코드로 에러 전달(backend 규약)
|
||||||
assert r.json()["result"]["desc"] == "NEGO_INVALID_STEP"
|
assert r.json()["result"]["desc"] == "NEGO_INVALID_STEP"
|
||||||
|
|
||||||
@ -57,5 +57,5 @@ async def test_step_invalid_distribution_code_is_domain_error(client):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_step_invalid_outcome(client):
|
async def test_step_invalid_outcome(client):
|
||||||
body = dict(_BODY, outcome="maybe")
|
body = dict(_BODY, outcome="maybe")
|
||||||
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=body)
|
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=body)
|
||||||
assert r.json()["result"]["desc"] == "INVALID_REQUEST_DATA"
|
assert r.json()["result"]["desc"] == "INVALID_REQUEST_DATA"
|
||||||
|
|||||||
@ -62,5 +62,5 @@ async def test_tenant_header_required(client):
|
|||||||
assert r.json()["result"]["desc"] == "TENANT_HEADER_MISSING"
|
assert r.json()["result"]["desc"] == "TENANT_HEADER_MISSING"
|
||||||
|
|
||||||
# 헤더가 있으면 미들웨어 통과 (라우트 미존재라 404).
|
# 헤더가 있으면 미들웨어 통과 (라우트 미존재라 404).
|
||||||
r = await client.get("/v1/some-protected-path", headers={"X-Tenant-ID": "ktcommerce"})
|
r = await client.get("/v1/some-protected-path", headers={"X-Tenant-ID": "imarketkorea"})
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|||||||
@ -22,7 +22,7 @@ def _loader() -> TenantConfigLoader:
|
|||||||
|
|
||||||
|
|
||||||
def test_platform_neutral_defaults_load():
|
def test_platform_neutral_defaults_load():
|
||||||
cfg = _loader().load("ktcommerce")
|
cfg = _loader().load("_base")
|
||||||
|
|
||||||
# 우리 플랫폼 중립 기본값 (CLEANROOM.md)
|
# 우리 플랫폼 중립 기본값 (CLEANROOM.md)
|
||||||
assert cfg.state.revenue.thresholds == [10_000_000, 50_000_000]
|
assert cfg.state.revenue.thresholds == [10_000_000, 50_000_000]
|
||||||
@ -47,12 +47,12 @@ def test_platform_neutral_defaults_load():
|
|||||||
|
|
||||||
|
|
||||||
def test_state_space_and_action_space_size():
|
def test_state_space_and_action_space_size():
|
||||||
cfg = _loader().load("ktcommerce")
|
cfg = _loader().load("_base")
|
||||||
assert cfg.state.state_space_size == 162 # 3×3×3×3×2 (차원 구성은 기능적 설계)
|
assert cfg.state.state_space_size == 162 # 3×3×3×3×2 (차원 구성은 기능적 설계)
|
||||||
assert cfg.action_mapping.action_space_size == 11
|
assert cfg.action_mapping.action_space_size == 11
|
||||||
# 합성 데모 카드 코드 (우리 스킴)
|
# 공용 카드 코드 (파일 폴백 스냅샷 — 정본은 DB 카탈로그)
|
||||||
assert cfg.action_mapping.action_to_card["0"] == "NGC-A001"
|
assert cfg.action_mapping.action_to_card["0"] == "NGC-001"
|
||||||
assert cfg.action_mapping.action_to_card["8"] == "NGC-A009"
|
assert cfg.action_mapping.action_to_card["8"] == "NGC-009"
|
||||||
|
|
||||||
|
|
||||||
def test_base_deep_merge_unit():
|
def test_base_deep_merge_unit():
|
||||||
@ -90,7 +90,6 @@ def test_base_self_does_not_inherit():
|
|||||||
|
|
||||||
def test_is_registered():
|
def test_is_registered():
|
||||||
loader = _loader()
|
loader = _loader()
|
||||||
assert loader.is_registered("ktcommerce") is True
|
|
||||||
assert loader.is_registered("imarketkorea") is True
|
assert loader.is_registered("imarketkorea") is True
|
||||||
# 미등록 company_id(uuid 등)는 _base 자동 온보딩 대상이라 '등록됨'으로 본다. 빈 키만 미등록.
|
# 미등록 company_id(uuid 등)는 _base 자동 온보딩 대상이라 '등록됨'으로 본다. 빈 키만 미등록.
|
||||||
assert loader.is_registered("00000000-0000-0000-0000-000000000001") is True
|
assert loader.is_registered("00000000-0000-0000-0000-000000000001") is True
|
||||||
@ -99,7 +98,7 @@ def test_is_registered():
|
|||||||
|
|
||||||
def test_no_proprietary_card_codes_or_labels_in_repo():
|
def test_no_proprietary_card_codes_or_labels_in_repo():
|
||||||
"""클린룸 가드: 독점 카드 코드/ verbatim 라벨이 로드된 config 에 존재하지 않는다."""
|
"""클린룸 가드: 독점 카드 코드/ verbatim 라벨이 로드된 config 에 존재하지 않는다."""
|
||||||
for tid in ("_base", "ktcommerce", "imarketkorea"):
|
for tid in ("_base", "imarketkorea"):
|
||||||
cfg = _loader().load(tid)
|
cfg = _loader().load(tid)
|
||||||
cards = " ".join(cfg.action_mapping.action_to_card.values())
|
cards = " ".join(cfg.action_mapping.action_to_card.values())
|
||||||
assert "NC26" not in cards # 참고 엔진의 고유 카드 코드
|
assert "NC26" not in cards # 참고 엔진의 고유 카드 코드
|
||||||
|
|||||||
@ -46,7 +46,7 @@ def _snapshot(**over) -> NegotiationSnapshot:
|
|||||||
|
|
||||||
|
|
||||||
def test_build_state_deterministic_and_in_range():
|
def test_build_state_deterministic_and_in_range():
|
||||||
cfg = _cfg("ktcommerce")
|
cfg = _cfg("_base")
|
||||||
snap = _snapshot()
|
snap = _snapshot()
|
||||||
s1 = build_state(snap, cfg.state)
|
s1 = build_state(snap, cfg.state)
|
||||||
s2 = build_state(snap, cfg.state)
|
s2 = build_state(snap, cfg.state)
|
||||||
@ -65,7 +65,7 @@ def test_encode_index_known_example():
|
|||||||
|
|
||||||
|
|
||||||
def test_mixed_radix_bijection_over_full_space():
|
def test_mixed_radix_bijection_over_full_space():
|
||||||
cfg = _cfg("ktcommerce")
|
cfg = _cfg("_base")
|
||||||
dims = state_dims(cfg.state)
|
dims = state_dims(cfg.state)
|
||||||
assert dims == [3, 3, 3, 3, 2]
|
assert dims == [3, 3, 3, 3, 2]
|
||||||
seen = set()
|
seen = set()
|
||||||
@ -77,17 +77,17 @@ def test_mixed_radix_bijection_over_full_space():
|
|||||||
|
|
||||||
|
|
||||||
def test_config_injection_changes_classification():
|
def test_config_injection_changes_classification():
|
||||||
# revenue=20,000,000 원: ktcommerce(th=[10M,50M]) → mid(1), imarketkorea(th=[30M,100M]) → low(0)
|
# revenue=20,000,000 원: _base(th=[10M,50M]) → mid(1), imarketkorea(th=[30M,100M]) → low(0)
|
||||||
snap = _snapshot(revenue_amount=20_000_000)
|
snap = _snapshot(revenue_amount=20_000_000)
|
||||||
kt = build_state(snap, _cfg("ktcommerce").state)
|
base = build_state(snap, _cfg("_base").state)
|
||||||
imk = build_state(snap, _cfg("imarketkorea").state)
|
imk = build_state(snap, _cfg("imarketkorea").state)
|
||||||
assert kt.revenue_idx == 1
|
assert base.revenue_idx == 1
|
||||||
assert imk.revenue_idx == 0
|
assert imk.revenue_idx == 0
|
||||||
assert kt != imk # 같은 입력이 테넌트 config 에 따라 다른 상태
|
assert base != imk # 같은 입력이 테넌트 config 에 따라 다른 상태
|
||||||
|
|
||||||
|
|
||||||
def test_distribution_unknown_code_raises():
|
def test_distribution_unknown_code_raises():
|
||||||
cfg = _cfg("ktcommerce")
|
cfg = _cfg("_base")
|
||||||
snap = _snapshot(distribution_code="Z") # code_map 에 없음
|
snap = _snapshot(distribution_code="Z") # code_map 에 없음
|
||||||
try:
|
try:
|
||||||
build_state(snap, cfg.state)
|
build_state(snap, cfg.state)
|
||||||
@ -97,7 +97,7 @@ def test_distribution_unknown_code_raises():
|
|||||||
|
|
||||||
|
|
||||||
def test_price_zone_and_partner_buckets():
|
def test_price_zone_and_partner_buckets():
|
||||||
cfg = _cfg("ktcommerce").state
|
cfg = _cfg("_base").state
|
||||||
# 제시가 ≤ 앵커가(9900) → 우선협상 구간(0)
|
# 제시가 ≤ 앵커가(9900) → 우선협상 구간(0)
|
||||||
assert build_state(_snapshot(input_price=9800), cfg).price_zone_idx == 0
|
assert build_state(_snapshot(input_price=9800), cfg).price_zone_idx == 0
|
||||||
# 제시가 > 앵커가 → 협상 지속 구간(1)
|
# 제시가 > 앵커가 → 협상 지속 구간(1)
|
||||||
@ -110,28 +110,28 @@ def test_price_zone_and_partner_buckets():
|
|||||||
|
|
||||||
def test_reward_deterministic_and_config_driven():
|
def test_reward_deterministic_and_config_driven():
|
||||||
snap = _snapshot(outcome=NegotiationOutcome.FAILURE, round_number=2)
|
snap = _snapshot(outcome=NegotiationOutcome.FAILURE, round_number=2)
|
||||||
kt = _cfg("ktcommerce")
|
base = _cfg("_base")
|
||||||
imk = _cfg("imarketkorea")
|
imk = _cfg("imarketkorea")
|
||||||
kt_rc = RewardCalculator(kt.reward, kt.state) # failure_penalty -0.5
|
base_rc = RewardCalculator(base.reward, base.state) # failure_penalty -0.5
|
||||||
imk_rc = RewardCalculator(imk.reward, imk.state) # failure_penalty -0.7
|
imk_rc = RewardCalculator(imk.reward, imk.state) # failure_penalty -0.7
|
||||||
|
|
||||||
r1 = kt_rc.calculate(snap)
|
r1 = base_rc.calculate(snap)
|
||||||
r2 = kt_rc.calculate(snap)
|
r2 = base_rc.calculate(snap)
|
||||||
assert r1 == r2 # 결정론
|
assert r1 == r2 # 결정론
|
||||||
assert r1.end_reward == -0.5 # config 반영
|
assert r1.end_reward == -0.5 # config 반영
|
||||||
assert imk_rc.calculate(snap).end_reward == -0.7
|
assert imk_rc.calculate(snap).end_reward == -0.7
|
||||||
|
|
||||||
# 성공 라운드 보상이 실패보다 크다 (방향성)
|
# 성공 라운드 보상이 실패보다 크다 (방향성)
|
||||||
success = _snapshot(outcome=NegotiationOutcome.SUCCESS, round_number=0)
|
success = _snapshot(outcome=NegotiationOutcome.SUCCESS, round_number=0)
|
||||||
assert kt_rc.calculate(success).total > kt_rc.calculate(_snapshot(outcome=NegotiationOutcome.FAILURE, round_number=0)).total
|
assert base_rc.calculate(success).total > base_rc.calculate(_snapshot(outcome=NegotiationOutcome.FAILURE, round_number=0)).total
|
||||||
|
|
||||||
|
|
||||||
def test_action_card_mapper_roundtrip_and_mask():
|
def test_action_card_mapper_roundtrip_and_mask():
|
||||||
cfg = _cfg("ktcommerce")
|
cfg = _cfg("_base")
|
||||||
mapper = ActionCardMapper(cfg.action_mapping)
|
mapper = ActionCardMapper(cfg.action_mapping)
|
||||||
assert mapper.action_space_size == 11
|
assert mapper.action_space_size == 11
|
||||||
assert mapper.get_card_id(0) == "NGC-A001"
|
assert mapper.get_card_id(0) == "NGC-001"
|
||||||
assert mapper.get_action_id("NGC-A001") == 0
|
assert mapper.get_action_id("NGC-001") == 0
|
||||||
assert mapper.get_card_id(99) is None
|
assert mapper.get_card_id(99) is None
|
||||||
|
|
||||||
# 중복방지 마스킹: 사용한 action 제외
|
# 중복방지 마스킹: 사용한 action 제외
|
||||||
|
|||||||
@ -27,22 +27,22 @@ def _registry() -> TenantEngineRegistry:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_two_tenants_distinct_engines():
|
async def test_two_tenants_distinct_engines():
|
||||||
reg = _registry()
|
reg = _registry()
|
||||||
e1 = await reg.get_engine("ktcommerce")
|
e1 = await reg.get_engine("_base")
|
||||||
e2 = await reg.get_engine("imarketkorea")
|
e2 = await reg.get_engine("imarketkorea")
|
||||||
assert e1 is not e2
|
assert e1 is not e2
|
||||||
assert e1.tenant_id == "ktcommerce" and e2.tenant_id == "imarketkorea"
|
assert e1.tenant_id == "_base" and e2.tenant_id == "imarketkorea"
|
||||||
# 서로 다른 카드매핑 (다른 카드셋)
|
# 서로 다른 카드매핑 (다른 카드셋 — _base=공용 카탈로그, imk=파일 오버라이드)
|
||||||
assert e1.mapper.get_card_id(0) == "NGC-A001"
|
assert e1.mapper.get_card_id(0) == "NGC-001"
|
||||||
assert e2.mapper.get_card_id(0) == "NGC-B001"
|
assert e2.mapper.get_card_id(0) == "NGC-B001"
|
||||||
# 차원
|
# 차원
|
||||||
assert e1.state_space_size == 162 and e1.action_space_size == 11
|
assert e1.state_space_size == 162 and e1.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_engine_cached():
|
async def test_engine_cached():
|
||||||
reg = _registry()
|
reg = _registry()
|
||||||
a = await reg.get_engine("ktcommerce")
|
a = await reg.get_engine("imarketkorea")
|
||||||
b = await reg.get_engine("ktcommerce")
|
b = await reg.get_engine("imarketkorea")
|
||||||
assert a is b # 캐시 — 동일 인스턴스
|
assert a is b # 캐시 — 동일 인스턴스
|
||||||
|
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ async def test_concurrent_first_build_once():
|
|||||||
return EngineFactory.build(config)
|
return EngineFactory.build(config)
|
||||||
|
|
||||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), factory=CountingFactory)
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), factory=CountingFactory)
|
||||||
results = await asyncio.gather(*[reg.get_engine("ktcommerce") for _ in range(12)])
|
results = await asyncio.gather(*[reg.get_engine("imarketkorea") for _ in range(12)])
|
||||||
# 모두 같은 인스턴스 + 1회만 조립
|
# 모두 같은 인스턴스 + 1회만 조립
|
||||||
assert all(r is results[0] for r in results)
|
assert all(r is results[0] for r in results)
|
||||||
assert builds["n"] == 1
|
assert builds["n"] == 1
|
||||||
@ -69,9 +69,9 @@ async def test_unregistered_company_id_auto_onboards():
|
|||||||
reg = _registry()
|
reg = _registry()
|
||||||
# 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state).
|
# 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state).
|
||||||
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
|
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
|
||||||
assert eng.action_space_size == 11 and eng.state_space_size == 162
|
assert eng.action_space_size == 9 and eng.state_space_size == 162 # DB 카탈로그 9장
|
||||||
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
||||||
assert reg.is_registered("ktcommerce") is True
|
assert reg.is_registered("imarketkorea") is True
|
||||||
# 빈 키만 미등록 → KeyError
|
# 빈 키만 미등록 → KeyError
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
await reg.get_engine("")
|
await reg.get_engine("")
|
||||||
@ -80,9 +80,9 @@ async def test_unregistered_company_id_auto_onboards():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reload_rebuilds_only_that_tenant():
|
async def test_reload_rebuilds_only_that_tenant():
|
||||||
reg = _registry()
|
reg = _registry()
|
||||||
a = await reg.get_engine("ktcommerce")
|
a = await reg.get_engine("_base")
|
||||||
b = await reg.get_engine("imarketkorea")
|
b = await reg.get_engine("imarketkorea")
|
||||||
reloaded = await reg.reload("ktcommerce")
|
reloaded = await reg.reload("_base")
|
||||||
assert reloaded is not a # 재조립됨
|
assert reloaded is not a # 재조립됨
|
||||||
assert await reg.get_engine("imarketkorea") is b # 타테넌트는 그대로
|
assert await reg.get_engine("imarketkorea") is b # 타테넌트는 그대로
|
||||||
|
|
||||||
@ -171,8 +171,8 @@ async def test_demo_tenant_keeps_file_brand(db_engine):
|
|||||||
loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0),
|
loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0),
|
||||||
company_repo=_FakeCompany(),
|
company_repo=_FakeCompany(),
|
||||||
)
|
)
|
||||||
eng = await reg.get_engine("ktcommerce") # 비-UUID → 조회 안 함
|
eng = await reg.get_engine("imarketkorea") # 비-UUID → 조회 안 함
|
||||||
assert eng.config.resources.company_name == "데모상사 A"
|
assert eng.config.resources.company_name == "데모상사 B"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -236,6 +236,6 @@ async def test_middleware_header_missing_unregistered_registered(client):
|
|||||||
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"
|
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"
|
||||||
|
|
||||||
# 등록 테넌트 → 미들웨어 통과
|
# 등록 테넌트 → 미들웨어 통과
|
||||||
r = await client.get("/v1/protected", headers={"X-Tenant-ID": "ktcommerce"})
|
r = await client.get("/v1/protected", headers={"X-Tenant-ID": "imarketkorea"})
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"
|
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"
|
||||||
|
|||||||
@ -58,8 +58,8 @@ async def test_warm_start_copies_base_with_decayed_visits(db_engine):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cold_start_creates_warmstart_version(db_engine):
|
async def test_cold_start_creates_warmstart_version(db_engine):
|
||||||
await _seed_base(A=11) # ktcommerce action_space=11 과 차원 일치해야 warm-start
|
await _seed_base(A=11) # imarketkorea action_space=11 과 차원 일치해야 warm-start
|
||||||
eng = await _reg().get_engine("ktcommerce") # 활성 버전 없음 → cold-start
|
eng = await _reg().get_engine("imarketkorea") # 활성 버전 없음 → cold-start
|
||||||
policy, version_id, repo = await QTablePolicyStore.load(eng)
|
policy, version_id, repo = await QTablePolicyStore.load(eng)
|
||||||
err, ver = await repo.read(lambda s: repo.get_active_version(s))
|
err, ver = await repo.read(lambda s: repo.get_active_version(s))
|
||||||
assert ver.version_name == "v000_warmstart_from_base"
|
assert ver.version_name == "v000_warmstart_from_base"
|
||||||
@ -69,27 +69,27 @@ async def test_cold_start_creates_warmstart_version(db_engine):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
|
async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
|
||||||
"""카탈로그 카드 수 변경(9→11) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
"""카탈로그 카드 수 변경(7→9) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
cid = str(_uuid.uuid4())
|
cid = str(_uuid.uuid4())
|
||||||
# 이 회사 활성 버전을 A=9 로 시드 + 셀 (5,2)=0.9
|
# 이 회사 활성 버전을 A=7 로 시드 + 셀 (5,2)=0.9
|
||||||
repo = LearningRepository(cid)
|
repo = LearningRepository(cid)
|
||||||
vid = await repo.get_or_create_active_version(
|
vid = await repo.get_or_create_active_version(
|
||||||
state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95,
|
state_space_size=162, action_space_size=7, learning_rate=0.1, discount_factor=0.95,
|
||||||
scope=2, version_name="old_v9")
|
scope=2, version_name="old_v7")
|
||||||
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7)
|
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7)
|
||||||
|
|
||||||
# 엔진(_base type:db → 카탈로그 11장) 로드 → 9≠11 감지 → 마이그레이션
|
# 엔진(_base type:db → 카탈로그 9장) 로드 → 7≠9 감지 → 마이그레이션
|
||||||
eng = await _reg().get_engine(cid)
|
eng = await _reg().get_engine(cid)
|
||||||
assert eng.action_space_size == 11
|
assert eng.action_space_size == 9
|
||||||
policy, new_vid, _ = await QTablePolicyStore.load(eng)
|
policy, new_vid, _ = await QTablePolicyStore.load(eng)
|
||||||
assert str(new_vid) != str(vid) # 새 버전
|
assert str(new_vid) != str(vid) # 새 버전
|
||||||
assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존
|
assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존
|
||||||
assert policy.qtable.q[5, 10] == 0.0 # 새 카드(action 10) fresh
|
assert policy.qtable.q[5, 8] == 0.0 # 새 카드(action 8) fresh
|
||||||
assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존
|
assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존
|
||||||
# 새 버전이 활성 · 차원 11
|
# 새 버전이 활성 · 차원 11
|
||||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||||
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11
|
assert str(active.version_id) == str(new_vid) and active.action_space_size == 9
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -140,7 +140,7 @@ async def test_no_base_returns_none(db_engine):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_existing_version_not_warmstarted(db_engine):
|
async def test_existing_version_not_warmstarted(db_engine):
|
||||||
await _seed_base()
|
await _seed_base()
|
||||||
eng = await _reg().get_engine("ktcommerce")
|
eng = await _reg().get_engine("imarketkorea")
|
||||||
# 첫 로드 → warm-start 버전 생성
|
# 첫 로드 → warm-start 버전 생성
|
||||||
await QTablePolicyStore.load(eng)
|
await QTablePolicyStore.load(eng)
|
||||||
# 둘째 로드 → 기존 활성 버전 재사용(중복 warm-start 안 함)
|
# 둘째 로드 → 기존 활성 버전 재사용(중복 warm-start 안 함)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ reset-learning, reset-all, q-table/{versions,switch,current}, experience-logs, t
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
H = {"X-Tenant-ID": "ktcommerce"}
|
H = {"X-Tenant-ID": "imarketkorea"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -97,7 +97,7 @@ async def test_invalidate_and_reset_scoped(client, db_engine):
|
|||||||
sid = cr.json()["session_id"]
|
sid = cr.json()["session_id"]
|
||||||
if cr.json().get("chat_end"):
|
if cr.json().get("chat_end"):
|
||||||
break
|
break
|
||||||
H2 = {"X-Tenant-ID": "imarketkorea"}
|
H2 = {"X-Tenant-ID": "00000000-0000-0000-0000-0000000000b2"} # 실고객사 모사(자동 온보딩)
|
||||||
sid2 = None
|
sid2 = None
|
||||||
for ui in convo:
|
for ui in convo:
|
||||||
cr = await client.post("/v1/chat", headers=H2, json={"session_id": sid2, "user_input": ui})
|
cr = await client.post("/v1/chat", headers=H2, json={"session_id": sid2, "user_input": ui})
|
||||||
@ -108,7 +108,7 @@ async def test_invalidate_and_reset_scoped(client, db_engine):
|
|||||||
before2 = (await client.get("/v1/experience-logs", headers=H2)).json()["total"]
|
before2 = (await client.get("/v1/experience-logs", headers=H2)).json()["total"]
|
||||||
assert before2 >= 1
|
assert before2 >= 1
|
||||||
|
|
||||||
# ktcommerce reset-all → imarketkorea 무영향
|
# imarketkorea reset-all → 타테넌트(H2) 무영향
|
||||||
assert (await client.post("/v1/reset-all", headers=H)).json()["success"]
|
assert (await client.post("/v1/reset-all", headers=H)).json()["success"]
|
||||||
assert (await client.get("/v1/experience-logs", headers=H)).json()["total"] == 0
|
assert (await client.get("/v1/experience-logs", headers=H)).json()["total"] == 0
|
||||||
assert (await client.get("/v1/experience-logs", headers=H2)).json()["total"] == before2
|
assert (await client.get("/v1/experience-logs", headers=H2)).json()["total"] == before2
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
1. 전체 대화: 서비스안내→담당자확인→협상품목안내→가격협상→와일드카드→협상완료→협상종료(chat_end).
|
1. 전체 대화: 서비스안내→담당자확인→협상품목안내→가격협상→와일드카드→협상완료→협상종료(chat_end).
|
||||||
2. 가격협상 턴에서 카드 선택 + 학습(card_id, updated_q).
|
2. 가격협상 턴에서 카드 선택 + 학습(card_id, updated_q).
|
||||||
3. 와일드카드 발동(wild_card_1pct) + 종료 보상(success).
|
3. 와일드카드 발동(wild_card_1pct) + 종료 보상(success).
|
||||||
4. 브랜드 치환 테넌트별(데모상사 A/B), 클린룸(스크립트에 KT 흔적 없음).
|
4. 브랜드 치환(데모상사 B), 클린룸(스크립트에 KT 흔적 없음).
|
||||||
5. 경험로그 적재 + 종료 후 진행 시 에러.
|
5. 경험로그 적재 + 종료 후 진행 시 에러.
|
||||||
6. (HTTP) 헤더로 새 세션 시작 + 한 턴 진행.
|
6. (HTTP) 헤더로 새 세션 시작 + 한 턴 진행.
|
||||||
"""
|
"""
|
||||||
@ -44,7 +44,7 @@ async def _run(svc, eng, turns):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_conversation_reaches_completion(db_engine):
|
async def test_full_conversation_reaches_completion(db_engine):
|
||||||
reset_sessions()
|
reset_sessions()
|
||||||
eng = await _reg().get_engine("ktcommerce")
|
eng = await _reg().get_engine("imarketkorea")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
# anchor=9900, target=10000(기본). 11000(>anchor*1.02)→가격협상(카드),
|
# anchor=9900, target=10000(기본). 11000(>anchor*1.02)→가격협상(카드),
|
||||||
# 10000(anchor<p≤anchor*1.02)→1% 와일드카드→수락하면 협상완료.
|
# 10000(anchor<p≤anchor*1.02)→1% 와일드카드→수락하면 협상완료.
|
||||||
@ -59,7 +59,7 @@ async def test_full_conversation_reaches_completion(db_engine):
|
|||||||
|
|
||||||
# 가격협상 카드선택 + 학습
|
# 가격협상 카드선택 + 학습
|
||||||
nego = [r for r in out if r.step == "가격협상"]
|
nego = [r for r in out if r.step == "가격협상"]
|
||||||
assert nego and nego[0].card_id and nego[0].card_id.startswith("NGC-A")
|
assert nego and nego[0].card_id and nego[0].card_id.startswith("NGC-B")
|
||||||
assert nego[0].updated_q is not None
|
assert nego[0].updated_q is not None
|
||||||
|
|
||||||
# 와일드카드 발동 (1% 인하)
|
# 와일드카드 발동 (1% 인하)
|
||||||
@ -70,11 +70,11 @@ async def test_full_conversation_reaches_completion(db_engine):
|
|||||||
assert done and done[0].reward_total is not None
|
assert done and done[0].reward_total is not None
|
||||||
|
|
||||||
# 브랜드 치환 + 클린룸
|
# 브랜드 치환 + 클린룸
|
||||||
assert "데모상사 A" in out[0].script
|
assert "데모상사 B" in out[0].script
|
||||||
assert not _KT.search(out[0].script)
|
assert not _KT.search(out[0].script)
|
||||||
|
|
||||||
# 경험로그 적재
|
# 경험로그 적재
|
||||||
repo = LearningRepository("ktcommerce")
|
repo = LearningRepository("imarketkorea")
|
||||||
err, cnt = await repo.read(lambda s: repo.count_experience(s))
|
err, cnt = await repo.read(lambda s: repo.count_experience(s))
|
||||||
assert cnt >= 1
|
assert cnt >= 1
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ async def test_wildcard_1pct_accept_settles_at_offer_price(db_engine):
|
|||||||
잡히던 버그 — settled_price 가 인하가(예: 19800)로 내려와야 한다.
|
잡히던 버그 — settled_price 가 인하가(예: 19800)로 내려와야 한다.
|
||||||
"""
|
"""
|
||||||
reset_sessions()
|
reset_sessions()
|
||||||
eng = await _reg().get_engine("ktcommerce")
|
eng = await _reg().get_engine("imarketkorea")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
# anchor=9900(기본). 10000 은 anchor*1.02(10098) 이내 → wild_card_1pct 발동, offer_1pct=9900.
|
# anchor=9900(기본). 10000 은 anchor*1.02(10098) 이내 → wild_card_1pct 발동, offer_1pct=9900.
|
||||||
out = await _run(svc, eng, [None, "확인", "예", "확인", "10000", "예", "예",
|
out = await _run(svc, eng, [None, "확인", "예", "확인", "10000", "예", "예",
|
||||||
@ -129,7 +129,7 @@ def test_card_id_fixed_mapping_and_selection_mask():
|
|||||||
"""카드 정리 후: action_id↔카드는 테넌트 매핑으로 고정, 견적 선택은 available_mask 로 걸러진다.
|
"""카드 정리 후: action_id↔카드는 테넌트 매핑으로 고정, 견적 선택은 available_mask 로 걸러진다.
|
||||||
(구 인덱스 방식 폐기 — selected[action_id] 인덱싱은 견적마다 action_id 의미가 달라져 Q-table 오염.)"""
|
(구 인덱스 방식 폐기 — selected[action_id] 인덱싱은 견적마다 action_id 의미가 달라져 Q-table 오염.)"""
|
||||||
class _Mapper:
|
class _Mapper:
|
||||||
_m = {i: f"NGC-A{i + 1:03d}" for i in range(11)}
|
_m = {i: f"NGC-B{i + 1:03d}" for i in range(11)}
|
||||||
def get_card_id(self, a):
|
def get_card_id(self, a):
|
||||||
return self._m.get(a)
|
return self._m.get(a)
|
||||||
def get_action_id(self, num):
|
def get_action_id(self, num):
|
||||||
@ -139,15 +139,15 @@ def test_card_id_fixed_mapping_and_selection_mask():
|
|||||||
action_space_size = 11
|
action_space_size = 11
|
||||||
eng = _Engine()
|
eng = _Engine()
|
||||||
session = ChatSession(
|
session = ChatSession(
|
||||||
session_id="00000000-0000-0000-0000-000000000001", tenant_id="ktcommerce", company_id="ktcommerce",
|
session_id="00000000-0000-0000-0000-000000000001", tenant_id="imarketkorea", company_id="imarketkorea",
|
||||||
context={"selected_nego_card_numbers": ["NGC-A003", "NGC-A008"]}, action_space_size=11,
|
context={"selected_nego_card_numbers": ["NGC-B003", "NGC-B008"]}, action_space_size=11,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ① card_id 는 고정 매핑 (선택 리스트 인덱싱 아님)
|
# ① card_id 는 고정 매핑 (선택 리스트 인덱싱 아님)
|
||||||
assert ChatService._card_id_for_action(eng, session, 0) == "NGC-A001"
|
assert ChatService._card_id_for_action(eng, session, 0) == "NGC-B001"
|
||||||
assert ChatService._card_id_for_action(eng, session, 2) == "NGC-A003"
|
assert ChatService._card_id_for_action(eng, session, 2) == "NGC-B003"
|
||||||
|
|
||||||
# ② 선택은 mask 로 — NGC-A003(action 2), NGC-A008(action 7) 만 pickable
|
# ② 선택은 mask 로 — NGC-B003(action 2), NGC-B008(action 7) 만 pickable
|
||||||
mask = ChatService._selection_mask(eng, session)
|
mask = ChatService._selection_mask(eng, session)
|
||||||
assert mask is not None and mask[2] and mask[7]
|
assert mask is not None and mask[2] and mask[7]
|
||||||
assert not mask[0] and not mask[5] and mask.sum() == 2
|
assert not mask[0] and not mask[5] and mask.sum() == 2
|
||||||
@ -162,15 +162,40 @@ def test_card_id_fixed_mapping_and_selection_mask():
|
|||||||
assert ChatService._selection_mask(eng, session) is None
|
assert ChatService._selection_mask(eng, session) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_counter_display_price_equals_settlement():
|
||||||
|
"""회귀(표시가≠투찰가): 카운터 제시 중 멘트에 보이는 절충/중간 변수
|
||||||
|
(middle_price·target_mid_price)는 수락 시 타결가(pending_counter_price)와 정확히 일치해야 한다.
|
||||||
|
|
||||||
|
버그: WC-05(중간값 절충)에서 compute_counter 는 target 클램프·prev_customer 갱신으로 1,700,000 을
|
||||||
|
pending 으로 적재하는데, vars_for 가 {middle_price} 를 재계산해 1,740,000 으로 표시 → 화면엔
|
||||||
|
1,740,000 인데 실제로는 1,700,000 으로 투찰되던 문제. pending 으로 고정해 표시가==타결가."""
|
||||||
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
|
repo = ScriptRepository(cfg, _TENANTS_DIR)
|
||||||
|
engine = ChatEngine(repo, rq_type="재협상")
|
||||||
|
session = ChatSession(
|
||||||
|
session_id="00000000-0000-0000-0000-000000000009",
|
||||||
|
tenant_id="imarketkorea", company_id="imarketkorea", action_space_size=0,
|
||||||
|
context={
|
||||||
|
"anchor_price": 2000000, "target_price": 1700000, "input_price": 1780000,
|
||||||
|
"prev_customer_price": 1700000, # 종결 전술이 counter 로 덮어쓴 상태
|
||||||
|
"pending_counter_price": 1700000, # compute_counter 의 target 클램프 결과(실제 타결가)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v = engine.vars_for(session)
|
||||||
|
assert v["counter_price"] == 1700000
|
||||||
|
assert v["middle_price"] == 1700000 # 재계산값 1,740,000 이 아니라 pending
|
||||||
|
assert v["target_mid_price"] == 1700000
|
||||||
|
|
||||||
|
|
||||||
def test_default_1pct_wildcard_still_runs_without_selected_wildcard():
|
def test_default_1pct_wildcard_still_runs_without_selected_wildcard():
|
||||||
"""1% 인하는 기본 제공 카드라 DB 견적에서 와일드카드를 선택하지 않아도 발동한다."""
|
"""1% 인하는 기본 제공 카드라 DB 견적에서 와일드카드를 선택하지 않아도 발동한다."""
|
||||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
repo = ScriptRepository(cfg, _TENANTS_DIR)
|
repo = ScriptRepository(cfg, _TENANTS_DIR)
|
||||||
engine = ChatEngine(repo, rq_type="재협상")
|
engine = ChatEngine(repo, rq_type="재협상")
|
||||||
session = ChatSession(
|
session = ChatSession(
|
||||||
session_id="00000000-0000-0000-0000-000000000002",
|
session_id="00000000-0000-0000-0000-000000000002",
|
||||||
tenant_id="ktcommerce",
|
tenant_id="imarketkorea",
|
||||||
company_id="ktcommerce",
|
company_id="imarketkorea",
|
||||||
step="가격협상_확인",
|
step="가격협상_확인",
|
||||||
action_space_size=0,
|
action_space_size=0,
|
||||||
context={
|
context={
|
||||||
@ -188,13 +213,13 @@ def test_default_1pct_wildcard_still_runs_without_selected_wildcard():
|
|||||||
|
|
||||||
def test_budget_wildcard_requires_selected_wildcard_for_db_context():
|
def test_budget_wildcard_requires_selected_wildcard_for_db_context():
|
||||||
"""재원부족 구간은 DB 견적에서 와일드카드를 선택했을 때만 발동한다."""
|
"""재원부족 구간은 DB 견적에서 와일드카드를 선택했을 때만 발동한다."""
|
||||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
repo = ScriptRepository(cfg, _TENANTS_DIR)
|
repo = ScriptRepository(cfg, _TENANTS_DIR)
|
||||||
engine = ChatEngine(repo, rq_type="재협상")
|
engine = ChatEngine(repo, rq_type="재협상")
|
||||||
session = ChatSession(
|
session = ChatSession(
|
||||||
session_id="00000000-0000-0000-0000-000000000003",
|
session_id="00000000-0000-0000-0000-000000000003",
|
||||||
tenant_id="ktcommerce",
|
tenant_id="imarketkorea",
|
||||||
company_id="ktcommerce",
|
company_id="imarketkorea",
|
||||||
step="가격협상_확인",
|
step="가격협상_확인",
|
||||||
action_space_size=0,
|
action_space_size=0,
|
||||||
context={
|
context={
|
||||||
@ -214,7 +239,7 @@ def test_budget_wildcard_requires_selected_wildcard_for_db_context():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_priority_completes_without_wildcard(db_engine):
|
async def test_priority_completes_without_wildcard(db_engine):
|
||||||
reset_sessions()
|
reset_sessions()
|
||||||
eng = await _reg().get_engine("ktcommerce")
|
eng = await _reg().get_engine("imarketkorea")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
# 첫 제시가가 앵커가(9900) 이하 → 우선협상 → 바로 협상완료(카드/와일드카드 없이)
|
# 첫 제시가가 앵커가(9900) 이하 → 우선협상 → 바로 협상완료(카드/와일드카드 없이)
|
||||||
out = await _run(svc, eng, [None, "확인", "예", "확인", "9800", "예",
|
out = await _run(svc, eng, [None, "확인", "예", "확인", "9800", "예",
|
||||||
@ -237,7 +262,7 @@ async def test_tenant_brand_isolation(db_engine):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_advance_after_end_errors(db_engine):
|
async def test_advance_after_end_errors(db_engine):
|
||||||
reset_sessions()
|
reset_sessions()
|
||||||
eng = await _reg().get_engine("ktcommerce")
|
eng = await _reg().get_engine("imarketkorea")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
out = await _run(svc, eng, [None, "확인", "예", "확인", "1000", "예",
|
out = await _run(svc, eng, [None, "확인", "예", "확인", "1000", "예",
|
||||||
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."])
|
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."])
|
||||||
@ -248,12 +273,12 @@ async def test_advance_after_end_errors(db_engine):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_http_chat_start(client):
|
async def test_http_chat_start(client):
|
||||||
r = await client.post("/v1/chat", headers={"X-Tenant-ID": "ktcommerce"}, json={"rq_type": "재협상"})
|
r = await client.post("/v1/chat", headers={"X-Tenant-ID": "imarketkorea"}, json={"rq_type": "재협상"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
d = r.json()
|
d = r.json()
|
||||||
assert d["step"] == "서비스안내"
|
assert d["step"] == "서비스안내"
|
||||||
assert d["input_options"] == ["확인"]
|
assert d["input_options"] == ["확인"]
|
||||||
assert "데모상사 A" in d["script"]
|
assert "데모상사 B" in d["script"]
|
||||||
# 헤더 없으면 400
|
# 헤더 없으면 400
|
||||||
r2 = await client.post("/v1/chat", json={"rq_type": "재협상"})
|
r2 = await client.post("/v1/chat", json={"rq_type": "재협상"})
|
||||||
assert r2.status_code == 400
|
assert r2.status_code == 400
|
||||||
|
|||||||
@ -25,7 +25,7 @@ def _eng():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_persists_and_resumes_across_instances(db_engine):
|
async def test_session_persists_and_resumes_across_instances(db_engine):
|
||||||
reg = _eng()
|
reg = _eng()
|
||||||
eng = await reg.get_engine("ktcommerce")
|
eng = await reg.get_engine("imarketkorea")
|
||||||
|
|
||||||
# 인스턴스 1: 협상 시작 + 몇 턴 진행 (컨텍스트는 DB 조회 — 행이 없으므로 기본값 폴백)
|
# 인스턴스 1: 협상 시작 + 몇 턴 진행 (컨텍스트는 DB 조회 — 행이 없으므로 기본값 폴백)
|
||||||
svc1 = ChatService()
|
svc1 = ChatService()
|
||||||
@ -51,19 +51,19 @@ async def test_session_persists_and_resumes_across_instances(db_engine):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_company_scoped(db_engine):
|
async def test_session_company_scoped(db_engine):
|
||||||
reg = _eng()
|
reg = _eng()
|
||||||
eng = await reg.get_engine("ktcommerce")
|
eng = await reg.get_engine("imarketkorea")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
r = await svc.chat(eng, Req_Chat())
|
r = await svc.chat(eng, Req_Chat())
|
||||||
sid = r.session_id
|
sid = r.session_id
|
||||||
|
|
||||||
# 자사(ktcommerce)로는 조회됨
|
# 자사(imarketkorea)로는 조회됨
|
||||||
assert await ChatSessionRepository(eng.company_id).get(sid) is not None
|
assert await ChatSessionRepository(eng.company_id).get(sid) is not None
|
||||||
# 타테넌트(imarketkorea) company_id 로는 조회 안 됨 (격리)
|
# 타테넌트 company_id 로는 조회 안 됨 (격리)
|
||||||
assert await ChatSessionRepository("imarketkorea").get(sid) is None
|
assert await ChatSessionRepository("00000000-0000-0000-0000-0000000000aa").get(sid) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_none_for_missing(db_engine):
|
async def test_get_none_for_missing(db_engine):
|
||||||
repo = ChatSessionRepository("ktcommerce")
|
repo = ChatSessionRepository("imarketkorea")
|
||||||
assert await repo.get(None) is None
|
assert await repo.get(None) is None
|
||||||
assert await repo.get("00000000-0000-0000-0000-000000000000") is None
|
assert await repo.get("00000000-0000-0000-0000-000000000000") is None
|
||||||
|
|||||||
157
agent/tests/test_script_naturalizer.py
Normal file
157
agent/tests/test_script_naturalizer.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""Phase 2 표현층 검증 — ScriptNaturalizer (LLM 카드 멘트 자연화).
|
||||||
|
|
||||||
|
원칙 검증: LLM 은 말만 다듬고 숫자는 절대 만들지 않는다.
|
||||||
|
① 치환자 보존 성공 경로 ② 치환자 누락/추가 → 폐기 ③ 새 숫자 → 폐기
|
||||||
|
④ 타임아웃/예외 → 폐기(폴백) ⑤ 상황 라벨은 정성(수치 미노출)
|
||||||
|
⑥ 챗 플로우: llm.enabled=True 면 카드 멘트가 자연화본으로, 실패 시 원본으로.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer, build_situation
|
||||||
|
|
||||||
|
_TEMPLATE = "제안해 주신 **{input_price}원**, 감사합니다. 한 번 더 검토해 가격을 제안해 주시겠어요?"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake(reply):
|
||||||
|
"""llm_call 더블 — 고정 응답."""
|
||||||
|
def call(messages):
|
||||||
|
return {"script": reply}
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naturalize_success_preserves_placeholders():
|
||||||
|
nat = ScriptNaturalizer(llm_call=_fake(
|
||||||
|
"긍정적으로 검토 중입니다. 다만 **{input_price}원**은 조정 여지가 있어 보입니다. 재제안 부탁드립니다."))
|
||||||
|
out = await nat.naturalize(_TEMPLATE, situation={"라운드": "초반 조율"}, tone=2, strategy=1)
|
||||||
|
assert out and "{input_price}" in out and out != _TEMPLATE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naturalize_rejects_missing_placeholder():
|
||||||
|
nat = ScriptNaturalizer(llm_call=_fake("가격 재검토 부탁드립니다.")) # 치환자 삭제됨
|
||||||
|
assert await nat.naturalize(_TEMPLATE) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naturalize_rejects_added_placeholder():
|
||||||
|
nat = ScriptNaturalizer(llm_call=_fake("{input_price}원과 {secret_discount}까지 드리겠습니다."))
|
||||||
|
assert await nat.naturalize(_TEMPLATE) is None # 원본에 없던 변수 환각
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naturalize_rejects_new_digits():
|
||||||
|
nat = ScriptNaturalizer(llm_call=_fake("{input_price}원에서 5% 더 인하해 주시면 즉시 계약하겠습니다."))
|
||||||
|
assert await nat.naturalize(_TEMPLATE) is None # LLM 이 만든 숫자(5) 금지
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naturalize_rejects_dropped_emphasis_markers():
|
||||||
|
"""고객사가 지정한 볼드/색 마커를 LLM 이 떨어뜨리면 폐기 → 원본(스타일 보존) 폴백."""
|
||||||
|
tmpl = "제안해 주신 **{input_price}원**, {{강조|재검토}} 부탁드립니다."
|
||||||
|
# 볼드·색 마커를 모두 지운 재작성 → 검증 실패
|
||||||
|
nat = ScriptNaturalizer(llm_call=_fake("제안해 주신 {input_price}원, 재검토 부탁드립니다."))
|
||||||
|
assert await nat.naturalize(tmpl) is None
|
||||||
|
# 마커를 그대로 유지한 재작성 → 통과
|
||||||
|
nat = ScriptNaturalizer(llm_call=_fake("제시하신 **{input_price}원** 관련, {{강조|재검토}}를 요청드립니다."))
|
||||||
|
out = await nat.naturalize(tmpl)
|
||||||
|
assert out and out.count("**") == 2 and "{{강조|" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naturalize_timeout_falls_back():
|
||||||
|
def slow(messages):
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
return {"script": _TEMPLATE}
|
||||||
|
nat = ScriptNaturalizer(llm_call=slow, timeout_seconds=0.05)
|
||||||
|
assert await nat.naturalize(_TEMPLATE) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naturalize_exception_falls_back():
|
||||||
|
def boom(messages):
|
||||||
|
raise RuntimeError("LLM down")
|
||||||
|
nat = ScriptNaturalizer(llm_call=boom)
|
||||||
|
assert await nat.naturalize(_TEMPLATE) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_situation_is_qualitative_only():
|
||||||
|
"""상황 라벨에 실제 수치가 노출되지 않는다(숫자 환각 차단의 전제)."""
|
||||||
|
ctx = {"round": 2, "input_price": 10200, "anchor_price": 9900, "target_price": 10000, "item_price": 11000}
|
||||||
|
s = build_situation(ctx)
|
||||||
|
assert s["라운드"] == "초반 조율"
|
||||||
|
assert s["가격구간"] == "목표 상회(추가 인하 필요)"
|
||||||
|
joined = str(s)
|
||||||
|
for n in ("10200", "9900", "10000", "11000"):
|
||||||
|
assert n not in joined # 수치 미노출
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_flow_uses_naturalized_script_when_llm_enabled(db_engine):
|
||||||
|
"""llm.enabled=True + 자격증명 존재 시 가격협상 카드 멘트가 자연화본(치환 완료)으로 나온다."""
|
||||||
|
from router.v1.chat.protocol import Req_Chat
|
||||||
|
from services.chat_service import ChatService, reset_sessions
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tenancy.registry import TenantEngineRegistry
|
||||||
|
|
||||||
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||||
|
reset_sessions()
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine("imarketkorea")
|
||||||
|
eng.config.llm.enabled = True # 테넌트 게이트 on
|
||||||
|
|
||||||
|
svc = ChatService()
|
||||||
|
svc._naturalizer = ScriptNaturalizer(llm_call=_fake(
|
||||||
|
"【자연화】 제시해 주신 **{input_price}원** 잘 검토했습니다. 초반 조율 단계이니 한 걸음 더 부탁드립니다."))
|
||||||
|
# 자격증명 게이트 우회(테스트 환경에 키가 없어도 동작 검증)
|
||||||
|
orig_available = ScriptNaturalizer.available
|
||||||
|
ScriptNaturalizer.available = staticmethod(lambda: True)
|
||||||
|
try:
|
||||||
|
sid = None
|
||||||
|
for ui in [None, "확인", "예", "확인", "11000", "예"]:
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
|
||||||
|
sid = r.session_id
|
||||||
|
assert r.step == "가격협상" and r.card_id
|
||||||
|
assert r.script.startswith("【자연화】") # LLM 재작성본 사용
|
||||||
|
assert "11000" in r.script # 치환은 엔진이 수행(숫자 정확)
|
||||||
|
assert "{input_price}" not in r.script # 치환 완료
|
||||||
|
finally:
|
||||||
|
ScriptNaturalizer.available = orig_available
|
||||||
|
eng.config.llm.enabled = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_flow_falls_back_to_template_on_llm_failure(db_engine):
|
||||||
|
"""LLM 실패 시 카드 원본 멘트로 폴백 — 협상은 절대 멈추지 않는다."""
|
||||||
|
from router.v1.chat.protocol import Req_Chat
|
||||||
|
from services.chat_service import ChatService, reset_sessions
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tenancy.registry import TenantEngineRegistry
|
||||||
|
|
||||||
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||||
|
reset_sessions()
|
||||||
|
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||||
|
eng = await reg.get_engine("imarketkorea")
|
||||||
|
eng.config.llm.enabled = True
|
||||||
|
|
||||||
|
def boom(messages):
|
||||||
|
raise RuntimeError("LLM down")
|
||||||
|
svc = ChatService()
|
||||||
|
svc._naturalizer = ScriptNaturalizer(llm_call=boom)
|
||||||
|
orig_available = ScriptNaturalizer.available
|
||||||
|
ScriptNaturalizer.available = staticmethod(lambda: True)
|
||||||
|
try:
|
||||||
|
sid = None
|
||||||
|
for ui in [None, "확인", "예", "확인", "11000", "예"]:
|
||||||
|
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
|
||||||
|
sid = r.session_id
|
||||||
|
assert r.step == "가격협상" and r.card_id
|
||||||
|
assert r.script and "11000" in r.script # 원본 템플릿 + 치환으로 정상 응답
|
||||||
|
finally:
|
||||||
|
ScriptNaturalizer.available = orig_available
|
||||||
|
eng.config.llm.enabled = False
|
||||||
@ -20,7 +20,7 @@ _TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__fi
|
|||||||
_FORBIDDEN = re.compile(r"kt\s*commerce|케이티|커머스|nego-?wiz", re.IGNORECASE)
|
_FORBIDDEN = re.compile(r"kt\s*commerce|케이티|커머스|nego-?wiz", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _repo(tenant_id="ktcommerce"):
|
def _repo(tenant_id="imarketkorea"):
|
||||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load(tenant_id)
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load(tenant_id)
|
||||||
return ScriptRepository(cfg, _TENANTS_DIR)
|
return ScriptRepository(cfg, _TENANTS_DIR)
|
||||||
|
|
||||||
@ -42,7 +42,10 @@ def test_requote_structure_preserved():
|
|||||||
for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]:
|
for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]:
|
||||||
assert key in s
|
assert key in s
|
||||||
assert s["배송형태선택"]["next_input_mode"] == "delivery_type"
|
assert s["배송형태선택"]["next_input_mode"] == "delivery_type"
|
||||||
assert s["배송형태선택"]["input_options"] == ["협력사배송", "지정택배배송", "픽업배송"]
|
# 리소스 원본은 회사 용어 토큰({label_*}) — 렌더 시 회사 라벨(없으면 기본값)로 치환된다.
|
||||||
|
assert s["배송형태선택"]["input_options"] == [
|
||||||
|
"{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_wildcard_present_and_merged():
|
def test_wildcard_present_and_merged():
|
||||||
@ -65,11 +68,9 @@ def test_cleanroom_no_proprietary_brand_in_any_resource():
|
|||||||
|
|
||||||
|
|
||||||
def test_brand_substitution_per_tenant():
|
def test_brand_substitution_per_tenant():
|
||||||
kt = _repo("ktcommerce").get_step("서비스안내", "재협상")
|
|
||||||
im = _repo("imarketkorea").get_step("서비스안내", "재협상")
|
im = _repo("imarketkorea").get_step("서비스안내", "재협상")
|
||||||
assert "데모상사 A" in kt["script"] and "Negosium" in kt["script"]
|
assert "데모상사 B" in im["script"] and "Negosium" in im["script"]
|
||||||
assert "데모상사 B" in im["script"]
|
assert "{company_name}" not in im["script"] # 치환 완료
|
||||||
assert "{company_name}" not in kt["script"] # 치환 완료
|
|
||||||
|
|
||||||
|
|
||||||
def test_variable_substitution_and_missing_kept():
|
def test_variable_substitution_and_missing_kept():
|
||||||
@ -103,12 +104,16 @@ async def test_resolve_card_script_file_mode_default():
|
|||||||
repo = _repo()
|
repo = _repo()
|
||||||
assert repo._config.cards.source_type == "file"
|
assert repo._config.cards.source_type == "file"
|
||||||
# action 0 파일 멘트가 변수 치환되어 나온다 (DB 무접근)
|
# action 0 파일 멘트가 변수 치환되어 나온다 (DB 무접근)
|
||||||
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
|
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
|
||||||
assert out and "9800" in out
|
assert out and "9800" in out
|
||||||
|
|
||||||
|
|
||||||
class _FakeCardRepo:
|
from negotiation.cards.ports.card_script_port import ICardScriptRepository
|
||||||
"""ICardScriptRepository 더블 — DB 없이 카드코드→멘트 매핑만 흉내(세션 인자 무시)."""
|
|
||||||
|
|
||||||
|
class _FakeCardRepo(ICardScriptRepository):
|
||||||
|
"""ICardScriptRepository 더블 — DB 없이 카드코드→멘트 매핑만 흉내(세션 인자 무시).
|
||||||
|
포트 상속으로 get_card_by_number 기본 구현(메타 None)을 물려받는다."""
|
||||||
|
|
||||||
def __init__(self, by_number: dict):
|
def __init__(self, by_number: dict):
|
||||||
self._by = by_number
|
self._by = by_number
|
||||||
@ -121,9 +126,9 @@ class _FakeCardRepo:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_card_script_db_mode_prefers_db(monkeypatch):
|
async def test_resolve_card_script_db_mode_prefers_db(monkeypatch):
|
||||||
"""source_type='backoffice_db': card.nego_cards.script(정본)를 파일보다 우선 사용 + 마커 보존."""
|
"""source_type='backoffice_db': card.nego_cards.script(정본)를 파일보다 우선 사용 + 마커 보존."""
|
||||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
cfg.cards.source_type = "backoffice_db"
|
cfg.cards.source_type = "backoffice_db"
|
||||||
fake = _FakeCardRepo({"NGC-A001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
|
fake = _FakeCardRepo({"NGC-B001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
|
||||||
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
||||||
|
|
||||||
# execute_lambda 를 세션 없이 콜백만 실행하도록 대체(순수 단위검증)
|
# execute_lambda 를 세션 없이 콜백만 실행하도록 대체(순수 단위검증)
|
||||||
@ -132,14 +137,14 @@ async def test_resolve_card_script_db_mode_prefers_db(monkeypatch):
|
|||||||
from negotiation.chat.service import script_repository as _sr
|
from negotiation.chat.service import script_repository as _sr
|
||||||
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
||||||
|
|
||||||
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
|
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
|
||||||
assert out == "DB 편집 멘트 **9800원** 검토 중입니다." # DB 우선 + 마커(**) 불투명 보존 + 변수 치환
|
assert out == "DB 편집 멘트 **9800원** 검토 중입니다." # DB 우선 + 마커(**) 불투명 보존 + 변수 치환
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch):
|
async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch):
|
||||||
"""DB 에 해당 카드 멘트가 없으면 파일(scripts_cards.json)로 폴백."""
|
"""DB 에 해당 카드 멘트가 없으면 파일(scripts_cards.json)로 폴백."""
|
||||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
cfg.cards.source_type = "backoffice_db"
|
cfg.cards.source_type = "backoffice_db"
|
||||||
fake = _FakeCardRepo({}) # DB 미보유
|
fake = _FakeCardRepo({}) # DB 미보유
|
||||||
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
||||||
@ -149,14 +154,14 @@ async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch):
|
|||||||
from negotiation.chat.service import script_repository as _sr
|
from negotiation.chat.service import script_repository as _sr
|
||||||
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
||||||
|
|
||||||
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
|
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
|
||||||
assert out and "9800" in out # 파일 폴백 멘트
|
assert out and "9800" in out # 파일 폴백 멘트
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
|
async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
|
||||||
"""견적에서 선택된 백오피스 카드 번호는 file 모드여도 DB 멘트를 우선한다."""
|
"""견적에서 선택된 백오피스 카드 번호는 file 모드여도 DB 멘트를 우선한다."""
|
||||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||||
assert cfg.cards.source_type == "file"
|
assert cfg.cards.source_type == "file"
|
||||||
fake = _FakeCardRepo({"2": "선택 카드 DB 멘트 **{input_price}원**"})
|
fake = _FakeCardRepo({"2": "선택 카드 DB 멘트 **{input_price}원**"})
|
||||||
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
||||||
@ -168,3 +173,24 @@ async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
|
|||||||
|
|
||||||
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
|
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
|
||||||
assert out == "선택 카드 DB 멘트 **10200원**"
|
assert out == "선택 카드 DB 멘트 **10200원**"
|
||||||
|
|
||||||
|
|
||||||
|
def test_option_label_tokens_rendered():
|
||||||
|
"""검증: 옵션에 회사 용어 토큰({label_delivery_type_*})이 있는 스텝을 정상 렌더·에러 재렌더로 출력.
|
||||||
|
기대결과: 두 경로 모두 버튼 문자열이 기본 라벨(협력사배송 등)로 치환되고 토큰이 남지 않는다."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
|
||||||
|
tenants = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||||
|
cfg = TenantConfigLoader(tenants_dir=tenants, cache_ttl_seconds=0).load("_base")
|
||||||
|
engine = ChatEngine(ScriptRepository(cfg, tenants), rq_type="재견적")
|
||||||
|
session = ChatSession(session_id="s", tenant_id="_base", company_id="_base")
|
||||||
|
|
||||||
|
view = engine.render_step(session, "배송형태선택")
|
||||||
|
assert view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||||
|
|
||||||
|
# 에러 재렌더(잘못된 입력 등)도 같은 치환을 타야 한다 — raw 옵션이면 토큰이 버튼에 노출된다.
|
||||||
|
err_view = engine._error(session, "다시 선택해 주세요.")
|
||||||
|
assert err_view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
"""카드 스크립트 → 임베딩 캐시 생성 (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()
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
"""기존 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()
|
|
||||||
@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
실행:
|
실행:
|
||||||
cd agent
|
cd agent
|
||||||
APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 시나리오
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 기본 시나리오
|
||||||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 로깅 없이
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 로깅 없이
|
||||||
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --interactive
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@ -74,7 +74,7 @@ def _scenario():
|
|||||||
async def run(tenant_id: str, use_db: bool, interactive: bool):
|
async def run(tenant_id: str, use_db: bool, interactive: bool):
|
||||||
loader = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)
|
loader = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)
|
||||||
if not loader.is_registered(tenant_id):
|
if not loader.is_registered(tenant_id):
|
||||||
print(f"[!] 미등록 테넌트: {tenant_id}. 등록된 테넌트: ktcommerce, imarketkorea, _base")
|
print(f"[!] 미등록 테넌트: {tenant_id}. 등록된 테넌트: imarketkorea, _base")
|
||||||
return
|
return
|
||||||
registry = TenantEngineRegistry(loader=loader)
|
registry = TenantEngineRegistry(loader=loader)
|
||||||
engine = await registry.get_engine(tenant_id)
|
engine = await registry.get_engine(tenant_id)
|
||||||
@ -172,7 +172,7 @@ def _interactive_turns():
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="협상 의사결정 루프 콘솔 데모 (P0~P4)")
|
ap = argparse.ArgumentParser(description="협상 의사결정 루프 콘솔 데모 (P0~P4)")
|
||||||
ap.add_argument("--tenant", default="ktcommerce", help="테넌트 id (ktcommerce|imarketkorea)")
|
ap.add_argument("--tenant", default="imarketkorea", help="테넌트 id (imarketkorea|_base)")
|
||||||
ap.add_argument("--no-db", action="store_true", help="DB 로깅 비활성화")
|
ap.add_argument("--no-db", action="store_true", help="DB 로깅 비활성화")
|
||||||
ap.add_argument("--interactive", action="store_true", help="턴마다 직접 입력")
|
ap.add_argument("--interactive", action="store_true", help="턴마다 직접 입력")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,317 +0,0 @@
|
|||||||
"""결함 회귀 게이트 — 실전에서 발견된 협상 결함을 시나리오로 재생해 서빙 번들을 검증한다.
|
|
||||||
|
|
||||||
프로브(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()
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,365 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -58,6 +58,21 @@ class suppliers(MAIN_BASE):
|
|||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
|
class companies(MAIN_BASE):
|
||||||
|
# company.companies (고객사). 공급사 포털 브랜딩(settings.branding) 조회 전용 미러.
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.PARTNER.value
|
||||||
|
|
||||||
|
__tablename__ = "companies"
|
||||||
|
__table_args__ = {"schema": "company"}
|
||||||
|
|
||||||
|
company_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 회사 식별자(PK)
|
||||||
|
name = Column(String(100), nullable=False) # 회사명
|
||||||
|
settings = Column(JSONB, nullable=True) # 회사별 커스터마이징(branding/labels 등, negodata 소유)
|
||||||
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
class items(MAIN_BASE):
|
class items(MAIN_BASE):
|
||||||
# partner.items (상품).
|
# partner.items (상품).
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -123,6 +138,7 @@ class sessions(MAIN_BASE):
|
|||||||
reject_reason = Column(String(255), nullable=True) # 거절 사유
|
reject_reason = Column(String(255), nullable=True) # 거절 사유
|
||||||
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
|
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
|
||||||
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
|
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
|
||||||
|
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
@ -157,6 +173,7 @@ class quotations(MAIN_BASE):
|
|||||||
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
|
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
|
||||||
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)
|
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)
|
||||||
preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷)
|
preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷)
|
||||||
|
close_reason = Column(SmallInteger, nullable=True) # 마감 사유(CloseReason). 재협상 요청 자격 판정에 읽는다
|
||||||
equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
|
equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
|
||||||
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON)
|
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON)
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||||
@ -164,6 +181,28 @@ class quotations(MAIN_BASE):
|
|||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
|
class notifications(MAIN_BASE):
|
||||||
|
# company.notifications (담당자 인박스). 포털은 재협상 요청 알림을 만들기 위해서만 쓴다(조회는 negodata).
|
||||||
|
# company 스키마 전용 DBType 이 없어 USER 커넥션을 재사용한다(물리 DB 동일).
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.USER.value
|
||||||
|
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
__table_args__ = {"schema": "company"}
|
||||||
|
|
||||||
|
notification_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False) # 수신자(company.users.user_id) = 견적 작성자
|
||||||
|
type = Column(SmallInteger, nullable=False) # NotificationType
|
||||||
|
ref_qt_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
ref_session_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
data = Column(JSONB, nullable=True) # 렌더 스냅샷(공급사명·사유·희망가 등)
|
||||||
|
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"))
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')"))
|
||||||
|
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
|
||||||
|
|
||||||
class quotation_settings(MAIN_BASE):
|
class quotation_settings(MAIN_BASE):
|
||||||
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값).
|
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값).
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -183,6 +222,34 @@ class quotation_settings(MAIN_BASE):
|
|||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
|
class nego_cards(MAIN_BASE):
|
||||||
|
# card.nego_cards (협상카드). backend 는 번호→UUID 변환(chats.card_id 저장)만 위해 최소 컬럼 미러.
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.NEGOTIATION.value # 같은 negosium_db — chats 와 동일 세션풀로 조회
|
||||||
|
|
||||||
|
__tablename__ = "nego_cards"
|
||||||
|
__table_args__ = {"schema": "card"}
|
||||||
|
|
||||||
|
nego_card_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
||||||
|
number = Column(String(10), nullable=True) # 카드 번호(agent turn.card_id 와 매칭)
|
||||||
|
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
|
||||||
|
|
||||||
|
class wild_cards(MAIN_BASE):
|
||||||
|
# card.wild_cards (와일드카드). backend 는 번호→UUID 변환(chats.card_id 저장)만 위해 최소 컬럼 미러.
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.NEGOTIATION.value # 같은 negosium_db
|
||||||
|
|
||||||
|
__tablename__ = "wild_cards"
|
||||||
|
__table_args__ = {"schema": "card"}
|
||||||
|
|
||||||
|
wild_card_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
||||||
|
number = Column(String(10), nullable=True)
|
||||||
|
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
|
||||||
|
|
||||||
class chats(MAIN_BASE):
|
class chats(MAIN_BASE):
|
||||||
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
|
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -130,6 +130,41 @@ class QuotationStatus(Enum):
|
|||||||
CLOSED = 3 # 견적마감
|
CLOSED = 3 # 견적마감
|
||||||
|
|
||||||
|
|
||||||
|
class CloseReason(Enum):
|
||||||
|
"""견적 마감 사유. quotation.quotations.close_reason
|
||||||
|
낙찰(AWARDED) 외 OPEN_* 는 낙찰자 미정으로 마감된 '결렬' 건 — 공급사 재협상 요청 대상."""
|
||||||
|
|
||||||
|
AWARDED = 1 # 낙찰
|
||||||
|
OPEN_PRICE = 5 # 개찰: 낙찰 기준 미달
|
||||||
|
OPEN_EQUAL = 6 # 개찰: 동가
|
||||||
|
OPEN_NOSHOW = 7 # 개찰: 전원 미응찰
|
||||||
|
OPEN_REJECT = 8 # 개찰: 협상거부 존재
|
||||||
|
|
||||||
|
|
||||||
|
# 재협상 요청 가능한 마감 사유(낙찰 건은 제외).
|
||||||
|
RENEGOTIABLE_CLOSE_REASONS = (
|
||||||
|
CloseReason.OPEN_PRICE.value,
|
||||||
|
CloseReason.OPEN_EQUAL.value,
|
||||||
|
CloseReason.OPEN_NOSHOW.value,
|
||||||
|
CloseReason.OPEN_REJECT.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RenegotiationStatus(Enum):
|
||||||
|
"""sessions.custom.renegotiation.status — 공급사 재협상 요청 상태(IMK #15)."""
|
||||||
|
|
||||||
|
PENDING = 1 # 접수, 담당자 심사 대기
|
||||||
|
APPROVED = 2 # 승인 — 다음 라운드 생성됨
|
||||||
|
REJECTED = 3 # 반려
|
||||||
|
CANCELED = 4 # 공급사 철회
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationType(Enum):
|
||||||
|
"""company.notifications.type — negodata 담당자 인박스. 포털에서 만드는 건 재협상 요청뿐."""
|
||||||
|
|
||||||
|
RENEGO_REQUESTED = 5
|
||||||
|
|
||||||
|
|
||||||
class ChatSender(Enum):
|
class ChatSender(Enum):
|
||||||
"""채팅 발신자 코드. negotiation.chats.sender """
|
"""채팅 발신자 코드. negotiation.chats.sender """
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import chats, items, sessions
|
from common.database.model.models import chats, items, sessions, nego_cards, wild_cards
|
||||||
from common.enums import ErrorType, SessionStatus
|
from common.enums import ErrorType, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
@ -47,6 +47,14 @@ class IChatCRUD(ABC):
|
|||||||
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_wild_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChatCRUD(IChatCRUD):
|
class ChatCRUD(IChatCRUD):
|
||||||
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
||||||
@ -112,6 +120,30 @@ class ChatCRUD(IChatCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
# 협상카드 번호(agent turn.card_id) → nego_card_id(UUID). 없으면 None. 카드 사용 로그(chats.card_id) 저장용.
|
||||||
|
try:
|
||||||
|
query = select(nego_cards.nego_card_id).where(nego_cards.number == number, nego_cards.deleted == False).limit(1) # noqa: E712
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_nego_card_id_by_number({number}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS or not row_list:
|
||||||
|
return None
|
||||||
|
return row_list[0]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_wild_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
# 와일드카드 번호(agent turn.card_id, wild_card_dynamic) → wild_card_id(UUID). 없으면 None.
|
||||||
|
try:
|
||||||
|
query = select(wild_cards.wild_card_id).where(wild_cards.number == number, wild_cards.deleted == False).limit(1) # noqa: E712
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_wild_card_id_by_number({number}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS or not row_list:
|
||||||
|
return None
|
||||||
|
return row_list[0]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return None
|
||||||
|
|
||||||
async def finalize_session(
|
async def finalize_session(
|
||||||
self, cdb: AsyncSession, session_id, status: int,
|
self, cdb: AsyncSession, session_id, status: int,
|
||||||
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
||||||
|
|||||||
@ -1,24 +1,40 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import case, func, nulls_last, select, update
|
from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import items, quotations, sessions
|
from common.database.model.models import chats, items, quotations, sessions
|
||||||
from common.enums import ErrorType, SessionStatus
|
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
|
|
||||||
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
||||||
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_status():
|
||||||
|
"""표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다.
|
||||||
|
|
||||||
|
참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을
|
||||||
|
목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다.
|
||||||
|
"""
|
||||||
|
ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now())
|
||||||
|
return case(
|
||||||
|
(and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value),
|
||||||
|
else_=sessions.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ISessionCRUD(ABC):
|
class ISessionCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
|
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -38,23 +54,56 @@ class ISessionCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
async def update_session_reject(
|
||||||
|
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
||||||
|
) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SessionCRUD(ISessionCRUD):
|
class SessionCRUD(ISessionCRUD):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __filters(supplier_id, status, qt_type):
|
def __filters(supplier_id, status, qt_type, keyword=None, result=None):
|
||||||
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
||||||
if status is not None:
|
if status is not None:
|
||||||
conds.append(sessions.status == status)
|
# 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다.
|
||||||
|
conds.append(_effective_status() == status)
|
||||||
if qt_type is not None:
|
if qt_type is not None:
|
||||||
conds.append(sessions.qt_type == qt_type)
|
conds.append(sessions.qt_type == qt_type)
|
||||||
|
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
|
||||||
|
# ILIKE 와일드카드(%,_)는 escape 해 사용자 입력이 패턴으로 새지 않게 한다.
|
||||||
|
if keyword and keyword.strip():
|
||||||
|
kw = keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
like = f"%{kw}%"
|
||||||
|
conds.append(or_(sessions.qt_number.ilike(like), items.name.ilike(like), items.code.ilike(like)))
|
||||||
|
# 결과(SessionResult) 필터 — _to_result 파생 규칙을 SQL WHERE 로 그대로 복제(집계·필터 일치용).
|
||||||
|
# 1=낙찰 2=미낙찰 3=결렬(개찰). 전부 견적 마감(CLOSED) 이 전제.
|
||||||
|
if result in (1, 2, 3):
|
||||||
|
conds.append(quotations.status == QuotationStatus.CLOSED.value)
|
||||||
|
if result == 1:
|
||||||
|
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
|
||||||
|
conds.append(quotations.preferred_sp_id == sessions.supplier_id)
|
||||||
|
elif result == 2:
|
||||||
|
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
|
||||||
|
conds.append(or_(quotations.preferred_sp_id.is_(None), quotations.preferred_sp_id != sessions.supplier_id))
|
||||||
|
else:
|
||||||
|
conds.append(quotations.close_reason.in_(RENEGOTIABLE_CLOSE_REASONS))
|
||||||
return conds
|
return conds
|
||||||
|
|
||||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
||||||
try:
|
try:
|
||||||
conds = self.__filters(supplier_id, status, qt_type)
|
conds = self.__filters(supplier_id, status, qt_type, keyword, result)
|
||||||
|
|
||||||
# 정렬 규칙:
|
# 정렬 규칙:
|
||||||
# - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬).
|
# - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬).
|
||||||
@ -69,7 +118,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
else:
|
else:
|
||||||
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
||||||
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
||||||
actionable = sessions.status.in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
||||||
status_rank = case((actionable, 0), else_=1)
|
status_rank = case((actionable, 0), else_=1)
|
||||||
action_order = case((actionable, quotations.end_time), else_=None).asc()
|
action_order = case((actionable, quotations.end_time), else_=None).asc()
|
||||||
done_order = case((~actionable, quotations.end_time), else_=None).desc()
|
done_order = case((~actionable, quotations.end_time), else_=None).desc()
|
||||||
@ -78,7 +127,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
sessions.session_id,
|
sessions.session_id,
|
||||||
sessions.status,
|
_effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다
|
||||||
sessions.qt_type,
|
sessions.qt_type,
|
||||||
sessions.qt_number,
|
sessions.qt_number,
|
||||||
quotations.end_time, # qt_end_time = 견적 마감 시각
|
quotations.end_time, # qt_end_time = 견적 마감 시각
|
||||||
@ -86,6 +135,17 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
items.name,
|
items.name,
|
||||||
items.model_name,
|
items.model_name,
|
||||||
items.manufacturer,
|
items.manufacturer,
|
||||||
|
sessions.custom,
|
||||||
|
quotations.status, # 재협상 요청 자격 판정용(마감 여부)
|
||||||
|
quotations.close_reason, # 개찰(결렬) 사유
|
||||||
|
quotations.round,
|
||||||
|
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
|
||||||
|
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
|
||||||
|
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
|
||||||
|
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
|
||||||
|
# 거부 건이 제출한 사유·희망가 — 목록의 '거부 내역' 열람용(의견은 custom.opinion).
|
||||||
|
sessions.reject_reason,
|
||||||
|
sessions.reject_price,
|
||||||
)
|
)
|
||||||
.join(items, items.item_id == sessions.item_id)
|
.join(items, items.item_id == sessions.item_id)
|
||||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
@ -102,9 +162,9 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
|
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
|
||||||
try:
|
try:
|
||||||
conds = self.__filters(supplier_id, status, qt_type)
|
conds = self.__filters(supplier_id, status, qt_type, keyword, result)
|
||||||
query = (
|
query = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(sessions)
|
.select_from(sessions)
|
||||||
@ -162,12 +222,58 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
async def update_session_reject(
|
||||||
|
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
||||||
|
) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
|
values = {"status": status, "reject_reason": reject_reason}
|
||||||
|
# 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
|
||||||
|
if reject_price is not None:
|
||||||
|
values["reject_price"] = reject_price
|
||||||
query = (
|
query = (
|
||||||
update(sessions)
|
update(sessions)
|
||||||
.where(sessions.session_id == session_id)
|
.where(sessions.session_id == session_id)
|
||||||
.values(status=status, reject_reason=reject_reason)
|
.values(**values)
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
|
||||||
|
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
|
||||||
|
try:
|
||||||
|
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, 0
|
||||||
|
# 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님).
|
||||||
|
top = rows[0] if rows else None
|
||||||
|
return ErrorType.SUCCESS, int(top or 0)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
|
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
||||||
|
# sessions.custom 부분 갱신(기존 키 보존). 부가정보와 재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(sessions)
|
||||||
|
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
|
||||||
|
.values(custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)))
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
||||||
|
# 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드).
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(sessions)
|
||||||
|
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
|
||||||
|
.values(custom=custom)
|
||||||
)
|
)
|
||||||
return await DB_SESSION_MNG.add(cdb, query)
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from sqlalchemy import delete, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers
|
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers, companies, sessions
|
||||||
from common.enums import ErrorType, TokenType
|
from common.enums import ErrorType, TokenType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -28,6 +28,14 @@ class IUserCRUD(ABC):
|
|||||||
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
|
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_branding_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
@ -117,6 +125,43 @@ class UserCRUD(IUserCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
|
||||||
|
"""공급사 소속 회사 설정(companies.settings) 전체. 브랜딩·협상완료 필드 등이 들어있다. 미설정이면 빈 dict."""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(companies.settings)
|
||||||
|
.join(suppliers, suppliers.company_id == companies.company_id)
|
||||||
|
.where(suppliers.supplier_id == supplier_id, suppliers.deleted == False, companies.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_company_settings(supplier_id:{supplier_id}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, {}
|
||||||
|
settings = row_list[0] if row_list else None
|
||||||
|
return ErrorType.SUCCESS, settings or {}
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, {}
|
||||||
|
|
||||||
|
async def get_branding_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, dict]:
|
||||||
|
"""세션이 속한 회사의 브랜딩(companies.settings.branding). 로그인 전 화면이 쓰므로 branding 만 꺼낸다."""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(companies.settings)
|
||||||
|
.join(suppliers, suppliers.company_id == companies.company_id)
|
||||||
|
.join(sessions, sessions.supplier_id == suppliers.supplier_id)
|
||||||
|
.where(sessions.session_id == session_id, sessions.deleted == False, companies.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_branding_by_session(session_id:{session_id}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, {}
|
||||||
|
settings = (row_list[0] if row_list else None) or {}
|
||||||
|
return ErrorType.SUCCESS, settings.get("branding") or {}
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, {}
|
||||||
|
|
||||||
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Path, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
@ -20,6 +20,7 @@ from .protocol import (
|
|||||||
Res_Me,
|
Res_Me,
|
||||||
Res_PopupStatus,
|
Res_PopupStatus,
|
||||||
Res_RefreshToken,
|
Res_RefreshToken,
|
||||||
|
Res_SessionBranding,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
||||||
@ -106,3 +107,16 @@ async def hide_popup(
|
|||||||
service: AuthService = Depends(),
|
service: AuthService = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(await service.hide_popup(user_info, credentials.credentials, req.popup_type))
|
return RemoveNoneResponse(await service.hide_popup(user_info, credentials.credentials, req.popup_type))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
path="/session-branding/{session_id}",
|
||||||
|
response_model=Res_SessionBranding,
|
||||||
|
summary="세션 브랜딩(무인증)",
|
||||||
|
description="초청 링크로 진입한 로그인 전 화면에서 회사 서비스명·로고·색상만 조회한다. 인증 없이 열려 있으므로 브랜딩 외 정보는 내리지 않는다.",
|
||||||
|
)
|
||||||
|
async def session_branding(
|
||||||
|
session_id: str = Path(..., description="협상 세션 uuid (초청 링크의 session_id)"),
|
||||||
|
service: AuthService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.session_branding(session_id))
|
||||||
|
|||||||
@ -48,6 +48,9 @@ class Res_Me(Res_WebPacketProtocol):
|
|||||||
supplier_id: str = Field("", description="소속 공급사 uuid")
|
supplier_id: str = Field("", description="소속 공급사 uuid")
|
||||||
supplier_name: str = Field("", description="공급사명")
|
supplier_name: str = Field("", description="공급사명")
|
||||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||||
|
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
|
||||||
|
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력")
|
||||||
|
guide_notices: list = Field(default_factory=list, description="협상 유의사항 항목(companies.settings.guide_notices). 빈 값이면 포털 기본 문구")
|
||||||
|
|
||||||
|
|
||||||
class Res_Logout(Res_WebPacketProtocol):
|
class Res_Logout(Res_WebPacketProtocol):
|
||||||
@ -64,3 +67,9 @@ class Req_HidePopup(AuthProtocol):
|
|||||||
|
|
||||||
class Res_HidePopup(Res_WebPacketProtocol):
|
class Res_HidePopup(Res_WebPacketProtocol):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Res_SessionBranding(Res_WebPacketProtocol):
|
||||||
|
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
|
||||||
|
logo_url: str = Field("", description="회사 로고 URL")
|
||||||
|
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")
|
||||||
|
|||||||
@ -63,6 +63,7 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
|||||||
session_id: str = Field("", description="협상 세션 uuid")
|
session_id: str = Field("", description="협상 세션 uuid")
|
||||||
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
|
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
|
||||||
quotation_id: str = Field("", description="소속 견적 uuid")
|
quotation_id: str = Field("", description="소속 견적 uuid")
|
||||||
|
qt_number: str = Field("", description="견적번호(EST-...)")
|
||||||
quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)")
|
quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)")
|
||||||
quotation_memo: str = Field("", description="견적 메모")
|
quotation_memo: str = Field("", description="견적 메모")
|
||||||
item_id: str = Field("", description="상품 uuid")
|
item_id: str = Field("", description="상품 uuid")
|
||||||
@ -77,6 +78,10 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
|||||||
item_min_order_quantity: str = Field("", description="최소 주문 수량")
|
item_min_order_quantity: str = Field("", description="최소 주문 수량")
|
||||||
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
||||||
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
||||||
|
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
|
||||||
|
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
|
||||||
|
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")
|
||||||
|
|
||||||
|
|
||||||
# 대화 히스토리(재진입 복원)
|
# 대화 히스토리(재진입 복원)
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||||
@ -14,6 +16,14 @@ class ListItem(WebPacketProtocol):
|
|||||||
item_name: str = Field("", description="상품명")
|
item_name: str = Field("", description="상품명")
|
||||||
model_name: str = Field("", description="모델명")
|
model_name: str = Field("", description="모델명")
|
||||||
maker_name: str = Field("", description="제조사")
|
maker_name: str = Field("", description="제조사")
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict")
|
||||||
|
renegotiable: bool = Field(False, description="재협상 요청 가능 여부 — 낙찰 없이 마감(개찰)된 마지막 차수이고 대기 중 요청이 없을 때만 True")
|
||||||
|
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
|
||||||
|
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
|
||||||
|
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
|
||||||
|
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
|
||||||
|
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
|
||||||
|
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
|
||||||
|
|
||||||
|
|
||||||
class Res_SessionList(Res_WebPacketProtocol):
|
class Res_SessionList(Res_WebPacketProtocol):
|
||||||
@ -29,7 +39,27 @@ class Res_Participate(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Req_Reject(WebPacketProtocol):
|
class Req_Reject(WebPacketProtocol):
|
||||||
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
||||||
|
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
|
||||||
|
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")
|
||||||
|
|
||||||
|
|
||||||
class Res_Reject(Res_WebPacketProtocol):
|
class Res_Reject(Res_WebPacketProtocol):
|
||||||
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class Req_ExtraInfo(WebPacketProtocol):
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값 {key: value} (회사 정의 session_fields 대로)")
|
||||||
|
|
||||||
|
|
||||||
|
class Res_ExtraInfo(Res_WebPacketProtocol):
|
||||||
|
session_id: str = Field("", description="부가정보 저장된 세션 uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class Req_Renegotiation(WebPacketProtocol):
|
||||||
|
reason: str = Field("", max_length=255, description="재협상 요청 사유(프리셋 라벨 또는 직접 입력)")
|
||||||
|
desired_price: Optional[int] = Field(None, description="희망 공급가(원). 담당자 판단 근거로만 쓰인다")
|
||||||
|
|
||||||
|
|
||||||
|
class Res_Renegotiation(Res_WebPacketProtocol):
|
||||||
|
session_id: str = Field("", description="요청이 기록된 세션 uuid")
|
||||||
|
status: int = Field(0, description="요청 상태(RenegotiationStatus): 1=심사중 2=승인 3=반려 4=철회")
|
||||||
|
|||||||
@ -6,7 +6,16 @@ from fastapi.security import HTTPAuthorizationCredentials
|
|||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
||||||
from services.negotiation_service import NegotiationService
|
from services.negotiation_service import NegotiationService
|
||||||
from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList
|
from .protocol import (
|
||||||
|
Req_ExtraInfo,
|
||||||
|
Req_Reject,
|
||||||
|
Req_Renegotiation,
|
||||||
|
Res_ExtraInfo,
|
||||||
|
Res_Participate,
|
||||||
|
Res_Reject,
|
||||||
|
Res_Renegotiation,
|
||||||
|
Res_SessionList,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
@ -26,9 +35,11 @@ async def list_sessions(
|
|||||||
order: Optional[str] = Query(None, description="마감일 전체 정렬: asc(임박순)/desc(여유순). 미지정 시 기본 그룹 정렬('할 일' 우선 → 종료는 하단·최근순). 지정하면 그룹 없이 전체를 마감 기준으로 정렬."),
|
order: Optional[str] = Query(None, description="마감일 전체 정렬: asc(임박순)/desc(여유순). 미지정 시 기본 그룹 정렬('할 일' 우선 → 종료는 하단·최근순). 지정하면 그룹 없이 전체를 마감 기준으로 정렬."),
|
||||||
page: int = Query(1, ge=1, description="페이지 (1부터)"),
|
page: int = Query(1, ge=1, description="페이지 (1부터)"),
|
||||||
page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"),
|
page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"),
|
||||||
|
keyword: Optional[str] = Query(None, description="검색어 — 견적번호·상품명·상품코드 부분일치(대소문자 무시)"),
|
||||||
|
result: Optional[int] = Query(None, description="결과 필터(SessionResult): 1=낙찰 2=미낙찰 3=결렬(개찰). 미지정 시 전체"),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(
|
return RemoveNoneResponse(
|
||||||
await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size)
|
await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size, keyword, result)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -51,7 +62,7 @@ async def participate(
|
|||||||
path="/sessions/{session_id}/reject",
|
path="/sessions/{session_id}/reject",
|
||||||
response_model=Res_Reject,
|
response_model=Res_Reject,
|
||||||
summary="협상 거부",
|
summary="협상 거부",
|
||||||
description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.",
|
description="세션 참여를 거부하거나 진행 중인 협상을 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유·공급 희망 가격·의견을 저장.",
|
||||||
)
|
)
|
||||||
async def reject(
|
async def reject(
|
||||||
session_id: str = Path(description="대상 협상 세션 uuid"),
|
session_id: str = Path(description="대상 협상 세션 uuid"),
|
||||||
@ -60,4 +71,59 @@ async def reject(
|
|||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
service: NegotiationService = Depends(),
|
service: NegotiationService = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
return RemoveNoneResponse(
|
||||||
|
await service.reject(
|
||||||
|
user_info, credentials.credentials, session_id, req.reject_reason, req.reject_price, req.opinion,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
path="/sessions/{session_id}/extra-info",
|
||||||
|
response_model=Res_ExtraInfo,
|
||||||
|
summary="협상완료 부가정보 저장",
|
||||||
|
description="협상 타결(완료) 세션에 부가정보(표준납기/MOQ/발주배수/배송유형 등, 회사 정의 session_fields)를 저장한다. 본인 공급사의 완료 세션만 허용.",
|
||||||
|
)
|
||||||
|
async def save_extra_info(
|
||||||
|
session_id: str = Path(description="대상 협상 세션 uuid"),
|
||||||
|
req: Req_ExtraInfo = ...,
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: NegotiationService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
path="/session/{session_id}/renegotiation",
|
||||||
|
response_model=Res_Renegotiation,
|
||||||
|
summary="재협상 요청",
|
||||||
|
description="낙찰 없이 마감된(개찰) 건에 대해 공급사가 재협상을 요청한다. 담당자 승인 시 다음 라운드가 생성된다. 본인 공급사의 마지막 라운드 세션만 허용.",
|
||||||
|
)
|
||||||
|
async def request_renegotiation(
|
||||||
|
req: Req_Renegotiation,
|
||||||
|
session_id: str = Path(..., description="협상 세션 uuid"),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: NegotiationService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(
|
||||||
|
await service.request_renegotiation(user_info, credentials.credentials, session_id, req)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
path="/session/{session_id}/renegotiation",
|
||||||
|
response_model=Res_Renegotiation,
|
||||||
|
summary="재협상 요청 철회",
|
||||||
|
description="심사 대기(PENDING) 중인 본인 요청을 철회한다.",
|
||||||
|
)
|
||||||
|
async def cancel_renegotiation(
|
||||||
|
session_id: str = Path(..., description="협상 세션 uuid"),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: NegotiationService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(
|
||||||
|
await service.cancel_renegotiation(user_info, credentials.credentials, session_id)
|
||||||
|
)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from router.v1.auth.protocol import (
|
|||||||
Res_Me,
|
Res_Me,
|
||||||
Res_PopupStatus,
|
Res_PopupStatus,
|
||||||
Res_RefreshToken,
|
Res_RefreshToken,
|
||||||
|
Res_SessionBranding,
|
||||||
)
|
)
|
||||||
from router.v1.validator.dependencies import CreateAccessToken, CreateRefreshToken, GetHashedPW, VerifyPW
|
from router.v1.validator.dependencies import CreateAccessToken, CreateRefreshToken, GetHashedPW, VerifyPW
|
||||||
|
|
||||||
@ -250,6 +251,35 @@ class AuthService:
|
|||||||
res.supplier_id = info.supplier_id
|
res.supplier_id = info.supplier_id
|
||||||
res.supplier_name = info.supplier_name
|
res.supplier_name = info.supplier_name
|
||||||
res.role = info.role
|
res.role = info.role
|
||||||
|
# 소속 회사 설정(companies.settings) — 로고/서비스명(branding) + 협상완료 부가필드(session_fields). 실패해도 기본값.
|
||||||
|
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
suppliers.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_company_settings(s, uuid.UUID(info.supplier_id)),
|
||||||
|
)
|
||||||
|
settings = settings or {}
|
||||||
|
res.branding = settings.get("branding") or {}
|
||||||
|
res.session_fields = settings.get("session_fields") or []
|
||||||
|
res.guide_notices = settings.get("guide_notices") or []
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def session_branding(self, session_id: str) -> Res_SessionBranding:
|
||||||
|
"""로그인 전(초청 링크 진입) 화면용 브랜딩. 인증 없이 session_id 로만 조회하며 브랜딩 외 정보는 내리지 않는다."""
|
||||||
|
res = Res_SessionBranding()
|
||||||
|
try:
|
||||||
|
sid = uuid.UUID(session_id)
|
||||||
|
except ValueError:
|
||||||
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
|
return res
|
||||||
|
_e, branding = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
suppliers.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_branding_by_session(s, sid),
|
||||||
|
)
|
||||||
|
branding = branding or {}
|
||||||
|
res.service_name = branding.get("service_name") or ""
|
||||||
|
res.logo_url = branding.get("logo_url") or ""
|
||||||
|
res.helpdesk = branding.get("helpdesk") or []
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
|
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
|
||||||
|
|||||||
@ -24,6 +24,7 @@ from common.logger import LOG
|
|||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.chat_crud import ChatCRUD, IChatCRUD
|
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||||
|
from crud.user_crud import IUserCRUD, UserCRUD
|
||||||
from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||||
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
@ -44,11 +45,13 @@ class ChatService:
|
|||||||
auth: AuthService = Depends(AuthService),
|
auth: AuthService = Depends(AuthService),
|
||||||
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
||||||
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
||||||
|
user_crud: IUserCRUD = Depends(UserCRUD),
|
||||||
agent: IAgentClient = Depends(get_agent_client),
|
agent: IAgentClient = Depends(get_agent_client),
|
||||||
):
|
):
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
self.session_crud = session_crud
|
self.session_crud = session_crud
|
||||||
self.chat_crud = chat_crud
|
self.chat_crud = chat_crud
|
||||||
|
self.user_crud = user_crud
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
|
|
||||||
# ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ----
|
# ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ----
|
||||||
@ -59,6 +62,35 @@ class ChatService:
|
|||||||
digits = "".join(ch for ch in text if ch.isdigit())
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
return int(digits) if digits else None
|
return int(digits) if digits else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_reject(text: Optional[str]) -> dict:
|
||||||
|
"""통일 결렬 폼 제출 문자열 파싱 → {offer_price, reason, opinion}.
|
||||||
|
형식: '공급희망가격-{원}, 합의불가사유-{사유}, 의견-{의견}' (사유는 '기타-{내용}' 가능).
|
||||||
|
의견은 자유서술이라 콤마 포함 가능 → 맨 뒤 '의견-' 기준으로 먼저 떼어낸다."""
|
||||||
|
s = text or ""
|
||||||
|
opinion = None
|
||||||
|
if ", 의견-" in s:
|
||||||
|
s, opinion = s.split(", 의견-", 1)
|
||||||
|
# 폼이 아닌 자유 입력("협상 포기합니다" 등)은 원문이 곧 사유다. 폼 마커가 없으면 가격도 읽지 않는다
|
||||||
|
# — 문장에 섞인 숫자를 희망가로 오인해 저장하는 것을 막는다.
|
||||||
|
if "합의불가사유-" not in s and "공급희망가격-" not in s:
|
||||||
|
return {
|
||||||
|
"offer_price": None,
|
||||||
|
"reason": s.strip()[:255] or None,
|
||||||
|
"opinion": (opinion.strip() or None) if opinion is not None else None,
|
||||||
|
}
|
||||||
|
reason = None
|
||||||
|
if ", 합의불가사유-" in s:
|
||||||
|
price_part, reason = s.split(", 합의불가사유-", 1)
|
||||||
|
else:
|
||||||
|
price_part = s
|
||||||
|
price_digits = "".join(ch for ch in price_part.replace("공급희망가격-", "") if ch.isdigit())
|
||||||
|
return {
|
||||||
|
"offer_price": int(price_digits) if price_digits else None,
|
||||||
|
"reason": reason or None,
|
||||||
|
"opinion": (opinion.strip() or None) if opinion is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||||
if not target_price:
|
if not target_price:
|
||||||
@ -103,13 +135,18 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None, card_uuid=None, card_type=None) -> chats:
|
||||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
||||||
|
# nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재
|
||||||
|
# → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업)
|
||||||
return chats(
|
return chats(
|
||||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||||
sender=ChatSender.BOT.value,
|
sender=ChatSender.BOT.value,
|
||||||
target_price=int(sess.target_price or 0),
|
target_price=int(sess.target_price or 0),
|
||||||
indicator_value=turn.indicator_value,
|
indicator_value=turn.indicator_value,
|
||||||
|
card_id=card_uuid,
|
||||||
|
card_type=card_type if card_uuid else None, # CardType: 1=nego, 2=wild
|
||||||
|
card_used_yn=True if card_uuid else None,
|
||||||
meta={
|
meta={
|
||||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||||
@ -198,15 +235,15 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
||||||
|
|
||||||
# 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙).
|
# 미참여/협상거부 세션도 '결과 보기'로 지난 대화를 열람할 수 있다(중간 이탈·거부로 끝난 건).
|
||||||
# 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다.
|
# 대화 재개는 send() 가 협상중(2)만 허용하므로 여기서 막지 않아도 읽기 전용이다.
|
||||||
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
|
|
||||||
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
await self._ensure_in_progress(sess, quote)
|
||||||
return res
|
|
||||||
|
|
||||||
res.session_id = str(sess.session_id)
|
res.session_id = str(sess.session_id)
|
||||||
res.session_status = sess.status
|
res.session_status = sess.status
|
||||||
res.quotation_id = str(sess.quotation_id)
|
res.quotation_id = str(sess.quotation_id)
|
||||||
|
res.qt_number = sess.qt_number or ""
|
||||||
res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else ""
|
res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else ""
|
||||||
res.quotation_memo = quote.memo or ""
|
res.quotation_memo = quote.memo or ""
|
||||||
res.item_id = str(item.item_id)
|
res.item_id = str(item.item_id)
|
||||||
@ -221,8 +258,65 @@ class ChatService:
|
|||||||
res.item_min_order_quantity = item.moq or ""
|
res.item_min_order_quantity = item.moq or ""
|
||||||
res.item_vat_yn = item.vat_yn
|
res.item_vat_yn = item.vat_yn
|
||||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||||
|
res.custom = sess.custom or {}
|
||||||
|
# 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다.
|
||||||
|
res.reject_reason = sess.reject_reason or ""
|
||||||
|
res.reject_price = sess.reject_price
|
||||||
|
|
||||||
|
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
|
||||||
|
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
|
||||||
|
)
|
||||||
|
res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||||
|
|
||||||
|
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
|
||||||
|
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||||
|
|
||||||
|
# VAT 표기 — 부가세 전체 통일 회사(features.vat_mode)는 상품 잔존값과 무관하게 'VAT 별도' 고정(False).
|
||||||
|
# 상품별 관리 회사가 vat_yn 을 숨겼으면(구 방식) 표기 자체를 생략한다(값 null → 프론트 라벨 생략).
|
||||||
|
if _features.get("vat_mode") == "unified_excluded":
|
||||||
|
res.item_vat_yn = False
|
||||||
|
elif "vat_yn" in _hidden:
|
||||||
|
res.item_vat_yn = None
|
||||||
|
|
||||||
|
# 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field).
|
||||||
|
# agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다.
|
||||||
|
_baseline = _features.get("nego_baseline_field")
|
||||||
|
if _baseline not in ("price", "purchase_price"):
|
||||||
|
# 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻.
|
||||||
|
_baseline = "purchase_price" if ("price" in _hidden and "purchase_price" not in _hidden) else "price"
|
||||||
|
if _baseline == "purchase_price":
|
||||||
|
res.item_price = item.purchase_price or 0
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
async def _ensure_in_progress(self, sess, quote) -> None:
|
||||||
|
"""협상생성(1) 세션을 채팅 진입만으로 협상중(2)으로 전이한다(participate 와 동일 전이).
|
||||||
|
|
||||||
|
negodata 안내 메일/링크는 목록의 참여 버튼을 거치지 않고 chat 으로 바로 들어오는데,
|
||||||
|
오프닝 메시지는 협상중일 때만 seed 되므로 전이가 없으면 빈 채팅으로 멈춘다.
|
||||||
|
마감된 견적은 진입해도 대화가 불가하므로 전이하지 않는다."""
|
||||||
|
if sess.status != SessionStatus.CREATED.value:
|
||||||
|
return
|
||||||
|
if quote is None or quote.status == QuotationStatus.CLOSED.value:
|
||||||
|
return
|
||||||
|
end = quote.end_time
|
||||||
|
if end is not None and end.tzinfo is None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
if end is not None and end < datetime.now(timezone.utc):
|
||||||
|
return
|
||||||
|
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[
|
||||||
|
lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value),
|
||||||
|
lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return
|
||||||
|
sess.status = SessionStatus.IN_PROGRESS.value
|
||||||
|
|
||||||
# ---- messages -------------------------------------------------------
|
# ---- messages -------------------------------------------------------
|
||||||
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
|
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
|
||||||
res = Res_ChatMessages()
|
res = Res_ChatMessages()
|
||||||
@ -239,6 +333,15 @@ class ChatService:
|
|||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# 협상생성 상태로 바로 진입한 경우(메일 링크) 여기서도 전이한다 —
|
||||||
|
# init 과 병렬로 호출돼 init 의 전이를 못 본 채 읽었을 수 있다.
|
||||||
|
if not rows and sess.status == SessionStatus.CREATED.value:
|
||||||
|
_, quote = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
quotations.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||||
|
)
|
||||||
|
await self._ensure_in_progress(sess, quote)
|
||||||
|
|
||||||
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
|
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
|
||||||
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
|
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
|
||||||
opening = await self._seed_opening(sess)
|
opening = await self._seed_opening(sess)
|
||||||
@ -371,8 +474,43 @@ class ChatService:
|
|||||||
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
||||||
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
||||||
|
|
||||||
|
# 카드 번호(turn.card_id) → UUID 변환. 번호 정본 표기(NGC-/WC- prefix)로 종류를 가르고,
|
||||||
|
# prefix 없는 구번호는 step 휴리스틱 폴백. 1차 조회가 비면 반대 테이블 재조회 —
|
||||||
|
# 종결 전술의 와일드카드는 step 이 '가격협상_카운터'(wild 미시작)라 step 만으론 카드가
|
||||||
|
# 영영 null 로 남았다(사용 카드 통계·화면 누락 원인).
|
||||||
|
# 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공)
|
||||||
|
card_uuid = None
|
||||||
|
card_type = None
|
||||||
|
if turn.card_id:
|
||||||
|
number = str(turn.card_id)
|
||||||
|
if number.startswith("WC"):
|
||||||
|
wild_first = True
|
||||||
|
elif number.startswith("NGC"):
|
||||||
|
wild_first = False
|
||||||
|
else:
|
||||||
|
wild_first = bool(turn.step and turn.step.startswith("wild"))
|
||||||
|
|
||||||
|
async def _lookup(wild: bool):
|
||||||
|
if wild:
|
||||||
|
found = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_wild_card_id_by_number(s, number),
|
||||||
|
)
|
||||||
|
return found, 2
|
||||||
|
found = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_nego_card_id_by_number(s, number),
|
||||||
|
)
|
||||||
|
return found, 1
|
||||||
|
|
||||||
|
card_uuid, card_type = await _lookup(wild_first)
|
||||||
|
if card_uuid is None:
|
||||||
|
card_uuid, card_type = await _lookup(not wild_first)
|
||||||
|
if card_uuid is None:
|
||||||
|
card_type = None
|
||||||
|
|
||||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
|
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, card_uuid=card_uuid, card_type=card_type)
|
||||||
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||||
@ -392,10 +530,15 @@ class ChatService:
|
|||||||
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
|
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
|
||||||
else:
|
else:
|
||||||
new_status = SessionStatus.REJECTED.value
|
new_status = SessionStatus.REJECTED.value
|
||||||
|
parsed = self._parse_reject(user_input)
|
||||||
funcs.append(lambda s: self.chat_crud.finalize_session(
|
funcs.append(lambda s: self.chat_crud.finalize_session(
|
||||||
s, sess.session_id, new_status,
|
s, sess.session_id, new_status,
|
||||||
reject_reason=(user_input or None), reject_price=price,
|
reject_reason=parsed["reason"], reject_price=parsed["offer_price"],
|
||||||
))
|
))
|
||||||
|
if parsed["opinion"]:
|
||||||
|
funcs.append(lambda s, op=parsed["opinion"]: self.session_crud.merge_session_custom(
|
||||||
|
s, sess.session_id, sess.supplier_id, {"opinion": op},
|
||||||
|
))
|
||||||
|
|
||||||
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
|
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
@ -505,7 +648,17 @@ class ChatService:
|
|||||||
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
|
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
|
||||||
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
|
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
|
||||||
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
|
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
|
||||||
item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else ""
|
# 회사가 배송유형 보기를 자기 용어로 바꿨으면(settings.labels['delivery_type.N']) 그 단어를 쓴다 —
|
||||||
|
# 협상 중 공급사가 고른 보기와 요약 표기가 갈리지 않도록.
|
||||||
|
item_delivery_label = ""
|
||||||
|
if item and item.delivery_type is not None:
|
||||||
|
_e2, _settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
|
||||||
|
)
|
||||||
|
_labels = (_settings.get("labels") or {}) if _e2 == ErrorType.SUCCESS and _settings else {}
|
||||||
|
item_delivery_label = (_labels.get(f"delivery_type.{item.delivery_type}")
|
||||||
|
or DeliveryType.label_of(item.delivery_type))
|
||||||
|
|
||||||
def _iso(dt):
|
def _iso(dt):
|
||||||
if dt is None:
|
if dt is None:
|
||||||
|
|||||||
@ -1,14 +1,35 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import sessions
|
from common.database.model.models import chats, notifications, sessions
|
||||||
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
from common.enums import (
|
||||||
|
CloseReason,
|
||||||
|
DBWRType,
|
||||||
|
ErrorType,
|
||||||
|
NotificationType,
|
||||||
|
QuotationStatus,
|
||||||
|
RENEGOTIABLE_CLOSE_REASONS,
|
||||||
|
RenegotiationStatus,
|
||||||
|
SessionStatus,
|
||||||
|
)
|
||||||
|
from common.logger import LOG
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
|
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||||
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList
|
from router.v1.negotiation.protocol import (
|
||||||
|
ListItem,
|
||||||
|
Req_ExtraInfo,
|
||||||
|
Req_Renegotiation,
|
||||||
|
Res_ExtraInfo,
|
||||||
|
Res_Participate,
|
||||||
|
Res_Reject,
|
||||||
|
Res_Renegotiation,
|
||||||
|
Res_SessionList,
|
||||||
|
)
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
@ -18,11 +39,20 @@ class NegotiationService:
|
|||||||
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
|
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD)):
|
# 부가정보 입력 폼을 띄우는 요약 말풍선 종류. 이 말풍선이 나온 뒤면 협상은 타결된 것으로 본다.
|
||||||
|
_SUMMARY_BOT_TYPES = ("summaryRSP", "summaryCM")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
auth: AuthService = Depends(AuthService),
|
||||||
|
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
||||||
|
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
||||||
|
):
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
self.session_crud = session_crud
|
self.session_crud = session_crud
|
||||||
|
self.chat_crud = chat_crud
|
||||||
|
|
||||||
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList:
|
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int, keyword: str = None, result: int = None) -> Res_SessionList:
|
||||||
res = Res_SessionList()
|
res = Res_SessionList()
|
||||||
|
|
||||||
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
||||||
@ -38,7 +68,7 @@ class NegotiationService:
|
|||||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
sessions.DBType(),
|
sessions.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size),
|
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size, keyword, result),
|
||||||
)
|
)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
@ -48,31 +78,274 @@ class NegotiationService:
|
|||||||
err_type, total = await DB_SESSION_MNG.execute_lambda(
|
err_type, total = await DB_SESSION_MNG.execute_lambda(
|
||||||
sessions.DBType(),
|
sessions.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type),
|
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type, keyword, result),
|
||||||
)
|
)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
res.items = [
|
# 같은 견적번호(체인)의 최대 차수 — 이미 다음 라운드가 있으면 재협상 요청 대상이 아니다.
|
||||||
ListItem(
|
max_rounds: dict = {}
|
||||||
session_id=str(r[0]),
|
for number in {r[3] for r in rows if r[3]}:
|
||||||
session_status=r[1],
|
_e, mx = await DB_SESSION_MNG.execute_lambda(
|
||||||
qt_type=r[2],
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
qt_number=r[3],
|
lambda s, n=number: self.session_crud.chain_max_round(s, n),
|
||||||
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
|
|
||||||
item_code=r[5] or "",
|
|
||||||
item_name=r[6] or "",
|
|
||||||
model_name=r[7] or "",
|
|
||||||
maker_name=r[8] or "",
|
|
||||||
)
|
)
|
||||||
for r in rows
|
max_rounds[number] = mx or 0
|
||||||
]
|
|
||||||
|
res.items = [self._to_list_item(r, max_rounds) for r in rows]
|
||||||
res.total = total
|
res.total = total
|
||||||
res.page = page
|
res.page = page
|
||||||
res.page_size = page_size
|
res.page_size = page_size
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
async def save_extra_info(self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_ExtraInfo) -> Res_ExtraInfo:
|
||||||
|
"""협상완료(타결) 부가정보 저장. 견적 마감 여부와 무관하게, 본인 공급사의 '협상완료' 세션에만 허용.
|
||||||
|
|
||||||
|
_load_actionable_session 은 견적마감·마감시간을 막으므로(타결 후엔 마감됐을 수 있음) 쓰지 않고 직접 검증한다.
|
||||||
|
"""
|
||||||
|
res = Res_ExtraInfo()
|
||||||
|
|
||||||
|
# 1) 인증
|
||||||
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(session_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 2) 세션 조회 + 소유 검증
|
||||||
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or sess is None:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
if str(sess.supplier_id) != info.supplier_id:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 3) 협상완료(타결) 세션만 부가정보 입력 허용.
|
||||||
|
# 단 '협상완료' 요약 말풍선은 chat_end=false 라 세션이 아직 협상중(2)이다
|
||||||
|
# (동의 → '협상종료' 턴에서야 완료로 전이). 폼은 요약 시점에 뜨므로 그 구간도 허용한다.
|
||||||
|
if sess.status != SessionStatus.DONE.value:
|
||||||
|
if sess.status != SessionStatus.IN_PROGRESS.value or not await self._is_after_summary(sess.session_id):
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 4) 저장(supplier_id 가드 crud)
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.session_crud.update_session_custom(s, session_id, uuid.UUID(info.supplier_id), req.custom or {})],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.session_id = str(session_id)
|
||||||
|
return res
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_list_item(r, max_rounds: dict) -> ListItem:
|
||||||
|
"""세션 행 → 목록 아이템. 재협상 요청 가능 여부는 서버가 판정해 내려준다(프론트가 규칙을 몰라도 되게)."""
|
||||||
|
custom = r[9] or {}
|
||||||
|
renego = custom.get("renegotiation") or {}
|
||||||
|
status = renego.get("status") or 0
|
||||||
|
|
||||||
|
is_last_round = (r[12] or 0) >= max_rounds.get(r[3], 0)
|
||||||
|
renegotiable = (
|
||||||
|
r[10] == QuotationStatus.CLOSED.value
|
||||||
|
and r[11] in RENEGOTIABLE_CLOSE_REASONS
|
||||||
|
and is_last_round
|
||||||
|
and status
|
||||||
|
not in (
|
||||||
|
RenegotiationStatus.PENDING.value,
|
||||||
|
RenegotiationStatus.APPROVED.value,
|
||||||
|
RenegotiationStatus.REJECTED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ListItem(
|
||||||
|
session_id=str(r[0]),
|
||||||
|
session_status=r[1],
|
||||||
|
qt_type=r[2],
|
||||||
|
qt_number=r[3],
|
||||||
|
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
|
||||||
|
item_code=r[5] or "",
|
||||||
|
item_name=r[6] or "",
|
||||||
|
model_name=r[7] or "",
|
||||||
|
maker_name=r[8] or "",
|
||||||
|
custom=custom,
|
||||||
|
renegotiable=renegotiable,
|
||||||
|
renegotiation_status=status,
|
||||||
|
renegotiation_memo=renego.get("memo") or "",
|
||||||
|
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
|
||||||
|
has_chat=bool(r[15]),
|
||||||
|
reject_reason=r[16] or "",
|
||||||
|
reject_price=r[17],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_result(qt_status, close_reason, winner_id, my_id) -> int:
|
||||||
|
"""공급사 관점 협상 결과(SessionResult). 견적 마감 전이면 0(미정).
|
||||||
|
낙찰 건은 낙찰자가 나면 1(낙찰)·아니면 2(미낙찰), 개찰(OPEN_*) 마감은 3(결렬=재협상 대상)."""
|
||||||
|
if qt_status != QuotationStatus.CLOSED.value:
|
||||||
|
return 0
|
||||||
|
if close_reason == CloseReason.AWARDED.value:
|
||||||
|
return 1 if winner_id is not None and str(winner_id) == str(my_id) else 2
|
||||||
|
if close_reason in RENEGOTIABLE_CLOSE_REASONS:
|
||||||
|
return 3
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def request_renegotiation(
|
||||||
|
self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_Renegotiation
|
||||||
|
) -> Res_Renegotiation:
|
||||||
|
"""결렬(개찰) 마감 건에 대해 공급사가 재협상을 요청한다(IMK #15).
|
||||||
|
전용 테이블 없이 sessions.custom.renegotiation 에 기록하고, 견적 작성자에게 알림을 남긴다."""
|
||||||
|
res = Res_Renegotiation()
|
||||||
|
|
||||||
|
err_type, info, sess, quote = await self._load_renegotiable(user_info, access_token, session_id_str)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 심사 대기·승인·반려 건은 재요청을 막는다(전용 테이블이 없어 유니크 대신 여기서 검증).
|
||||||
|
# 반려는 담당자가 이미 판단한 결과라 같은 건으로 다시 올릴 수 없다. 철회(CANCELED)만 재요청 허용.
|
||||||
|
current = (sess.custom or {}).get("renegotiation") or {}
|
||||||
|
if current.get("status") in (
|
||||||
|
RenegotiationStatus.PENDING.value,
|
||||||
|
RenegotiationStatus.APPROVED.value,
|
||||||
|
RenegotiationStatus.REJECTED.value,
|
||||||
|
):
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||||
|
return res
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"status": RenegotiationStatus.PENDING.value,
|
||||||
|
"reason": (req.reason or "").strip(),
|
||||||
|
"desired_price": req.desired_price,
|
||||||
|
"requested_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.session_crud.merge_session_custom(
|
||||||
|
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": payload}
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
await self._notify_renegotiation(quote, sess, info, payload)
|
||||||
|
res.session_id = str(sess.session_id)
|
||||||
|
res.status = RenegotiationStatus.PENDING.value
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def cancel_renegotiation(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Renegotiation:
|
||||||
|
"""공급사가 자기 요청을 철회한다. 심사 대기(PENDING) 중에만 가능."""
|
||||||
|
res = Res_Renegotiation()
|
||||||
|
|
||||||
|
err_type, info, sess, _quote = await self._load_renegotiable(user_info, access_token, session_id_str)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
current = (sess.custom or {}).get("renegotiation") or {}
|
||||||
|
if current.get("status") != RenegotiationStatus.PENDING.value:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||||
|
return res
|
||||||
|
|
||||||
|
patch = {**current, "status": RenegotiationStatus.CANCELED.value}
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.session_crud.merge_session_custom(
|
||||||
|
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": patch}
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.session_id = str(sess.session_id)
|
||||||
|
res.status = RenegotiationStatus.CANCELED.value
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def _load_renegotiable(self, user_info: UserInfo, access_token: str, session_id_str: str):
|
||||||
|
"""재협상 요청 자격 검증 — 인증 → 본인 세션 → 결렬(개찰) 마감 → 마지막 라운드.
|
||||||
|
성공 시 (SUCCESS, info, sess, quote)."""
|
||||||
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, None, None, None
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(session_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ErrorType.NEGO_NOT_FOUND, None, None, None
|
||||||
|
|
||||||
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or sess is None:
|
||||||
|
return ErrorType.NEGO_NOT_FOUND, None, None, None
|
||||||
|
if str(sess.supplier_id) != info.supplier_id:
|
||||||
|
return ErrorType.NEGO_FORBIDDEN, None, None, None
|
||||||
|
|
||||||
|
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or quote is None:
|
||||||
|
return ErrorType.NEGO_NOT_FOUND, None, None, None
|
||||||
|
|
||||||
|
# 낙찰됐거나 아직 진행 중인 건은 요청 대상이 아니다.
|
||||||
|
if quote.status != QuotationStatus.CLOSED.value or quote.close_reason not in RENEGOTIABLE_CLOSE_REASONS:
|
||||||
|
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
|
||||||
|
|
||||||
|
# 이미 다음 라운드가 만들어졌으면 요청할 이유가 없다.
|
||||||
|
_e, max_round = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.chain_max_round(s, quote.number),
|
||||||
|
)
|
||||||
|
if max_round and quote.round < max_round:
|
||||||
|
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
|
||||||
|
|
||||||
|
return ErrorType.SUCCESS, info, sess, quote
|
||||||
|
|
||||||
|
async def _notify_renegotiation(self, quote, sess, info, payload: dict) -> None:
|
||||||
|
"""견적 작성자 인박스에 재협상 요청 알림을 남긴다. 부가 효과라 실패해도 본 흐름을 막지 않는다."""
|
||||||
|
notif = notifications(
|
||||||
|
user_id=quote.user_id,
|
||||||
|
type=NotificationType.RENEGO_REQUESTED.value,
|
||||||
|
ref_qt_id=quote.qt_id,
|
||||||
|
ref_session_id=sess.session_id,
|
||||||
|
data={
|
||||||
|
"supplier_name": info.supplier_name,
|
||||||
|
"qt_number": quote.number,
|
||||||
|
"qt_round": quote.round,
|
||||||
|
"reason": payload.get("reason"),
|
||||||
|
"desired_price": payload.get("desired_price"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[notifications.DBType()],
|
||||||
|
[lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)],
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
LOG.e_no_callstack(f"[renego] 알림 기록 실패 qt={quote.qt_id} session={sess.session_id}")
|
||||||
|
|
||||||
|
async def _is_after_summary(self, session_id) -> bool:
|
||||||
|
"""마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지."""
|
||||||
|
err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_last(s, session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or not last_meta:
|
||||||
|
return False
|
||||||
|
return last_meta.get("bot_chat_type") in self._SUMMARY_BOT_TYPES
|
||||||
|
|
||||||
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
||||||
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
||||||
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
||||||
@ -165,7 +438,10 @@ class NegotiationService:
|
|||||||
res.session_id = str(sess.session_id)
|
res.session_id = str(sess.session_id)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject:
|
async def reject(
|
||||||
|
self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str,
|
||||||
|
reject_price: Optional[int] = None, opinion: Optional[str] = None,
|
||||||
|
) -> Res_Reject:
|
||||||
res = Res_Reject()
|
res = Res_Reject()
|
||||||
|
|
||||||
# 거부 사유 필수
|
# 거부 사유 필수
|
||||||
@ -185,11 +461,19 @@ class NegotiationService:
|
|||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
|
# 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장.
|
||||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
# 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리.
|
||||||
[sessions.DBType()],
|
funcs = [
|
||||||
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
|
lambda s: self.session_crud.update_session_reject(
|
||||||
)
|
s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
note = (opinion or "").strip()[:255]
|
||||||
|
if note:
|
||||||
|
funcs.append(
|
||||||
|
lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note})
|
||||||
|
)
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|||||||
@ -220,12 +220,58 @@ async def test_chat_init_returns_meta(client, chat_seed):
|
|||||||
assert body["quotation_end_time"] # 타이머용 마감 시각
|
assert body["quotation_end_time"] # 타이머용 마감 시각
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_init_returns_reject_detail(client, chat_seed, db_engine):
|
||||||
|
"""검증: 협상 거부로 끝난 세션에 재진입('결과 보기')했을 때의 init 응답.
|
||||||
|
기대결과: 대화에 남지 않는 제출 내역(reject_reason·reject_price)이 실려 열람 카드를 그릴 수 있다."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = chat_seed["sids"]["P"]
|
||||||
|
await client.post(
|
||||||
|
f"/v1/negotiation/sessions/{sid}/reject",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"reject_reason": "단종", "reject_price": 91000, "opinion": "후속 모델로 제안 가능합니다"},
|
||||||
|
)
|
||||||
|
body = (await _init(client, token, sid)).json()
|
||||||
|
assert body["session_status"] == 5
|
||||||
|
assert body["reject_reason"] == "단종"
|
||||||
|
assert body["reject_price"] == 91000
|
||||||
|
assert body["custom"]["opinion"] == "후속 모델로 제안 가능합니다"
|
||||||
|
|
||||||
|
|
||||||
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
||||||
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_init_vat_mode_unified_shows_excluded(client, chat_seed, db_engine):
|
||||||
|
"""검증: 부가세 전체 통일 회사(features.vat_mode=unified_excluded)의 세션 채팅 init.
|
||||||
|
기대결과: 상품에 vat_yn=true 잔존값이 있어도 item_vat_yn=False — 프론트가 'VAT별도'로 고정 표기."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
company_id = uuid.uuid4()
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO company.companies (company_id, name, status, settings) VALUES (:c, :n, 1, CAST(:s AS JSONB))"),
|
||||||
|
{"c": company_id, "n": f"{MARK}VAT통일사", "s": json.dumps({"features": {"vat_mode": "unified_excluded"}})},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("UPDATE partner.suppliers SET company_id = :c WHERE supplier_id = :sid"),
|
||||||
|
{"c": company_id, "sid": chat_seed["supplier_id"]},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("UPDATE partner.items SET vat_yn = true WHERE item_id = (SELECT item_id FROM negotiation.sessions WHERE session_id = :s)"),
|
||||||
|
{"s": chat_seed["sids"]["P"]},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
|
||||||
|
assert body["result"]["success"] is True
|
||||||
|
assert body["item_vat_yn"] is False
|
||||||
|
finally:
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(text("DELETE FROM company.companies WHERE company_id = :c"), {"c": company_id})
|
||||||
|
|
||||||
|
|
||||||
# ---- messages (오프닝 seed) -------------------------------------------------
|
# ---- messages (오프닝 seed) -------------------------------------------------
|
||||||
async def test_messages_seeds_opening(client, chat_seed):
|
async def test_messages_seeds_opening(client, chat_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
@ -351,23 +397,28 @@ async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine)
|
|||||||
|
|
||||||
|
|
||||||
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
|
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
|
||||||
|
"""검증: 마감시간이 지난 협상생성 세션으로 채팅 진입.
|
||||||
|
기대결과: DB 상태가 미참여(4)로 정리되고, init 자체는 열람용으로 성공한다."""
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
|
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
|
||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
||||||
body = (await _init(client, token, sid)).json()
|
body = (await _init(client, token, sid)).json()
|
||||||
# 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다.
|
assert body["result"]["success"] is True
|
||||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
assert body["session_status"] == 4
|
||||||
assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨
|
assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이
|
||||||
|
|
||||||
|
|
||||||
async def test_init_blocks_rejected_session(client, chat_seed, db_engine):
|
async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine):
|
||||||
|
"""검증: 협상거부(5)로 끝난 세션에 '결과 보기'로 재진입.
|
||||||
|
기대결과: init 성공(열람 허용) — 대화 재개는 send 가 협상중만 허용해 막는다."""
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
sid = chat_seed["sids"]["P"]
|
sid = chat_seed["sids"]["P"]
|
||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
|
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
|
||||||
body = (await _init(client, token, sid)).json()
|
body = (await _init(client, token, sid)).json()
|
||||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단
|
assert body["result"]["success"] is True and body["session_status"] == 5
|
||||||
|
assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
|
||||||
|
|
||||||
|
|
||||||
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
||||||
|
|||||||
@ -16,6 +16,35 @@ TEST_SUPPLIER_NAME = "파이테스트협상공급사"
|
|||||||
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup):
|
||||||
|
"""상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다.
|
||||||
|
hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id)."""
|
||||||
|
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||||
|
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
||||||
|
),
|
||||||
|
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
||||||
|
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
||||||
|
),
|
||||||
|
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO negotiation.sessions "
|
||||||
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
||||||
|
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
||||||
|
),
|
||||||
|
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||||
|
)
|
||||||
|
return session_id, qt_id
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def nego_seed(db_engine):
|
async def nego_seed(db_engine):
|
||||||
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
||||||
@ -33,6 +62,11 @@ async def nego_seed(db_engine):
|
|||||||
sids, qids = {}, {}
|
sids, qids = {}, {}
|
||||||
|
|
||||||
async def _cleanup(conn):
|
async def _cleanup(conn):
|
||||||
|
# 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다).
|
||||||
|
await conn.execute(text(
|
||||||
|
f"DELETE FROM negotiation.chats WHERE session_id IN "
|
||||||
|
f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"
|
||||||
|
))
|
||||||
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
||||||
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
||||||
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
||||||
@ -52,31 +86,9 @@ async def nego_seed(db_engine):
|
|||||||
),
|
),
|
||||||
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||||
)
|
)
|
||||||
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
|
for spec in specs:
|
||||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
code = spec[0]
|
||||||
sids[code], qids[code] = session_id, qt_id
|
sids[code], qids[code] = await _seed_case(conn, *spec)
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
|
||||||
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
|
||||||
),
|
|
||||||
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
|
||||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
|
||||||
),
|
|
||||||
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"INSERT INTO negotiation.sessions "
|
|
||||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
|
||||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
|
||||||
),
|
|
||||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
|
||||||
)
|
|
||||||
|
|
||||||
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
||||||
|
|
||||||
@ -141,6 +153,63 @@ async def test_list_filter_status(client, nego_seed):
|
|||||||
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||||
|
"""검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등).
|
||||||
|
기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"])
|
||||||
|
token = await _login_token(client)
|
||||||
|
|
||||||
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1")
|
||||||
|
assert listed["session_status"] == 4
|
||||||
|
|
||||||
|
waiting = (await _list(client, token, status=1)).json()
|
||||||
|
assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"}
|
||||||
|
assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||||
|
"""검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션.
|
||||||
|
기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로)."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"])
|
||||||
|
token = await _login_token(client)
|
||||||
|
|
||||||
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE")
|
||||||
|
assert listed["session_status"] == 4
|
||||||
|
assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed):
|
||||||
|
"""검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인.
|
||||||
|
기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정)."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"])
|
||||||
|
await conn.execute(text(
|
||||||
|
f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'"))
|
||||||
|
# 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다.
|
||||||
|
await conn.execute(text(
|
||||||
|
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) "
|
||||||
|
f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))"))
|
||||||
|
token = await _login_token(client)
|
||||||
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN")
|
||||||
|
assert listed["result"] == 3 and listed["renegotiable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed):
|
||||||
|
"""검증: 대화 이력이 있는 세션과 없는 세션의 has_chat.
|
||||||
|
기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다."""
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"),
|
||||||
|
{"sid": nego_seed["sids"]["C"]},
|
||||||
|
)
|
||||||
|
token = await _login_token(client)
|
||||||
|
by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]}
|
||||||
|
assert by_code[f"{MARK}C"] is True
|
||||||
|
assert by_code[f"{MARK}A"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_list_filter_qt_type(client, nego_seed):
|
async def test_list_filter_qt_type(client, nego_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
body = (await _list(client, token, qt_type=2)).json()
|
body = (await _list(client, token, qt_type=2)).json()
|
||||||
@ -177,6 +246,56 @@ async def test_list_requires_auth(client):
|
|||||||
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
|
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 검색(keyword) ----------------------------------------------------------
|
||||||
|
async def test_search_by_qt_number_and_item_code(client, nego_seed):
|
||||||
|
"""검증: 견적번호/상품코드가 같은 값(PYTESTNEGO-B)으로 검색.
|
||||||
|
기대결과: B 1건만, total 도 1(카운트도 같은 필터 적용)."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, keyword=f"{MARK}B")).json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert [i["item_code"] for i in body["items"]] == [f"{MARK}B"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_by_item_name(client, nego_seed):
|
||||||
|
"""검증: 상품명 일부('상품 A')로 검색.
|
||||||
|
기대결과: A 1건만."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, keyword="상품 A")).json()
|
||||||
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_prefix_matches_all_own(client, nego_seed):
|
||||||
|
"""검증: 공통 prefix(PYTESTNEGO)로 검색.
|
||||||
|
기대결과: 본인 공급사 3건 전부(타 공급사 X 는 제외 유지)."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, keyword=MARK.rstrip("-"))).json()
|
||||||
|
assert body["total"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_case_insensitive(client, nego_seed):
|
||||||
|
"""검증: 소문자로 검색(pytestnego-c).
|
||||||
|
기대결과: ILIKE 라 대소문자 무시하고 C 매칭."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, keyword=f"{MARK}c".lower())).json()
|
||||||
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}C"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_no_match_returns_empty(client, nego_seed):
|
||||||
|
"""검증: 어디에도 없는 검색어.
|
||||||
|
기대결과: 0건, total 0."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, keyword="존재하지않는검색어zzz")).json()
|
||||||
|
assert body["total"] == 0 and body["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_wildcard_is_escaped(client, nego_seed):
|
||||||
|
"""검증: ILIKE 와일드카드('%')를 그대로 검색 — 패턴으로 새면 전건 매칭될 위험.
|
||||||
|
기대결과: escape 되어 리터럴 '%' 로 취급 → 매칭 0건."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, keyword="%")).json()
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
# ---- 참여 -------------------------------------------------------------------
|
# ---- 참여 -------------------------------------------------------------------
|
||||||
async def test_participate_success(client, nego_seed, db_engine):
|
async def test_participate_success(client, nego_seed, db_engine):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
@ -266,6 +385,65 @@ async def test_reject_success(client, nego_seed, db_engine):
|
|||||||
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_with_price_and_opinion(client, nego_seed, db_engine):
|
||||||
|
# 채팅 내 협상 거부 경로 — 사유 외에 공급 희망 가격과 의견까지 함께 남긴다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = nego_seed["sids"]["B"]
|
||||||
|
r = await client.post(
|
||||||
|
f"/v1/negotiation/sessions/{sid}/reject",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"reject_reason": "품절", "reject_price": 88000, "opinion": "대체품으로 재견적 부탁드립니다"},
|
||||||
|
)
|
||||||
|
assert r.json()["result"]["success"] is True
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT status, reject_reason, reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
||||||
|
{"sid": sid},
|
||||||
|
)).first()
|
||||||
|
assert row.status == 5 and row.reject_reason == "품절"
|
||||||
|
assert row.reject_price == 88000
|
||||||
|
assert row.custom["opinion"] == "대체품으로 재견적 부탁드립니다"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
|
||||||
|
# 목록 거부 경로 — 가격이 없으면 reject_price 를 건드리지 않는다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = nego_seed["sids"]["B"]
|
||||||
|
r = await _reject(client, token, sid, "단종")
|
||||||
|
assert r.json()["result"]["success"] is True
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
||||||
|
{"sid": sid},
|
||||||
|
)).first()
|
||||||
|
assert row.reject_price is None and row.custom is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_returns_reject_detail(client, nego_seed):
|
||||||
|
# 거부 제출 내역은 대화에 남지 않는다 — 목록이 사유·희망가를 실어야 '거부 내역'을 열람할 수 있다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = nego_seed["sids"]["B"]
|
||||||
|
await client.post(
|
||||||
|
f"/v1/negotiation/sessions/{sid}/reject",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"reject_reason": "품절", "reject_price": 77000, "opinion": "재고 확보 후 연락드리겠습니다"},
|
||||||
|
)
|
||||||
|
items = (await _list(client, token)).json()["items"]
|
||||||
|
row = next(i for i in items if i["session_id"] == str(sid))
|
||||||
|
assert row["reject_reason"] == "품절"
|
||||||
|
assert row["reject_price"] == 77000
|
||||||
|
assert row["custom"]["opinion"] == "재고 확보 후 연락드리겠습니다"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_reject_detail_empty_for_active(client, nego_seed):
|
||||||
|
# 거부 건이 아니면 빈 값 — 프론트가 '거부 내역' 버튼 노출을 상태로만 판단하므로 값이 새면 안 된다.
|
||||||
|
token = await _login_token(client)
|
||||||
|
items = (await _list(client, token)).json()["items"]
|
||||||
|
row = next(i for i in items if i["session_id"] == str(nego_seed["sids"]["A"]))
|
||||||
|
assert row["reject_reason"] == ""
|
||||||
|
assert row.get("reject_price") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_reject_empty_reason(client, nego_seed):
|
async def test_reject_empty_reason(client, nego_seed):
|
||||||
token = await _login_token(client)
|
token = await _login_token(client)
|
||||||
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
||||||
@ -294,3 +472,76 @@ async def test_reject_requires_auth(client, nego_seed):
|
|||||||
sid = nego_seed["sids"]["B"]
|
sid = nego_seed["sids"]["B"]
|
||||||
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
|
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
|
||||||
assert r.status_code in (401, 403)
|
assert r.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 결과 필터(result) ------------------------------------------------------
|
||||||
|
# 마감(CLOSED) + 마감사유/낙찰자로 낙찰(1)·미낙찰(2)·결렬(3)을 만들고 result= 로 거른다.
|
||||||
|
# nego_seed 의 공급사/로그인을 재사용하고, MARK prefix 라 픽스처 teardown 이 함께 정리한다.
|
||||||
|
async def _seed_result_row(engine, *, supplier_id, code, close_reason, winner_id):
|
||||||
|
import uuid as _uuid
|
||||||
|
item_id, qt_id, session_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||||
|
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 'M', '제조사')"),
|
||||||
|
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}"},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO quotation.quotations "
|
||||||
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
|
||||||
|
" preferred_sp_id, round, start_time, end_time) VALUES "
|
||||||
|
"(:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 2, 3, :cr, "
|
||||||
|
" :win, 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"),
|
||||||
|
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "cr": close_reason, "win": winner_id},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO negotiation.sessions "
|
||||||
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||||
|
" target_price, status, bid_price, end_time) VALUES "
|
||||||
|
"(:sid, :qid, :iid, :sup, :num, 1, 2, 100000, 3, 95000, now() - make_interval(hours => 1))"),
|
||||||
|
{"sid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id, "num": f"{MARK}{code}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def result_rows(nego_seed, db_engine):
|
||||||
|
"""nego_seed 공급사에 낙찰/미낙찰/결렬 각 1건을 추가한다(개찰 5=OPEN_PRICE, 1=AWARDED)."""
|
||||||
|
sup = nego_seed["supplier_id"]
|
||||||
|
await _seed_result_row(db_engine, supplier_id=sup, code="RWON", close_reason=1, winner_id=sup) # 낙찰(나)
|
||||||
|
await _seed_result_row(db_engine, supplier_id=sup, code="RLOST", close_reason=1, winner_id=uuid.uuid4()) # 미낙찰(남)
|
||||||
|
await _seed_result_row(db_engine, supplier_id=sup, code="ROPEN", close_reason=5, winner_id=None) # 결렬(개찰)
|
||||||
|
return nego_seed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_result_filter_won(client, result_rows):
|
||||||
|
"""검증: result=1(낙찰)로 필터. 기대결과: 낙찰 건만, total=1."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, result=1)).json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["items"][0]["item_code"] == f"{MARK}RWON"
|
||||||
|
assert body["items"][0]["result"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_result_filter_lost(client, result_rows):
|
||||||
|
"""검증: result=2(미낙찰)로 필터. 기대결과: 미낙찰 건만."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, result=2)).json()
|
||||||
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}RLOST"}
|
||||||
|
assert body["items"][0]["result"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_result_filter_open(client, result_rows):
|
||||||
|
"""검증: result=3(결렬)로 필터. 기대결과: 개찰 결렬 건만 + 재협상 대상(renegotiable=True)."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _list(client, token, result=3)).json()
|
||||||
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}ROPEN"}
|
||||||
|
assert body["items"][0]["result"] == 3
|
||||||
|
assert body["items"][0]["renegotiable"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_result_filter_composes_with_paging(client, result_rows):
|
||||||
|
"""검증: 결과 필터가 total(페이징)에 반영. 기대결과: result=1 이면 total=1(전체 목록과 별개)."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
all_total = (await _list(client, token)).json()["total"]
|
||||||
|
won_total = (await _list(client, token, result=1)).json()["total"]
|
||||||
|
assert won_total == 1 and all_total > won_total
|
||||||
|
|||||||
215
backend/tests/test_renegotiation.py
Normal file
215
backend/tests/test_renegotiation.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"""공급사 재협상 요청/철회(IMK #15) 포털 e2e — 요청 접수 + 철회.
|
||||||
|
|
||||||
|
담당자 심사(승인/반려)는 negodata 백엔드 몫이고, 여기(포털)는 공급사가
|
||||||
|
sessions.custom.renegotiation 에 요청을 남기고(PENDING) 스스로 철회(CANCELED)하는 절반을 본다:
|
||||||
|
· 개찰(OPEN_*) 마감 + 본인 마지막 라운드 세션 → 요청 기록(PENDING) + 담당자 알림
|
||||||
|
· 낙찰(AWARDED) 건 → 요청 거부
|
||||||
|
· 남의 공급사 세션 → 거부(FORBIDDEN)
|
||||||
|
· 이미 대기 중인데 재요청 → 거부(중복 방지)
|
||||||
|
· 대기 중 철회 → CANCELED, 이후 재요청 허용
|
||||||
|
|
||||||
|
dev negosium_db 를 그대로 쓰므로(APP_ENV=local) 전용 테스트 행만 시드하고 끝나면 지운다.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from common.enums import CloseReason, QuotationStatus, RenegotiationStatus, SessionStatus
|
||||||
|
|
||||||
|
TEST_LOGIN_ID = "pytest_renego_user"
|
||||||
|
TEST_PW = "pytest1234"
|
||||||
|
TEST_SUPPLIER_NAME = "파이테스트재협상공급사"
|
||||||
|
MARK = "PYTESTRENEGO-" # 시드 식별용 prefix (item code / qt number)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def renego_seed(db_engine):
|
||||||
|
"""공급사 + 로그인유저 + 재협상 후보 세션들을 시드하고 (supplier_id, sids, uids) 반환.
|
||||||
|
|
||||||
|
(code, quotation.status, close_reason, 소속 공급사) — 요청 자격은 견적 마감사유·소유로 갈린다.
|
||||||
|
"""
|
||||||
|
supplier_id = uuid.uuid4()
|
||||||
|
other_supplier_id = uuid.uuid4()
|
||||||
|
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
specs = [
|
||||||
|
("OPEN", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, supplier_id), # 개찰 → 요청 가능
|
||||||
|
("AWARD", QuotationStatus.CLOSED.value, CloseReason.AWARDED.value, supplier_id), # 낙찰 → 불가
|
||||||
|
("OTHER", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, other_supplier_id), # 남의 공급사
|
||||||
|
]
|
||||||
|
sids, uids = {}, {}
|
||||||
|
|
||||||
|
async def _cleanup(conn):
|
||||||
|
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text(f"DELETE FROM company.notifications WHERE ref_qt_id IN "
|
||||||
|
f"(SELECT qt_id FROM quotation.quotations WHERE number LIKE '{MARK}%')"))
|
||||||
|
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
||||||
|
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
||||||
|
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _cleanup(conn)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) "
|
||||||
|
"VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
|
||||||
|
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
|
||||||
|
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"),
|
||||||
|
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||||
|
)
|
||||||
|
for code, quote_st, close_reason, sup in specs:
|
||||||
|
item_id, qt_id, session_id, user_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||||
|
sids[code], uids[code] = session_id, user_id
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||||
|
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"),
|
||||||
|
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO quotation.quotations "
|
||||||
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
|
||||||
|
" round, start_time, end_time) VALUES "
|
||||||
|
"(:qid, :uid, gen_random_uuid(), gen_random_uuid(), :name, :num, 2, :st, :cr, "
|
||||||
|
" 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"),
|
||||||
|
{"qid": qt_id, "uid": user_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "st": quote_st, "cr": close_reason},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO negotiation.sessions "
|
||||||
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||||
|
" target_price, status, bid_price, end_time) VALUES "
|
||||||
|
"(:sesid, :qid, :iid, :sup, :qtn, 1, 2, 100000, :sst, 95000, now() - make_interval(hours => 1))"),
|
||||||
|
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "sst": SessionStatus.DONE.value},
|
||||||
|
)
|
||||||
|
|
||||||
|
yield {"supplier_id": supplier_id, "sids": sids, "uids": uids}
|
||||||
|
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _cleanup(conn)
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_token(client):
|
||||||
|
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _request(client, token, session_id, *, reason="가격 재검토", desired_price=90000):
|
||||||
|
return await client.post(
|
||||||
|
f"/v1/negotiation/session/{session_id}/renegotiation",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"reason": reason, "desired_price": desired_price},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cancel(client, token, session_id):
|
||||||
|
return await client.delete(
|
||||||
|
f"/v1/negotiation/session/{session_id}/renegotiation",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _renego(db_engine, session_id):
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT custom FROM negotiation.sessions WHERE session_id = :sid"),
|
||||||
|
{"sid": session_id},
|
||||||
|
)).scalar()
|
||||||
|
return (row or {}).get("renegotiation") or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notif_count(db_engine, qt_number):
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
return (await conn.execute(
|
||||||
|
text("SELECT count(*) FROM company.notifications WHERE ref_qt_id IN "
|
||||||
|
"(SELECT qt_id FROM quotation.quotations WHERE number = :num)"),
|
||||||
|
{"num": qt_number},
|
||||||
|
)).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 요청 -------------------------------------------------------------------
|
||||||
|
async def test_request_records_pending(client, renego_seed, db_engine):
|
||||||
|
"""검증: 개찰(OPEN_PRICE) 마감 + 본인 마지막 라운드 세션에 재협상 요청.
|
||||||
|
기대결과: success + PENDING 기록(사유·희망가 저장) + 담당자 알림 1건."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = renego_seed["sids"]["OPEN"]
|
||||||
|
|
||||||
|
body = (await _request(client, token, sid, reason="원자재 인상 반영", desired_price=88000)).json()
|
||||||
|
|
||||||
|
assert body["result"]["success"] is True
|
||||||
|
assert body["status"] == RenegotiationStatus.PENDING.value
|
||||||
|
saved = await _renego(db_engine, sid)
|
||||||
|
assert saved["status"] == RenegotiationStatus.PENDING.value
|
||||||
|
assert saved["reason"] == "원자재 인상 반영"
|
||||||
|
assert saved["desired_price"] == 88000
|
||||||
|
assert await _notif_count(db_engine, f"{MARK}OPEN") == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_twice_blocked(client, renego_seed, db_engine):
|
||||||
|
"""검증: 이미 대기(PENDING) 요청이 있는 세션에 다시 요청.
|
||||||
|
기대결과: 2번째는 거부(중복 방지) + 상태는 여전히 PENDING 1건."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = renego_seed["sids"]["OPEN"]
|
||||||
|
|
||||||
|
first = (await _request(client, token, sid)).json()
|
||||||
|
second = (await _request(client, token, sid)).json()
|
||||||
|
|
||||||
|
assert first["result"]["success"] is True
|
||||||
|
assert second["result"]["success"] is False
|
||||||
|
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_blocked_on_awarded(client, renego_seed, db_engine):
|
||||||
|
"""검증: 낙찰(AWARDED)로 마감된 건에 재협상 요청.
|
||||||
|
기대결과: 거부(낙찰 건은 재협상 불가) + custom.renegotiation 미기록."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = renego_seed["sids"]["AWARD"]
|
||||||
|
|
||||||
|
body = (await _request(client, token, sid)).json()
|
||||||
|
|
||||||
|
assert body["result"]["success"] is False
|
||||||
|
assert await _renego(db_engine, sid) == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_forbidden_other_supplier(client, renego_seed, db_engine):
|
||||||
|
"""검증: 다른 공급사 소유 세션에 재협상 요청.
|
||||||
|
기대결과: 거부 + custom.renegotiation 미기록(소유 가드)."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = renego_seed["sids"]["OTHER"]
|
||||||
|
|
||||||
|
body = (await _request(client, token, sid)).json()
|
||||||
|
|
||||||
|
assert body["result"]["success"] is False
|
||||||
|
assert await _renego(db_engine, sid) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 철회 -------------------------------------------------------------------
|
||||||
|
async def test_cancel_sets_canceled_and_allows_rerequest(client, renego_seed, db_engine):
|
||||||
|
"""검증: 대기 중 요청을 철회한 뒤 다시 요청.
|
||||||
|
기대결과: 철회 시 CANCELED → 재요청 시 다시 PENDING(철회 건은 재요청 허용)."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = renego_seed["sids"]["OPEN"]
|
||||||
|
|
||||||
|
await _request(client, token, sid)
|
||||||
|
cancelled = (await _cancel(client, token, sid)).json()
|
||||||
|
assert cancelled["result"]["success"] is True
|
||||||
|
assert cancelled["status"] == RenegotiationStatus.CANCELED.value
|
||||||
|
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.CANCELED.value
|
||||||
|
|
||||||
|
again = (await _request(client, token, sid)).json()
|
||||||
|
assert again["result"]["success"] is True
|
||||||
|
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_requires_pending(client, renego_seed, db_engine):
|
||||||
|
"""검증: 대기 요청이 없는 세션에 철회 시도.
|
||||||
|
기대결과: 거부(철회할 대기 요청 없음)."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = renego_seed["sids"]["OPEN"]
|
||||||
|
|
||||||
|
body = (await _cancel(client, token, sid)).json()
|
||||||
|
|
||||||
|
assert body["result"]["success"] is False
|
||||||
51
backend/tests/test_session_result.py
Normal file
51
backend/tests/test_session_result.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""공급사 관점 협상 결과 파생(SessionResult) 단위 테스트.
|
||||||
|
|
||||||
|
목록의 result 코드는 견적 마감상태·마감사유·낙찰자로 파생한다(DDL 무변경). 공급사가 이 배지로
|
||||||
|
'내가 낙찰인지 / 결렬이라 재협상 요청 대상인지'를 구분한다. 결렬(3)만 renegotiable 과 짝을 이룬다.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from common.enums import CloseReason, QuotationStatus
|
||||||
|
from services.negotiation_service import NegotiationService
|
||||||
|
|
||||||
|
_R = NegotiationService._to_result
|
||||||
|
ME = uuid.uuid4()
|
||||||
|
OTHER = uuid.uuid4()
|
||||||
|
CLOSED = QuotationStatus.CLOSED.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_undecided_before_close():
|
||||||
|
"""검증: 견적이 아직 마감 전(진행중)이면 결과 미정.
|
||||||
|
기대결과: 0(미정)."""
|
||||||
|
assert _R(QuotationStatus.IN_PROGRESS.value, None, None, ME) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_won_when_winner_is_me():
|
||||||
|
"""검증: 낙찰(AWARDED) 마감 + 낙찰자가 나.
|
||||||
|
기대결과: 1(낙찰)."""
|
||||||
|
assert _R(CLOSED, CloseReason.AWARDED.value, ME, ME) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_lost_when_winner_is_other():
|
||||||
|
"""검증: 낙찰 마감이지만 낙찰자가 남.
|
||||||
|
기대결과: 2(미낙찰)."""
|
||||||
|
assert _R(CLOSED, CloseReason.AWARDED.value, OTHER, ME) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_lost_when_awarded_without_winner_id():
|
||||||
|
"""검증: 낙찰인데 낙찰자 id 가 비어 나와 대조 불가.
|
||||||
|
기대결과: 2(미낙찰) — 낙찰이라 단정 못 하면 낙찰로 오인시키지 않는다."""
|
||||||
|
assert _R(CLOSED, CloseReason.AWARDED.value, None, ME) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_open_is_renegotiable():
|
||||||
|
"""검증: 개찰(OPEN_*) 4종으로 마감(낙찰자 미정=결렬).
|
||||||
|
기대결과: 전부 3(결렬) — 재협상 요청 대상."""
|
||||||
|
for cr in (CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT):
|
||||||
|
assert _R(CLOSED, cr.value, None, ME) == 3, cr
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_none_when_closed_without_reason():
|
||||||
|
"""검증: 마감됐지만 close_reason 이 아직 없음(경계).
|
||||||
|
기대결과: 0(미정) — 낙찰/결렬 어느 쪽도 아님."""
|
||||||
|
assert _R(CLOSED, None, None, ME) == 0
|
||||||
BIN
dev-settings.png
Normal file
BIN
dev-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user