Compare commits
3 Commits
main
...
feat/switc
| Author | SHA1 | Date | |
|---|---|---|---|
| 94c7a6568d | |||
| 51b3820cc9 | |||
| d4acdd0ac5 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -24,11 +24,3 @@ CLAUDE.md
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
|
|
||||||
/Temp.md
|
|
||||||
/new.md
|
|
||||||
|
|
||||||
/mobile.mov
|
|
||||||
.gstack/
|
|
||||||
.playwright-mcp/
|
|
||||||
|
|||||||
BIN
0729~30_테스트.xlsx
BIN
0729~30_테스트.xlsx
Binary file not shown.
Binary file not shown.
Binary file not shown.
190
README.md
190
README.md
@ -1,198 +1,63 @@
|
|||||||
# O2O Negosium
|
# O2O Negosium
|
||||||
|
|
||||||
AI 협상 솔루션. 여러 백엔드·프론트·배치가 **하나의 PostgreSQL 인스턴스**를 공유하고,
|
동일 구조의 두 서비스(**negosium**, **negodata**)가 **하나의 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 # 전체 서비스 (DB 는 compose 밖, config 로 외부 연결)
|
├── docker-compose.yml # 두 backend (DB 는 외부)
|
||||||
├── postgres-init/ # DB·스키마·시드 SQL (대상 DB 에 1회 적용)
|
├── postgres-init/ # DB·테이블 셋업 SQL (대상 DB 에 1회 적용)
|
||||||
│
|
├── backend/ # negosium 백엔드 (포트 9300)
|
||||||
├── backend/ # negosium 백엔드 — 공급사/협상 API (:9300)
|
├── negodata/backend/ # negodata 백엔드 (포트 9400)
|
||||||
├── frontend/ # negosium 공급사 프론트 (:3300, 프로덕션 빌드 정적 서빙)
|
├── agent/ front/ # (예정)
|
||||||
├── agent/ # 협상 에이전트 — RL(learning 스키마) (:9500)
|
└── negodata/front/ # (예정)
|
||||||
│
|
|
||||||
├── 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
negosium·negodata·agent 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다.
|
두 백엔드는 같은 코드 골격(MVC · 람다 DB · Depends 주입 · JWT 로그인)을 쓴다.
|
||||||
아키텍처/패턴 상세는 각 서브 README 참고:
|
아키텍처/패턴 상세는 각 서버 README 참고: [backend](backend/README.md) · [negodata/backend](negodata/backend/README.md)
|
||||||
- 백엔드: [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 · negodata-backend · agent · anchoring 공유
|
├── negosium_db ← negosium-backend
|
||||||
│ ├── company / supplier / partner : 회사·유저·협력사·상품
|
└── negodata_db ← negodata-backend
|
||||||
│ ├── card / quotation / negotiation: 협상 카드·견적·협상 세션
|
|
||||||
│ ├── learning : RL 자산 (agent 소유)
|
|
||||||
│ └── anchoring : 앵커링 조정 (schedules/anchoring 소유)
|
|
||||||
└── lps_db ← lps-api · lps-worker
|
|
||||||
```
|
```
|
||||||
- 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (compose 가 `DB_HOST` 로 override)
|
- 컨테이너(docker env)에서 호스트 DB 접근: `host.docker.internal:5432` (`config.docker.toml`)
|
||||||
- 로컬 실행/테스트(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 |
|
||||||
|
|---|---|---|---|
|
||||||
### 1. 견적 라이프사이클 (전체 개요)
|
| negosium-backend | http://localhost:9300 | /docs | negosium_db |
|
||||||
|
| 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 에 스키마 + 시드 적용
|
||||||
# 스키마 DDL (구 01~05 통합, 전부 IF NOT EXISTS 라 재실행 안전)
|
psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/00-init.sql # 스키마 전체 (negosium_db + 도메인·learning·anchoring schema)
|
||||||
psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f postgres-init/init-data/init.sql
|
psql -h 127.0.0.1 -p 5432 -U postgres -f postgres-init/temp-data.sql # 임시 데이터 시드 (admin / admin1234)
|
||||||
# 로컬/개발 시드 (admin / admin1234, 회사·유저·협상 카드)
|
|
||||||
psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f postgres-init/init-data/init-data.sql
|
|
||||||
|
|
||||||
# (DBeaver 로 처음부터 새로 깔 때는 postgres-init/dbeaver/ 의 0~5 순서 스크립트를 쓴다:
|
# 2) 백엔드 기동
|
||||||
# 0 drop&create → 1 스키마 → 2 시드 → 3 lps_db → 4 카드 리셋 → 5 o2o OWNER 유저)
|
docker compose up -d # 두 backend (DB 는 config 대로 외부 연결)
|
||||||
|
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, agent, lps ...
|
cd backend # 또는 negodata/backend
|
||||||
pip install pytest pytest-asyncio httpx
|
pip install pytest pytest-asyncio httpx
|
||||||
python -m pytest
|
python -m pytest
|
||||||
```
|
```
|
||||||
- httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e.
|
- httpx `ASGITransport` 로 네트워크 없이 앱을 직접 호출하는 e2e (각 5개).
|
||||||
- `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`).
|
- `DB_SESSION_MNG` 싱글톤의 커넥션 풀이 첫 이벤트 루프에 묶이므로, 모든 테스트가 단일 session 루프를 공유한다(`pytest.ini`).
|
||||||
- ⚠️ 테스트는 `APP_ENV=test` 로 격리한다(테스트 DB). dev DB(`negosium_db`)에 대고 돌리면 데이터가 날아간다.
|
|
||||||
|
|
||||||
## 성능 / 벤치마크
|
## 성능 / 벤치마크
|
||||||
|
|
||||||
@ -234,9 +99,4 @@ python -m locust -f loadtest/locustfile.py --host http://localhost:9300 --headle
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
- **백엔드**: FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn
|
FastAPI · SQLAlchemy(async) · asyncpg · PostgreSQL 16 · python-jose(JWT) · bcrypt · uvicorn · Docker Compose
|
||||||
- **프론트**: 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. 여러 회사(imarketkorea 등)가 **각자 데이터로 분기 학습**하는
|
범용 멀티테넌트 협상 솔루션 PoC. 여러 회사(ktcommerce·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 imarketkorea # 기본 3턴 시나리오
|
APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 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 imarketkorea --interactive # 직접 입력
|
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive # 직접 입력
|
||||||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 없이
|
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --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: imarketkorea' # 통과(라우트 미존재라 404 Not Found)
|
curl localhost:9500/v1/foo -H 'X-Tenant-ID: ktcommerce' # 통과(라우트 미존재라 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: imarketkorea' -H 'Content-Type: application/json' \
|
-H 'X-Tenant-ID: ktcommerce' -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 imarketkorea`. (`tests/test_h5_*` 5/5)
|
"학습하면 성과가 오른다" 정량 입증. `python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce`. (`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 imarketkorea
|
python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
||||||
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
|
||||||
|
|||||||
BIN
agent/artifacts/autonomy_serving.npz
Normal file
BIN
agent/artifacts/autonomy_serving.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v32.npz
Normal file
BIN
agent/artifacts/autonomy_v32.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v34_rejected.npz
Normal file
BIN
agent/artifacts/autonomy_v34_rejected.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/autonomy_v35.npz
Normal file
BIN
agent/artifacts/autonomy_v35.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/card_embeddings.npz
Normal file
BIN
agent/artifacts/card_embeddings.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/dqn_serving.npz
Normal file
BIN
agent/artifacts/dqn_serving.npz
Normal file
Binary file not shown.
BIN
agent/artifacts/feature_dqn_ktcommerce.pt
Normal file
BIN
agent/artifacts/feature_dqn_ktcommerce.pt
Normal file
Binary file not shown.
BIN
agent/artifacts/full_autonomy.pt
Normal file
BIN
agent/artifacts/full_autonomy.pt
Normal file
Binary file not shown.
14
agent/artifacts/retrain_report.json
Normal file
14
agent/artifacts/retrain_report.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"rows": 25,
|
||||||
|
"episodes": 4,
|
||||||
|
"skipped": {
|
||||||
|
"종료행/카드턴 없음(미완결 세션)": 8
|
||||||
|
},
|
||||||
|
"min_episodes": 1,
|
||||||
|
"deployed": false,
|
||||||
|
"ope_candidate": 0.9029104414200676,
|
||||||
|
"ope_candidate_ess": 1.0,
|
||||||
|
"ope_current": 0.9029104414200676,
|
||||||
|
"ope_current_ess": 1.0,
|
||||||
|
"result": "gate_failed"
|
||||||
|
}
|
||||||
@ -40,20 +40,3 @@ 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,23 +4,10 @@ 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' 경고 제거)."""
|
||||||
|
|||||||
203
agent/docs/완전자율에이전트_변경정리.md
Normal file
203
agent/docs/완전자율에이전트_변경정리.md
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
# 완전 자율 협상 에이전트 — 처음 대비 변경 정리
|
||||||
|
|
||||||
|
> 기준: 협상카드 + 룰 엔진 시절(처음) → 완전 자율 에이전트 v3.2 + LLM 멘트 (2026-07-10 현재)
|
||||||
|
> 롤백: `docker-compose.yml` 의 `AUTONOMY_MODE=0` 하나로 룰 엔진 즉시 복귀 (재빌드 불필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 한눈에 보기 — 무엇이 바뀌었나
|
||||||
|
|
||||||
|
| 영역 | 처음 (룰 + 카드) | 지금 (자율 에이전트) |
|
||||||
|
|---|---|---|
|
||||||
|
| **협상 판정** | 하드코딩 룰 (앵커 이하 타결 / 와일드카드 존 / 3라운드 결렬) | RL 정책이 매 턴 행동을 직접 선택 |
|
||||||
|
| **발화 선택** | DB 협상카드(NGC-001~011)를 UCB/Q-table 로 선택 | 카드 없음 — 행동 30개 중 신경망이 선택 |
|
||||||
|
| **역제안 금액** | 카드에 박힌 고정값 | 앵커~목표가 6단 사다리에서 정책이 선택 |
|
||||||
|
| **와일드카드** | 사람이 등록한 카드(WC-01~05) 발동 | 최종제안·역제시 타이밍을 정책+봉투가 자율 수행 |
|
||||||
|
| **멘트** | 고정 템플릿 | Gemini LLM 생성 + 할루시네이션 가드 (실패 시 템플릿 폴백) |
|
||||||
|
| **입력 상태** | 가격 스냅샷 9차원 | 21차원 (마감·협력사 이력·인터넷최저가·에피소드 기억 추가) |
|
||||||
|
| **학습** | Q-table 온라인 갱신 | 시뮬레이터 DQN 학습 → 프로브 게이트 → npz 번들 배포 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 의사결정 — 행동 공간 30개
|
||||||
|
|
||||||
|
```
|
||||||
|
ACCEPT 수락 (협상완료, 제시가 타결)
|
||||||
|
WALK 결렬 의사 → 최종제안 1회 보장 후 종료
|
||||||
|
COUNTER 역제안: 금액 위치 6단 {-5%, 0, 25, 50, 75, 100% of (목표가-앵커가)} × 화법 4종
|
||||||
|
PRESS 압박(설득): 화법 4종
|
||||||
|
```
|
||||||
|
|
||||||
|
행동의 실체는 `Action(kind, counter_q, strategy)` — **(무엇을, 얼마에, 어떤 말투로)** 좌표 3개짜리 데이터다
|
||||||
|
(`policies/autonomy_actions.py`, DB 아님). 에이전트는 매 턴 30개 중 조합 1개를 고르고,
|
||||||
|
원화 환산(앵커 + q×스팬)과 문장(LLM)은 선택 이후의 실행 단계.
|
||||||
|
|
||||||
|
역제안 사다리 6단 (실스케일 앵커 418,966/목표 423,198 기준):
|
||||||
|
q=−0.05→418,754 / 0→418,966(앵커) / 0.25→420,024 / 0.5→421,082 / 0.75→422,140 / 1.0→423,198(목표가).
|
||||||
|
비율(q)이라 견적 스케일과 무관하게 같은 행동 공간이 재사용된다.
|
||||||
|
|
||||||
|
화법 4종은 기존 카드 전략 분류를 그대로 승계: **경쟁 압박 / 수용 공감 / 기준 고수 / 협력 파트너**.
|
||||||
|
톤 선택도 학습 결과 — 라이브에서 초반 경쟁(1)→중반 수용(2)→교착 협력(4)으로 국면별 전환 관측.
|
||||||
|
|
||||||
|
설계 출처: 화법 4종·금액 범위(앵커~목표가)는 제품 승계, 수락·결렬 포함은 완전 자율 정의의 필연,
|
||||||
|
**격자 6단만 설계 재량**(`COUNTER_GRID` 수정+재학습으로 변경 가능). 알려진 한계: 부를 수 있는
|
||||||
|
금액이 격자 6지점뿐 — 연속 금액 미세조정은 불가(필요 시 격자 확장이 현실적).
|
||||||
|
|
||||||
|
- 모델: action-as-feature DQN (ScoreNet MLP — 상태 21 + 행동특징 9 → 점수 1개)
|
||||||
|
- 서빙: **numpy 전용** (`autonomy_serving.npz`) — 컨테이너에 PyTorch 불필요
|
||||||
|
- 현재 서빙본: **v3.5** (백업 `autonomy_v35.npz` / 반려본 v3.4 / 이전 v3.2)
|
||||||
|
|
||||||
|
## 3. 입력 상태 — 9차원 → 21차원
|
||||||
|
|
||||||
|
"완전한 에이전트에는 다 들어가야 한다" 요구로 확장:
|
||||||
|
|
||||||
|
| 그룹 | 차원 | 내용 | 출처 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 기본 | 9 | 매출액·유통코드·협력사수·수락률·제시가·앵커가·목표가·라운드 등 | 기존 스냅샷 |
|
||||||
|
| 테넌트 | 5 | 보상 설정 특징 | reward config |
|
||||||
|
| **에피소드 기억** | 2 | 직전 역제안 유무·위치 | ctx `autonomy_last` |
|
||||||
|
| **마감** | 1 | 마감 잔여율 | quotations start/end_time |
|
||||||
|
| **협력사 이력** | 3 | 과거 협상 횟수·성공률·평균 타결비율 | experience_logs ⨝ sessions |
|
||||||
|
| **시장가** | 1 | 인터넷 최저가 갭 | items.internet_lowest_price |
|
||||||
|
|
||||||
|
- 소스가 없으면 중립값(0.5/0) — 학습 시뮬의 '미상' 표현과 동일
|
||||||
|
- 상대 **발화 내용 파싱은 보류** (사용자 결정 — 프론트 입력 UI 변경 필요)
|
||||||
|
|
||||||
|
## 4. 행동 봉투 — 실전 테스트에서 잡은 결함의 구조적 방지
|
||||||
|
|
||||||
|
룰과 다름: **룰은 결과를 정하고, 봉투는 행동만 금지**한다. 나머지(타이밍·속도·금액)는 전부 정책 학습.
|
||||||
|
|
||||||
|
| # | 봉투 | 막는 결함 (실제 발생 사례) | 성격 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ① | 목표가 초과 제시가는 **수락 불가** | v3.1 이 보상 구멍을 착취해 목표가+14% 매입 | 안전 (영구) |
|
||||||
|
| ② | 직전 역제안보다 **낮은 금액 재제시 금지** (단조 양보) | 423,198 → 420,024 제안 철회 사건 | 안전 (영구) |
|
||||||
|
| ③ | 역제시는 **설득 ≥2회 후 해금** (`AUTONOMY_MIN_PRESS`) | 첫 턴부터 역제시 — 옛 의미론(일반카드=설득, 와일드카드만 역제시) 복원 | 예절 (해제 후보) |
|
||||||
|
| ④ | 목표가 0.5% 이내 **마무리 국면에선 압박 금지** | 802원 차이에 "재검토 부탁" 반복하던 푼돈 흥정 | 예절 (해제 후보) |
|
||||||
|
| ⑤ | **첫 역제안은 앵커가 이하만** (q ≤ 0) | 사다리 꼭대기 근처(422,140)에서 개시해 올라갈 계단이 없던 문제 | 예절 (해제 후보) |
|
||||||
|
| ⑥ | 같은 금액 반복·결렬 의사 → **자율_최종제안 1회 보장, 금액은 목표가** | 확인 없이 결렬 / 직전 금액을 "최종"으로 반복해 승인 여지를 남긴 채 종료하던 문제 | 안전 (영구) |
|
||||||
|
| ⑦ | **결렬(walk)도 해금 전 금지** — 설득 ≥2회 전에는 설득만 가능 | 설득 0회에 walk 선택 시 최종제안 보장(⑥)과 결합해 "첫 턴 목표가 통보"가 됨 (v3.4 라이브 결함) | 예절 (해제 후보) |
|
||||||
|
|
||||||
|
- 구현: 서빙 `policy/autonomy_store.py` 후보 마스크 + 학습 `tools/train_full_autonomy.py` `available_actions` **양쪽 동일**
|
||||||
|
- 예절 봉투(③④⑤)는 실로그가 쌓이면 `AUTONOMY_MIN_PRESS=0` 등으로 해제 실험 가능
|
||||||
|
|
||||||
|
## 5. 멘트 — 템플릿 → LLM + 가드레일
|
||||||
|
|
||||||
|
**역할 분리(안전 설계):** 무엇을 말할지(금액/전략/수락/결렬)는 RL 이 결정, LLM 은 **표현만** 담당.
|
||||||
|
|
||||||
|
```
|
||||||
|
설정: agent/config/config.local.toml [OpenAIConfig]
|
||||||
|
모델: gemini-2.5-flash-lite (OpenAI 호환 base_url)
|
||||||
|
· 2.5-flash → thinking 지연으로 백엔드 10초 한도 초과 ("협상 응답 지연" 토스트 원인)
|
||||||
|
· 2.0-flash → 은퇴(404)
|
||||||
|
시간: LLM_TIMEOUT_S=6 초과 시 템플릿 폴백 (검증 최대 응답 2.9초)
|
||||||
|
끄기: AUTONOMY_LLM=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**할루시네이션 가드 (하나라도 걸리면 템플릿 폴백, 협상은 계속):**
|
||||||
|
|
||||||
|
| 가드 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 숫자 화이트리스트 | 프롬프트로 준 금액(제시가·제안가·직전제안가·양보폭) 외 숫자 = 즉시 폐기 |
|
||||||
|
| **목표가 비공개** | 압박 프롬프트에 목표가 미포함 + 화이트리스트에서도 제외 — 노출 사고 재발 방지 |
|
||||||
|
| 금지어 | 보장/물량/독점/최저가/시장가/%/계약기간/법적 등 승인 안 된 전술·커밋 |
|
||||||
|
| 문장 완결 | thinking 토큰 소진으로 잘린 문장 폐기 (max_tokens 2048) |
|
||||||
|
| 제안가 포함 | 역제안·최종제안 멘트에 제안 금액 필수 |
|
||||||
|
|
||||||
|
**추가 기능:**
|
||||||
|
- **인터넷 최저가 인용** (구 NGC-008 자율판): 수집돼 있고 제시가 > 최저가일 때만 근거 인용 허용 — 그 턴에만 '최저가' 금지어 해제, 수치는 화이트리스트 검증
|
||||||
|
- **대화 기억**: 직전 제안 거절 사실·양보폭을 멘트에 반영("직전 제안에서 5원 상향한…") + 직전 멘트와 같은 문장구조 반복 금지 — "멘트가 다 똑같다" 해결. temperature 0.9
|
||||||
|
|
||||||
|
## 6. 학습 시뮬레이터 버전 이력 — 실패 2건 포함
|
||||||
|
|
||||||
|
| 버전 | 변경 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| v1 | 최초 학습 | 한 방 큰 컷 + 같은 숫자 반복 → "이게 협상이야??" |
|
||||||
|
| v2 | 에피소드 기억·컷 특징·협력사 반복 짜증/이탈 | 개선되나 지형 불일치 잔존 |
|
||||||
|
| v3 | 상태 21차원 확장 | — |
|
||||||
|
| v3.1 | **지형 정합**: 앵커율 0.8~6% 샘플링 (실제 ~1% vs 시뮬 20%) | ⚠️ 보상 구멍 착취 — 목표가+14% 매입 학습 → 봉투 ① 신설 |
|
||||||
|
| **v3.2** | 컷 반발·반복 짜증·**양보 상호성**(우리가 올리면 상대도 내림)·floor ≤ 첫제시가×0.98 | ✅ **현재 서빙본** (목표가 초과 타결 0/30) |
|
||||||
|
| v3.3 | 단조·상호성 반영 재학습 | ❌ "무조건 목표가 즉시 지르기"로 퇴화 → **프로브 게이트 반려** (`full_autonomy.pt` 만 보관, 미서빙) |
|
||||||
|
| v3.4 | 봉투 ①~⑤ 정합 + **보상 수정**(목표가 초과 타결 = 결렬 취급) 재학습 | ❌ **반려** — 초기 게이트(실스케일 단일 지형) 통과 후 라이브에서 퇴화 발견: 소액 지형에서 첫 턴 walk→목표가 통보 / walk 잠금 후엔 압박 12연발·최종제안 생략·화법 단조(전부 전략3). 게이트를 2개 지형으로 확장해 재판정 → v3.2 우위 확인, v3.2 복원 (`autonomy_v34_rejected.npz` 보관) |
|
||||||
|
| **v3.5** | v3.4 + **관측성 마스크**: 마감 40% 미관측(0.5 고정)·15% 완전 미상 에피소드 — 서빙 중립값 상태를 시뮬 분포에 혼입 (v3.4 퇴화 원인 해소) | ✅ **현재 서빙본** — 게이트 78/78, 사다리 3단 사용, 협조 케이스 목표가 대비 -2,116원 타결. 게이트가 이 과정에서 **철회 실버그** 발견(아래) |
|
||||||
|
|
||||||
|
> **교훈 1 — 보상 = 유일한 스펙**: 룰을 제거하면 보상 함수의 구멍이 곧 행동이 된다 (v3.1).
|
||||||
|
> **교훈 2 — 프로브 게이트**: 재학습은 퇴화할 수 있다. 배포 전 반드시 실스케일 제시가별 행동표(`tools/probe_serving_dqn.py`)로 비교 검증 (v3.3).
|
||||||
|
> **교훈 3 — 지형 일반화**: 한 지형의 게이트 통과가 다른 지형을 보증하지 않는다 (v3.4 — 실스케일 통과, 소액 퇴화).
|
||||||
|
> **교훈 4 — 시뮬은 관측까지 닮아야 한다**: 세계뿐 아니라 '무엇을 모르는지'도 서빙과 같아야 한다. 마감·이력 미상(중립값) 상태가 시뮬에 없으면 그 상태가 분포 밖이 된다 (v3.4 원인 → v3.5 해소).
|
||||||
|
|
||||||
|
**철회 실버그 (게이트가 발견, 2026-07-10 수정):** 단조 봉투의 기준 `autonomy_last`가 '마지막 행동'이라 counter→**press**→counter 순서에서 설득이 역제안 기억을 덮어써 봉투가 뚫렸다(9,975 제안 후 9,900 재제안). 역제안 기억을 `autonomy_last_counter`로 별도 보존하도록 수정 — 시뮬(역제안만 추적)과도 일치. v3.2는 이 패턴을 쓰지 않아 드러나지 않았을 뿐 프로덕션에 실존하던 구멍.
|
||||||
|
|
||||||
|
## 7. 현재 협상 흐름 (검증 완료)
|
||||||
|
|
||||||
|
```
|
||||||
|
협력사 제시
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
설득(압박) ≥2회 ── 인터넷최저가 근거 인용 가능, 목표가 절대 비공개
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
역제안 해금 ── 첫 제안은 앵커가 이하로 개시 (봉투⑤)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
단조 상향 사다리 ── 후퇴 금지(봉투②), 양보폭·속도는 정책이 결정
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
목표가 0.5% 이내 ── 압박 중단, 클로징만 (봉투④)
|
||||||
|
│
|
||||||
|
├─ 제시가 ≤ 목표가 → 수락 → 협상완료
|
||||||
|
├─ 같은 금액 반복 / 결렬 의사 → 자율_최종제안 1회, 금액=목표가 (봉투⑥)
|
||||||
|
│ ├─ 예 → 협상완료 └─ 아니오 → 협상실패
|
||||||
|
└─ 12턴 초과(엔지니어링 캡) → 최종제안(목표가) 1회 거쳐 종료 — 캡도 봉투⑥을 우회하지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 운영 스위치 & 파이프라인
|
||||||
|
|
||||||
|
| 스위치 (docker-compose agent env) | 값 | 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| `AUTONOMY_MODE` | 1 | 자율 모드 (0 = 룰 엔진 복귀) |
|
||||||
|
| `DQN_SERVING` | 1 | 카드 선택 DQN (0 = UCB Q-table) |
|
||||||
|
| `AUTONOMY_LLM` | 1(기본) | LLM 멘트 (0 = 템플릿만) |
|
||||||
|
| `AUTONOMY_MIN_PRESS` | 2(기본) | 역제시 해금에 필요한 설득 횟수 |
|
||||||
|
| `LLM_TIMEOUT_S` | 6(기본) | LLM 시간 상한, 초과 시 템플릿 폴백 |
|
||||||
|
|
||||||
|
**학습→배포 파이프라인:**
|
||||||
|
```
|
||||||
|
tools/train_full_autonomy (시뮬 15k ep, 룰 베이스라인 비교)
|
||||||
|
→ tools/export_autonomy_serving (artifacts/autonomy_serving.npz, .prev 자동 백업)
|
||||||
|
→ tools/probe_serving_dqn (실스케일 행동표 — 눈으로 보는 진단)
|
||||||
|
→ tools/test_autonomy_defects (결함 회귀 게이트 — 지형 2종×시나리오 3종 + 단위·멘트가드 검사,
|
||||||
|
자동 합격/불합격. 단, v3.4 사례처럼 게이트 통과 ≠ 품질 보증:
|
||||||
|
궤적 자체도 눈으로 비교할 것)
|
||||||
|
→ docker compose build agent (npz 는 이미지에 베이크)
|
||||||
|
```
|
||||||
|
|
||||||
|
**로깅:** 자율 행동도 experience_logs 에 기록 (card_id = `AUT|종류|위치|전략`, 진행 row + 종결 row). 카드 재학습(`retrain_from_logs`)은 AUT 세션 자동 제외.
|
||||||
|
|
||||||
|
## 9. 변경 파일 지도
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `negotiation/policy/autonomy_store.py` | **신규** — 자율 정책 numpy 서빙 + 봉투 ①~⑤ 마스크 |
|
||||||
|
| `negotiation/policies/autonomy_actions.py` | **신규** — 행동 30개·특징 인코딩 (학습/서빙 공유) |
|
||||||
|
| `negotiation/chat/service/ment_generator.py` | **신규** — LLM 멘트 생성 + 가드레일 |
|
||||||
|
| `negotiation/chat/service/chat_engine.py` | 자율 스텝(자율_역제안/최종제안/압박_1~4) + `_autonomy_next` 봉투⑥ |
|
||||||
|
| `services/chat_service.py` | decider 주입·행동 로깅·대화기억 ctx 관리 |
|
||||||
|
| `negotiation/chat/infra/repository/nego_context_crud.py` | 인터넷최저가·견적기간·협력사이력 조회 |
|
||||||
|
| `negotiation/chat/service/negotiation_context_loader.py` | 확장 컨텍스트 로드 (company_id) |
|
||||||
|
| `tools/train_full_autonomy.py` | **신규** — 시뮬레이터(현실화 협력사 모델) + DQN 학습 |
|
||||||
|
| `tools/export_autonomy_serving.py` / `probe_serving_dqn.py` | **신규** — 번들 내보내기 / 프로브 게이트 |
|
||||||
|
| `tools/test_autonomy_defects.py` | **신규** — 결함 회귀 게이트: 실전에서 발견된 결함 41항목을 시나리오·단위·멘트가드 검사로 자동 재생 (서빙 실물 코드 구동, DB/LLM 불필요) |
|
||||||
|
| `config/config.local.toml` | Gemini 접속 정보 (gitignore, 이미지에 베이크) |
|
||||||
|
| `docker-compose.yml` | `AUTONOMY_MODE` / `DQN_SERVING` 플래그 |
|
||||||
|
|
||||||
|
## 10. 남은 일
|
||||||
|
|
||||||
|
- [x] 결함 회귀 게이트 구축 — `test_autonomy_defects.py` 41항목, v3.2 전항목 통과 확인 (2026-07-10)
|
||||||
|
- [x] 보상 수정 — 목표가 초과 타결은 학습 보상에서 결렬 취급 (v3.1 구멍을 유인 수준에서 차단, 봉투 ①과 이중 방어)
|
||||||
|
- [ ] ⚠️ **Gemini API 키 재발급** — 채팅에 노출된 키, 테스트 종료 후 반드시 교체 (config.local.toml + 이미지 리빌드)
|
||||||
|
- [x] 봉투 정합 재학습 — v3.4 반려(소액 지형 퇴화) → 원인 규명(관측성 불일치) → **v3.5 관측성 마스크로 해소, 배포 완료** (2026-07-10)
|
||||||
|
- [x] 철회 실버그 수정 — counter→press→counter 에서 단조 봉투 뚫림 → `autonomy_last_counter` 별도 보존
|
||||||
|
- [x] 턴캡 최종제안 보장 — 캡 종료도 "끝내기 전 한 번 더"를 거침
|
||||||
|
- [ ] 상대 발화 LLM 파싱 (보류 중 — 프론트 입력 UI 변경 필요)
|
||||||
|
- [ ] 실로그 축적 후: 예절 봉투(③④⑤) 해제 실험 → LLM 협력사 셀프플레이 (집컴 GPU 단계)
|
||||||
|
- [ ] (소소) negodata 프론트 "목표 마진율 1000%" 표시 버그 후보
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# 알고리즘 비교 실험 기본 설정 (H5). E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
|
# 알고리즘 비교 실험 기본 설정 (H5). E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
||||||
episodes: 600 # 정책당 협상 에피소드 수 (action 11장 탐색 수렴 위해 상향)
|
episodes: 600 # 정책당 협상 에피소드 수 (action 11장 탐색 수렴 위해 상향)
|
||||||
seed: 42 # 재현용 (구매자 randomness 페어드)
|
seed: 42 # 재현용 (구매자 randomness 페어드)
|
||||||
max_turns: 5 # 협상 라운드 상한
|
max_turns: 5 # 협상 라운드 상한
|
||||||
|
|||||||
104
agent/eval_harness/feature_buyer.py
Normal file
104
agent/eval_harness/feature_buyer.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"""FeatureBuyer — 카드 '내용(전략)'과 협력사 '프로필'에 반응하는 시뮬 협력사 (Phase 2·3).
|
||||||
|
|
||||||
|
효과를 2축으로 분리한다(성향 조건화가 의미를 가지려면 트레이드오프가 필요):
|
||||||
|
- 양보력(concession power): 이 카드가 가격을 얼마나 끌어내리는가
|
||||||
|
- 수락력(accept power) : 이 카드가 합의(수락) 확률을 얼마나 높이는가
|
||||||
|
|
||||||
|
전략별 기본 프로필(트레이드오프):
|
||||||
|
경쟁(1): 양보력↑↑ 수락력↓ — 세게 깎지만 결렬 위험
|
||||||
|
수용(2): 양보력↓ 수락력↑
|
||||||
|
고수(3): 양보력·수락력 중간
|
||||||
|
협력(4): 양보력↓ 수락력↑↑ — 잘 성사되지만 덜 깎임
|
||||||
|
|
||||||
|
여기에 협력사 세그먼트 적합도(AFFINITY)가 곱해진다: 전략이 그 협력사에 안 맞으면 둘 다 죽는다.
|
||||||
|
소형·경쟁多 → 경쟁압박이 잘 먹힘 / 대형·단독 → 협력이 잘 먹힘(압박 역효과)
|
||||||
|
|
||||||
|
→ '가격 중시' 고객사는 경쟁 카드(많이 깎음, 결렬 감수), '성사 중시' 고객사는 협력 카드가 정답이
|
||||||
|
되는 구조. 에이전트는 카드 특징 + 협력사 특징 + 고객사 성향으로 이를 학습해야 한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from eval_harness.buyer import BuyerResponse, Scenario
|
||||||
|
|
||||||
|
# strategy_type: 1=경쟁, 2=수용, 3=고수, 4=협력 (card.nego_cards)
|
||||||
|
# 전략별 (양보력, 수락력) 기본 프로필 — 트레이드오프의 원천
|
||||||
|
STRATEGY_PROFILE: Dict[int, Tuple[float, float]] = {
|
||||||
|
1: (0.90, 0.25), # 경쟁: 세게 깎지만 성사 어려움
|
||||||
|
2: (0.35, 0.70), # 수용
|
||||||
|
3: (0.55, 0.50), # 고수: 중간
|
||||||
|
4: (0.30, 0.90), # 협력: 잘 성사되지만 덜 깎임
|
||||||
|
}
|
||||||
|
|
||||||
|
# 세그먼트별 전략 적합도 m ∈ [0,1] — 전략이 그 협력사에 얼마나 '먹히는가'
|
||||||
|
AFFINITY: Dict[Tuple[str, str], Dict[int, float]] = {
|
||||||
|
("small", "multi"): {1: 0.90, 2: 0.45, 3: 0.60, 4: 0.40}, # 소형·경쟁多 → 경쟁압박
|
||||||
|
("small", "single"): {1: 0.35, 2: 0.60, 3: 0.80, 4: 0.55}, # 소형·단독 → 고수/논리
|
||||||
|
("big", "multi"): {1: 0.65, 2: 0.50, 3: 0.70, 4: 0.60},
|
||||||
|
("big", "single"): {1: 0.20, 2: 0.70, 3: 0.50, 4: 0.90}, # 대형·단독 → 협력 (압박 역효과)
|
||||||
|
}
|
||||||
|
REVENUE_BIG = 50_000_000 # state config 'high' 경계와 정합
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SupplierProfile:
|
||||||
|
"""협력사 프로필 — 에피소드마다 달라지는 협상 상대. snapshot 필드와 정합."""
|
||||||
|
|
||||||
|
revenue_amount: float
|
||||||
|
partner_count: int # 이 품목의 대안 협력사 수 (BATNA. 1:1 채팅이어도 다양)
|
||||||
|
distribution_code: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def segment(self) -> Tuple[str, str]:
|
||||||
|
size = "big" if self.revenue_amount > REVENUE_BIG else "small"
|
||||||
|
comp = "multi" if self.partner_count >= 2 else "single"
|
||||||
|
return (size, comp)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_supplier(rng: np.random.Generator) -> SupplierProfile:
|
||||||
|
"""무작위 협력사 생성 (학습 데이터 다양성)."""
|
||||||
|
return SupplierProfile(
|
||||||
|
revenue_amount=float(rng.choice([5_000_000, 20_000_000, 80_000_000, 200_000_000])),
|
||||||
|
partner_count=int(rng.choice([1, 1, 2, 3])), # 단독 비중 높게
|
||||||
|
distribution_code=str(rng.choice(["A", "B", "C"])),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureBuyer:
|
||||||
|
"""전략 프로필 x 세그먼트 적합도 기반 협력사 모델. (양보력, 수락력) 2축."""
|
||||||
|
|
||||||
|
def __init__(self, supplier: SupplierProfile, card_strategy: Dict[str, int], seed: int = 0,
|
||||||
|
accept_base: float = 0.08, max_turns: int = 5, jitter: float = 0.05):
|
||||||
|
self.supplier = supplier
|
||||||
|
self.card_strategy = card_strategy # {card_number: strategy_type}
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
self.accept_base = accept_base
|
||||||
|
self.max_turns = max_turns
|
||||||
|
self.jitter = jitter
|
||||||
|
# 숨은 하한가(reservation): 앵커의 94~110%. 앵커보다 높으면(약 60%) 가격만으로는
|
||||||
|
# 타결 불가 → 수락을 받아내야 함 → 수락력 낮은(경쟁) 카드에 진짜 결렬 위험이 생긴다.
|
||||||
|
self.floor_ratio = float(self.rng.uniform(0.94, 1.10))
|
||||||
|
|
||||||
|
def powers(self, card_number: str) -> Tuple[float, float]:
|
||||||
|
"""숨은 (양보력, 수락력). 전략 프로필 × 세그먼트 적합도 + 카드별 결정론적 지터."""
|
||||||
|
strat = self.card_strategy.get(card_number, 3)
|
||||||
|
conc_base, acc_base = STRATEGY_PROFILE.get(strat, (0.5, 0.5))
|
||||||
|
m = AFFINITY[self.supplier.segment].get(strat, 0.5)
|
||||||
|
scale = 0.35 + 0.85 * m # 적합도: 안 맞으면 둘 다 죽음 (0.35~1.2)
|
||||||
|
j = (hash(card_number) % 1000 / 1000.0 - 0.5) * 2 * self.jitter
|
||||||
|
c_pow = float(np.clip(conc_base * scale + j, 0.02, 0.98))
|
||||||
|
a_pow = float(np.clip(acc_base * scale + j, 0.02, 0.98))
|
||||||
|
return c_pow, a_pow
|
||||||
|
|
||||||
|
def respond(self, card_number: str, scenario: Scenario, turn: int, current_price: float) -> BuyerResponse:
|
||||||
|
c_pow, a_pow = self.powers(card_number)
|
||||||
|
floor = scenario.anchor_price * self.floor_ratio # 숨은 하한가 (앵커 이하 보장 없음)
|
||||||
|
concession = (current_price - floor) * (0.10 + 0.55 * c_pow)
|
||||||
|
new_price = max(floor, current_price - concession)
|
||||||
|
p_accept = min(0.97, self.accept_base + 0.80 * a_pow + 0.05 * (turn - 1))
|
||||||
|
accept = bool(self.rng.random() < p_accept)
|
||||||
|
walked = (not accept) and (turn >= self.max_turns)
|
||||||
|
return BuyerResponse(accept=accept, new_price=new_price, walked=walked)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"""eval_harness 러너 — 정책 비교 + 학습곡선 (H5, PoC 본체).
|
"""eval_harness 러너 — 정책 비교 + 학습곡선 (H5, PoC 본체).
|
||||||
|
|
||||||
E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
|
E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
||||||
|
|
||||||
판정: 학습형(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="imarketkorea")
|
ap.add_argument("--tenant", default="ktcommerce")
|
||||||
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,13 +20,6 @@ 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",
|
||||||
)
|
)
|
||||||
@ -34,40 +27,17 @@ _WILD_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(_WILD_CARDS.c.script)
|
select(_NEGO_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] or not rows[0][0]:
|
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||||
return err_type, None
|
return err_type, None
|
||||||
script, tone, strategy = rows[0]
|
return ErrorType.SUCCESS, str(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
|
||||||
|
|||||||
@ -1,240 +0,0 @@
|
|||||||
"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
|
|
||||||
|
|
||||||
카드 멘트가 제시하는 가격({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,18 +21,3 @@ 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,49 +23,24 @@ _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",
|
||||||
)
|
)
|
||||||
# 견적 설정 — card_count(협상 내 협상카드 사용 횟수 상한) 조회용.
|
_ITEMS = table("items", column("item_id"), column("price"), column("internet_lowest_price"),
|
||||||
_QUOTATION_SETTINGS = table(
|
column("deleted"), schema="partner")
|
||||||
"quotation_settings",
|
_SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner")
|
||||||
column("qt_setting_id"), column("card_count"), column("deleted"),
|
|
||||||
schema="quotation",
|
|
||||||
)
|
|
||||||
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
|
|
||||||
column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
|
|
||||||
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼을 여기서 정한다.
|
|
||||||
_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
|
|
||||||
|
|
||||||
# 협상 기준가 후보 컬럼. 어느 컬럼을 고르든 공급사 화면 호칭은 '공급가'로 고정한다 —
|
|
||||||
# 같은 돈을 고객사는 매입가·상품 단가 등으로 부르지만 챗은 공급사가 보는 화면이라
|
|
||||||
# 공급사 관점 용어 하나만 쓴다. 회사 용어 사전(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 = table(
|
||||||
"quotations",
|
"quotations",
|
||||||
column("qt_id"), column("version_id"), column("supplier_type"), column("deleted"),
|
column("qt_id"), column("version_id"), column("supplier_type"),
|
||||||
|
column("start_time"), column("end_time"), column("deleted"),
|
||||||
schema="quotation",
|
schema="quotation",
|
||||||
)
|
)
|
||||||
|
# 자율 에이전트 이력 특징용 — agent 소유 learning 스키마 (done 행 = 협상 1건의 최종 결과).
|
||||||
|
_EXP_LOGS = table(
|
||||||
|
"experience_logs",
|
||||||
|
column("session_id"), column("company_id"), column("done"), column("settled_price"),
|
||||||
|
schema="learning",
|
||||||
|
)
|
||||||
_VERSION_NEGO_CARDS = table(
|
_VERSION_NEGO_CARDS = table(
|
||||||
"version_nego_cards",
|
"version_nego_cards",
|
||||||
column("version_id"), column("nego_card_id"), column("created_at"), column("deleted"),
|
column("version_id"), column("nego_card_id"), column("created_at"), column("deleted"),
|
||||||
@ -78,13 +53,12 @@ _VERSION_WILD_CARDS = table(
|
|||||||
)
|
)
|
||||||
_NEGO_CARDS = table(
|
_NEGO_CARDS = table(
|
||||||
"nego_cards",
|
"nego_cards",
|
||||||
column("nego_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
column("nego_card_id"), column("number"), column("deleted"),
|
||||||
schema="card",
|
schema="card",
|
||||||
)
|
)
|
||||||
_WILD_CARDS = table(
|
_WILD_CARDS = table(
|
||||||
"wild_cards",
|
"wild_cards",
|
||||||
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
column("wild_card_id"), column("number"), column("deleted"),
|
||||||
column("available"),
|
|
||||||
schema="card",
|
schema="card",
|
||||||
)
|
)
|
||||||
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
||||||
@ -98,28 +72,12 @@ _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, done_ceiling_price, item_id, quotation_id, supplier_id). 없으면 None."""
|
"""세션 행 (qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id). 없으면 None."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
|
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||||
"""협상 기준가·그 호칭·회사 용어 사전 (가격, 호칭, labels).
|
"""품목 기준가(items.price). 없으면 0."""
|
||||||
|
|
||||||
어느 컬럼을 기준가로 쓰는지는 회사 설정(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
|
||||||
@ -127,16 +85,6 @@ 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=총판).
|
||||||
@ -154,9 +102,24 @@ class INegoContextCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
|
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
|
||||||
"""견적 version_id 에 연결된 (일반카드 행 목록, 와일드카드 행 목록). 없으면 빈 목록.
|
"""견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록."""
|
||||||
행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 카드 전술(CardSpec)을 만든다."""
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_item_internet_lowest(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||||
|
"""상품 인터넷최저가(items.internet_lowest_price). 미수집이면 0."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_quotation_period(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||||
|
"""견적 협상 기간 (start_time, end_time). 없으면 None."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_supplier_history(self, cdb: AsyncSession, company_id: str, supplier_id,
|
||||||
|
exclude_session_id) -> Tuple[ErrorType, tuple]:
|
||||||
|
"""이 협력사와의 과거 협상 이력 (횟수, 성사율, 평균 타결가/목표가). 없으면 (0, None, None)."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -165,7 +128,6 @@ 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)
|
||||||
@ -178,61 +140,14 @@ 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_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
|
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||||
_fallback = (0, _SUPPLIER_PRICE_LABEL, {})
|
|
||||||
try:
|
try:
|
||||||
# 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
|
|
||||||
query = (
|
query = (
|
||||||
select(_ITEMS.c.price, _ITEMS.c.purchase_price, _COMPANIES.c.settings)
|
select(_ITEMS.c.price)
|
||||||
.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_baseline failed.", raise_error=False)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_price failed.", raise_error=False)
|
||||||
if err_type != ErrorType.SUCCESS or not rows:
|
|
||||||
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])
|
|
||||||
except Exception as ex:
|
|
||||||
LOG.e_no_callstack(ex)
|
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
|
||||||
|
|
||||||
async def get_item_lowest_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
|
||||||
try:
|
|
||||||
query = (
|
|
||||||
select(_ITEMS.c.internet_lowest_price)
|
|
||||||
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_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])
|
||||||
@ -240,6 +155,67 @@ 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_item_internet_lowest(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||||
|
"""상품의 인터넷최저가(partner.items.internet_lowest_price). 미수집이면 0."""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(_ITEMS.c.internet_lowest_price)
|
||||||
|
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_internet_lowest failed.", raise_error=False)
|
||||||
|
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||||
|
return err_type, 0
|
||||||
|
return ErrorType.SUCCESS, int(rows[0])
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
|
async def get_quotation_period(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||||
|
"""견적 협상 기간 (start_time, end_time). 자율 에이전트의 마감 잔여율 특징용."""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(_QUOTATIONS.c.start_time, _QUOTATIONS.c.end_time)
|
||||||
|
.where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_quotation_period failed.", raise_error=False)
|
||||||
|
if err_type != ErrorType.SUCCESS or not rows:
|
||||||
|
return err_type, None
|
||||||
|
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_supplier_history(self, cdb: AsyncSession, company_id: str, supplier_id,
|
||||||
|
exclude_session_id) -> Tuple[ErrorType, tuple]:
|
||||||
|
"""이 협력사와의 과거 협상 이력 집계 → (횟수, 성사율, 평균 타결가/목표가).
|
||||||
|
|
||||||
|
소스 = learning.experience_logs 의 종료행(done=True) ⨝ negotiation.sessions
|
||||||
|
(agent 가 직접 기록한 결과라 카드/자율 모드 무관하게 쌓인다). 이력 없으면 (0, None, None).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(_EXP_LOGS.c.settled_price, _SESSIONS.c.target_price)
|
||||||
|
.select_from(_EXP_LOGS.join(_SESSIONS, _SESSIONS.c.session_id == _EXP_LOGS.c.session_id))
|
||||||
|
.where(_EXP_LOGS.c.done == True, # noqa: E712
|
||||||
|
_EXP_LOGS.c.company_id == company_id,
|
||||||
|
_SESSIONS.c.supplier_id == supplier_id,
|
||||||
|
_EXP_LOGS.c.session_id != exclude_session_id,
|
||||||
|
_SESSIONS.c.deleted == False) # noqa: E712
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_supplier_history failed.", raise_error=False)
|
||||||
|
if err_type != ErrorType.SUCCESS or not rows:
|
||||||
|
return err_type, (0, None, None)
|
||||||
|
n = len(rows)
|
||||||
|
settled = [(int(sp), int(tp)) for sp, tp in rows if sp and tp]
|
||||||
|
success = len([1 for sp, tp in rows if sp]) / n
|
||||||
|
avg_ratio = (sum(sp / tp for sp, tp in settled) / len(settled)) if settled else None
|
||||||
|
return ErrorType.SUCCESS, (n, success, avg_ratio)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, (0, None, None)
|
||||||
|
|
||||||
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
|
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
@ -255,36 +231,6 @@ 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 = (
|
||||||
@ -330,7 +276,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[tuple], list[tuple]]]:
|
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
|
||||||
try:
|
try:
|
||||||
version_q = (
|
version_q = (
|
||||||
select(_QUOTATIONS.c.version_id)
|
select(_QUOTATIONS.c.version_id)
|
||||||
@ -345,7 +291,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
version_id = rows[0]
|
version_id = rows[0]
|
||||||
|
|
||||||
nego_q = (
|
nego_q = (
|
||||||
select(_NEGO_CARDS.c.number, _NEGO_CARDS.c.script, _NEGO_CARDS.c.tactic)
|
select(_NEGO_CARDS.c.number)
|
||||||
.select_from(
|
.select_from(
|
||||||
_VERSION_NEGO_CARDS.join(
|
_VERSION_NEGO_CARDS.join(
|
||||||
_NEGO_CARDS,
|
_NEGO_CARDS,
|
||||||
@ -364,7 +310,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
return n_err, ([], [])
|
return n_err, ([], [])
|
||||||
|
|
||||||
wild_q = (
|
wild_q = (
|
||||||
select(_WILD_CARDS.c.number, _WILD_CARDS.c.script, _WILD_CARDS.c.tactic)
|
select(_WILD_CARDS.c.number)
|
||||||
.select_from(
|
.select_from(
|
||||||
_VERSION_WILD_CARDS.join(
|
_VERSION_WILD_CARDS.join(
|
||||||
_WILD_CARDS,
|
_WILD_CARDS,
|
||||||
@ -375,8 +321,6 @@ 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)
|
||||||
)
|
)
|
||||||
@ -385,8 +329,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
return w_err, ([], [])
|
return w_err, ([], [])
|
||||||
|
|
||||||
return ErrorType.SUCCESS, (
|
return ErrorType.SUCCESS, (
|
||||||
[(str(r[0]), r[1], r[2]) for r in n_rows if r[0] is not None],
|
[str(r) for r in n_rows if r is not None],
|
||||||
[(str(r[0]), r[1], r[2]) for r in w_rows if r[0] is not None],
|
[str(r) for r in w_rows if r is not None],
|
||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
|
|||||||
@ -10,28 +10,62 @@ 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 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
MAX_ROUNDS = 3
|
||||||
# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 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 에 머무는 버그가 났었다.)
|
||||||
@ -49,39 +83,6 @@ 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
|
||||||
@ -113,11 +114,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
|
||||||
self.scripts = scripts_repo.load_scripts(rq_type)
|
# 자율 스텝은 병합만 해둔다(repo 캐시 오염 방지 위해 새 dict) — decider 미주입 시 도달 불가.
|
||||||
|
self.scripts = {**scripts_repo.load_scripts(rq_type), **_AUTONOMY_STEPS}
|
||||||
self.step_map = scripts_repo.client_step_mapping()
|
self.step_map = scripts_repo.client_step_mapping()
|
||||||
# 결정 스택 규칙층(Phase 1): 와일드카드 진입 임계·라운드 상한을 테넌트 config 에서 읽는다.
|
# 완전 자율 모드: ChatService 가 AutonomyStore 정책을 주입하면 가격협상 판정 룰을 대체한다.
|
||||||
# (하드코딩 1.02/1.05/3 을 데이터화 — 고객사별로 튜닝 가능, 코드 수정 불필요)
|
self.autonomy_decider = None # Callable[[dict], autonomy_actions.Action]
|
||||||
self.rules = scripts_repo.config.negotiation
|
|
||||||
|
|
||||||
# ---- public --------------------------------------------------------
|
# ---- public --------------------------------------------------------
|
||||||
def start(self, session: ChatSession) -> StepView:
|
def start(self, session: ChatSession) -> StepView:
|
||||||
@ -135,29 +136,25 @@ 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 ----------------------------------------------------
|
||||||
@ -185,12 +182,20 @@ 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 구매자 관점 조건 평가 (임계값은 config negotiation.* — 규칙층 데이터화).
|
"""KT 구매자 관점 조건 평가.
|
||||||
- 협력사 제시가 ≤ anchor → 우선협상(협상완료).
|
- 협력사 제시가 ≤ anchor → 우선협상(협상완료).
|
||||||
- anchor 살짝 초과(≤ anchor×wildcard_entry_ratio) + 와일드카드 미사용 → 와일드카드로 인하 압박.
|
- anchor 살짝 초과(≤ anchor*1.05) + 와일드카드 미사용 → 와일드카드로 인하 압박.
|
||||||
- 설정 카드(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)
|
||||||
@ -205,99 +210,91 @@ class ChatEngine:
|
|||||||
and anchor > 0
|
and anchor > 0
|
||||||
and anchor < price
|
and anchor < price
|
||||||
and (
|
and (
|
||||||
price <= anchor * self.rules.wildcard_1pct_ratio
|
price <= anchor * 1.02
|
||||||
or (bool(ctx.get("allow_selected_wildcards", True))
|
or (bool(ctx.get("allow_selected_wildcards", True)) and price <= anchor * 1.05)
|
||||||
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). 카운터제안이 상한을 넘거나 카드가
|
# round 는 매 가격입력마다 증가(기존가격제시=1). 그 이후 카운터제안이 MAX_ROUNDS 회를
|
||||||
# 소진되면 곧장 실패가 아니라 **종결 국면**으로 처리한다(실제 MD 협상 방식):
|
# 넘으면 종료한다. 카드선택(RL)이 실패(state ValueError)해도 used_action_ids 가 안 늘어
|
||||||
# ① 아직 종결 전술을 안 썼으면 → 가격협상으로 보내되 force_closing 마킹
|
# 카드 소진 조건만으로는 종료되지 않으므로, 라운드 상한을 독립적으로 둬 무한 가격입력을 막는다.
|
||||||
# (ChatService 가 종결 전술 — 중간값 절충/최후통첩 — 을 강제 발동)
|
# (선행 chat_server 의 `iteration >= 3` 와 동일한 안전장치.)
|
||||||
# ② 종결 전술까지 소진(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×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
"""앵커가에 아주 근접(≤ anchor*1.02)한 구간에서만 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)
|
||||||
target = ctx.get("target_price", 0)
|
if anchor > 0 and price <= anchor * 1.02:
|
||||||
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
|
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
||||||
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
|
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||||
# 제안가 공통 유효조건(≤목표가 · <제시가 · 직전 당사 제안 이상=역행 금지)은 시스템 1% 카드에도
|
ctx["wildcard_used"] = True
|
||||||
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
|
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가
|
||||||
prev_customer = ctx.get("prev_customer_price") or 0
|
return "wild_card_1pct"
|
||||||
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 --------------------------------------------------------
|
||||||
@ -307,109 +304,41 @@ 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"] = out["target_price"] = int(ctx["target_price"])
|
out["target"] = int(ctx["target_price"])
|
||||||
|
# DB 카드 정본(negodata 편집, card.nego_cards.script)은 {target_price} 변수명을 쓴다
|
||||||
|
# — 파일 스크립트의 {target}과 별개로 둘 다 지원(미치환 토큰 노출 방지).
|
||||||
|
out["target_price"] = int(ctx["target_price"])
|
||||||
if "anchor_price" in ctx:
|
if "anchor_price" in ctx:
|
||||||
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
|
out["anchor"] = int(ctx["anchor_price"])
|
||||||
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"])
|
out["anchoring_price"] = int(ctx["anchor_price"]) # DB 카드 정본 변수명(NGC-007 등)
|
||||||
# 용어 토큰 — 회사 용어 사전(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"])
|
||||||
# 인터넷 최저가: LPS 대표값(items.internet_lowest_price). 카드는 {internet_lowest_price},
|
if "autonomy_offer" in ctx:
|
||||||
# 라벨 매핑(variable_mapping.json)은 internet_min_price 를 쓰므로 target/anchor 처럼 양쪽 이름 모두 채운다.
|
out["autonomy_offer"] = int(ctx["autonomy_offer"])
|
||||||
# 미수집(0/없음)이면 키를 만들지 않는다 — 원형 유지 → 허위 시장가 인용 방지(NGC-008 은 값 있을 때만 유효).
|
# 인터넷 최저가(NGC-008): 수집값이 컨텍스트에 없으면 앵커가로 폴백 — 원형 토큰 노출 방지.
|
||||||
ilp = ctx.get("internet_lowest_price") or 0
|
# TODO: partner.item_internet_lowest_prices 최신 성공 수집값을 context loader 로 연결.
|
||||||
if ilp > 0:
|
if ctx.get("internet_lowest_price"):
|
||||||
out["internet_lowest_price"] = out["internet_min_price"] = int(ilp)
|
out["internet_lowest_price"] = int(ctx["internet_lowest_price"])
|
||||||
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
|
elif "anchor_price" in ctx:
|
||||||
anchor = ctx.get("anchor_price") or 0
|
out["internet_lowest_price"] = int(ctx["anchor_price"])
|
||||||
target = ctx.get("target_price") or 0
|
# 고객사 교환·요구 조건(NGC-009/010): 런타임 소스 미구현 — 중립 문구 폴백.
|
||||||
# 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다.
|
# TODO: 견적/카드 편집 단계에서 입력받아 컨텍스트로 전달.
|
||||||
# prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가
|
out["customer_condition"] = ctx.get("customer_condition") or "상호 협의된 조건"
|
||||||
# "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다.
|
# 인하율 = (기존 공급가 - 제시가) / 기존 공급가 * 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:
|
||||||
rate = ((base - ctx["input_price"]) / base) * 100
|
out["discount_rate"] = f"{((base - ctx['input_price']) / base) * 100:.1f}"
|
||||||
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"))
|
||||||
@ -422,14 +351,11 @@ 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", ""), step_vars),
|
script=self.repo.format_script(node.get("script", ""), self._vars(session)),
|
||||||
input_mode=node.get("next_input_mode", "null"),
|
input_mode=node.get("next_input_mode", "null"),
|
||||||
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
|
input_options=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 == "가격협상"),
|
||||||
@ -438,13 +364,9 @@ 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=self.repo.format_script(node.get("script", ""), step_vars),
|
step=session.step, script=node.get("script", ""),
|
||||||
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=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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
"""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 → 원문 폴백(엔진 재질문)
|
|
||||||
188
agent/negotiation/chat/service/ment_generator.py
Normal file
188
agent/negotiation/chat/service/ment_generator.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
"""MentGenerator — 자율 협상 행동을 LLM 이 자연어 멘트로 표현 (v2: 행동은 RL, 문장은 LLM).
|
||||||
|
|
||||||
|
역할 분리(안전 설계):
|
||||||
|
- 무엇을 말할지(수락/역제안 금액/압박 전략/결렬)는 RL 정책이 결정 — LLM 은 표현만 담당.
|
||||||
|
- 가드레일: 역제안 멘트에 제안 금액이 정확히 포함되지 않으면 폐기, 예외/미설정 시 None
|
||||||
|
→ 호출부(ChatService)가 기존 템플릿 멘트로 폴백한다. LLM 이 죽어도 협상은 계속된다.
|
||||||
|
|
||||||
|
설정: config.local.toml [OpenAIConfig] (Gemini 는 OpenAI 호환 base_url 로 접속).
|
||||||
|
비활성화: AUTONOMY_LLM=0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from negotiation.profiling.config import LlmCredentials
|
||||||
|
|
||||||
|
_STRATEGY_TONE = {
|
||||||
|
1: "경쟁 압박형 — 복수 공급처와 비교 검토 중임을 암시하며 긴장감을 준다",
|
||||||
|
2: "수용 공감형 — 상대 제안의 취지에 공감하며 부드럽게 조정을 요청한다",
|
||||||
|
3: "기준 고수형 — 내부 산정 기준과 목표가를 근거로 원칙을 지킨다",
|
||||||
|
4: "협력 파트너형 — 장기 파트너십과 후속 거래 확대 가능성을 강조한다",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SYSTEM = """너는 대기업 구매팀의 가격 협상 챗봇이다. 주어진 '전달 의도'를 자연스러운 한국어 협상 멘트로 바꿔 쓴다.
|
||||||
|
규칙 (위반 시 출력은 폐기된다):
|
||||||
|
- 1~3문장, 정중하되 간결하게. 출력은 멘트 텍스트만 (따옴표·설명 없이).
|
||||||
|
- 금액 숫자는 주어진 그대로 정확히 포함하고 단위는 '원'을 쓴다. 주어지지 않은 숫자·비율을 절대 만들지 않는다.
|
||||||
|
- 지정된 '화법' 전략 안에서만 말한다. 그 외의 협상 전술(물량·기간 약속, 조건 교환, 거래 연계,
|
||||||
|
독점 제안, 시장가·최저가 주장, 할인 약속 등)을 지어내지 않는다.
|
||||||
|
- 회사의 정책·사실을 단정하지 않는다. 주어진 의도에 없는 정보는 말하지 않는다.
|
||||||
|
- 상대는 협력사(판매자)이고 우리는 구매자다."""
|
||||||
|
|
||||||
|
# 생성문 금지어 — 승인되지 않은 커밋/주장 계열. 걸리면 템플릿 폴백(협상은 계속).
|
||||||
|
_FORBIDDEN = ("보장", "물량", "독점", "무조건", "최저가", "시장 가격", "시장가", "계약 기간",
|
||||||
|
"법적", "위약", "%")
|
||||||
|
|
||||||
|
|
||||||
|
def _configured() -> bool:
|
||||||
|
if os.getenv("AUTONOMY_LLM", "1").lower() in ("0", "false", "no"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return LlmCredentials.from_config().is_configured()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _digits(s) -> str:
|
||||||
|
return re.sub(r"[^\d]", "", str(s))
|
||||||
|
|
||||||
|
|
||||||
|
def _history_hints(ctx: dict) -> str:
|
||||||
|
"""대화 기억 힌트 — 무기억 생성이 '매번 같은 멘트'를 만들던 문제의 해법.
|
||||||
|
|
||||||
|
① 직전 우리 제안이 거절된 사실과 이번 제안과의 관계(양보/입장유지)를 짚게 하고
|
||||||
|
② 직전 봇 멘트를 보여주며 같은 문장 구조·표현의 반복을 금지한다."""
|
||||||
|
hints = []
|
||||||
|
prev = ctx.get("autonomy_prev")
|
||||||
|
if prev and prev.get("kind") == "counter":
|
||||||
|
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
|
||||||
|
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
|
||||||
|
cur_offer = int(ctx.get("autonomy_offer") or 0)
|
||||||
|
if cur_offer > prev_offer:
|
||||||
|
hints.append(f"참고: 직전 라운드에 우리가 {prev_offer:,}원을 제안했으나 거절당했고, "
|
||||||
|
f"이번에는 {cur_offer - prev_offer:,}원 더 양보한 제안이다. 이 진전을 자연스럽게 짚어라.")
|
||||||
|
elif cur_offer == prev_offer and cur_offer > 0:
|
||||||
|
hints.append(f"참고: 직전에 제안한 {prev_offer:,}원을 거절당했지만 같은 금액을 유지한다. "
|
||||||
|
f"입장이 확고함을 정중하게 전하라.")
|
||||||
|
elif prev_offer > 0:
|
||||||
|
hints.append(f"참고: 직전 제안({prev_offer:,}원)이 거절된 뒤의 재제안이다.")
|
||||||
|
last_ment = ctx.get("autonomy_last_ment")
|
||||||
|
if last_ment:
|
||||||
|
hints.append(f'직전 봇 멘트: "{last_ment}" — 이와 같은 문장 구조·표현을 반복하지 말고 다르게 써라.')
|
||||||
|
return " ".join(hints)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_for(step: str, ctx: dict) -> Optional[str]:
|
||||||
|
price = int(ctx.get("input_price") or 0)
|
||||||
|
rnd = ctx.get("round", 1)
|
||||||
|
if step in ("자율_역제안", "자율_최종제안"):
|
||||||
|
offer = int(ctx.get("autonomy_offer") or 0)
|
||||||
|
if offer <= 0:
|
||||||
|
return None
|
||||||
|
strategy = int((ctx.get("autonomy_last") or {}).get("s") or 3)
|
||||||
|
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
|
||||||
|
final = ("이번이 우리가 제시할 수 있는 마지막 제안이며, 거절하시면 이번 협상은 종료됨을 "
|
||||||
|
"분명하되 정중하게 밝혀라. " if step == "자율_최종제안" else "")
|
||||||
|
return (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
|
||||||
|
f"전달 의도: 우리는 **{offer:,}원**이면 즉시 수락하고 우선협상 대상으로 확정할 수 있다 — "
|
||||||
|
f"이 핵심 의미는 유지하되 문장 표현은 자유롭게 새로 써라. {final}화법: {tone}. "
|
||||||
|
f"{_history_hints(ctx)} 마지막에 수락 여부를 물어라.")
|
||||||
|
if step.startswith("자율_압박_"):
|
||||||
|
strategy = int(step.rsplit("_", 1)[1])
|
||||||
|
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
|
||||||
|
# 주의: 목표가는 프롬프트에 넣지 않는다 — 압박 중 목표가 노출은 우리 상한을 까는 것
|
||||||
|
# (상대가 그 밑으로 내려올 이유가 사라진다). 숫자 커밋은 역제안/최종제안에서만.
|
||||||
|
base = (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
|
||||||
|
f"전달 의도: 어떤 금액도 언급하지 말고(내부 기준·목표가 숫자 금지), 제시가와 우리 기준의 "
|
||||||
|
f"거리가 있다는 취지로 가격 재제안을 요청한다. 화법: {tone}. "
|
||||||
|
f"{_history_hints(ctx)}")
|
||||||
|
# 시장가 근거 (구 NGC-008 의 자율 버전): 수집된 인터넷최저가가 실재하고 제시가가 그보다
|
||||||
|
# 높을 때만 사실 근거로 인용을 허용한다 — 미수집 품목에서 지어내는 주장은 가드가 차단.
|
||||||
|
if _market_evidence(ctx):
|
||||||
|
il = int(ctx["internet_lowest_price"])
|
||||||
|
base += (f" 참고 사실(인용 허용되는 유일한 금액): 동일 품목의 인터넷 최저가가 {il:,}원으로 "
|
||||||
|
f"확인된다. 현재 제시가가 이보다 높다는 점을 근거로 조정 여지를 정중히 짚어라.")
|
||||||
|
return base
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _market_evidence(ctx: dict) -> bool:
|
||||||
|
"""시장가 근거 인용 가능 조건: 인터넷최저가 수집됨 + 제시가가 그보다 높음."""
|
||||||
|
il = int(ctx.get("internet_lowest_price") or 0)
|
||||||
|
return il > 0 and float(ctx.get("input_price") or 0) > il
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_amounts(ctx: dict) -> set:
|
||||||
|
"""멘트에 등장해도 되는 숫자 집합 — 우리가 프롬프트로 준 값들뿐. 이 밖의 금액 = 할루시네이션."""
|
||||||
|
# 목표가는 화이트리스트에 없다 — 압박 멘트가 목표가를 새면(상한 노출) 즉시 폐기된다.
|
||||||
|
# 역제안·최종제안의 제안가(autonomy_offer)가 목표가와 같은 경우만 그 값으로 허용된다.
|
||||||
|
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
|
||||||
|
out = {int(ctx.get("input_price") or 0), int(ctx.get("autonomy_offer") or 0),
|
||||||
|
int(ctx.get("round") or 0)}
|
||||||
|
if _market_evidence(ctx):
|
||||||
|
out.add(int(ctx["internet_lowest_price"])) # 시장가 근거 인용 시 그 수치만 허용
|
||||||
|
prev = ctx.get("autonomy_prev")
|
||||||
|
if prev and prev.get("kind") == "counter":
|
||||||
|
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
|
||||||
|
out |= {prev_offer, abs(int(ctx.get("autonomy_offer") or 0) - prev_offer)}
|
||||||
|
return {str(v) for v in out if v}
|
||||||
|
|
||||||
|
|
||||||
|
def _guard(step: str, ctx: dict, text: str) -> bool:
|
||||||
|
"""LLM 출력 검증(할루시네이션 차단) — 실패 시 템플릿 폴백.
|
||||||
|
|
||||||
|
① 길이/문장 완결 ② 금지어(승인 안 된 커밋·주장) ③ 숫자 화이트리스트: 멘트의 모든
|
||||||
|
3자리+ 숫자는 우리가 준 값(제시가·제안가·목표가·직전제안가)이어야 한다 — 지어낸 금액 즉시 폐기.
|
||||||
|
④ 역제안은 제안 금액 포함 필수."""
|
||||||
|
if not text or len(text) < 10 or len(text) > 600:
|
||||||
|
return False
|
||||||
|
if not text.rstrip().endswith(("다.", "요.", "요?", "까?", "니까?", ".", "?")):
|
||||||
|
return False # 문장 중간 잘림(thinking 토큰에 한도 소진 등) → 템플릿 폴백
|
||||||
|
forbidden = _FORBIDDEN
|
||||||
|
if _market_evidence(ctx):
|
||||||
|
# 시장가 근거가 정당한 턴에는 '최저가/시장가' 언급을 허용 (수치는 아래 화이트리스트가 검증).
|
||||||
|
forbidden = tuple(w for w in _FORBIDDEN if w not in ("최저가", "시장가", "시장 가격"))
|
||||||
|
if any(w in text for w in forbidden):
|
||||||
|
return False
|
||||||
|
allowed = _allowed_amounts(ctx)
|
||||||
|
for num in re.findall(r"\d{3,}", text.replace(",", "")):
|
||||||
|
if num not in allowed:
|
||||||
|
return False # 프롬프트에 없던 금액 생성 = 할루시네이션
|
||||||
|
if step in ("자율_역제안", "자율_최종제안"):
|
||||||
|
return _digits(ctx.get("autonomy_offer")) in _digits(text)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def generate(step: str, ctx: dict) -> Optional[str]:
|
||||||
|
"""자율 스텝 멘트 생성. 미설정/실패/검증불통과 → None (호출부 템플릿 유지)."""
|
||||||
|
if not _configured():
|
||||||
|
return None
|
||||||
|
prompt = _prompt_for(step, ctx)
|
||||||
|
if prompt is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from negotiation.profiling.infra.llm_adapter import chat_complete
|
||||||
|
# openai SDK 는 동기 — 이벤트루프 블로킹 방지 위해 스레드로 넘긴다.
|
||||||
|
# max_tokens 넉넉히 — Gemini 2.5 계열은 thinking 토큰이 한도에 포함돼 짧으면 본문이 잘린다.
|
||||||
|
# 시간 상한: backend→agent 타임아웃(10s)보다 확실히 짧아야 한다 — 초과 시 템플릿 폴백으로
|
||||||
|
# 협상은 즉시 계속된다("협상 응답 지연" 토스트 방지). LLM_TIMEOUT_S 로 조절.
|
||||||
|
text = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(
|
||||||
|
chat_complete,
|
||||||
|
[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
|
||||||
|
None, False, 0.9, 2048, # temperature 0.9 — 표현 다양성 (의미는 프롬프트 가드)
|
||||||
|
),
|
||||||
|
timeout=float(os.getenv("LLM_TIMEOUT_S", "6")),
|
||||||
|
)
|
||||||
|
text = (text or "").strip().strip('"')
|
||||||
|
if _guard(step, ctx, text):
|
||||||
|
return text
|
||||||
|
LOG.w(f"[MentGenerator] 가드레일 불통과 → 템플릿 폴백 (step={step})")
|
||||||
|
return None
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(f"[MentGenerator] LLM 실패 → 템플릿 폴백: {ex}")
|
||||||
|
return None
|
||||||
@ -19,7 +19,6 @@ 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
|
||||||
|
|
||||||
@ -39,30 +38,29 @@ 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(무할인 폴백)
|
||||||
done_ceiling_price: int # 타결 상한가 — sessions.done_ceiling_price(생성 시 박제). 없으면 target
|
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
|
||||||
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)
|
||||||
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
|
# ---- 자율 에이전트 v3 상태 특징 소스 (없으면 0/None — 특징은 중립 기본값으로 폴백) ----
|
||||||
# 카드번호 → 전술 {offer_variable, min_round, closing}. 스크립트 파싱 + tactic JSONB 로 시작 시 1회 확정 —
|
internet_lowest_price: int = 0 # items.internet_lowest_price (미수집 0)
|
||||||
# 진행 중 협상은 카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다(세션 컨텍스트에 박제).
|
deadline_end_ts: Optional[float] = None # 견적 마감(epoch 초) — quotations.end_time
|
||||||
card_specs: dict
|
deadline_total_s: Optional[float] = None # 협상 전체 기간(초) — end−start
|
||||||
|
hist_n: int = 0 # 이 협력사와의 과거 협상 횟수
|
||||||
|
hist_success: Optional[float] = None # 과거 성사율 (이력 없으면 None)
|
||||||
|
hist_settle_ratio: Optional[float] = None # 과거 평균 타결가/목표가 (성사 이력 없으면 None)
|
||||||
|
|
||||||
|
|
||||||
class NegotiationContextLoader:
|
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]) -> Optional[NegotiationDbContext]:
|
async def load(self, session_id: Optional[str],
|
||||||
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백)."""
|
company_id: Optional[str] = None) -> Optional[NegotiationDbContext]:
|
||||||
|
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백).
|
||||||
|
company_id 는 협력사 이력 집계(experience_logs 테넌트 스코프)용 — 없으면 이력 특징 생략."""
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@ -74,10 +72,8 @@ 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, done_ceiling_price, item_id, quotation_id, supplier_id = row
|
qt_type, target_price, anchoring_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 정책상
|
||||||
@ -94,16 +90,8 @@ 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)
|
||||||
|
|
||||||
# 협상 기준가 + 그 호칭 — 어느 컬럼을 쓸지는 고객사 설정(hidden_fields)이 정한다(crud).
|
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
||||||
# 없으면 0(인하율 멘트 미표시).
|
_, item_price = await self.crud.get_item_price(s, item_id)
|
||||||
_, (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.
|
||||||
@ -116,41 +104,39 @@ 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)
|
||||||
nego_rows, wild_rows = selected_cards
|
selected_nego_cards, selected_wild_cards = 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
|
# ---- 자율 에이전트 v3 특징 소스 (조회 실패는 전부 중립 폴백 — 협상은 계속돼야 한다) ----
|
||||||
_, card_count = await self.crud.get_card_count(s, sid)
|
_, internet_lowest = await self.crud.get_item_internet_lowest(s, item_id)
|
||||||
|
_, period = await self.crud.get_quotation_period(s, quotation_id)
|
||||||
|
deadline_end_ts = deadline_total_s = None
|
||||||
|
if period and period[1] is not None:
|
||||||
|
end_ts = period[1].timestamp()
|
||||||
|
start_ts = period[0].timestamp() if period[0] is not None else None
|
||||||
|
total = (end_ts - start_ts) if start_ts else None
|
||||||
|
if total and total > 0:
|
||||||
|
deadline_end_ts, deadline_total_s = end_ts, total
|
||||||
|
hist_n, hist_success, hist_settle = 0, None, None
|
||||||
|
if company_id:
|
||||||
|
_, hist = await self.crud.get_supplier_history(s, company_id, supplier_id, sid)
|
||||||
|
hist_n, hist_success, hist_settle = hist
|
||||||
|
|
||||||
return NegotiationDbContext(
|
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,
|
||||||
card_count=card_count,
|
internet_lowest_price=internet_lowest,
|
||||||
card_specs=card_specs,
|
deadline_end_ts=deadline_end_ts,
|
||||||
|
deadline_total_s=deadline_total_s,
|
||||||
|
hist_n=hist_n,
|
||||||
|
hist_success=hist_success,
|
||||||
|
hist_settle_ratio=hist_settle,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
"""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,11 +38,6 @@ 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
|
||||||
@ -94,47 +89,25 @@ 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]:
|
||||||
"""카드 멘트 해석(템플릿 + 변수 치환). DB(정본) 우선 → 파일 폴백. 마커는 불투명 텍스트."""
|
"""카드 멘트 해석. cards.source_type == 'backoffice_db' 면 card.nego_cards.script(DB)를
|
||||||
template, _, _ = await self.resolve_card_template(action_id, card_id, prefer_db=prefer_db)
|
우선 조회하고, 없거나 file 모드면 scripts_cards.json(파일) 폴백. 변수 치환 후 반환.
|
||||||
if not template:
|
|
||||||
return None
|
|
||||||
return self.format_script(template, variables)
|
|
||||||
|
|
||||||
async def resolve_wild_card_template(self, number: str) -> Optional[str]:
|
DB 멘트는 백오피스(negodata)가 편집한 정본이라 파일보다 우선한다. 마커(**굵게** 등)가
|
||||||
"""선택형 와일드카드(WC-*) 멘트 템플릿 — card.wild_cards(DB, negodata 편집 정본) 조회.
|
섞여 있어도 agent 는 불투명 텍스트로 취급 — 표현 렌더는 프론트 소유.
|
||||||
없거나 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):
|
||||||
_, script = await self._card_repo.get_wild_card_by_number(s, number)
|
_, text = await self._card_repo.get_script_by_number(s, card_id)
|
||||||
return script
|
return text
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
85
agent/negotiation/policies/autonomy_actions.py
Normal file
85
agent/negotiation/policies/autonomy_actions.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""완전 자율 협상 행동 공간 (numpy 전용 — 학습(tools)과 서빙(policy)이 공유).
|
||||||
|
|
||||||
|
카드 카탈로그 대신 행동의 '의미'만 남긴다:
|
||||||
|
ACCEPT 현재 제시가로 타결
|
||||||
|
WALK 협상 결렬 선언
|
||||||
|
COUNTER(q, s) "C원이면 수락" 역제안. C = anchor + q×(target−anchor), s = 화법 전략
|
||||||
|
PRESS(s) 설득 압박 (카드의 일반화 — 전략 1경쟁/2수용/3고수/4협력)
|
||||||
|
|
||||||
|
특징 벡터(ACTION_DIM=8) = 유형 one-hot(3) + 가격 위치(1) + 전략 one-hot(4).
|
||||||
|
ScoreNet(상태 + 행동특징) → 스칼라 점수로 후보 30개를 채점해 argmax 한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
COUNTER_GRID = [-0.05, 0.0, 0.25, 0.5, 0.75, 1.0] # C = anchor + q×(target−anchor)
|
||||||
|
ACTION_DIM = 3 + 1 + 4 + 1 # 유형(3) + 위치(1) + 전략(4) + 컷폭(1: 현 제시가 대비 인하 요구율)
|
||||||
|
|
||||||
|
# 자율 전용 추가 상태 (v3):
|
||||||
|
# [0] 직전 역제안 존재(0/1) [1] 직전 역제안 위치 q ← 에피소드 기억(같은 숫자 반복 방지)
|
||||||
|
# [2] 마감 잔여율(남은시간/전체, 미상 0.5) ← 견적 마감(quotations.end_time)
|
||||||
|
# [3] 과거 협상 횟수 min(n,5)/5 [4] 과거 성사율(미상 0.5)
|
||||||
|
# [5] 과거 평균 타결수준 norm((타결가/목표가−0.8)/0.4, 미상 0.5) ← 이 협력사와의 이력(experience_logs)
|
||||||
|
# [6] 인터넷최저가 갭 clip((최저가−앵커)/앵커/0.1, ±1, 미상 0) ← 숨은 하한가의 관측 가능한 힌트
|
||||||
|
# 특징은 학습 시뮬에도 동일하게 존재해야 한다(train_full_autonomy 가 대응물을 생성).
|
||||||
|
EXTRA_STATE_DIM = 7
|
||||||
|
|
||||||
|
|
||||||
|
def extra_state(last_kind: str = "", last_q: float = 0.0, deadline: float = 0.5,
|
||||||
|
hist_n: float = 0.0, hist_success: float = 0.5, hist_settle: float = 0.5,
|
||||||
|
internet_gap: float = 0.0) -> np.ndarray:
|
||||||
|
has_counter = 1.0 if last_kind == "counter" else 0.0
|
||||||
|
return np.array([
|
||||||
|
has_counter,
|
||||||
|
float(np.clip(last_q, -1.0, 1.0)) * has_counter,
|
||||||
|
float(np.clip(deadline, 0.0, 1.0)),
|
||||||
|
float(np.clip(hist_n, 0.0, 1.0)),
|
||||||
|
float(np.clip(hist_success, 0.0, 1.0)),
|
||||||
|
float(np.clip(hist_settle, 0.0, 1.0)),
|
||||||
|
float(np.clip(internet_gap, -1.0, 1.0)),
|
||||||
|
], dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def settle_norm(avg_settle_ratio: float) -> float:
|
||||||
|
"""평균 (타결가/목표가) → 0~1 정규화 (0.8→0, 1.0→0.5, 1.2→1)."""
|
||||||
|
return float(np.clip((avg_settle_ratio - 0.8) / 0.4, 0.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def internet_gap_feat(internet_lowest: float, anchor: float) -> float:
|
||||||
|
"""인터넷최저가의 앵커 대비 갭 (±10% 스케일). 최저가 없으면 0을 쓴다."""
|
||||||
|
if not internet_lowest or anchor <= 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.clip((internet_lowest - anchor) / anchor / 0.1, -1.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Action:
|
||||||
|
kind: str # accept | walk | counter | press
|
||||||
|
counter_q: float = 0.0 # counter 위치 (anchor~target 스팬 비율)
|
||||||
|
strategy: int = 0 # press/counter 의 화법 전략 (1~4, 0=없음)
|
||||||
|
|
||||||
|
def feat(self, price_pos: float, cut: float = 0.0) -> np.ndarray:
|
||||||
|
"""cut: 이 행동이 요구하는 인하폭 (현 제시가 대비, counter 만 >0) — 대형컷의 무례함을
|
||||||
|
정책이 지각하게 한다. 갭이 크면 역제안 대신 압박이 낫다는 걸 배우는 근거 특징."""
|
||||||
|
t = {"accept": [1, 0, 0], "walk": [0, 1, 0]}.get(self.kind, [0, 0, 1])
|
||||||
|
pos = price_pos if self.kind == "accept" else self.counter_q
|
||||||
|
s = np.zeros(4, dtype=np.float32)
|
||||||
|
if self.strategy:
|
||||||
|
s[self.strategy - 1] = 1.0
|
||||||
|
return np.concatenate([np.array(t, dtype=np.float32),
|
||||||
|
np.array([float(np.clip(pos, -1.0, 2.0)),
|
||||||
|
], dtype=np.float32), s,
|
||||||
|
np.array([float(np.clip(cut, 0.0, 1.0))], dtype=np.float32)])
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_actions():
|
||||||
|
"""전 행동 후보: 수락 1 + 결렬 1 + 역제안 6×전략4 + 압박 4 = 30."""
|
||||||
|
out = [Action("accept"), Action("walk")]
|
||||||
|
out += [Action("counter", q, s) for q in COUNTER_GRID for s in (1, 2, 3, 4)]
|
||||||
|
out += [Action("press", 0.0, s) for s in (1, 2, 3, 4)]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
ACTIONS = candidate_actions()
|
||||||
@ -41,9 +41,6 @@ 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
|
||||||
|
|||||||
132
agent/negotiation/policies/feature_dqn_policy.py
Normal file
132
agent/negotiation/policies/feature_dqn_policy.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""FeatureDQNPolicy — action-as-feature DQN (Phase 2·3).
|
||||||
|
|
||||||
|
고정 슬롯 Q(s)→[11개] 대신 ScoreNet(상태벡터 + 카드임베딩) → 스칼라 점수.
|
||||||
|
결정 시 가용 카드 풀을 순회 채점해 argmax → 카드 추가/삭제/새 카드(zero-shot)에 구조 변화 없음.
|
||||||
|
협력사 특징은 상태벡터에 포함(feature_builder) → '협력사를 입력으로' 달성.
|
||||||
|
|
||||||
|
가변 행동 학습: replay 에 다음 상태의 '가용 카드 임베딩들'을 함께 저장,
|
||||||
|
target = r + γ · max_{c'∈next_avail} Q(s', c') · (1-done)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from collections import deque
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreNet(nn.Module):
|
||||||
|
"""(상태 + 카드임베딩) → 스칼라 점수."""
|
||||||
|
|
||||||
|
def __init__(self, state_dim: int, card_dim: int, hidden: int = 128):
|
||||||
|
super().__init__()
|
||||||
|
self.net = nn.Sequential(
|
||||||
|
nn.Linear(state_dim + card_dim, hidden), nn.ReLU(),
|
||||||
|
nn.Linear(hidden, hidden), nn.ReLU(),
|
||||||
|
nn.Linear(hidden, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [B, state+card]
|
||||||
|
return self.net(x).squeeze(-1) # [B]
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureDQNPolicy:
|
||||||
|
name = "feature_dqn"
|
||||||
|
|
||||||
|
def __init__(self, state_dim: int, card_dim: int, device: str = "cpu",
|
||||||
|
lr: float = 1e-3, gamma: float = 0.95, hidden: int = 128,
|
||||||
|
eps_start: float = 1.0, eps_end: float = 0.05, eps_decay: int = 6000,
|
||||||
|
buffer_size: int = 50_000, batch_size: int = 64, target_sync: int = 500):
|
||||||
|
self.device = device
|
||||||
|
self.gamma = gamma
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.target_sync = target_sync
|
||||||
|
self.q = ScoreNet(state_dim, card_dim, hidden).to(device)
|
||||||
|
self.tgt = ScoreNet(state_dim, card_dim, hidden).to(device)
|
||||||
|
self.tgt.load_state_dict(self.q.state_dict())
|
||||||
|
self.opt = torch.optim.Adam(self.q.parameters(), lr=lr)
|
||||||
|
self.buf: deque = deque(maxlen=buffer_size)
|
||||||
|
self.eps_start, self.eps_end, self.eps_decay = eps_start, eps_end, eps_decay
|
||||||
|
self.steps = 0
|
||||||
|
self.greedy = False # 평가 모드(탐색 끔)
|
||||||
|
|
||||||
|
# ---- 탐색 스케줄 ----------------------------------------------------
|
||||||
|
def eps(self) -> float:
|
||||||
|
if self.greedy:
|
||||||
|
return 0.0
|
||||||
|
return self.eps_end + (self.eps_start - self.eps_end) * math.exp(-self.steps / self.eps_decay)
|
||||||
|
|
||||||
|
# ---- 채점/선택 -------------------------------------------------------
|
||||||
|
def scores(self, state_feat: np.ndarray, card_embs: np.ndarray) -> np.ndarray:
|
||||||
|
"""가용 카드 K개 일괄 채점. card_embs: [K, card_dim] → [K]."""
|
||||||
|
k = card_embs.shape[0]
|
||||||
|
x = np.concatenate([np.repeat(state_feat[None, :], k, axis=0), card_embs], axis=1)
|
||||||
|
with torch.no_grad():
|
||||||
|
return self.q(torch.tensor(x, device=self.device)).cpu().numpy()
|
||||||
|
|
||||||
|
def select(self, state_feat: np.ndarray, card_embs: np.ndarray) -> Tuple[int, float, float]:
|
||||||
|
"""(선택 인덱스, propensity, 선택 점수). 인덱스는 card_embs 행 기준."""
|
||||||
|
k = card_embs.shape[0]
|
||||||
|
sc = self.scores(state_feat, card_embs)
|
||||||
|
e = self.eps()
|
||||||
|
if random.random() < e:
|
||||||
|
i = random.randrange(k)
|
||||||
|
prop = e / k
|
||||||
|
else:
|
||||||
|
i = int(sc.argmax())
|
||||||
|
prop = (1.0 - e) + e / k
|
||||||
|
return i, prop, float(sc[i])
|
||||||
|
|
||||||
|
# ---- 경험/학습 -------------------------------------------------------
|
||||||
|
def remember(self, state_feat: np.ndarray, card_emb: np.ndarray, reward: float,
|
||||||
|
next_state_feat: Optional[np.ndarray], next_card_embs: Optional[np.ndarray],
|
||||||
|
done: bool):
|
||||||
|
self.buf.append((state_feat, card_emb, reward, next_state_feat, next_card_embs, done))
|
||||||
|
|
||||||
|
def train_step(self) -> Optional[float]:
|
||||||
|
if len(self.buf) < self.batch_size:
|
||||||
|
return None
|
||||||
|
batch = random.sample(self.buf, self.batch_size)
|
||||||
|
|
||||||
|
# Q(s, a_chosen)
|
||||||
|
xs = np.stack([np.concatenate([s, c]) for s, c, *_ in batch])
|
||||||
|
q_sa = self.q(torch.tensor(xs, device=self.device))
|
||||||
|
|
||||||
|
# target = r + γ·max_{c'} Q_tgt(s', c') — 가변 후보라 후보 전체를 한 번에 forward 후 세그먼트 max
|
||||||
|
rewards = torch.tensor([b[2] for b in batch], device=self.device, dtype=torch.float32)
|
||||||
|
dones = torch.tensor([float(b[5]) for b in batch], device=self.device)
|
||||||
|
next_rows, owner = [], []
|
||||||
|
for bi, (_, _, _, s2, cands, done) in enumerate(batch):
|
||||||
|
if done or s2 is None or cands is None or len(cands) == 0:
|
||||||
|
continue
|
||||||
|
for c in cands:
|
||||||
|
next_rows.append(np.concatenate([s2, c]))
|
||||||
|
owner.append(bi)
|
||||||
|
q_next_max = torch.zeros(self.batch_size, device=self.device)
|
||||||
|
if next_rows:
|
||||||
|
with torch.no_grad():
|
||||||
|
q_all = self.tgt(torch.tensor(np.stack(next_rows), device=self.device))
|
||||||
|
owner_t = torch.tensor(owner, device=self.device)
|
||||||
|
q_next_max = q_next_max.index_reduce_(0, owner_t, q_all, "amax", include_self=False)
|
||||||
|
target = rewards + self.gamma * q_next_max * (1.0 - dones)
|
||||||
|
|
||||||
|
loss = nn.functional.smooth_l1_loss(q_sa, target)
|
||||||
|
self.opt.zero_grad()
|
||||||
|
loss.backward()
|
||||||
|
self.opt.step()
|
||||||
|
self.steps += 1
|
||||||
|
if self.steps % self.target_sync == 0:
|
||||||
|
self.tgt.load_state_dict(self.q.state_dict())
|
||||||
|
return float(loss)
|
||||||
|
|
||||||
|
# ---- 저장/로드 -------------------------------------------------------
|
||||||
|
def save(self, path: str):
|
||||||
|
torch.save(self.q.state_dict(), path)
|
||||||
|
|
||||||
|
def load(self, path: str):
|
||||||
|
sd = torch.load(path, map_location=self.device)
|
||||||
|
self.q.load_state_dict(sd)
|
||||||
|
self.tgt.load_state_dict(sd)
|
||||||
@ -36,8 +36,7 @@ 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],
|
def _ucb_scores(self, state_index: int, available: List[int]) -> np.ndarray:
|
||||||
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)
|
||||||
@ -46,15 +45,11 @@ 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, prior=ctx.prior_bonus)
|
scores = self._ucb_scores(ctx.state_index, available)
|
||||||
action_id = int(np.argmax(scores))
|
action_id = int(np.argmax(scores))
|
||||||
n = len(available)
|
n = len(available)
|
||||||
# ε-greedy 근사 propensity (greedy 액션)
|
# ε-greedy 근사 propensity (greedy 액션)
|
||||||
|
|||||||
152
agent/negotiation/policy/autonomy_store.py
Normal file
152
agent/negotiation/policy/autonomy_store.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
"""AutonomyStore — 완전 자율 협상 정책 서빙 (룰 대체, numpy 전용).
|
||||||
|
|
||||||
|
AUTONOMY_MODE=1 이면 가격협상 판정 룰(앵커 이하 타결 / 와일드카드 존 / 라운드 상한)과
|
||||||
|
카드 선택을 전부 이 정책의 행동 결정으로 대체한다:
|
||||||
|
accept → 협상완료 (제시가 타결) walk → 협상실패
|
||||||
|
counter → "C원이면 수락" 역제안 스텝 press → 전략별 압박 멘트 스텝
|
||||||
|
|
||||||
|
행동의 유일한 유인은 보상 함수다. 남는 제한은 두 가지뿐이며 비즈니스 룰이 아니다:
|
||||||
|
- 역제안 후보 격자가 [anchor−5%span, target] 안 (행동 공간 정의)
|
||||||
|
- 세션 턴 상한(엔지니어링 타임아웃, ChatEngine._AUTONOMY_TURN_CAP)
|
||||||
|
|
||||||
|
번들: artifacts/autonomy_serving.npz (tools/export_autonomy_serving.py).
|
||||||
|
불가(플래그 꺼짐/번들 없음)면 None → 기존 룰 엔진 그대로 (즉시 롤백 경로).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from negotiation.policies.autonomy_actions import (
|
||||||
|
ACTIONS, Action, extra_state, internet_gap_feat, settle_norm)
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import (
|
||||||
|
build_state_features, build_tenant_features)
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BUNDLE_PATH = os.path.join(_HERE, "..", "..", "artifacts", "autonomy_serving.npz")
|
||||||
|
|
||||||
|
|
||||||
|
class AutonomyPolicy:
|
||||||
|
"""세션 컨텍스트 → 상태특징 → 행동(greedy). ChatEngine 에 decider 로 주입된다."""
|
||||||
|
|
||||||
|
name = "full_autonomy"
|
||||||
|
|
||||||
|
def __init__(self, z, reward_cfg):
|
||||||
|
self._W = (z["W0"], z["b0"], z["W1"], z["b1"], z["W2"], z["b2"])
|
||||||
|
self._state_dim = int(z["state_dim"])
|
||||||
|
self._tenant_feat = build_tenant_features(reward_cfg)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _acceptance(ctx: dict) -> float:
|
||||||
|
base = ctx.get("item_price") or ctx.get("first_offer_price") or 0
|
||||||
|
cur = ctx.get("input_price") or 0
|
||||||
|
if base <= 0 or cur <= 0:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, (base - cur) / base)
|
||||||
|
|
||||||
|
def decide(self, ctx: dict) -> Action:
|
||||||
|
"""ChatSession.context → Action. 상태 구성은 ChatService._snapshot 과 동일 규칙."""
|
||||||
|
snap = NegotiationSnapshot(
|
||||||
|
revenue_amount=ctx["revenue_amount"], distribution_code=ctx["distribution_code"],
|
||||||
|
partner_count=ctx["partner_count"], acceptance_ratio=self._acceptance(ctx),
|
||||||
|
input_price=ctx.get("input_price", ctx["anchor_price"]), anchor_price=ctx["anchor_price"],
|
||||||
|
target_price=ctx["target_price"], round_number=ctx.get("round", 0),
|
||||||
|
)
|
||||||
|
# v3 추가 특징: 직전 역제안 기억 + 마감 잔여율 + 협력사 이력 + 인터넷최저가 갭.
|
||||||
|
# 소스가 없으면 전부 중립값(0.5/0) — 학습 시뮬의 '미상' 표현과 동일해야 한다.
|
||||||
|
# 역제안 기억은 autonomy_last_counter(역제안만 갱신) — autonomy_last(마지막 행동)를 쓰면
|
||||||
|
# 사이에 낀 설득이 기억을 지워 단조 봉투가 뚫린다(counter→press→counter 철회 실버그).
|
||||||
|
# 시뮬의 last_kind/last_q 도 역제안만 추적하므로 이쪽이 학습 분포와도 일치한다.
|
||||||
|
last = ctx.get("autonomy_last_counter") or {}
|
||||||
|
deadline = 0.5
|
||||||
|
end_ts, total_s = ctx.get("deadline_end_ts"), ctx.get("deadline_total_s")
|
||||||
|
if end_ts and total_s:
|
||||||
|
import time
|
||||||
|
deadline = float(np.clip((end_ts - time.time()) / total_s, 0.0, 1.0))
|
||||||
|
hist_n = int(ctx.get("hist_n") or 0)
|
||||||
|
hist_success = float(ctx["hist_success"]) if ctx.get("hist_success") is not None else 0.5
|
||||||
|
hist_settle = (settle_norm(float(ctx["hist_settle_ratio"]))
|
||||||
|
if ctx.get("hist_settle_ratio") is not None else 0.5)
|
||||||
|
sf = np.concatenate([build_state_features(snap), self._tenant_feat, extra_state(
|
||||||
|
last.get("kind", ""), float(last.get("q", 0.0)),
|
||||||
|
deadline=deadline, hist_n=min(hist_n, 5) / 5.0,
|
||||||
|
hist_success=hist_success if hist_n else 0.5,
|
||||||
|
hist_settle=hist_settle,
|
||||||
|
internet_gap=internet_gap_feat(float(ctx.get("internet_lowest_price") or 0),
|
||||||
|
float(snap.anchor_price)),
|
||||||
|
)])
|
||||||
|
span = max(snap.target_price - snap.anchor_price, 1.0)
|
||||||
|
pos = (snap.input_price - snap.anchor_price) / span
|
||||||
|
# 행동 봉투 (학습 available_actions 와 동일해야 한다):
|
||||||
|
# ① 목표가 초과 제시가는 '수락' 제외 — 매입 승인 범위(v3.1 착취 방지)
|
||||||
|
# ② 직전 역제안보다 낮은 금액의 역제안 제외 — 단조 양보 원칙(제안 철회는 협상 예절 위반;
|
||||||
|
# 올리는 '속도'는 정책 학습, 후퇴 '금지'만 구조로 보장)
|
||||||
|
# ③ 역제시 해금 조건 — 옛 제품 의미론 복원(제품 결정 2026-07-10): 일반 카드는 설득만,
|
||||||
|
# 역제시(숫자 제안)는 와일드카드처럼 마무리 수단. 최소 AUTONOMY_MIN_PRESS(기본 2)회
|
||||||
|
# 설득 이후에만 역제시 후보가 열린다. 해금 후의 타이밍·금액은 정책 학습.
|
||||||
|
# ④ 마무리 국면 — 제시가가 목표가 0.5% 이내로 붙으면 압박 제외(+역제시 잠금 해제):
|
||||||
|
# 푼돈 차이에서 '재검토 부탁' 반복은 상대만 지치게 한다. 클로징(역제안/최종제안)하거나 끝내거나.
|
||||||
|
min_press = int(os.getenv("AUTONOMY_MIN_PRESS", "2"))
|
||||||
|
near_target = snap.input_price <= snap.target_price * 1.005
|
||||||
|
counter_locked = (int(ctx.get("autonomy_press_n") or 0) < min_press) and not near_target
|
||||||
|
last_counter_q = float(last["q"]) if last.get("kind") == "counter" else None
|
||||||
|
if last_counter_q is not None:
|
||||||
|
counter_locked = False # 이미 역제시를 시작했으면 잠그지 않는다(단조 봉투가 관리)
|
||||||
|
# ⑤ 첫 역제안은 앵커가 이하(q ≤ 0)만 — 낮게 개시해 목표가까지 천천히 올라간다
|
||||||
|
# (제품 결정: 사다리를 다 쓰는 앵커링 개시. 이후 단조 봉투가 상향을 관리).
|
||||||
|
# ⑥ 결렬(walk)도 해금 전 금지 — 설득 0회에 walk 를 고르면 최종제안 보장(엔진)과 결합해
|
||||||
|
# '첫 턴 목표가 통보'가 된다(v3.4 라이브 결함). 해금 전에는 설득만 가능.
|
||||||
|
cands = [a for a in ACTIONS
|
||||||
|
if not (a.kind == "accept" and snap.input_price > snap.target_price)
|
||||||
|
and not (a.kind == "counter" and counter_locked)
|
||||||
|
and not (a.kind == "walk" and counter_locked)
|
||||||
|
and not (a.kind == "press" and near_target)
|
||||||
|
and not (a.kind == "counter" and last_counter_q is None and a.counter_q > 1e-9)
|
||||||
|
and not (a.kind == "counter" and last_counter_q is not None
|
||||||
|
and a.counter_q < last_counter_q - 1e-9)]
|
||||||
|
feats = []
|
||||||
|
for a in cands:
|
||||||
|
cut = 0.0
|
||||||
|
if a.kind == "counter":
|
||||||
|
c = snap.anchor_price + a.counter_q * span
|
||||||
|
cut = max(0.0, (snap.input_price - c) / max(snap.input_price, 1.0))
|
||||||
|
feats.append(a.feat(pos, cut))
|
||||||
|
feats = np.stack(feats)
|
||||||
|
W0, b0, W1, b1, W2, b2 = self._W
|
||||||
|
x = np.concatenate([np.repeat(sf[None, :], feats.shape[0], axis=0), feats], axis=1)
|
||||||
|
h = np.maximum(x @ W0.T + b0, 0.0)
|
||||||
|
h = np.maximum(h @ W1.T + b1, 0.0)
|
||||||
|
scores = (h @ W2.T + b2).squeeze(-1)
|
||||||
|
return cands[int(np.argmax(scores))]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def counter_price(ctx: dict, act: Action) -> int:
|
||||||
|
span = max(ctx["target_price"] - ctx["anchor_price"], 1.0)
|
||||||
|
return int(round(ctx["anchor_price"] + act.counter_q * span))
|
||||||
|
|
||||||
|
|
||||||
|
class AutonomyStore:
|
||||||
|
_z = None
|
||||||
|
_load_failed = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls) -> bool:
|
||||||
|
return os.getenv("AUTONOMY_MODE", "0").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def policy_for(cls, engine) -> Optional[AutonomyPolicy]:
|
||||||
|
"""engine: tenancy.registry.TenantEngine. 비활성/번들 없음 → None (룰 엔진 유지)."""
|
||||||
|
if not cls.enabled() or cls._load_failed:
|
||||||
|
return None
|
||||||
|
if cls._z is None:
|
||||||
|
try:
|
||||||
|
cls._z = np.load(BUNDLE_PATH, allow_pickle=False)
|
||||||
|
LOG.i("[Autonomy] 완전 자율 정책 번들 로드 완료 — 협상 판정 룰 대체 모드")
|
||||||
|
except Exception as ex:
|
||||||
|
cls._load_failed = True
|
||||||
|
LOG.e_no_callstack(f"[Autonomy] 번들 로드 실패 → 룰 엔진 유지: {ex}")
|
||||||
|
return None
|
||||||
|
return AutonomyPolicy(cls._z, engine.config.reward)
|
||||||
118
agent/negotiation/policy/dqn_store.py
Normal file
118
agent/negotiation/policy/dqn_store.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""DQNServingStore — action-as-feature DQN 서빙 (선택 전용, 학습 없음).
|
||||||
|
|
||||||
|
tools/export_dqn_serving.py 가 만든 dqn_serving.npz(ScoreNet 가중치 + 카드특징 392차원)를
|
||||||
|
numpy 로 추론한다 — 서빙 컨테이너에 PyTorch 불필요.
|
||||||
|
|
||||||
|
역할 분담(계획서 H 트랙으로 가기 전 파일럿):
|
||||||
|
- 카드 '선택'만 DQN(greedy). Q-table 학습/영속/experience_logs 로깅 경로는 기존 그대로 유지
|
||||||
|
(Q-learning 은 오프폴리시라 DQN 이 고른 행동으로 갱신해도 유효, 로그는 DQN 오프라인 재학습 재료).
|
||||||
|
- 폴백: 플래그 꺼짐 / 번들 없음 / 가용 카드 전부 특징 미보유(신규 카드) → None 반환,
|
||||||
|
호출부(ChatService)가 기존 UCB Q-table 선택으로 진행한다.
|
||||||
|
|
||||||
|
활성화: 환경변수 DQN_SERVING=1 (docker-compose agent environment).
|
||||||
|
신규 카드 주의: 번들에 없는 카드번호는 후보에서 제외된다 — 카드 추가 시
|
||||||
|
tools/build_card_embeddings.py → tools/export_dqn_serving.py 재실행 후 재배포 필요.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from negotiation.policies.base import ActionDecision, PolicyContext
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import (
|
||||||
|
build_state_features, build_tenant_features)
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BUNDLE_PATH = os.path.join(_HERE, "..", "..", "artifacts", "dqn_serving.npz")
|
||||||
|
|
||||||
|
|
||||||
|
class _Bundle:
|
||||||
|
def __init__(self, z):
|
||||||
|
self.W0, self.b0 = z["W0"], z["b0"]
|
||||||
|
self.W1, self.b1 = z["W1"], z["b1"]
|
||||||
|
self.W2, self.b2 = z["W2"], z["b2"]
|
||||||
|
self.state_dim = int(z["state_dim"])
|
||||||
|
self.card_feats = {str(n): z["card_feats"][i]
|
||||||
|
for i, n in enumerate(z["card_numbers"])}
|
||||||
|
|
||||||
|
def scores(self, state_feat: np.ndarray, card_feats: np.ndarray) -> np.ndarray:
|
||||||
|
"""가용 카드 K개 일괄 채점: [K, state+card] → [K]."""
|
||||||
|
k = card_feats.shape[0]
|
||||||
|
x = np.concatenate([np.repeat(state_feat[None, :], k, axis=0), card_feats], axis=1)
|
||||||
|
h = np.maximum(x @ self.W0.T + self.b0, 0.0)
|
||||||
|
h = np.maximum(h @ self.W1.T + self.b1, 0.0)
|
||||||
|
return (h @ self.W2.T + self.b2).squeeze(-1)
|
||||||
|
|
||||||
|
|
||||||
|
class DQNServingPolicy:
|
||||||
|
"""UCBQTablePolicy.select 와 동일한 PolicyContext → ActionDecision 계약(선택 전용)."""
|
||||||
|
|
||||||
|
name = "feature_dqn"
|
||||||
|
_EPS = 0.1 # propensity 근사용 ε (UCB 정책과 동일 관례 — OPE 지지 확보용, 선택은 greedy)
|
||||||
|
|
||||||
|
def __init__(self, bundle: _Bundle, engine): # engine: tenancy.registry.TenantEngine
|
||||||
|
self._bundle = bundle
|
||||||
|
self._mapper = engine.mapper
|
||||||
|
self._tenant_feat = build_tenant_features(engine.config.reward)
|
||||||
|
|
||||||
|
def _available(self, ctx: PolicyContext) -> List[int]:
|
||||||
|
# UCBQTablePolicy._available 과 동일 규칙 (마스크 → used 제외 → 소진 시 전체 허용)
|
||||||
|
if ctx.available_mask is not None:
|
||||||
|
avail = [a for a in range(ctx.action_space_size) if ctx.available_mask[a]]
|
||||||
|
else:
|
||||||
|
used = ctx.episode.used_action_ids if ctx.episode else set()
|
||||||
|
avail = [a for a in range(ctx.action_space_size) if a not in used]
|
||||||
|
return avail or list(range(ctx.action_space_size))
|
||||||
|
|
||||||
|
def select(self, ctx: PolicyContext) -> Optional[ActionDecision]:
|
||||||
|
"""카드특징이 있는 가용 카드가 없으면 None → 호출부가 Q-table 로 폴백."""
|
||||||
|
candidates = [] # (action_id, card_feat)
|
||||||
|
for a in self._available(ctx):
|
||||||
|
num = self._mapper.get_card_id(a)
|
||||||
|
feat = self._bundle.card_feats.get(num) if num else None
|
||||||
|
if feat is not None:
|
||||||
|
candidates.append((a, feat))
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
state_feat = np.concatenate([build_state_features(ctx.snapshot), self._tenant_feat])
|
||||||
|
if state_feat.shape[0] != self._bundle.state_dim:
|
||||||
|
LOG.e_no_callstack(
|
||||||
|
f"[DQNServing] state_dim 불일치: {state_feat.shape[0]} != {self._bundle.state_dim}")
|
||||||
|
return None
|
||||||
|
sc = self._bundle.scores(state_feat, np.stack([f for _, f in candidates]))
|
||||||
|
i = int(sc.argmax())
|
||||||
|
n = len(candidates)
|
||||||
|
return ActionDecision(
|
||||||
|
action_id=candidates[i][0],
|
||||||
|
propensity=(1.0 - self._EPS) + self._EPS / n,
|
||||||
|
q_value=float(sc[i]),
|
||||||
|
ucb_score=float(sc[i]),
|
||||||
|
available_actions=[a for a, _ in candidates],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DQNServingStore:
|
||||||
|
"""번들 lazy 로드 + 캐시. 비활성/부재 시 None (호출부 Q-table 폴백)."""
|
||||||
|
|
||||||
|
_bundle: Optional[_Bundle] = None
|
||||||
|
_load_failed = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls) -> bool:
|
||||||
|
return os.getenv("DQN_SERVING", "0").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def policy_for(cls, engine) -> Optional[DQNServingPolicy]:
|
||||||
|
if not cls.enabled() or cls._load_failed:
|
||||||
|
return None
|
||||||
|
if cls._bundle is None:
|
||||||
|
try:
|
||||||
|
cls._bundle = _Bundle(np.load(BUNDLE_PATH, allow_pickle=False))
|
||||||
|
LOG.i(f"[DQNServing] 번들 로드 완료: 카드 {len(cls._bundle.card_feats)}장")
|
||||||
|
except Exception as ex:
|
||||||
|
cls._load_failed = True # 요청마다 재시도하지 않음
|
||||||
|
LOG.e_no_callstack(f"[DQNServing] 번들 로드 실패 → Q-table 폴백: {ex}")
|
||||||
|
return None
|
||||||
|
return DQNServingPolicy(cls._bundle, engine)
|
||||||
44
agent/negotiation/qtable/domain/service/feature_builder.py
Normal file
44
agent/negotiation/qtable/domain/service/feature_builder.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""build_state_features — snapshot(raw 연속값) → 실수 벡터 (DQN/action-as-feature 용).
|
||||||
|
|
||||||
|
state_calculator.build_state(이산화)와 대비되는 연속 표현. 이산화(등급/162칸)를 하지 않고
|
||||||
|
정규화된 raw 값을 그대로 벡터로 내보낸다. 협력사 특징(매출·경쟁사수·유통)이 벡터에 포함되므로
|
||||||
|
'협력사를 입력으로'(Phase 3)가 자연스럽게 달성된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||||
|
|
||||||
|
DIST_CLASSES = ("A", "B", "C")
|
||||||
|
STATE_FEATURE_DIM = 9 # build_state_features 벡터 길이. feature 추가 시 갱신.
|
||||||
|
TENANT_FEATURE_DIM = 5 # build_tenant_features 벡터 길이.
|
||||||
|
|
||||||
|
|
||||||
|
def build_tenant_features(reward_cfg) -> np.ndarray:
|
||||||
|
"""고객사 '성향'을 ID 가 아니라 보상 설정값(내용)으로 벡터화 (Phase 3 고객사 조건화).
|
||||||
|
|
||||||
|
새 고객사도 tenant.yaml 의 reward 설정만 있으면 즉시 조건화된다 (cold-start 없음).
|
||||||
|
"""
|
||||||
|
return np.array([
|
||||||
|
reward_cfg.max_weight, # 가격 중시 정도 (W↑ = 가격보상 비중↑)
|
||||||
|
reward_cfg.success_reward / 2.0, # 성사를 얼마나 크게 치는가
|
||||||
|
-reward_cfg.failure_penalty / 2.0, # 결렬을 얼마나 무서워하는가
|
||||||
|
reward_cfg.penalty_lambda * 20.0, # 속도 성향 (오래 끌수록 벌점)
|
||||||
|
reward_cfg.beta, # 앵커 초과달성 보너스 성향
|
||||||
|
], dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def build_state_features(s: NegotiationSnapshot) -> np.ndarray:
|
||||||
|
"""정규화된 연속 상태 벡터. 등급화 없음 — 990원과 850원이 구별된다."""
|
||||||
|
dist_onehot = [1.0 if s.distribution_code == c else 0.0 for c in DIST_CLASSES]
|
||||||
|
anchor = max(s.anchor_price, 1.0)
|
||||||
|
target = max(s.target_price, 1.0)
|
||||||
|
return np.array([
|
||||||
|
min(s.revenue_amount, 5e8) / 5e8, # 협력사 매출 (0~1)
|
||||||
|
*dist_onehot, # 유통 A/B/C
|
||||||
|
min(s.partner_count, 5) / 5.0, # 대안 협력사 수 (BATNA)
|
||||||
|
float(np.clip(s.acceptance_ratio, 0.0, 1.0)), # 수용률
|
||||||
|
float(np.clip((s.input_price - anchor) / anchor, -1.0, 2.0)), # 앵커 대비 격차 (연속!)
|
||||||
|
float(np.clip((target - s.input_price) / target, -2.0, 1.0)), # 목표 대비 여유
|
||||||
|
min(s.round_number, 10) / 10.0, # 라운드
|
||||||
|
], dtype=np.float32)
|
||||||
@ -53,9 +53,6 @@ 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,19 +13,16 @@ 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.cards.domain.tactics import (
|
from negotiation.chat.service import ment_generator
|
||||||
Offer, available, compute_offer_detail, is_played, mark_played, playable, record_offer, spec_from_context,
|
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView
|
||||||
)
|
|
||||||
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
|
||||||
@ -41,19 +38,9 @@ _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)
|
||||||
@ -63,9 +50,19 @@ 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)
|
db_ctx = None if session else await NegotiationContextLoader().load(req.session_id, engine.company_id)
|
||||||
rq_type = session.rq_type if session else (db_ctx.rq_type if db_ctx else _DEFAULT_RQ_TYPE)
|
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 가 따라옴),
|
||||||
@ -86,12 +83,8 @@ 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, *( [card_cap] if card_cap else [] ))
|
min(len(selected_nego_cards), engine.action_space_size)
|
||||||
if db_ctx is not None
|
if db_ctx is not None
|
||||||
else engine.action_space_size
|
else engine.action_space_size
|
||||||
)
|
)
|
||||||
@ -112,40 +105,28 @@ 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:
|
||||||
# Phase 3 이해층: 자유 발화(버튼/정형 입력이 아닌 텍스트)를 기대 입력으로 해석.
|
view = chat_engine.advance(session, req.user_input)
|
||||||
# 해석 실패/미설정 시 원문 그대로 → 기존 엔진 재질문 흐름 유지.
|
|
||||||
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
|
||||||
@ -162,18 +143,6 @@ 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:
|
||||||
@ -181,6 +150,17 @@ 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
|
||||||
@ -206,53 +186,6 @@ 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:
|
||||||
@ -282,11 +215,6 @@ 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)
|
||||||
@ -296,35 +224,28 @@ 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, 사용분 제외 + 전술 발동조건 AND).
|
# available_mask 로 처리한다(선택 카드만 pickable, 사용분 제외).
|
||||||
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._combined_mask(engine, session),
|
available_mask=self._selection_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)))
|
||||||
decision = policy.select(ctx)
|
# 카드 '선택'은 DQN 서빙(활성 시), 학습/영속은 아래 Q-table 경로 그대로(오프폴리시 갱신).
|
||||||
|
# DQN 불가(비활성/번들 없음/후보 특징 없음)면 None → 기존 UCB 선택 폴백.
|
||||||
|
dqn = DQNServingStore.policy_for(engine)
|
||||||
|
decision = dqn.select(ctx) if dqn is not None else None
|
||||||
|
selector_name = dqn.name if decision is not None else policy.name
|
||||||
|
if decision is None:
|
||||||
|
decision = policy.select(ctx)
|
||||||
session.used_action_ids.add(decision.action_id)
|
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, done=False,
|
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
|
||||||
decision=decision, policy=policy)
|
|
||||||
|
|
||||||
res.card_id = card_id
|
res.card_id = card_id
|
||||||
res.policy = policy.name
|
res.policy = selector_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])
|
||||||
@ -334,17 +255,12 @@ 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 편집 정본), 아니면 파일 폴백.
|
||||||
template, tone, strategy = await scripts.resolve_card_template(
|
card_script = await scripts.resolve_card_script(
|
||||||
decision.action_id, card_id,
|
decision.action_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
|
||||||
@ -354,59 +270,48 @@ class ChatService:
|
|||||||
res.indicator_range = ind[1]
|
res.indicator_range = ind[1]
|
||||||
res.bot_chat_type = "indicator"
|
res.bot_chat_type = "indicator"
|
||||||
|
|
||||||
# 카운터 제시 카드면 수락/거절 스텝(가격협상_카운터)으로 전환 — 카드 멘트({target_price} 등
|
async def _autonomy_learn(self, engine: TenantEngine, session: ChatSession, view: StepView, res: Res_Chat):
|
||||||
# 카운터가 포함)는 그대로 두고, 입력만 [수락|다른 가격 제시] 버튼으로 바꾼다.
|
"""완전 자율 행동 로깅 — Q-table 은 건드리지 않고 experience_logs 만 적재한다.
|
||||||
if counter is not None:
|
|
||||||
view2 = chat_engine.render_step(session, "가격협상_카운터")
|
action_id = autonomy_actions.ACTIONS 인덱스, card_id = "AUT|종류|위치|전략" (카드 재학습
|
||||||
res.step, res.client_step = view2.step, view2.client_step
|
파이프라인이 임베딩 매칭에서 자동 제외하도록 프리픽스로 구분). 종료 시 최종 보상 행(done=True)을
|
||||||
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
남겨 retrain 의 에피소드 재구성 규약(카드턴 N + 종료 1)과 정합을 맞춘다.
|
||||||
if not card_script:
|
"""
|
||||||
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
|
def _card_id(d) -> str:
|
||||||
|
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
|
||||||
ctx["closing_played"] = True
|
lrepo = LearningRepository(engine.company_id)
|
||||||
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
pending = ctx.pop("autonomy_pending", None)
|
||||||
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
if pending is not None:
|
||||||
closing_number, closing_offer = None, None
|
snap = self._snapshot(session, NegotiationOutcome.ONGOING)
|
||||||
for n in (ctx.get("selected_wild_card_numbers") or []):
|
try:
|
||||||
n = str(n)
|
idx = state_index(snap, engine.config.state) # 로깅 호환용 이산 인덱스
|
||||||
spec = spec_from_context(ctx, n)
|
except ValueError:
|
||||||
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
|
idx = 0 # 자율 모드는 이산 상태를 쓰지 않으므로 폴백해도 학습 오염 없음
|
||||||
continue
|
if ctx.get("autonomy_last"):
|
||||||
offer = compute_offer_detail(spec, ctx)
|
ctx["autonomy_prev"] = ctx["autonomy_last"] # 직전 결정 보존 — 멘트 생성 힌트(양보 언급)용
|
||||||
if offer is not None:
|
ctx["autonomy_last"] = dict(pending, state_index=idx)
|
||||||
closing_number, closing_offer = n, offer
|
if pending.get("kind") == "counter":
|
||||||
break
|
# 역제안 기억은 별도 키로 보존 — autonomy_last 는 '마지막 행동'이라 사이에 낀
|
||||||
if closing_offer is None:
|
# 설득이 덮어쓴다. 단조 봉투·탄약소진 판정이 이 기억을 기준으로 해야
|
||||||
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
|
# counter→press→counter 에서 제안 철회가 새지 않는다 (게이트가 잡은 실버그).
|
||||||
closing_number = None
|
ctx["autonomy_last_counter"] = dict(pending)
|
||||||
target = int(ctx.get("target_price") or 0)
|
if pending.get("kind") == "press":
|
||||||
price = int(ctx.get("input_price") or 0)
|
# 설득 횟수 누적 — 역제시 해금 조건(autonomy_store ③)의 카운터.
|
||||||
if 0 < target < price:
|
ctx["autonomy_press_n"] = int(ctx.get("autonomy_press_n") or 0) + 1
|
||||||
closing_offer = Offer(price=target, variable="target_price",
|
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||||
prev_customer=int(ctx.get("prev_customer_price") or ctx.get("anchor_price") or 0),
|
await self._log(lrepo, session, idx, pending["idx"], _card_id(pending), snap,
|
||||||
prev_partner=price)
|
reward, (1.0 - 0.1) + 0.1 / len(AUTONOMY_ACTIONS), done=False)
|
||||||
if closing_offer is None:
|
res.policy = "full_autonomy"
|
||||||
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
last = ctx.get("autonomy_last")
|
||||||
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
if view.outcome is not None and last is not None:
|
||||||
record_offer(ctx, closing_offer)
|
oc = NegotiationOutcome.SUCCESS if view.outcome == "success" else NegotiationOutcome.FAILURE
|
||||||
|
snap = self._snapshot(session, oc)
|
||||||
template = None
|
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||||
if closing_number:
|
res.reward_total = reward.total
|
||||||
template = await scripts.resolve_wild_card_template(closing_number)
|
await self._log(lrepo, session, last["state_index"], last["idx"], _card_id(last), snap,
|
||||||
if template and engine.config.llm.enabled and ScriptNaturalizer.available():
|
reward, None, done=True)
|
||||||
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
|
||||||
@ -421,7 +326,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, done=True)
|
self._card_id_for_action(engine, session, last_action), snap, reward, None, 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
|
||||||
@ -452,74 +357,13 @@ class ChatService:
|
|||||||
dtype=bool,
|
dtype=bool,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
|
||||||
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(),
|
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
|
||||||
"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, policy)
|
res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
|
async def _log(self, engine, session_id, idx, decision, snap, reward) -> 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,8 +108,6 @@ 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 # imarketkorea 등 데모 테넌트명 → 파일 브랜드 유지
|
return None # ktcommerce 등 데모 테넌트명 → 파일 브랜드 유지
|
||||||
|
|
||||||
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,11 +148,6 @@ 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,7 +15,5 @@
|
|||||||
"가격협상_와일드": "가격협상",
|
"가격협상_와일드": "가격협상",
|
||||||
"협상완료": "협상종료",
|
"협상완료": "협상종료",
|
||||||
"협상실패": "협상종료",
|
"협상실패": "협상종료",
|
||||||
"협상종료": "협상종료",
|
"협상종료": "협상종료"
|
||||||
"가격협상_카운터": "가격협상",
|
}
|
||||||
"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": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 {label_target_price}(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||||
"2": "좋은 제안 감사합니다. 다른 {label_supplier}들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
"2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
||||||
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
|
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
|
||||||
"4": "제시해 주신 조건은 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
"4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
||||||
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
|
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
|
||||||
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
|
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
|
||||||
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. {label_target_price}({target}원)에 가까운 금액을 제안해 주세요.",
|
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.",
|
||||||
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,37 +5,25 @@
|
|||||||
"editor_script_id": "시작",
|
"editor_script_id": "시작",
|
||||||
"next_input_mode": "null",
|
"next_input_mode": "null",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": {
|
"next_step": { "default": "서비스안내" },
|
||||||
"default": "서비스안내"
|
|
||||||
},
|
|
||||||
"type": "null",
|
"type": "null",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"서비스안내": {
|
"서비스안내": {
|
||||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
||||||
"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": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
"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
|
||||||
},
|
},
|
||||||
@ -43,19 +31,13 @@
|
|||||||
"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": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
||||||
"editor_script_id": "정보변경_완료",
|
"editor_script_id": "정보변경_완료",
|
||||||
"next_input_mode": "null",
|
"next_input_mode": "null",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
@ -67,12 +49,8 @@
|
|||||||
"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
|
||||||
},
|
},
|
||||||
@ -81,9 +59,7 @@
|
|||||||
"editor_script_id": "기존가격제시",
|
"editor_script_id": "기존가격제시",
|
||||||
"next_input_mode": "price",
|
"next_input_mode": "price",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": {
|
"next_step": { "default": "가격협상_확인" },
|
||||||
"default": "가격협상_확인"
|
|
||||||
},
|
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -92,42 +68,22 @@
|
|||||||
"editor_script_id": "가격협상_재입력",
|
"editor_script_id": "가격협상_재입력",
|
||||||
"next_input_mode": "price",
|
"next_input_mode": "price",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": {
|
"next_step": { "default": "가격협상_확인" },
|
||||||
"default": "가격협상_확인"
|
|
||||||
},
|
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
"가격협상_확인": {
|
"가격협상_확인": {
|
||||||
"script": "제시하신 가격은 **{input_price}원**입니다. {discount_phrase}이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
"script": "제시하신 가격은 **{input_price}원**으로, 기존 공급가 대비 약 **{discount_rate}%** 인하된 금액입니다. 이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
||||||
"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_wildcard_entry",
|
{ "condition": "check_is_supplier_type_c", "next": "협상완료" },
|
||||||
"next": "가격협상_와일드"
|
{ "condition": "check_price_match", "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": "가격협상"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"아니오": "가격협상_재입력"
|
"아니오": "가격협상_재입력"
|
||||||
},
|
},
|
||||||
@ -138,14 +94,8 @@
|
|||||||
"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
|
||||||
},
|
},
|
||||||
@ -154,9 +104,7 @@
|
|||||||
"editor_script_id": "가격협상",
|
"editor_script_id": "가격협상",
|
||||||
"next_input_mode": "price",
|
"next_input_mode": "price",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": {
|
"next_step": { "default": "가격협상_확인" },
|
||||||
"default": "가격협상_확인"
|
|
||||||
},
|
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -164,12 +112,8 @@
|
|||||||
"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
|
||||||
},
|
},
|
||||||
@ -178,9 +122,7 @@
|
|||||||
"editor_script_id": "협상실패",
|
"editor_script_id": "협상실패",
|
||||||
"next_input_mode": "null",
|
"next_input_mode": "null",
|
||||||
"input_options": [],
|
"input_options": [],
|
||||||
"next_step": {
|
"next_step": { "default": "협상종료" },
|
||||||
"default": "협상종료"
|
|
||||||
},
|
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
},
|
},
|
||||||
@ -192,21 +134,5 @@
|
|||||||
"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}와 {label_supplier} 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
||||||
"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": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||||
"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": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
|
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 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}는 아래 상품에 대해 신규 {label_supplier_를} 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 {label_supplier} 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
"script": "{company_name}는 아래 상품에 대해 신규 공급사를 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 공급사 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
||||||
"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": "{label_delivery_type_를} 선택해 주세요.",
|
"script": "배송 형태를 선택해 주세요.",
|
||||||
"editor_script_id": "배송형태선택",
|
"editor_script_id": "배송형태선택",
|
||||||
"next_input_mode": "delivery_type",
|
"next_input_mode": "delivery_type",
|
||||||
"input_options": ["{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}"],
|
"input_options": ["협력사배송", "지정택배배송", "픽업배송"],
|
||||||
"next_step": { "default": "가격협상_입력" },
|
"next_step": { "default": "가격협상_입력" },
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"chat_end": false
|
"chat_end": false
|
||||||
|
|||||||
@ -5,39 +5,17 @@
|
|||||||
"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": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 당사 {label_target_price}는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{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": {
|
"next_step": { "default": "가격협상_확인_버짓" },
|
||||||
"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: true
|
enabled: false
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
language: ko
|
language: ko
|
||||||
|
|||||||
@ -37,9 +37,7 @@ action_mapping:
|
|||||||
"10": "NGC-B011"
|
"10": "NGC-B011"
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
enabled: true
|
enabled: false
|
||||||
# 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:
|
||||||
|
|||||||
32
agent/tenants/ktcommerce/tenant.yaml
Normal file
32
agent/tenants/ktcommerce/tenant.yaml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# 데모 테넌트 프로파일 (합성/중립값 — 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"
|
||||||
@ -1,157 +0,0 @@
|
|||||||
"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 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("imarketkorea")
|
eng = await _reg().get_engine("ktcommerce")
|
||||||
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 == 9 # DB 카탈로그 9장(NGC-006·009 소프트삭제)
|
assert eng.action_space_size == 11 # base 기본 카드(162×11 정합)
|
||||||
assert eng.state_space_size == 162
|
assert eng.state_space_size == 162
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,154 +0,0 @@
|
|||||||
"""협상카드 선택 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)) # 세션 내 중복 없음
|
|
||||||
@ -1,499 +0,0 @@
|
|||||||
"""카드 전술 검증 — "스크립트에 꽂힌 변수가 곧 전술" (파싱 + 변수별 유효조건 + 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("imarketkorea")
|
eng = await reg.get_engine("ktcommerce")
|
||||||
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("imarketkorea")
|
eng = await reg.get_engine("ktcommerce")
|
||||||
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,30 +186,15 @@ 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, done_ceiling_price, item_id, quotation_id, supplier_id)
|
# (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제
|
||||||
# — 재견적(2)·앵커 미박제·타결상한 52,500(목표가 +5%)
|
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
|
||||||
return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
|
|
||||||
|
|
||||||
async def get_item_baseline(self, cdb, item_id):
|
async def get_item_price(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"
|
||||||
|
|
||||||
@ -220,39 +205,19 @@ 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):
|
||||||
# 행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 card_specs 를 만든다
|
return ErrorType.SUCCESS, (["NGC-003", "NGC-008"], ["WC-02"]) # 견적 선택 카드
|
||||||
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
|
||||||
@ -260,7 +225,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("imarketkorea")
|
eng = await reg.get_engine("ktcommerce")
|
||||||
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
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
"""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("imarketkorea")
|
eng = await reg.get_engine("ktcommerce")
|
||||||
svc = NegotiationService()
|
svc = NegotiationService()
|
||||||
|
|
||||||
def req():
|
def req():
|
||||||
@ -126,21 +126,20 @@ 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 index 는 imarketkorea config 로 동적 계산)
|
# DB 에서 state 전체 방문 누적 확인 (state 58 = ktcommerce 의 이 snapshot)
|
||||||
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_a = LearningRepository("imarketkorea")
|
repo_kt = LearningRepository("ktcommerce")
|
||||||
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)
|
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)
|
||||||
_, vcells = await repo_a.load_cells(vid)
|
_, vcells = await repo_kt.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
|
||||||
|
|
||||||
# 테넌트 격리: 자동 온보딩 고객사(UUID)는 별도 학습/별도 state
|
# 테넌트 격리: imarketkorea 는 별도 학습/별도 state
|
||||||
other = "00000000-0000-0000-0000-0000000000c1"
|
eng2 = await reg.get_engine("imarketkorea")
|
||||||
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_a.read(lambda s: repo_a.count_experience(s))
|
err, ck = await repo_kt.read(lambda s: repo_kt.count_experience(s))
|
||||||
err, ci = await LearningRepository(other).read(lambda s: LearningRepository(other).count_experience(s))
|
err, ci = await LearningRepository("imarketkorea").read(lambda s: LearningRepository("imarketkorea").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", ["_base", "imarketkorea"])
|
@pytest.mark.parametrize("tenant", ["ktcommerce", "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", "imarketkorea")
|
report = run("configs/exp_default.yaml", "ktcommerce")
|
||||||
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"]
|
||||||
|
|||||||
@ -1,161 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
"""협상 불변식 시나리오 하네스 — 실서비스 스택(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": "_base"}, json=_BODY)
|
rk = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, 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 에 따라 다른 상태/카드로 갈린다 (_base=공용 NGC-0xx, imk=NGC-Bxxx)
|
# 같은 입력이 테넌트 config 에 따라 다른 상태/카드로 갈린다
|
||||||
assert dk["card_id"].startswith("NGC-0")
|
assert dk["card_id"].startswith("NGC-A")
|
||||||
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": "imarketkorea"}, json=body)
|
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, 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": "imarketkorea"}, json=body)
|
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, 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": "imarketkorea"})
|
r = await client.get("/v1/some-protected-path", headers={"X-Tenant-ID": "ktcommerce"})
|
||||||
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("_base")
|
cfg = _loader().load("ktcommerce")
|
||||||
|
|
||||||
# 우리 플랫폼 중립 기본값 (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("_base")
|
cfg = _loader().load("ktcommerce")
|
||||||
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-001"
|
assert cfg.action_mapping.action_to_card["0"] == "NGC-A001"
|
||||||
assert cfg.action_mapping.action_to_card["8"] == "NGC-009"
|
assert cfg.action_mapping.action_to_card["8"] == "NGC-A009"
|
||||||
|
|
||||||
|
|
||||||
def test_base_deep_merge_unit():
|
def test_base_deep_merge_unit():
|
||||||
@ -90,6 +90,7 @@ 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
|
||||||
@ -98,7 +99,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", "imarketkorea"):
|
for tid in ("_base", "ktcommerce", "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("_base")
|
cfg = _cfg("ktcommerce")
|
||||||
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("_base")
|
cfg = _cfg("ktcommerce")
|
||||||
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 원: _base(th=[10M,50M]) → mid(1), imarketkorea(th=[30M,100M]) → low(0)
|
# revenue=20,000,000 원: ktcommerce(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)
|
||||||
base = build_state(snap, _cfg("_base").state)
|
kt = build_state(snap, _cfg("ktcommerce").state)
|
||||||
imk = build_state(snap, _cfg("imarketkorea").state)
|
imk = build_state(snap, _cfg("imarketkorea").state)
|
||||||
assert base.revenue_idx == 1
|
assert kt.revenue_idx == 1
|
||||||
assert imk.revenue_idx == 0
|
assert imk.revenue_idx == 0
|
||||||
assert base != imk # 같은 입력이 테넌트 config 에 따라 다른 상태
|
assert kt != imk # 같은 입력이 테넌트 config 에 따라 다른 상태
|
||||||
|
|
||||||
|
|
||||||
def test_distribution_unknown_code_raises():
|
def test_distribution_unknown_code_raises():
|
||||||
cfg = _cfg("_base")
|
cfg = _cfg("ktcommerce")
|
||||||
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("_base").state
|
cfg = _cfg("ktcommerce").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)
|
||||||
base = _cfg("_base")
|
kt = _cfg("ktcommerce")
|
||||||
imk = _cfg("imarketkorea")
|
imk = _cfg("imarketkorea")
|
||||||
base_rc = RewardCalculator(base.reward, base.state) # failure_penalty -0.5
|
kt_rc = RewardCalculator(kt.reward, kt.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 = base_rc.calculate(snap)
|
r1 = kt_rc.calculate(snap)
|
||||||
r2 = base_rc.calculate(snap)
|
r2 = kt_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 base_rc.calculate(success).total > base_rc.calculate(_snapshot(outcome=NegotiationOutcome.FAILURE, round_number=0)).total
|
assert kt_rc.calculate(success).total > kt_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("_base")
|
cfg = _cfg("ktcommerce")
|
||||||
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-001"
|
assert mapper.get_card_id(0) == "NGC-A001"
|
||||||
assert mapper.get_action_id("NGC-001") == 0
|
assert mapper.get_action_id("NGC-A001") == 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("_base")
|
e1 = await reg.get_engine("ktcommerce")
|
||||||
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 == "_base" and e2.tenant_id == "imarketkorea"
|
assert e1.tenant_id == "ktcommerce" and e2.tenant_id == "imarketkorea"
|
||||||
# 서로 다른 카드매핑 (다른 카드셋 — _base=공용 카탈로그, imk=파일 오버라이드)
|
# 서로 다른 카드매핑 (다른 카드셋)
|
||||||
assert e1.mapper.get_card_id(0) == "NGC-001"
|
assert e1.mapper.get_card_id(0) == "NGC-A001"
|
||||||
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 == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
assert e1.state_space_size == 162 and e1.action_space_size == 11
|
||||||
|
|
||||||
|
|
||||||
@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("imarketkorea")
|
a = await reg.get_engine("ktcommerce")
|
||||||
b = await reg.get_engine("imarketkorea")
|
b = await reg.get_engine("ktcommerce")
|
||||||
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("imarketkorea") for _ in range(12)])
|
results = await asyncio.gather(*[reg.get_engine("ktcommerce") 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 == 9 and eng.state_space_size == 162 # DB 카탈로그 9장
|
assert eng.action_space_size == 11 and eng.state_space_size == 162
|
||||||
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
||||||
assert reg.is_registered("imarketkorea") is True
|
assert reg.is_registered("ktcommerce") 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("_base")
|
a = await reg.get_engine("ktcommerce")
|
||||||
b = await reg.get_engine("imarketkorea")
|
b = await reg.get_engine("imarketkorea")
|
||||||
reloaded = await reg.reload("_base")
|
reloaded = await reg.reload("ktcommerce")
|
||||||
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("imarketkorea") # 비-UUID → 조회 안 함
|
eng = await reg.get_engine("ktcommerce") # 비-UUID → 조회 안 함
|
||||||
assert eng.config.resources.company_name == "데모상사 B"
|
assert eng.config.resources.company_name == "데모상사 A"
|
||||||
|
|
||||||
|
|
||||||
@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": "imarketkorea"})
|
r = await client.get("/v1/protected", headers={"X-Tenant-ID": "ktcommerce"})
|
||||||
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) # imarketkorea action_space=11 과 차원 일치해야 warm-start
|
await _seed_base(A=11) # ktcommerce action_space=11 과 차원 일치해야 warm-start
|
||||||
eng = await _reg().get_engine("imarketkorea") # 활성 버전 없음 → cold-start
|
eng = await _reg().get_engine("ktcommerce") # 활성 버전 없음 → 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):
|
||||||
"""카탈로그 카드 수 변경(7→9) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
"""카탈로그 카드 수 변경(9→11) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
cid = str(_uuid.uuid4())
|
cid = str(_uuid.uuid4())
|
||||||
# 이 회사 활성 버전을 A=7 로 시드 + 셀 (5,2)=0.9
|
# 이 회사 활성 버전을 A=9 로 시드 + 셀 (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=7, learning_rate=0.1, discount_factor=0.95,
|
state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95,
|
||||||
scope=2, version_name="old_v7")
|
scope=2, version_name="old_v9")
|
||||||
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 → 카탈로그 9장) 로드 → 7≠9 감지 → 마이그레이션
|
# 엔진(_base type:db → 카탈로그 11장) 로드 → 9≠11 감지 → 마이그레이션
|
||||||
eng = await _reg().get_engine(cid)
|
eng = await _reg().get_engine(cid)
|
||||||
assert eng.action_space_size == 9
|
assert eng.action_space_size == 11
|
||||||
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, 8] == 0.0 # 새 카드(action 8) fresh
|
assert policy.qtable.q[5, 10] == 0.0 # 새 카드(action 10) 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 == 9
|
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11
|
||||||
|
|
||||||
|
|
||||||
@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("imarketkorea")
|
eng = await _reg().get_engine("ktcommerce")
|
||||||
# 첫 로드 → 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": "imarketkorea"}
|
H = {"X-Tenant-ID": "ktcommerce"}
|
||||||
|
|
||||||
|
|
||||||
@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": "00000000-0000-0000-0000-0000000000b2"} # 실고객사 모사(자동 온보딩)
|
H2 = {"X-Tenant-ID": "imarketkorea"}
|
||||||
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
|
||||||
|
|
||||||
# imarketkorea reset-all → 타테넌트(H2) 무영향
|
# ktcommerce reset-all → imarketkorea 무영향
|
||||||
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. 브랜드 치환(데모상사 B), 클린룸(스크립트에 KT 흔적 없음).
|
4. 브랜드 치환 테넌트별(데모상사 A/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("imarketkorea")
|
eng = await _reg().get_engine("ktcommerce")
|
||||||
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-B")
|
assert nego and nego[0].card_id and nego[0].card_id.startswith("NGC-A")
|
||||||
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 "데모상사 B" in out[0].script
|
assert "데모상사 A" in out[0].script
|
||||||
assert not _KT.search(out[0].script)
|
assert not _KT.search(out[0].script)
|
||||||
|
|
||||||
# 경험로그 적재
|
# 경험로그 적재
|
||||||
repo = LearningRepository("imarketkorea")
|
repo = LearningRepository("ktcommerce")
|
||||||
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("imarketkorea")
|
eng = await _reg().get_engine("ktcommerce")
|
||||||
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-B{i + 1:03d}" for i in range(11)}
|
_m = {i: f"NGC-A{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="imarketkorea", company_id="imarketkorea",
|
session_id="00000000-0000-0000-0000-000000000001", tenant_id="ktcommerce", company_id="ktcommerce",
|
||||||
context={"selected_nego_card_numbers": ["NGC-B003", "NGC-B008"]}, action_space_size=11,
|
context={"selected_nego_card_numbers": ["NGC-A003", "NGC-A008"]}, action_space_size=11,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ① card_id 는 고정 매핑 (선택 리스트 인덱싱 아님)
|
# ① card_id 는 고정 매핑 (선택 리스트 인덱싱 아님)
|
||||||
assert ChatService._card_id_for_action(eng, session, 0) == "NGC-B001"
|
assert ChatService._card_id_for_action(eng, session, 0) == "NGC-A001"
|
||||||
assert ChatService._card_id_for_action(eng, session, 2) == "NGC-B003"
|
assert ChatService._card_id_for_action(eng, session, 2) == "NGC-A003"
|
||||||
|
|
||||||
# ② 선택은 mask 로 — NGC-B003(action 2), NGC-B008(action 7) 만 pickable
|
# ② 선택은 mask 로 — NGC-A003(action 2), NGC-A008(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,40 +162,15 @@ 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("imarketkorea")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
||||||
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="imarketkorea",
|
tenant_id="ktcommerce",
|
||||||
company_id="imarketkorea",
|
company_id="ktcommerce",
|
||||||
step="가격협상_확인",
|
step="가격협상_확인",
|
||||||
action_space_size=0,
|
action_space_size=0,
|
||||||
context={
|
context={
|
||||||
@ -213,13 +188,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("imarketkorea")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
||||||
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="imarketkorea",
|
tenant_id="ktcommerce",
|
||||||
company_id="imarketkorea",
|
company_id="ktcommerce",
|
||||||
step="가격협상_확인",
|
step="가격협상_확인",
|
||||||
action_space_size=0,
|
action_space_size=0,
|
||||||
context={
|
context={
|
||||||
@ -239,7 +214,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("imarketkorea")
|
eng = await _reg().get_engine("ktcommerce")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
# 첫 제시가가 앵커가(9900) 이하 → 우선협상 → 바로 협상완료(카드/와일드카드 없이)
|
# 첫 제시가가 앵커가(9900) 이하 → 우선협상 → 바로 협상완료(카드/와일드카드 없이)
|
||||||
out = await _run(svc, eng, [None, "확인", "예", "확인", "9800", "예",
|
out = await _run(svc, eng, [None, "확인", "예", "확인", "9800", "예",
|
||||||
@ -262,7 +237,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("imarketkorea")
|
eng = await _reg().get_engine("ktcommerce")
|
||||||
svc = ChatService()
|
svc = ChatService()
|
||||||
out = await _run(svc, eng, [None, "확인", "예", "확인", "1000", "예",
|
out = await _run(svc, eng, [None, "확인", "예", "확인", "1000", "예",
|
||||||
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."])
|
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."])
|
||||||
@ -273,12 +248,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": "imarketkorea"}, json={"rq_type": "재협상"})
|
r = await client.post("/v1/chat", headers={"X-Tenant-ID": "ktcommerce"}, 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 "데모상사 B" in d["script"]
|
assert "데모상사 A" 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("imarketkorea")
|
eng = await reg.get_engine("ktcommerce")
|
||||||
|
|
||||||
# 인스턴스 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("imarketkorea")
|
eng = await reg.get_engine("ktcommerce")
|
||||||
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
|
||||||
|
|
||||||
# 자사(imarketkorea)로는 조회됨
|
# 자사(ktcommerce)로는 조회됨
|
||||||
assert await ChatSessionRepository(eng.company_id).get(sid) is not None
|
assert await ChatSessionRepository(eng.company_id).get(sid) is not None
|
||||||
# 타테넌트 company_id 로는 조회 안 됨 (격리)
|
# 타테넌트(imarketkorea) company_id 로는 조회 안 됨 (격리)
|
||||||
assert await ChatSessionRepository("00000000-0000-0000-0000-0000000000aa").get(sid) is None
|
assert await ChatSessionRepository("imarketkorea").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("imarketkorea")
|
repo = ChatSessionRepository("ktcommerce")
|
||||||
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
|
||||||
|
|||||||
@ -1,157 +0,0 @@
|
|||||||
"""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="imarketkorea"):
|
def _repo(tenant_id="ktcommerce"):
|
||||||
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,10 +42,7 @@ 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"
|
||||||
# 리소스 원본은 회사 용어 토큰({label_*}) — 렌더 시 회사 라벨(없으면 기본값)로 치환된다.
|
assert s["배송형태선택"]["input_options"] == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||||
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():
|
||||||
@ -68,9 +65,11 @@ 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 "데모상사 B" in im["script"] and "Negosium" in im["script"]
|
assert "데모상사 A" in kt["script"] and "Negosium" in kt["script"]
|
||||||
assert "{company_name}" not in im["script"] # 치환 완료
|
assert "데모상사 B" in im["script"]
|
||||||
|
assert "{company_name}" not in kt["script"] # 치환 완료
|
||||||
|
|
||||||
|
|
||||||
def test_variable_substitution_and_missing_kept():
|
def test_variable_substitution_and_missing_kept():
|
||||||
@ -104,16 +103,12 @@ 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-B001", {"input_price": 9800})
|
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
|
||||||
assert out and "9800" in out
|
assert out and "9800" in out
|
||||||
|
|
||||||
|
|
||||||
from negotiation.cards.ports.card_script_port import ICardScriptRepository
|
class _FakeCardRepo:
|
||||||
|
"""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
|
||||||
@ -126,9 +121,9 @@ class _FakeCardRepo(ICardScriptRepository):
|
|||||||
@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("imarketkorea")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
||||||
cfg.cards.source_type = "backoffice_db"
|
cfg.cards.source_type = "backoffice_db"
|
||||||
fake = _FakeCardRepo({"NGC-B001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
|
fake = _FakeCardRepo({"NGC-A001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
|
||||||
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
||||||
|
|
||||||
# execute_lambda 를 세션 없이 콜백만 실행하도록 대체(순수 단위검증)
|
# execute_lambda 를 세션 없이 콜백만 실행하도록 대체(순수 단위검증)
|
||||||
@ -137,14 +132,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-B001", {"input_price": 9800})
|
out = await repo.resolve_card_script(0, "NGC-A001", {"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("imarketkorea")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
||||||
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)
|
||||||
@ -154,14 +149,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-B001", {"input_price": 9800})
|
out = await repo.resolve_card_script(0, "NGC-A001", {"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("imarketkorea")
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
||||||
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)
|
||||||
@ -173,24 +168,3 @@ 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 == ["협력사배송", "지정택배배송", "픽업배송"]
|
|
||||||
|
|||||||
63
agent/tools/build_card_embeddings.py
Normal file
63
agent/tools/build_card_embeddings.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"""카드 스크립트 → 임베딩 캐시 생성 (action-as-feature 준비, 1회 실행).
|
||||||
|
|
||||||
|
card.nego_cards(11장)의 name+script 를 문장 임베딩으로 변환해 artifacts/card_embeddings.npz 에 저장.
|
||||||
|
새 카드가 추가되면 이 스크립트를 다시 돌리면 된다(그 카드만 임베딩돼 캐시에 합류).
|
||||||
|
|
||||||
|
실행:
|
||||||
|
APP_ENV=local python -m tools.build_card_embeddings
|
||||||
|
출력:
|
||||||
|
artifacts/card_embeddings.npz (numbers, names, strategy, tone, embeddings[N,384])
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ARTIFACTS = os.path.join(_HERE, "..", "artifacts")
|
||||||
|
OUT_PATH = os.path.join(ARTIFACTS, "card_embeddings.npz")
|
||||||
|
|
||||||
|
MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2" # 384차원, 한국어 지원, 로컬/무료
|
||||||
|
|
||||||
|
|
||||||
|
async def load_cards():
|
||||||
|
"""card.nego_cards 에서 (number, name, script, strategy_type, tone) 로드."""
|
||||||
|
import asyncpg
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host="127.0.0.1", port=5432, user="postgres", password="password", database="negosium_db")
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT number, name, script, strategy_type, tone FROM card.nego_cards "
|
||||||
|
"WHERE deleted = FALSE ORDER BY number")
|
||||||
|
return [(r["number"], r["name"], r["script"], r["strategy_type"], r["tone"]) for r in rows]
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cards = asyncio.run(load_cards())
|
||||||
|
if not cards:
|
||||||
|
raise SystemExit("card.nego_cards 가 비어있음 — DB 시드 확인 (docker start negosium-pg)")
|
||||||
|
print(f"카드 {len(cards)}장 로드: {[c[0] for c in cards]}")
|
||||||
|
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
model = SentenceTransformer(MODEL_NAME)
|
||||||
|
texts = [f"{name}. {script}" for _, name, script, _, _ in cards]
|
||||||
|
emb = model.encode(texts, normalize_embeddings=True) # [N, 384], 단위벡터
|
||||||
|
print(f"임베딩 shape: {emb.shape}")
|
||||||
|
|
||||||
|
os.makedirs(ARTIFACTS, exist_ok=True)
|
||||||
|
np.savez(
|
||||||
|
OUT_PATH,
|
||||||
|
numbers=np.array([c[0] for c in cards]),
|
||||||
|
names=np.array([c[1] for c in cards]),
|
||||||
|
strategy=np.array([c[3] for c in cards], dtype=np.int64),
|
||||||
|
tone=np.array([c[4] for c in cards], dtype=np.int64),
|
||||||
|
embeddings=emb.astype(np.float32),
|
||||||
|
)
|
||||||
|
print(f"저장: {OUT_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
251
agent/tools/compare_qtable_vs_dqn.py
Normal file
251
agent/tools/compare_qtable_vs_dqn.py
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
"""기존 Q-Table(UCB) vs action-as-feature DQN 공정 비교 — 고객사 성향 조건화 환경 (최종).
|
||||||
|
|
||||||
|
같은 환경(FeatureBuyer 2축 + 협력사·고객사성향 랜덤)에서 동일 에피소드로 학습·평가.
|
||||||
|
- Q-Table: 이산 state 162칸 + 카드=슬롯. 성향(고객사) 입력 자체가 불가능 → 평균 성향에 수렴
|
||||||
|
- DQN : 연속 상태 + 성향 벡터 + 카드 특징(임베딩+전략/톤 one-hot)
|
||||||
|
|
||||||
|
평가 4종:
|
||||||
|
① 학습 카드 9장 — 평균보상(진짜 목적함수) + top3 적중(MC 정답 기준)
|
||||||
|
② zero-shot 11장 — 안 본 카드 2장 포함
|
||||||
|
③ 새 카드 첫 턴 사용률 — 구조적 차이
|
||||||
|
④ 성향 극단 테스트 — 같은 협력사, 성향만 바꿨을 때 카드를 바꾸는가
|
||||||
|
|
||||||
|
실행: APP_ENV=local python -m tools.compare_qtable_vs_dqn
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from eval_harness.buyer import Scenario
|
||||||
|
from eval_harness.feature_buyer import FeatureBuyer, SupplierProfile, sample_supplier
|
||||||
|
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
|
||||||
|
from negotiation.policies.qtable_policy import UCBQTablePolicy
|
||||||
|
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
|
||||||
|
from negotiation.qtable.domain.model.q_table import QTable
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import (
|
||||||
|
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features)
|
||||||
|
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||||
|
from negotiation.qtable.domain.service.state_calculator import state_index
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tools.train_feature_dqn import (
|
||||||
|
ANCHOR, HOLDOUT, MAX_TURNS, TARGET, load_cards, make_snapshot, pref_config, sample_tenant_pref)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 정책 어댑터 ------------------------------------------------------------------
|
||||||
|
class DQNAdapter:
|
||||||
|
name = "feature_dqn"
|
||||||
|
|
||||||
|
def __init__(self, policy, feat):
|
||||||
|
self.p, self.feat = policy, feat
|
||||||
|
|
||||||
|
def _sf(self, snap, tf):
|
||||||
|
return np.concatenate([build_state_features(snap), tf])
|
||||||
|
|
||||||
|
def choose(self, snap, tf, avail, greedy):
|
||||||
|
self.p.greedy = greedy
|
||||||
|
i, _, _ = self.p.select(self._sf(snap, tf), np.stack([self.feat[c] for c in avail]))
|
||||||
|
return avail[i]
|
||||||
|
|
||||||
|
def learn(self, snap, tf, card, reward, next_snap, next_avail, done):
|
||||||
|
sf = self._sf(snap, tf)
|
||||||
|
if done or next_snap is None:
|
||||||
|
self.p.remember(sf, self.feat[card], reward, None, None, True)
|
||||||
|
else:
|
||||||
|
self.p.remember(sf, self.feat[card], reward, self._sf(next_snap, tf),
|
||||||
|
np.stack([self.feat[c] for c in next_avail]), False)
|
||||||
|
self.p.train_step()
|
||||||
|
|
||||||
|
|
||||||
|
class QTableAdapter:
|
||||||
|
"""기존 UCBQTablePolicy. 성향(tf)은 구조상 받을 수 없다 — 이산 state 162칸에 그 축이 없음."""
|
||||||
|
|
||||||
|
name = "qtable_ucb"
|
||||||
|
|
||||||
|
def __init__(self, all_numbers, state_cfg, lr=0.1, gamma=0.95):
|
||||||
|
self.numbers = list(all_numbers)
|
||||||
|
self.a_of = {n: i for i, n in enumerate(self.numbers)}
|
||||||
|
self.state_cfg = state_cfg
|
||||||
|
self.qt = QTable(162, len(self.numbers), learning_rate=lr, discount_factor=gamma)
|
||||||
|
self.pol = UCBQTablePolicy(self.qt)
|
||||||
|
|
||||||
|
def choose(self, snap, tf, avail, greedy):
|
||||||
|
idx = state_index(snap, self.state_cfg)
|
||||||
|
if greedy:
|
||||||
|
q = self.qt.row(idx)
|
||||||
|
return max(avail, key=lambda c: q[self.a_of[c]])
|
||||||
|
mask = np.zeros(len(self.numbers), dtype=bool)
|
||||||
|
for c in avail:
|
||||||
|
mask[self.a_of[c]] = True
|
||||||
|
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=len(self.numbers),
|
||||||
|
episode=EpisodeState(), available_mask=mask)
|
||||||
|
return self.numbers[self.pol.select(ctx).action_id]
|
||||||
|
|
||||||
|
def learn(self, snap, tf, card, reward, next_snap, next_avail, done):
|
||||||
|
idx = state_index(snap, self.state_cfg)
|
||||||
|
nidx = state_index(next_snap, self.state_cfg) if (next_snap is not None and not done) else None
|
||||||
|
self.pol.update(Transition(state_index=idx, action_id=self.a_of[card], reward=reward,
|
||||||
|
next_state_index=nidx, done=done))
|
||||||
|
|
||||||
|
|
||||||
|
class RandomAdapter:
|
||||||
|
name = "random"
|
||||||
|
|
||||||
|
def __init__(self, seed=0):
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
def choose(self, snap, tf, avail, greedy):
|
||||||
|
return avail[self.rng.integers(len(avail))]
|
||||||
|
|
||||||
|
def learn(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 공용 에피소드 -----------------------------------------------------------------
|
||||||
|
def run_episode(adapter, sup, tf, pool, strat, rc, seed, learn=True, greedy=False, forced_first=None):
|
||||||
|
buyer = FeatureBuyer(sup, strat, seed=seed, max_turns=MAX_TURNS)
|
||||||
|
scenario = Scenario(anchor_price=ANCHOR, target_price=TARGET, revenue_amount=sup.revenue_amount,
|
||||||
|
distribution_code=sup.distribution_code, partner_count=sup.partner_count)
|
||||||
|
price0 = TARGET * 1.15
|
||||||
|
price, used, total_r, first_card = price0, set(), 0.0, None
|
||||||
|
|
||||||
|
for turn in range(1, MAX_TURNS + 1):
|
||||||
|
acceptance = max(0.0, (price0 - price) / price0)
|
||||||
|
snap = make_snapshot(sup, price, turn, acceptance)
|
||||||
|
avail = [c for c in pool if c not in used] or list(pool)
|
||||||
|
if turn == 1 and forced_first is not None:
|
||||||
|
card = forced_first
|
||||||
|
else:
|
||||||
|
card = adapter.choose(snap, tf, avail, greedy)
|
||||||
|
used.add(card)
|
||||||
|
if first_card is None:
|
||||||
|
first_card = card
|
||||||
|
|
||||||
|
resp = buyer.respond(card, scenario, turn, price)
|
||||||
|
price = resp.new_price
|
||||||
|
done = resp.accept or price <= ANCHOR or turn >= MAX_TURNS
|
||||||
|
success = resp.accept or price <= ANCHOR
|
||||||
|
outcome = (NegotiationOutcome.SUCCESS if success
|
||||||
|
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
|
||||||
|
# 채점은 최종 결과 시점만 (중간 턴 0 → γ 부트스트랩으로 전파).
|
||||||
|
# 진행 중 보상을 누적하면 '질질 끄는 전략'이 부당하게 유리해지는 인공물이 생긴다.
|
||||||
|
r = rc.calculate(make_snapshot(sup, price, turn, acceptance, outcome)).total if done else 0.0
|
||||||
|
total_r += r
|
||||||
|
|
||||||
|
if learn:
|
||||||
|
if done:
|
||||||
|
adapter.learn(snap, tf, card, r, None, None, True)
|
||||||
|
else:
|
||||||
|
acc2 = max(0.0, (price0 - price) / price0)
|
||||||
|
nsnap = make_snapshot(sup, price, turn + 1, acc2)
|
||||||
|
navail = [c for c in pool if c not in used] or list(pool)
|
||||||
|
adapter.learn(snap, tf, card, r, nsnap, navail, False)
|
||||||
|
if done:
|
||||||
|
return total_r, success, price, first_card
|
||||||
|
return total_r, False, price, first_card
|
||||||
|
|
||||||
|
|
||||||
|
# ---- MC 정답 랭킹: 이 (협력사, 성향)에서 진짜 좋은 첫 카드 top-k ---------------------
|
||||||
|
_rand = RandomAdapter(seed=1)
|
||||||
|
|
||||||
|
def rank_cards_mc(sup, tf, pool, strat, rc, seed, sims=6, k=3):
|
||||||
|
means = {}
|
||||||
|
for c in pool:
|
||||||
|
rs = [run_episode(_rand, sup, tf, pool, strat, rc, seed=seed + 17 * s,
|
||||||
|
learn=False, greedy=False, forced_first=c)[0] for s in range(sims)]
|
||||||
|
means[c] = np.mean(rs)
|
||||||
|
return sorted(means, key=lambda c: -means[c])[:k]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 학습/평가 ---------------------------------------------------------------------
|
||||||
|
def train(adapter, pool, strat, base_reward, state_cfg, episodes, seed):
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
for ep in range(1, episodes + 1):
|
||||||
|
sup = sample_supplier(rng)
|
||||||
|
rcfg, tf = sample_tenant_pref(rng, base_reward)
|
||||||
|
rc = RewardCalculator(rcfg, state_cfg)
|
||||||
|
run_episode(adapter, sup, tf, pool, strat, rc, seed=seed * 100 + ep, learn=True)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(adapter, pool, strat, base_reward, state_cfg, n=300, seed0=777, label=""):
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import build_tenant_features
|
||||||
|
rng = np.random.default_rng(seed0)
|
||||||
|
rewards, succ, ratios, hits, holdout_first = [], 0, [], 0, 0
|
||||||
|
for i in range(n):
|
||||||
|
sup = sample_supplier(rng)
|
||||||
|
rcfg, tf = sample_tenant_pref(rng, base_reward)
|
||||||
|
rc = RewardCalculator(rcfg, state_cfg)
|
||||||
|
good = rank_cards_mc(sup, tf, pool, strat, rc, seed=seed0 * 7 + i)
|
||||||
|
r, ok, price, first = run_episode(adapter, sup, tf, pool, strat, rc,
|
||||||
|
seed=seed0 * 1000 + i, learn=False, greedy=True)
|
||||||
|
rewards.append(r); succ += ok; ratios.append(price / TARGET)
|
||||||
|
hits += (first in good); holdout_first += (first in HOLDOUT)
|
||||||
|
m, ci = float(np.mean(rewards)), float(1.96 * np.std(rewards) / np.sqrt(n))
|
||||||
|
print(f"{label:<14} mean_rwd={m:.4f} ±{ci:.4f} success={succ/n:.3f} "
|
||||||
|
f"settled/tgt={np.mean(ratios):.3f} top3_hit={hits/n:.3f} 새카드첫턴={holdout_first/n:.3f}")
|
||||||
|
|
||||||
|
|
||||||
|
def pref_behavior_test(adapters, pool, strat, base_reward, state_cfg):
|
||||||
|
"""④ 같은 협력사, 성향만 바꿨을 때 카드를 바꾸는가 (greedy).
|
||||||
|
|
||||||
|
첫 턴은 '일단 깎기'가 공통 정답이라 성향 차이가 잘 안 드러난다.
|
||||||
|
→ 협상 중반(가격이 이미 target 근처, 3턴째) 상태를 함께 프로브: 여기서
|
||||||
|
성사중시는 '마무리(수락 잘 되는) 카드', 가격중시는 '더 깎는 카드'가 갈려야 한다.
|
||||||
|
"""
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import build_tenant_features
|
||||||
|
sups = [SupplierProfile(5_000_000, 3, "A"), # 소형·경쟁多
|
||||||
|
SupplierProfile(200_000_000, 1, "A")] # 대형·단독
|
||||||
|
probes = [("첫턴", TARGET * 1.15, 1, 0.0),
|
||||||
|
("중반(3턴,가격↓)", TARGET * 1.02, 3, 0.11)]
|
||||||
|
for pr_name, price, turn, acc in probes:
|
||||||
|
print(f"\n ── 프로브: {pr_name} (price={price:.0f}) ──")
|
||||||
|
print(f" {'협력사':<13} {'성향':<9} " + " ".join(f"{a.name:<15}" for a in adapters))
|
||||||
|
for sup in sups:
|
||||||
|
row = {}
|
||||||
|
for p, pname in [(0.05, "성사중시"), (0.95, "가격중시")]:
|
||||||
|
rcfg = pref_config(base_reward, p)
|
||||||
|
tf = build_tenant_features(rcfg)
|
||||||
|
picks = []
|
||||||
|
for a in adapters:
|
||||||
|
snap = make_snapshot(sup, price, turn, acc)
|
||||||
|
picks.append(a.choose(snap, tf, pool, True))
|
||||||
|
seg = f"{sup.segment[0]}·{sup.segment[1]}"
|
||||||
|
print(f" {seg:<13} {pname:<9} " + " ".join(f"{c}(전략{strat[c]})".ljust(15) for c in picks))
|
||||||
|
|
||||||
|
|
||||||
|
def main(episodes=10000, seed=42):
|
||||||
|
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
|
||||||
|
numbers, feat, strat = load_cards()
|
||||||
|
train_pool = [c for c in numbers if c not in HOLDOUT]
|
||||||
|
tcfg = TenantConfigLoader().load("ktcommerce")
|
||||||
|
card_dim = feat[numbers[0]].shape[0]
|
||||||
|
print(f"환경: 2축 FeatureBuyer + 성향 랜덤 · 학습 {episodes}ep · 카드특징 {card_dim}차원 "
|
||||||
|
f"(임베딩384+전략4+톤4) · 학습 {len(train_pool)}장 / 홀드아웃 {HOLDOUT}")
|
||||||
|
|
||||||
|
qt = QTableAdapter(numbers, tcfg.state)
|
||||||
|
dqn = DQNAdapter(FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM,
|
||||||
|
card_dim=card_dim, eps_decay=4000), feat)
|
||||||
|
|
||||||
|
print("\n[학습] qtable_ucb ...")
|
||||||
|
train(qt, train_pool, strat, tcfg.reward, tcfg.state, episodes, seed)
|
||||||
|
print("[학습] feature_dqn ...")
|
||||||
|
train(dqn, train_pool, strat, tcfg.reward, tcfg.state, episodes, seed)
|
||||||
|
|
||||||
|
print("\n=== ① 학습 카드 9장 풀 ===")
|
||||||
|
evaluate(RandomAdapter(seed), train_pool, strat, tcfg.reward, tcfg.state, label="random")
|
||||||
|
evaluate(qt, train_pool, strat, tcfg.reward, tcfg.state, label="qtable_ucb")
|
||||||
|
evaluate(dqn, train_pool, strat, tcfg.reward, tcfg.state, label="feature_dqn")
|
||||||
|
|
||||||
|
print("\n=== ② zero-shot 11장 풀 (안 본 카드 2장 포함) ===")
|
||||||
|
evaluate(RandomAdapter(seed), numbers, strat, tcfg.reward, tcfg.state, label="random")
|
||||||
|
evaluate(qt, numbers, strat, tcfg.reward, tcfg.state, label="qtable_ucb")
|
||||||
|
evaluate(dqn, numbers, strat, tcfg.reward, tcfg.state, label="feature_dqn")
|
||||||
|
|
||||||
|
print("\n=== ④ 성향 극단 테스트 — 같은 협력사, 성향만 바꾸면 카드를 바꾸는가 (11장 풀) ===")
|
||||||
|
pref_behavior_test([qt, dqn], numbers, strat, tcfg.reward, tcfg.state)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
실행:
|
실행:
|
||||||
cd agent
|
cd agent
|
||||||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 기본 시나리오
|
APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 시나리오
|
||||||
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 imarketkorea --interactive
|
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --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}. 등록된 테넌트: imarketkorea, _base")
|
print(f"[!] 미등록 테넌트: {tenant_id}. 등록된 테넌트: ktcommerce, 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="imarketkorea", help="테넌트 id (imarketkorea|_base)")
|
ap.add_argument("--tenant", default="ktcommerce", help="테넌트 id (ktcommerce|imarketkorea)")
|
||||||
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()
|
||||||
|
|||||||
42
agent/tools/export_autonomy_serving.py
Normal file
42
agent/tools/export_autonomy_serving.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""full_autonomy 체크포인트(.pt) → 서빙 번들(autonomy_serving.npz) export.
|
||||||
|
|
||||||
|
dqn_serving 과 동일 패턴: ScoreNet 가중치만 numpy 로 묶어 PyTorch 없이 서빙한다.
|
||||||
|
행동 특징은 코드(autonomy_actions)가 런타임 생성하므로 번들에는 가중치만 담는다.
|
||||||
|
|
||||||
|
실행(호스트, torch 필요): APP_ENV=local python -m tools.export_autonomy_serving
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from negotiation.policies.autonomy_actions import ACTION_DIM, EXTRA_STATE_DIM
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import STATE_FEATURE_DIM, TENANT_FEATURE_DIM
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "full_autonomy.pt")
|
||||||
|
OUT_PATH = os.path.join(_HERE, "..", "artifacts", "autonomy_serving.npz")
|
||||||
|
|
||||||
|
STATE_DIM = STATE_FEATURE_DIM + TENANT_FEATURE_DIM + EXTRA_STATE_DIM
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sd = torch.load(CKPT_PATH, map_location="cpu")
|
||||||
|
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
|
||||||
|
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
|
||||||
|
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
|
||||||
|
assert W0.shape[1] == STATE_DIM + ACTION_DIM, f"입력 차원 불일치: {W0.shape[1]}"
|
||||||
|
|
||||||
|
tmp = OUT_PATH + ".tmp"
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
np.savez(f, W0=W0, b0=b0, W1=W1, b1=b1, W2=W2, b2=b2,
|
||||||
|
state_dim=STATE_DIM, action_dim=ACTION_DIM)
|
||||||
|
if os.path.exists(OUT_PATH):
|
||||||
|
os.replace(OUT_PATH, OUT_PATH + ".prev")
|
||||||
|
os.replace(tmp, OUT_PATH)
|
||||||
|
print(f"[저장] {os.path.abspath(OUT_PATH)} (state {STATE_DIM} + action {ACTION_DIM})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
79
agent/tools/export_dqn_serving.py
Normal file
79
agent/tools/export_dqn_serving.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"""feature_dqn 체크포인트(.pt) → 서빙 번들(dqn_serving.npz) export.
|
||||||
|
|
||||||
|
서빙 컨테이너에 PyTorch 를 넣지 않기 위해 ScoreNet(3층 MLP) 가중치와 카드 특징
|
||||||
|
(임베딩384 + 전략 one-hot4 + 톤 one-hot4 = 392)을 numpy 번들 하나로 묶는다.
|
||||||
|
추론은 negotiation.policy.dqn_store 의 numpy forward 가 수행한다.
|
||||||
|
|
||||||
|
실행(호스트, torch 필요): APP_ENV=local python -m tools.export_dqn_serving
|
||||||
|
산출: agent/artifacts/dqn_serving.npz (.dockerignore 미제외 → 이미지에 포함)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from tools.train_feature_dqn import load_cards
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "feature_dqn_ktcommerce.pt")
|
||||||
|
OUT_PATH = os.path.join(_HERE, "..", "artifacts", "dqn_serving.npz")
|
||||||
|
|
||||||
|
STATE_DIM = 14 # build_state_features(9) + build_tenant_features(5)
|
||||||
|
CARD_DIM = 392
|
||||||
|
|
||||||
|
|
||||||
|
def _np_forward(x, W0, b0, W1, b1, W2, b2):
|
||||||
|
h = np.maximum(x @ W0.T + b0, 0.0)
|
||||||
|
h = np.maximum(h @ W1.T + b1, 0.0)
|
||||||
|
return h @ W2.T + b2
|
||||||
|
|
||||||
|
|
||||||
|
def export_bundle(sd, out_path: str) -> str:
|
||||||
|
"""state_dict → 서빙 번들 npz (원자적 교체: .tmp 작성 후 replace). 반환: 절대경로.
|
||||||
|
|
||||||
|
retrain_from_logs 재학습 배포도 이 함수를 쓴다 — 검증(torch/numpy 일치)은 main() 전용.
|
||||||
|
"""
|
||||||
|
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
|
||||||
|
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
|
||||||
|
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
|
||||||
|
assert W0.shape[1] == STATE_DIM + CARD_DIM, f"입력 차원 불일치: {W0.shape[1]}"
|
||||||
|
numbers, feat, _ = load_cards()
|
||||||
|
card_feats = np.stack([feat[n] for n in numbers]).astype(np.float32)
|
||||||
|
tmp = out_path + ".tmp"
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
np.savez(
|
||||||
|
f,
|
||||||
|
W0=W0, b0=b0, W1=W1, b1=b1, W2=W2, b2=b2,
|
||||||
|
card_numbers=np.array(numbers), card_feats=card_feats,
|
||||||
|
state_dim=STATE_DIM, card_dim=CARD_DIM,
|
||||||
|
)
|
||||||
|
if os.path.exists(out_path):
|
||||||
|
os.replace(out_path, out_path + ".prev") # 직전 번들 백업(롤백용)
|
||||||
|
os.replace(tmp, out_path)
|
||||||
|
return os.path.abspath(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sd = torch.load(CKPT_PATH, map_location="cpu")
|
||||||
|
# 정합성 검증: torch forward == numpy forward
|
||||||
|
from negotiation.policies.feature_dqn_policy import ScoreNet
|
||||||
|
net = ScoreNet(STATE_DIM, CARD_DIM)
|
||||||
|
net.load_state_dict(sd)
|
||||||
|
net.eval()
|
||||||
|
x = np.random.default_rng(0).normal(size=(8, STATE_DIM + CARD_DIM)).astype(np.float32)
|
||||||
|
with torch.no_grad():
|
||||||
|
ref = net(torch.tensor(x)).numpy()
|
||||||
|
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
|
||||||
|
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
|
||||||
|
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
|
||||||
|
out = _np_forward(x, W0, b0, W1, b1, W2, b2).squeeze(-1)
|
||||||
|
diff = float(np.abs(ref - out).max())
|
||||||
|
assert diff < 1e-4, f"numpy/torch forward 불일치: {diff}"
|
||||||
|
|
||||||
|
path = export_bundle(sd, OUT_PATH)
|
||||||
|
print(f"[저장] {path} forward 오차 {diff:.2e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
61
agent/tools/probe_serving_dqn.py
Normal file
61
agent/tools/probe_serving_dqn.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""probe_serving_dqn — 서빙 번들(dqn_serving.npz)의 상황별 카드 선택 프로브.
|
||||||
|
|
||||||
|
배포된 모델이 '상황에 맞게' 고르는지 눈으로 확인하는 진단 도구:
|
||||||
|
협력사 세그먼트 × 고객사 성향 × 협상 국면(가격대)별 선택 카드를 표로 출력한다.
|
||||||
|
전부 다르길 기대하는 게 아니라, 축을 바꿨을 때 선택이 '움직이는지'를 본다.
|
||||||
|
|
||||||
|
실행: APP_ENV=local python -m tools.probe_serving_dqn (numpy 만 필요, DB 불필요)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import build_state_features, build_tenant_features
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tools.export_dqn_serving import OUT_PATH
|
||||||
|
from tools.retrain_from_logs import np_scorer_from_bundle
|
||||||
|
from tools.train_feature_dqn import pref_config
|
||||||
|
|
||||||
|
ANCHOR, TARGET = 495_000.0, 500_000.0 # BUGCHECK 견적과 동일 스케일
|
||||||
|
|
||||||
|
SUPPLIERS = {
|
||||||
|
"소형·경쟁多": dict(revenue_amount=5_000_000, partner_count=3, distribution_code="A"),
|
||||||
|
"소형·단독": dict(revenue_amount=5_000_000, partner_count=1, distribution_code="A"),
|
||||||
|
"대형·경쟁多": dict(revenue_amount=200_000_000, partner_count=3, distribution_code="A"),
|
||||||
|
"대형·단독": dict(revenue_amount=200_000_000, partner_count=1, distribution_code="A"),
|
||||||
|
}
|
||||||
|
PHASES = { # (라운드, 제시가): 첫턴 높은 가격 / 중반 목표가 근접 / 막판 앵커존 직전
|
||||||
|
"첫턴(575k)": (1, 575_000.0),
|
||||||
|
"중반(510k)": (2, 510_000.0),
|
||||||
|
"막판(501k)": (3, 501_000.0),
|
||||||
|
}
|
||||||
|
PREFS = {"성사중시": 0.1, "가격중시": 0.9}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
score = np_scorer_from_bundle(OUT_PATH)
|
||||||
|
z = np.load(OUT_PATH, allow_pickle=False)
|
||||||
|
numbers = [str(n) for n in z["card_numbers"]]
|
||||||
|
feats = z["card_feats"]
|
||||||
|
base = TenantConfigLoader().load("ktcommerce").reward
|
||||||
|
|
||||||
|
for phase, (turn, price) in PHASES.items():
|
||||||
|
print(f"\n=== {phase} (앵커 {int(ANCHOR):,} / 목표 {int(TARGET):,}) ===")
|
||||||
|
print(f"{'협력사':<12}" + "".join(f"{p:>16}" for p in PREFS))
|
||||||
|
for sup_name, sup in SUPPLIERS.items():
|
||||||
|
row = []
|
||||||
|
for _, p in PREFS.items():
|
||||||
|
tf = build_tenant_features(pref_config(base, p))
|
||||||
|
snap = NegotiationSnapshot(
|
||||||
|
revenue_amount=sup["revenue_amount"], distribution_code=sup["distribution_code"],
|
||||||
|
partner_count=sup["partner_count"],
|
||||||
|
acceptance_ratio=max(0.0, (575_000.0 - price) / 575_000.0),
|
||||||
|
input_price=price, anchor_price=ANCHOR, target_price=TARGET, round_number=turn,
|
||||||
|
)
|
||||||
|
sf = np.concatenate([build_state_features(snap), tf])
|
||||||
|
row.append(numbers[int(np.argmax(score(sf, feats)))])
|
||||||
|
print(f"{sup_name:<12}" + "".join(f"{c:>16}" for c in row))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
238
agent/tools/retrain_from_logs.py
Normal file
238
agent/tools/retrain_from_logs.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"""retrain_from_logs — experience_logs 실데이터로 feature_dqn 오프라인 재학습 + OPE 게이트.
|
||||||
|
|
||||||
|
파이프라인:
|
||||||
|
① learning.experience_logs 로드(전 테넌트 — 범용 에이전트는 테넌트를 특징으로 조건화하므로 통합 학습)
|
||||||
|
② 세션별 에피소드 재구성: 카드턴(done=False) N개 + 종료행(done=True) 1개.
|
||||||
|
보상은 학습 규약(최종 결과 시점만 채점)에 맞춰 종료행 reward 만 쓰고 중간턴은 0.
|
||||||
|
③ 현재 체크포인트에서 fine-tune (낮은 lr — 시뮬 사전학습 망각 방지)
|
||||||
|
④ OPE(SNIPS, 궤적 IS): 후보 모델 vs 현재 서빙 번들. 후보가 못 넘으면 배포하지 않는다.
|
||||||
|
⑤ 통과 시 dqn_serving.npz 원자적 교체(직전본 .prev 백업) → `docker compose build agent && up -d agent` 로 배포.
|
||||||
|
|
||||||
|
실행(호스트, torch+DB 필요):
|
||||||
|
APP_ENV=local python -m tools.retrain_from_logs
|
||||||
|
환경변수:
|
||||||
|
MIN_EPISODES(기본 200) 재학습 최소 에피소드 수 — 미달 시 skip (과적합 방지)
|
||||||
|
EPOCHS(기본 20) / LR(기본 1e-4) / FORCE_DEPLOY=1 (OPE 게이트 무시 — 테스트 전용)
|
||||||
|
|
||||||
|
주의: 서빙이 greedy(탐색 없음)라 로그가 선택 편향됨 — OPE 의 유효표본(ESS)이 작으면
|
||||||
|
게이트가 보수적으로 배포를 막는다. 이는 의도된 동작이다(조용한 성능저하 방지).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import ExperienceLog
|
||||||
|
from common.enums import DBType, DBWRType
|
||||||
|
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import (
|
||||||
|
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features)
|
||||||
|
from sqlalchemy import select
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tools.export_dqn_serving import CKPT_PATH, OUT_PATH, export_bundle
|
||||||
|
from tools.train_feature_dqn import load_cards
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RETRAIN_CKPT = os.path.join(_HERE, "..", "artifacts", "feature_dqn_retrained.pt")
|
||||||
|
REPORT_PATH = os.path.join(_HERE, "..", "artifacts", "retrain_report.json")
|
||||||
|
|
||||||
|
MIN_EPISODES = int(os.getenv("MIN_EPISODES", "200"))
|
||||||
|
EPOCHS = int(os.getenv("EPOCHS", "20"))
|
||||||
|
LR = float(os.getenv("LR", "1e-4"))
|
||||||
|
FORCE_DEPLOY = os.getenv("FORCE_DEPLOY") == "1"
|
||||||
|
PROPENSITY_FALLBACK = 0.9 # 구로그 propensity 누락 시 (UCB/DQN 모두 greedy≈(1-ε)+ε/n)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ① 로그 로드 -------------------------------------------------------------
|
||||||
|
async def fetch_logs():
|
||||||
|
def _q(s):
|
||||||
|
q = (select(ExperienceLog.company_id, ExperienceLog.session_id, ExperienceLog.card_id,
|
||||||
|
ExperienceLog.reward, ExperienceLog.done, ExperienceLog.snapshot,
|
||||||
|
ExperienceLog.propensity, ExperienceLog.turn, ExperienceLog.id)
|
||||||
|
.where(ExperienceLog.is_invalidated == False) # noqa: E712
|
||||||
|
.order_by(ExperienceLog.company_id, ExperienceLog.session_id, ExperienceLog.id))
|
||||||
|
return DB_SESSION_MNG.execute(s, q)
|
||||||
|
err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ② 에피소드 재구성 --------------------------------------------------------
|
||||||
|
def build_episodes(rows, known_cards: set):
|
||||||
|
"""→ [{tenant, steps:[(snapshot, card, propensity)], terminal_reward}], 스킵 사유 카운트."""
|
||||||
|
by_session = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
if r[1] is not None:
|
||||||
|
by_session[(r[0], str(r[1]))].append(r)
|
||||||
|
|
||||||
|
episodes, skipped = [], defaultdict(int)
|
||||||
|
for (company_id, _sid), items in by_session.items():
|
||||||
|
selects = [r for r in items if not r[4] and r[5]] # done=False, snapshot 有
|
||||||
|
terminals = [r for r in items if r[4] and r[3] is not None] # done=True, reward 有
|
||||||
|
if not selects or not terminals:
|
||||||
|
skipped["종료행/카드턴 없음(미완결 세션)"] += 1
|
||||||
|
continue
|
||||||
|
if any(str(r[2] or "").startswith("AUT|") for r in selects):
|
||||||
|
skipped["완전 자율 세션(카드 재학습 대상 아님)"] += 1
|
||||||
|
continue
|
||||||
|
if any(r[2] not in known_cards for r in selects):
|
||||||
|
skipped["임베딩 없는 카드(파일매핑 테넌트 등)"] += 1
|
||||||
|
continue
|
||||||
|
episodes.append(dict(
|
||||||
|
tenant=company_id,
|
||||||
|
steps=[(r[5], r[2], r[6] if r[6] else PROPENSITY_FALLBACK) for r in selects],
|
||||||
|
terminal_reward=float(terminals[-1][3]),
|
||||||
|
))
|
||||||
|
return episodes, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_feat_for(cache: dict, loader: TenantConfigLoader, company_id: str) -> np.ndarray:
|
||||||
|
"""테넌트 보상설정 → 성향 특징. 미온보딩/로드 실패는 _base 폴백."""
|
||||||
|
if company_id not in cache:
|
||||||
|
try:
|
||||||
|
cfg = loader.load(company_id)
|
||||||
|
except Exception:
|
||||||
|
cfg = loader.load("_base")
|
||||||
|
cache[company_id] = build_tenant_features(cfg.reward)
|
||||||
|
return cache[company_id]
|
||||||
|
|
||||||
|
|
||||||
|
def to_transitions(episodes, feat, tenant_feats):
|
||||||
|
"""학습 규약(train_feature_dqn 과 동일): 중간턴 r=0, 종료턴만 terminal_reward. 다음 후보 = 전체 − 사용분."""
|
||||||
|
all_cards = list(feat.keys())
|
||||||
|
out = []
|
||||||
|
for ep in episodes:
|
||||||
|
tf = tenant_feats[ep["tenant"]]
|
||||||
|
used = set()
|
||||||
|
n = len(ep["steps"])
|
||||||
|
for i, (snap_d, card, _p) in enumerate(ep["steps"]):
|
||||||
|
sf = np.concatenate([build_state_features(NegotiationSnapshot.from_dict(snap_d)), tf])
|
||||||
|
used.add(card)
|
||||||
|
if i == n - 1:
|
||||||
|
out.append((sf, feat[card], ep["terminal_reward"], None, None, True))
|
||||||
|
else:
|
||||||
|
s2_d = ep["steps"][i + 1][0]
|
||||||
|
s2 = np.concatenate([build_state_features(NegotiationSnapshot.from_dict(s2_d)), tf])
|
||||||
|
cands = [c for c in all_cards if c not in used] or all_cards
|
||||||
|
out.append((sf, feat[card], 0.0, s2, np.stack([feat[c] for c in cands]), False))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ④ OPE (SNIPS, 궤적 단위 IS) ----------------------------------------------
|
||||||
|
def _greedy_match(score_fn, ep, feat, tf) -> float:
|
||||||
|
"""궤적 IS 가중치: Π 1[greedy(sᵢ)=aᵢ]/pᵢ. 한 턴이라도 불일치면 0."""
|
||||||
|
all_cards = list(feat.keys())
|
||||||
|
w, used = 1.0, set()
|
||||||
|
for snap_d, card, p in ep["steps"]:
|
||||||
|
sf = np.concatenate([build_state_features(NegotiationSnapshot.from_dict(snap_d)), tf])
|
||||||
|
cands = [c for c in all_cards if c not in used] or all_cards
|
||||||
|
sc = score_fn(sf, np.stack([feat[c] for c in cands]))
|
||||||
|
if cands[int(np.argmax(sc))] != card:
|
||||||
|
return 0.0
|
||||||
|
w /= max(p, 1e-3)
|
||||||
|
used.add(card)
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def snips(score_fn, episodes, feat, tenant_feats):
|
||||||
|
"""SNIPS 추정치 + 유효표본크기(ESS). 매치 0건이면 (None, 0)."""
|
||||||
|
ws, rs = [], []
|
||||||
|
for ep in episodes:
|
||||||
|
w = _greedy_match(score_fn, ep, feat, tenant_feats[ep["tenant"]])
|
||||||
|
ws.append(w)
|
||||||
|
rs.append(ep["terminal_reward"])
|
||||||
|
ws, rs = np.array(ws), np.array(rs)
|
||||||
|
if ws.sum() <= 0:
|
||||||
|
return None, 0.0
|
||||||
|
est = float((ws * rs).sum() / ws.sum())
|
||||||
|
ess = float(ws.sum() ** 2 / (ws ** 2).sum())
|
||||||
|
return est, ess
|
||||||
|
|
||||||
|
|
||||||
|
def np_scorer_from_bundle(path):
|
||||||
|
"""현재 서빙 번들(npz) → score_fn (dqn_store 와 동일 forward)."""
|
||||||
|
z = np.load(path, allow_pickle=False)
|
||||||
|
W0, b0, W1, b1, W2, b2 = z["W0"], z["b0"], z["W1"], z["b1"], z["W2"], z["b2"]
|
||||||
|
|
||||||
|
def score(sf, card_feats):
|
||||||
|
x = np.concatenate([np.repeat(sf[None, :], card_feats.shape[0], axis=0), card_feats], axis=1)
|
||||||
|
h = np.maximum(x @ W0.T + b0, 0.0)
|
||||||
|
h = np.maximum(h @ W1.T + b1, 0.0)
|
||||||
|
return (h @ W2.T + b2).squeeze(-1)
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 메인 ---------------------------------------------------------------------
|
||||||
|
async def run():
|
||||||
|
numbers, feat, _ = load_cards()
|
||||||
|
rows = await fetch_logs()
|
||||||
|
episodes, skipped = build_episodes(rows, set(numbers))
|
||||||
|
print(f"로그 {len(rows)}행 → 에피소드 {len(episodes)}개 (스킵: {dict(skipped) or '없음'})")
|
||||||
|
|
||||||
|
report = dict(rows=len(rows), episodes=len(episodes), skipped=dict(skipped),
|
||||||
|
min_episodes=MIN_EPISODES, deployed=False)
|
||||||
|
if len(episodes) < MIN_EPISODES and not FORCE_DEPLOY:
|
||||||
|
print(f"[skip] 에피소드 {len(episodes)} < MIN_EPISODES {MIN_EPISODES} — 과적합 위험으로 재학습 안 함")
|
||||||
|
report["result"] = "skipped_insufficient_data"
|
||||||
|
return report
|
||||||
|
|
||||||
|
loader = TenantConfigLoader()
|
||||||
|
tenant_feats = {}
|
||||||
|
for ep in episodes:
|
||||||
|
tenant_feat_for(tenant_feats, loader, ep["tenant"])
|
||||||
|
|
||||||
|
# ③ fine-tune (시뮬 사전학습 체크포인트에서 이어서, 낮은 lr)
|
||||||
|
transitions = to_transitions(episodes, feat, tenant_feats)
|
||||||
|
batch = min(64, max(8, len(transitions) // 4))
|
||||||
|
policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM,
|
||||||
|
card_dim=feat[numbers[0]].shape[0], lr=LR, batch_size=batch)
|
||||||
|
if os.path.exists(CKPT_PATH):
|
||||||
|
policy.load(CKPT_PATH)
|
||||||
|
print(f"[fine-tune] 시작점: {os.path.basename(CKPT_PATH)} lr={LR} batch={batch}")
|
||||||
|
policy.buf.extend(transitions)
|
||||||
|
steps = EPOCHS * max(1, len(transitions) // batch)
|
||||||
|
losses = [l for _ in range(steps) if (l := policy.train_step()) is not None]
|
||||||
|
print(f"[fine-tune] {steps} step loss {losses[0]:.4f} → {losses[-1]:.4f}" if losses else "[fine-tune] 스텝 없음")
|
||||||
|
|
||||||
|
# ④ OPE 게이트: 후보 vs 현재 서빙
|
||||||
|
def cand_score(sf, cf):
|
||||||
|
return policy.scores(sf, cf)
|
||||||
|
cand_est, cand_ess = snips(cand_score, episodes, feat, tenant_feats)
|
||||||
|
cur_est, cur_ess = (snips(np_scorer_from_bundle(OUT_PATH), episodes, feat, tenant_feats)
|
||||||
|
if os.path.exists(OUT_PATH) else (None, 0.0))
|
||||||
|
print(f"[OPE/SNIPS] 후보 {cand_est} (ESS {cand_ess:.1f}) vs 현재 {cur_est} (ESS {cur_ess:.1f})")
|
||||||
|
report.update(ope_candidate=cand_est, ope_candidate_ess=cand_ess,
|
||||||
|
ope_current=cur_est, ope_current_ess=cur_ess)
|
||||||
|
|
||||||
|
min_ess = max(3.0, 0.02 * len(episodes))
|
||||||
|
passed = (cand_est is not None and cand_ess >= min_ess
|
||||||
|
and (cur_est is None or cand_est >= cur_est - 0.01))
|
||||||
|
if not passed and not FORCE_DEPLOY:
|
||||||
|
print(f"[게이트 불통과] 배포하지 않음 (필요 ESS ≥ {min_ess:.1f}). 현재 번들 유지.")
|
||||||
|
report["result"] = "gate_failed"
|
||||||
|
return report
|
||||||
|
|
||||||
|
# ⑤ 배포: 후보 저장 + 번들 교체 (.prev 백업)
|
||||||
|
policy.save(RETRAIN_CKPT)
|
||||||
|
path = export_bundle(policy.q.state_dict(), OUT_PATH)
|
||||||
|
print(f"[배포] {path} (직전본 → dqn_serving.npz.prev)")
|
||||||
|
print(" 적용: docker compose build agent && docker compose up -d agent")
|
||||||
|
report.update(result="deployed" if passed else "force_deployed", deployed=True,
|
||||||
|
ckpt=os.path.abspath(RETRAIN_CKPT))
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
report = asyncio.run(run())
|
||||||
|
with open(REPORT_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"[리포트] {os.path.abspath(REPORT_PATH)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
317
agent/tools/test_autonomy_defects.py
Normal file
317
agent/tools/test_autonomy_defects.py
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
"""결함 회귀 게이트 — 실전에서 발견된 협상 결함을 시나리오로 재생해 서빙 번들을 검증한다.
|
||||||
|
|
||||||
|
프로브(probe_serving_dqn)가 '눈으로 보는 행동 표'라면 이것은 '자동 합격/불합격'이다.
|
||||||
|
모든 검사 항목은 과거 실제 발생했던 결함이며, 하나라도 실패하면 exit 1 — 배포 금지.
|
||||||
|
재학습 번들은 반드시 이 게이트를 통과한 뒤에만 autonomy_serving.npz 로 교체한다.
|
||||||
|
|
||||||
|
검사 대상은 서빙 실물이다: AutonomyPolicy.decide(봉투 마스크 포함) + ChatEngine._autonomy_next
|
||||||
|
(최종제안 전환) + ment_generator 가드(목표가 누설·할루시네이션). 시뮬 협력사는 스크립트라
|
||||||
|
결정론적이고, 정책도 greedy 라 실행마다 같은 결과가 나온다. DB/LLM/도커 불필요.
|
||||||
|
|
||||||
|
지형은 복수로 검사한다 — v3.4 가 실스케일(423,198)에선 통과하고 드라이브 지형(10,000)에서
|
||||||
|
'첫 턴 목표가 통보'로 퇴화했던 사고: 한 지형 통과는 다른 지형을 보증하지 않는다.
|
||||||
|
|
||||||
|
실행: agent 디렉터리에서 APP_ENV=local python -m tools.test_autonomy_defects [번들경로]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from negotiation.chat.service import ment_generator
|
||||||
|
from negotiation.chat.service.chat_engine import ChatEngine
|
||||||
|
from negotiation.policy import autonomy_store
|
||||||
|
from negotiation.policy.autonomy_store import AutonomyPolicy
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
|
||||||
|
# 검사 지형: 실제 견적(앵커율 ~1%) + 로컬 드라이브 견적(소액) — 스케일이 달라도 예절은 같아야 한다.
|
||||||
|
GEOS = {
|
||||||
|
"실스케일": dict(anchor=418_966, target=423_198, first=540_000, il=459_000),
|
||||||
|
"소액": dict(anchor=9_900, target=10_000, first=11_500, il=0),
|
||||||
|
}
|
||||||
|
MIN_PRESS = int(os.getenv("AUTONOMY_MIN_PRESS", "2"))
|
||||||
|
|
||||||
|
_RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, ok: bool, detail: str = ""):
|
||||||
|
_RESULTS.append((name, ok, detail))
|
||||||
|
print(f" {'✔' if ok else '✘ FAIL'} {name}" + (f" — {detail}" if detail and not ok else ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 하니스: 서빙 실물 구동 (chat_service 의 ctx 관리 순서를 그대로 재현) ----------------
|
||||||
|
def base_ctx(geo) -> dict:
|
||||||
|
return dict(revenue_amount=50_000_000, distribution_code="A", partner_count=3,
|
||||||
|
item_price=geo["first"], input_price=geo["first"], round=1,
|
||||||
|
anchor_price=geo["anchor"], target_price=geo["target"],
|
||||||
|
internet_lowest_price=geo["il"])
|
||||||
|
|
||||||
|
|
||||||
|
def run_scenario(policy, supplier, geo, max_steps=30):
|
||||||
|
"""정책 결정 → 스텝 전환 → 컨텍스트 부기(chat_service 순서) → 스크립트 협력사 반응 루프.
|
||||||
|
|
||||||
|
trace 원소: (step, kind, q, 당시 제시가, autonomy_offer, 결정 시점 press_n)
|
||||||
|
"""
|
||||||
|
eng = ChatEngine.__new__(ChatEngine) # _autonomy_next 는 decider 와 ctx 만 쓴다
|
||||||
|
ctx = base_ctx(geo)
|
||||||
|
|
||||||
|
def decide(c):
|
||||||
|
act = policy.decide(c)
|
||||||
|
c["autonomy_pending"] = {"kind": act.kind, "q": act.counter_q, "s": act.strategy}
|
||||||
|
return act
|
||||||
|
|
||||||
|
eng.autonomy_decider = decide
|
||||||
|
sess = SimpleNamespace(context=ctx)
|
||||||
|
trace, end = [], None
|
||||||
|
for _ in range(max_steps):
|
||||||
|
press_n_at = int(ctx.get("autonomy_press_n") or 0)
|
||||||
|
step = eng._autonomy_next(sess)
|
||||||
|
pending = ctx.pop("autonomy_pending", None)
|
||||||
|
if pending: # chat_service 부기: pending → last(+prev), 역제안 별도 보존, press 카운터
|
||||||
|
if ctx.get("autonomy_last"):
|
||||||
|
ctx["autonomy_prev"] = ctx["autonomy_last"]
|
||||||
|
ctx["autonomy_last"] = dict(pending)
|
||||||
|
if pending["kind"] == "counter":
|
||||||
|
ctx["autonomy_last_counter"] = dict(pending)
|
||||||
|
if pending["kind"] == "press":
|
||||||
|
ctx["autonomy_press_n"] = press_n_at + 1
|
||||||
|
trace.append((step, (pending or {}).get("kind"), (pending or {}).get("q"),
|
||||||
|
ctx["input_price"], ctx.get("autonomy_offer"), press_n_at))
|
||||||
|
if step in ("협상완료", "협상실패"):
|
||||||
|
end = step
|
||||||
|
break
|
||||||
|
if step == "자율_최종제안": # 예→그 금액 타결 / 아니오→협상실패 (엔진 스텝 정의)
|
||||||
|
end = "협상완료" if supplier.final_yes(ctx) else "협상실패"
|
||||||
|
break
|
||||||
|
if step == "자율_역제안" and supplier.counter_yes(ctx):
|
||||||
|
ctx["input_price"] = ctx["autonomy_offer"]
|
||||||
|
end = "협상완료"
|
||||||
|
break
|
||||||
|
ctx["input_price"] = int(supplier.next_price(ctx))
|
||||||
|
ctx["round"] = ctx.get("round", 1) + 1
|
||||||
|
return trace, end, ctx
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(trace):
|
||||||
|
out = []
|
||||||
|
for step, kind, q, price, offer, _ in trace:
|
||||||
|
s = f"{price:,}→{step}"
|
||||||
|
if kind == "counter":
|
||||||
|
s += f"({offer:,})"
|
||||||
|
out.append(s)
|
||||||
|
return " ".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 스크립트 협력사 (결정론, 지형 비율로 정의) -----------------------------------------
|
||||||
|
class Stubborn:
|
||||||
|
"""조금씩 내리지만 하한이 목표가 위(×1.028) — 성사 불가능. 역제안·최종 전부 거절.
|
||||||
|
|
||||||
|
기대 궤적: 설득 ≥2회 → 앵커 이하 개시 → 단조 상향 사다리 → 최종제안(목표가) → 결렬."""
|
||||||
|
def __init__(self, geo):
|
||||||
|
self.floor = int(geo["target"] * 1.028)
|
||||||
|
|
||||||
|
def next_price(self, ctx):
|
||||||
|
return max(self.floor, int(ctx["input_price"] * 0.96))
|
||||||
|
|
||||||
|
def counter_yes(self, ctx):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def final_yes(self, ctx):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class HoverNearTarget:
|
||||||
|
"""목표가 +0.19% 고정 — 마무리 국면. 압박이 나오면 안 되는 구간."""
|
||||||
|
def __init__(self, geo):
|
||||||
|
self.price = int(geo["target"] * 1.0019)
|
||||||
|
|
||||||
|
def next_price(self, ctx):
|
||||||
|
return self.price
|
||||||
|
|
||||||
|
def counter_yes(self, ctx):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def final_yes(self, ctx):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Dealable:
|
||||||
|
"""4% 씩 내려와 목표가 바로 아래까지 협조 — 성사 가능 케이스."""
|
||||||
|
def __init__(self, geo):
|
||||||
|
self.floor = int(geo["target"] * 0.9995)
|
||||||
|
self.accept_from = geo["anchor"] + 0.4 * (geo["target"] - geo["anchor"])
|
||||||
|
|
||||||
|
def next_price(self, ctx):
|
||||||
|
return max(self.floor, int(ctx["input_price"] * 0.96))
|
||||||
|
|
||||||
|
def counter_yes(self, ctx):
|
||||||
|
return ctx["autonomy_offer"] >= self.accept_from # 목표가 부근 제안은 수락
|
||||||
|
|
||||||
|
def final_yes(self, ctx):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 시나리오 검사 (각 항목 = 과거 실제 결함) -------------------------------------------
|
||||||
|
def assert_defects(tag, trace, end, geo):
|
||||||
|
anchor, target = geo["anchor"], geo["target"]
|
||||||
|
near = target * 1.005
|
||||||
|
# '역제안' 검사는 일반 역제안 스텝만 센다 — 같은 금액 재시도가 자율_최종제안으로 전환된 것은
|
||||||
|
# 반복이 아니라 설계된 최종 통보(제품 결정: 같은 금액 재호출 = 탄약 소진 → 마지막으로 묻고 종료).
|
||||||
|
counters = [(i, t) for i, t in enumerate(trace) if t[0] == "자율_역제안"]
|
||||||
|
presses = [t for t in trace if t[1] == "press"]
|
||||||
|
|
||||||
|
if trace and trace[0][3] > near:
|
||||||
|
check(f"[{tag}] 개시 턴은 설득 (결함: v3.4 첫턴 walk→목표가 통보)",
|
||||||
|
trace[0][1] == "press", f"첫 결정이 {trace[0][1]}")
|
||||||
|
if counters:
|
||||||
|
i0, first = counters[0]
|
||||||
|
check(f"[{tag}] 첫 역제안은 앵커 이하 (결함: 사다리 꼭대기 개시)",
|
||||||
|
first[4] <= anchor, f"첫 역제안 {first[4]:,} > 앵커 {anchor:,}")
|
||||||
|
pressed_before = sum(1 for t in trace[:i0] if t[1] == "press")
|
||||||
|
if first[3] > near: # 마무리 국면은 해금 예외
|
||||||
|
check(f"[{tag}] 역제시 해금 전 설득 ≥{MIN_PRESS}회 (결함: 첫턴 역제시)",
|
||||||
|
pressed_before >= MIN_PRESS, f"설득 {pressed_before}회 만에 역제안")
|
||||||
|
offers = [t[4] for _, t in counters]
|
||||||
|
check(f"[{tag}] 역제안 단조 상향 (결함: 제안 철회 423,198→420,024)",
|
||||||
|
all(b >= a for a, b in zip(offers, offers[1:])), f"철회 발생: {offers}")
|
||||||
|
check(f"[{tag}] 역제안 ≤ 목표가", all(o <= target for o in offers), f"{offers}")
|
||||||
|
check(f"[{tag}] 같은 금액 역제안 반복 없음 (결함: 421,082 반복)",
|
||||||
|
all(b != a for a, b in zip(offers, offers[1:])), f"{offers}")
|
||||||
|
check(f"[{tag}] 마무리 국면(≤목표가×1.005) 압박 없음 (결함: 802원 푼돈 흥정)",
|
||||||
|
all(t[3] > near for t in presses), "목표가 코앞에서 압박")
|
||||||
|
check(f"[{tag}] 목표가 초과 제시가 수락 없음 (결함: 목표가+14% 매입)",
|
||||||
|
not any(t[1] == "accept" and t[3] > target for t in trace), "")
|
||||||
|
finals = [t for t in trace if t[0] == "자율_최종제안"]
|
||||||
|
for f in finals:
|
||||||
|
check(f"[{tag}] 최종제안 금액 = 목표가 (결함: 직전 금액 재사용 60,548)",
|
||||||
|
f[4] == target, f"최종제안 {f[4]:,} ≠ 목표가 {target:,}")
|
||||||
|
# 결렬 의사(walk)로 끝났다면 반드시 최종제안을 거쳤어야 한다 (턴캡 종료는 예외)
|
||||||
|
walked_direct = any(t[1] == "walk" and t[0] == "협상실패" for t in trace)
|
||||||
|
capped = trace and trace[-1][0] == "협상실패" and trace[-1][1] is None
|
||||||
|
check(f"[{tag}] 결렬 전 최종제안 1회 보장 (결함: 최종 의사 확인 없이 종료)",
|
||||||
|
not walked_direct or capped or bool(finals), "walk 즉시 결렬")
|
||||||
|
check(f"[{tag}] 종료 보장 (무한 세션 없음)", end is not None, "max_steps 내 미종료")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 엔진 단위 검사 (정책 무관 — 전환 로직 자체) ----------------------------------------
|
||||||
|
def engine_unit_tests():
|
||||||
|
print("\n[엔진 전환 로직 단위 검사]")
|
||||||
|
geo = GEOS["실스케일"]
|
||||||
|
target = geo["target"]
|
||||||
|
|
||||||
|
def force(kind, q=0.0, s=3):
|
||||||
|
eng = ChatEngine.__new__(ChatEngine)
|
||||||
|
eng.autonomy_decider = lambda c: SimpleNamespace(kind=kind, counter_q=q, strategy=s)
|
||||||
|
return eng
|
||||||
|
|
||||||
|
# walk → 최종제안(목표가) → 재차 walk → 협상실패
|
||||||
|
ctx = base_ctx(geo)
|
||||||
|
eng = force("walk")
|
||||||
|
sess = SimpleNamespace(context=ctx)
|
||||||
|
step1 = eng._autonomy_next(sess)
|
||||||
|
check("walk 1회차 → 자율_최종제안 전환", step1 == "자율_최종제안", f"got {step1}")
|
||||||
|
check("walk 전환 최종제안 금액 = 목표가", ctx.get("autonomy_offer") == target,
|
||||||
|
f"{ctx.get('autonomy_offer')}")
|
||||||
|
step2 = eng._autonomy_next(sess)
|
||||||
|
check("walk 2회차(최종 거절 후) → 협상실패", step2 == "협상실패", f"got {step2}")
|
||||||
|
|
||||||
|
# 같은 q 역제안 반복 → 최종제안(목표가) 전환
|
||||||
|
ctx = base_ctx(geo)
|
||||||
|
ctx["autonomy_last"] = ctx["autonomy_last_counter"] = {"kind": "counter", "q": 0.5, "s": 3}
|
||||||
|
sess = SimpleNamespace(context=ctx)
|
||||||
|
step = force("counter", q=0.5)._autonomy_next(sess)
|
||||||
|
check("같은 금액 재역제안 → 자율_최종제안 전환", step == "자율_최종제안", f"got {step}")
|
||||||
|
check("탄약소진 최종제안 금액 = 목표가", ctx.get("autonomy_offer") == target,
|
||||||
|
f"{ctx.get('autonomy_offer')}")
|
||||||
|
|
||||||
|
# 턴 상한 — 캡 종료도 최종제안 보장을 우회하지 않는다
|
||||||
|
ctx = base_ctx(geo)
|
||||||
|
ctx["round"] = 13
|
||||||
|
sess = SimpleNamespace(context=ctx)
|
||||||
|
step = force("press")._autonomy_next(sess)
|
||||||
|
check("턴 상한 초과(최종 미실시) → 자율_최종제안", step == "자율_최종제안", f"got {step}")
|
||||||
|
check("턴캡 최종제안 금액 = 목표가", ctx.get("autonomy_offer") == target,
|
||||||
|
f"{ctx.get('autonomy_offer')}")
|
||||||
|
step = force("press")._autonomy_next(sess)
|
||||||
|
check("턴 상한 초과(최종 거절 후) → 협상실패", step == "협상실패", f"got {step}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 봉투 마스크 단위 검사 (모델 무관 — 후보 필터 자체) ----------------------------------
|
||||||
|
def envelope_unit_tests(policy):
|
||||||
|
print("\n[봉투 마스크 단위 검사]")
|
||||||
|
geo = GEOS["소액"]
|
||||||
|
ctx = base_ctx(geo) # 설득 0회, 제시가 목표가 위 → 설득만 가능해야 한다
|
||||||
|
act = policy.decide(ctx)
|
||||||
|
check("설득 0회 상태의 결정은 press 만 가능 (walk·counter·accept 잠금)",
|
||||||
|
act.kind == "press", f"got {act.kind}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 멘트 가드 검사 (목표가 누설·할루시네이션 — LLM 호출 없음) ---------------------------
|
||||||
|
def ment_guard_tests():
|
||||||
|
print("\n[멘트 가드 검사]")
|
||||||
|
geo = GEOS["실스케일"]
|
||||||
|
target, anchor, il = geo["target"], geo["anchor"], geo["il"]
|
||||||
|
ctx = base_ctx(geo)
|
||||||
|
|
||||||
|
prompt = ment_generator._prompt_for("자율_압박_3", ctx)
|
||||||
|
check("압박 프롬프트에 목표가 숫자 없음 (결함: 목표가 노출 멘트)",
|
||||||
|
str(target) not in prompt.replace(",", ""), "프롬프트가 목표가를 담고 있음")
|
||||||
|
|
||||||
|
leak = f"저희 내부 산정 기준은 {target:,}원입니다. 이 가격에 맞춰 재검토 부탁드립니다."
|
||||||
|
check("목표가 포함 압박 멘트 → 폐기", not ment_generator._guard("자율_압박_3", ctx, leak), "")
|
||||||
|
|
||||||
|
invented = "시장 상황을 고려해 400,000원 수준으로 재검토 부탁드립니다."
|
||||||
|
check("지어낸 금액 멘트 → 폐기 (할루시네이션)",
|
||||||
|
not ment_generator._guard("자율_압박_3", ctx, invented), "")
|
||||||
|
|
||||||
|
ctx2 = dict(ctx, autonomy_offer=anchor)
|
||||||
|
ok_ment = f"내부 검토 결과 {anchor:,}원이면 즉시 진행이 가능합니다. 수락해 주시겠습니까?"
|
||||||
|
check("정상 역제안 멘트(제안가 포함) → 통과",
|
||||||
|
ment_generator._guard("자율_역제안", ctx2, ok_ment), "")
|
||||||
|
no_offer = "말씀하신 조건을 검토했고 조정이 필요합니다. 수락해 주시겠습니까?"
|
||||||
|
check("제안가 없는 역제안 멘트 → 폐기",
|
||||||
|
not ment_generator._guard("자율_역제안", ctx2, no_offer), "")
|
||||||
|
|
||||||
|
ev = f"동일 품목 인터넷 최저가가 {il:,}원으로 확인됩니다. 재검토 부탁드립니다."
|
||||||
|
check("최저가 인용: 근거 있음(수집됨+제시가>최저가) → 허용",
|
||||||
|
ment_generator._guard("자율_압박_1", ctx, ev), "")
|
||||||
|
ctx3 = dict(ctx, internet_lowest_price=0)
|
||||||
|
ev0 = "동일 품목 인터넷 최저가 대비 높은 수준입니다. 재검토 부탁드립니다."
|
||||||
|
check("최저가 인용: 미수집 품목 → 폐기 (지어낸 시장 주장)",
|
||||||
|
not ment_generator._guard("자율_압박_1", ctx3, ev0), "")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
bundle = sys.argv[1] if len(sys.argv) > 1 else autonomy_store.BUNDLE_PATH
|
||||||
|
z = np.load(bundle, allow_pickle=False)
|
||||||
|
policy = AutonomyPolicy(z, TenantConfigLoader().load("ktcommerce").reward)
|
||||||
|
print(f"번들: {os.path.abspath(bundle)} (state_dim={int(z['state_dim'])})")
|
||||||
|
|
||||||
|
for geo_name, geo in GEOS.items():
|
||||||
|
print(f"\n{'─' * 60}\n지형 [{geo_name}] 앵커 {geo['anchor']:,} / 목표 {geo['target']:,} "
|
||||||
|
f"/ 첫 제시가 {geo['first']:,}")
|
||||||
|
for tag, sup_cls in (("완고", Stubborn), ("목표가위 고정", HoverNearTarget),
|
||||||
|
("협조", Dealable)):
|
||||||
|
trace, end, _ = run_scenario(policy, sup_cls(geo), geo)
|
||||||
|
full_tag = f"{geo_name}·{tag}"
|
||||||
|
print(f"\n[{full_tag}] {fmt(trace)} ⇒ {end}")
|
||||||
|
assert_defects(full_tag, trace, end, geo)
|
||||||
|
|
||||||
|
engine_unit_tests()
|
||||||
|
envelope_unit_tests(policy)
|
||||||
|
ment_guard_tests()
|
||||||
|
|
||||||
|
fails = [(n, d) for n, ok, d in _RESULTS if not ok]
|
||||||
|
print(f"\n{'=' * 60}\n결과: {len(_RESULTS) - len(fails)}/{len(_RESULTS)} 통과")
|
||||||
|
if fails:
|
||||||
|
print("실패 항목 — 이 번들은 배포 금지:")
|
||||||
|
for n, d in fails:
|
||||||
|
print(f" ✘ {n} {d}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("전 항목 통과 — 배포 가능.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
148
agent/tools/train_feature_dqn.py
Normal file
148
agent/tools/train_feature_dqn.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"""action-as-feature DQN 학습 (Phase 2·3) — 공용 환경 헬퍼 + 단독 학습 엔트리.
|
||||||
|
|
||||||
|
카드 특징 = 스크립트 임베딩(384) + 전략 one-hot(4) + 톤 one-hot(4) = 392차원
|
||||||
|
상태 특징 = 연속 상태(9) + 고객사 성향(5) = 14차원 ← 협력사·고객사 조건화
|
||||||
|
학습 환경 = FeatureBuyer(양보력/수락력 2축) + 에피소드마다 협력사·고객사성향 랜덤 샘플링
|
||||||
|
|
||||||
|
비교 평가는 tools.compare_qtable_vs_dqn 에서 수행한다.
|
||||||
|
실행: APP_ENV=local python -m tools.train_feature_dqn
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from eval_harness.buyer import Scenario
|
||||||
|
from eval_harness.feature_buyer import FeatureBuyer, sample_supplier
|
||||||
|
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import (
|
||||||
|
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features)
|
||||||
|
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
EMB_PATH = os.path.join(_HERE, "..", "artifacts", "card_embeddings.npz")
|
||||||
|
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "feature_dqn_ktcommerce.pt")
|
||||||
|
|
||||||
|
# zero-shot 실험용 홀드아웃 (전략 1·4 — 남은 풀에도 같은 전략 존재).
|
||||||
|
# 서빙용 최종 학습은 전체 풀 사용: FULL_POOL=1 python -m tools.train_feature_dqn
|
||||||
|
HOLDOUT = [] if os.getenv("FULL_POOL") == "1" else ["NGC-002", "NGC-010"]
|
||||||
|
ANCHOR, TARGET = 8000.0, 10000.0
|
||||||
|
MAX_TURNS = 5
|
||||||
|
N_STRATEGY, N_TONE = 4, 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 카드 특징: 임베딩 + 전략/톤 one-hot ------------------------------------------
|
||||||
|
def load_cards():
|
||||||
|
z = np.load(EMB_PATH, allow_pickle=True)
|
||||||
|
numbers = [str(n) for n in z["numbers"]]
|
||||||
|
feat, strat = {}, {}
|
||||||
|
for i, n in enumerate(numbers):
|
||||||
|
s, t = int(z["strategy"][i]), int(z["tone"][i])
|
||||||
|
s_oh = np.eye(N_STRATEGY, dtype=np.float32)[s - 1]
|
||||||
|
t_oh = np.eye(N_TONE, dtype=np.float32)[t - 1]
|
||||||
|
feat[n] = np.concatenate([z["embeddings"][i].astype(np.float32), s_oh, t_oh])
|
||||||
|
strat[n] = s
|
||||||
|
return numbers, feat, strat
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 고객사 성향: 보상 설정 샘플링 ---------------------------------------------------
|
||||||
|
def sample_tenant_pref(rng: np.random.Generator, base_cfg):
|
||||||
|
"""p ∈ [0,1]: 0=성사중시(협력 유리) ↔ 1=가격중시(경쟁 유리). 반환: (RewardConfig, tenant_feat)."""
|
||||||
|
p = float(rng.uniform(0.0, 1.0))
|
||||||
|
cfg = base_cfg.model_copy(update=dict(
|
||||||
|
max_weight=0.25 + 0.60 * p, # 가격보상 비중
|
||||||
|
min_weight=(0.25 + 0.60 * p) * 0.7,
|
||||||
|
success_reward=1.6 - 1.2 * p, # 성사중시일수록 성공보상↑
|
||||||
|
failure_penalty=-(1.4 - 1.1 * p), # 성사중시일수록 결렬이 아픔
|
||||||
|
beta=0.1 + 0.4 * p,
|
||||||
|
penalty_lambda=float(rng.uniform(0.005, 0.05)),
|
||||||
|
))
|
||||||
|
return cfg, build_tenant_features(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def pref_config(base_cfg, p: float, lam: float = 0.02):
|
||||||
|
"""평가용: 성향 p 를 고정해 RewardConfig 생성 (극단 테스트)."""
|
||||||
|
return base_cfg.model_copy(update=dict(
|
||||||
|
max_weight=0.25 + 0.60 * p, min_weight=(0.25 + 0.60 * p) * 0.7,
|
||||||
|
success_reward=1.6 - 1.2 * p, failure_penalty=-(1.4 - 1.1 * p),
|
||||||
|
beta=0.1 + 0.4 * p, penalty_lambda=lam,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def make_snapshot(sup, price: float, turn: int, acceptance: float,
|
||||||
|
outcome=NegotiationOutcome.ONGOING) -> NegotiationSnapshot:
|
||||||
|
return NegotiationSnapshot(
|
||||||
|
revenue_amount=sup.revenue_amount, distribution_code=sup.distribution_code,
|
||||||
|
partner_count=sup.partner_count, acceptance_ratio=acceptance,
|
||||||
|
input_price=price, anchor_price=ANCHOR, target_price=TARGET,
|
||||||
|
round_number=turn, outcome=outcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 단독 학습 엔트리 (비교는 compare_qtable_vs_dqn) --------------------------------
|
||||||
|
def main(episodes=10000, seed=42):
|
||||||
|
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
|
||||||
|
numbers, feat, strat = load_cards()
|
||||||
|
train_pool = [c for c in numbers if c not in HOLDOUT]
|
||||||
|
card_dim = feat[numbers[0]].shape[0]
|
||||||
|
print(f"카드 {len(numbers)}장 (학습 {len(train_pool)} / 홀드아웃 {HOLDOUT}) card_dim={card_dim}")
|
||||||
|
|
||||||
|
tcfg = TenantConfigLoader().load("ktcommerce")
|
||||||
|
policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM,
|
||||||
|
card_dim=card_dim, eps_decay=4000, gamma=0.95)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
print(f"=== 학습 {episodes} 에피소드 (협력사·성향 랜덤, CPU) ===")
|
||||||
|
recent = []
|
||||||
|
for ep in range(1, episodes + 1):
|
||||||
|
sup = sample_supplier(rng)
|
||||||
|
rcfg, tf = sample_tenant_pref(rng, tcfg.reward)
|
||||||
|
rc = RewardCalculator(rcfg, tcfg.state)
|
||||||
|
buyer = FeatureBuyer(sup, strat, seed=seed * 100 + ep, max_turns=MAX_TURNS)
|
||||||
|
scenario = Scenario(anchor_price=ANCHOR, target_price=TARGET, revenue_amount=sup.revenue_amount,
|
||||||
|
distribution_code=sup.distribution_code, partner_count=sup.partner_count)
|
||||||
|
price0 = TARGET * 1.15
|
||||||
|
price, used, total_r = price0, set(), 0.0
|
||||||
|
for turn in range(1, MAX_TURNS + 1):
|
||||||
|
acceptance = max(0.0, (price0 - price) / price0)
|
||||||
|
s = make_snapshot(sup, price, turn, acceptance)
|
||||||
|
sf = np.concatenate([build_state_features(s), tf])
|
||||||
|
avail = [c for c in train_pool if c not in used] or list(train_pool)
|
||||||
|
embs = np.stack([feat[c] for c in avail])
|
||||||
|
i, _, _ = policy.select(sf, embs)
|
||||||
|
card = avail[i]; used.add(card)
|
||||||
|
resp = buyer.respond(card, scenario, turn, price)
|
||||||
|
price = resp.new_price
|
||||||
|
done = resp.accept or price <= ANCHOR or turn >= MAX_TURNS
|
||||||
|
success = resp.accept or price <= ANCHOR
|
||||||
|
outcome = (NegotiationOutcome.SUCCESS if success
|
||||||
|
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
|
||||||
|
# 최종 결과 시점만 채점 (중간 0 → γ 부트스트랩) — compare 스크립트와 동일 규칙.
|
||||||
|
r = rc.calculate(make_snapshot(sup, price, turn, acceptance, outcome)).total if done else 0.0
|
||||||
|
total_r += r
|
||||||
|
if done:
|
||||||
|
policy.remember(sf, feat[card], r, None, None, True)
|
||||||
|
else:
|
||||||
|
acc2 = max(0.0, (price0 - price) / price0)
|
||||||
|
s2 = make_snapshot(sup, price, turn + 1, acc2)
|
||||||
|
navail = [c for c in train_pool if c not in used] or list(train_pool)
|
||||||
|
policy.remember(sf, feat[card], r, np.concatenate([build_state_features(s2), tf]),
|
||||||
|
np.stack([feat[c] for c in navail]), False)
|
||||||
|
policy.train_step()
|
||||||
|
if done:
|
||||||
|
break
|
||||||
|
recent.append(total_r)
|
||||||
|
if ep % 2000 == 0:
|
||||||
|
print(f" ep {ep:>6} eps={policy.eps():.3f} 최근2000 평균보상={np.mean(recent[-2000:]):.4f}")
|
||||||
|
|
||||||
|
policy.save(CKPT_PATH)
|
||||||
|
print(f"[저장] {CKPT_PATH}")
|
||||||
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
365
agent/tools/train_full_autonomy.py
Normal file
365
agent/tools/train_full_autonomy.py
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
"""train_full_autonomy — 행동 룰 0개, 완전 자율 협상 에이전트 (v2 시뮬 프로토타입).
|
||||||
|
|
||||||
|
기존 시스템의 룰(앵커 이하 강제타결 / 3라운드 강제결렬 / 와일드카드 존 / 카드 카탈로그)을
|
||||||
|
전부 제거하고, 모든 결정을 에이전트 행동으로 이관한다:
|
||||||
|
|
||||||
|
행동 공간 (action-as-feature, ScoreNet 이 후보 열거 채점):
|
||||||
|
ACCEPT 현재 제시가로 타결 ← '앵커 이하 강제타결' 룰 대체
|
||||||
|
WALK 협상 결렬 선언 ← '3라운드 강제결렬' 룰 대체
|
||||||
|
COUNTER(C) "C원이면 수락" 역제안 ← '와일드카드 1%' 룰 대체 (금액도 학습)
|
||||||
|
PRESS(strategy) 설득 압박(카드의 일반화) ← 카드 카탈로그 대체 (전략만 남음)
|
||||||
|
|
||||||
|
룰이 사라진 자리는 보상이 채운다(유일한 스펙):
|
||||||
|
R = W×R_price + (1−W)×R_end − λ×round (기존 RewardCalculator 그대로)
|
||||||
|
협상이 끝나는 길: 에이전트의 ACCEPT/WALK, 협력사의 COUNTER 수락, 협력사의 인내심 소진(이탈).
|
||||||
|
마지막 것은 시스템 룰이 아니라 상대방 특성이다.
|
||||||
|
|
||||||
|
베이스라인 = 현행 룰 시스템을 같은 환경에서 재현(앵커타결/1%클로징/3라운드결렬 + 압박).
|
||||||
|
|
||||||
|
실행: APP_ENV=local PYTHONUTF8=1 python -m tools.train_full_autonomy
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from eval_harness.feature_buyer import AFFINITY, STRATEGY_PROFILE, SupplierProfile, sample_supplier
|
||||||
|
from negotiation.policies.autonomy_actions import (
|
||||||
|
ACTION_DIM, ACTIONS, COUNTER_GRID, EXTRA_STATE_DIM, Action, extra_state,
|
||||||
|
internet_gap_feat, settle_norm as extra_settle)
|
||||||
|
from negotiation.policies.feature_dqn_policy import FeatureDQNPolicy
|
||||||
|
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||||
|
from negotiation.qtable.domain.service.feature_builder import (
|
||||||
|
STATE_FEATURE_DIM, TENANT_FEATURE_DIM, build_state_features, build_tenant_features)
|
||||||
|
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||||
|
from tenancy.config_loader import TenantConfigLoader
|
||||||
|
from tools.train_feature_dqn import pref_config, sample_tenant_pref
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "full_autonomy.pt")
|
||||||
|
|
||||||
|
TARGET = 10000.0
|
||||||
|
# 앵커율(v3.1): 실운영 기하 정합 — 앵커가 = 목표가×(1−a), a ∈ [0.8%, 6%] 를 에피소드마다 샘플링.
|
||||||
|
# (기존 고정 20% 폭은 실제(≈1%)와 지형이 달라, 실서비스에서 압박/역제안 밸런스가 어긋났다.)
|
||||||
|
ANCHOR_RATE_RANGE = (0.008, 0.06)
|
||||||
|
# 행동 공간(Action/ACTIONS/COUNTER_GRID/ACTION_DIM)은 negotiation.policies.autonomy_actions 공유
|
||||||
|
# — 서빙(autonomy_store, numpy 전용)과 학습이 같은 인코딩을 쓴다.
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 협력사 모델 (상대 반응: 역제안 수락/재제안 포함) ---------------------------------
|
||||||
|
class AutonomousBuyer:
|
||||||
|
"""FeatureBuyer 확장: 역제안(C)에 반응한다. 이탈은 '인내심' — 시스템 룰이 아닌 상대 특성."""
|
||||||
|
|
||||||
|
def __init__(self, sup: SupplierProfile, seed: int):
|
||||||
|
self.sup = sup
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
# 기질 t ∈ [0,1]: 0=터프(하한 높고 안 물러섬) ↔ 1=수월. 관측 가능한 이력·최저가가
|
||||||
|
# 이 숨은 기질과 상관되게 생성된다 → 에이전트가 이력/최저가 특징을 읽을 '이유'가 생긴다.
|
||||||
|
# 하한은 '우리 앵커'가 아니라 협력사 사정(≈목표가 기준)으로 정해진다(v3.1) —
|
||||||
|
# 하한 > 목표가(≈35%)면 애초에 성사 불가능한 협상이고, 그걸 빨리 알아채고 끊는 것도 실력이다.
|
||||||
|
t = float(self.rng.uniform(0.0, 1.0))
|
||||||
|
self.floor = TARGET * float(np.clip(1.12 - 0.24 * t + self.rng.normal(0, 0.02), 0.85, 1.18))
|
||||||
|
self.patience = int(self.rng.integers(4, 9)) + (1 if t > 0.7 else 0)
|
||||||
|
# 첫 제시가: 목표가의 105~150% — 실운영(기존 공급가가 목표가를 26%+ 상회) 분포를 덮는다.
|
||||||
|
# 좁게(110~125%) 학습하면 큰 갭 상황에서 정책이 분포 밖 일반화(대형컷 역제안)를 한다.
|
||||||
|
self.price = TARGET * float(self.rng.uniform(1.05, 1.50))
|
||||||
|
# 하한가가 첫 제시가보다 높을 수 없다(자기 하한 밑으로 부르고 시작하는 판매자는 없음).
|
||||||
|
# 이 보정이 없으면 on_press 의 max(floor,·)가 가격을 '역주행'시키는 비현실이 생긴다.
|
||||||
|
self.floor = min(self.floor, self.price * 0.98)
|
||||||
|
self._last_c: Optional[float] = None # 직전 역제안 (같은 숫자 반복 짜증 모델링)
|
||||||
|
# ---- 관측 가능 부가정보 (v3 특징 소스 — 기질과 상관, 노이즈 있음) ----
|
||||||
|
self.hist_n = int(self.rng.integers(0, 6)) # 과거 협상 횟수 (0=신규)
|
||||||
|
if self.hist_n:
|
||||||
|
self.hist_success = float(np.clip(0.25 + 0.6 * t + self.rng.normal(0, 0.10), 0.0, 1.0))
|
||||||
|
self.hist_settle_ratio = float(np.clip(1.18 - 0.28 * t + self.rng.normal(0, 0.04), 0.80, 1.30))
|
||||||
|
else:
|
||||||
|
self.hist_success = self.hist_settle_ratio = None
|
||||||
|
# 인터넷최저가: 숨은 하한가의 노이즈 관측치. 60% 확률로만 수집돼 있음(현실: 미수집 흔함).
|
||||||
|
self.internet_lowest = (self.floor * float(self.rng.uniform(0.98, 1.08))
|
||||||
|
if self.rng.random() < 0.6 else None)
|
||||||
|
|
||||||
|
def _powers(self, strategy: int) -> Tuple[float, float]:
|
||||||
|
conc, acc = STRATEGY_PROFILE.get(strategy, (0.5, 0.5))
|
||||||
|
m = AFFINITY[self.sup.segment].get(strategy, 0.5)
|
||||||
|
scale = 0.35 + 0.85 * m
|
||||||
|
return conc * scale, acc * scale
|
||||||
|
|
||||||
|
def on_press(self, strategy: int, turn: int) -> Tuple[bool, float]:
|
||||||
|
"""(이탈여부, 새 제시가). 압박이 안 먹히는 세그먼트면 이탈 위험이 실재한다."""
|
||||||
|
c_pow, a_pow = self._powers(strategy)
|
||||||
|
walk_p = 0.04 + 0.30 * (1.0 - a_pow) * (turn / self.patience)
|
||||||
|
if self.rng.random() < walk_p:
|
||||||
|
return True, self.price
|
||||||
|
concession = (self.price - self.floor) * (0.10 + 0.55 * c_pow)
|
||||||
|
self.price = max(self.floor, self.price - concession)
|
||||||
|
return False, self.price
|
||||||
|
|
||||||
|
def on_counter(self, c: float, strategy: int, turn: int) -> Tuple[str, float]:
|
||||||
|
"""역제안 C 반응: 'accept'(C로 타결) | 'walk' | 'counter'(새 제시가).
|
||||||
|
|
||||||
|
현실화(v2): 현 제시가 대비 인하 요구폭(cut)이 클수록 수락률이 급감하고 이탈 위험이 커진다
|
||||||
|
— 초기 버전에서 에이전트가 't1 원샷 로우볼'로 시뮬 허점을 착취하던 것을 막는다.
|
||||||
|
압박으로 가격을 충분히 끌어내린 뒤 작은 컷으로 클로징해야 통하는 구조.
|
||||||
|
"""
|
||||||
|
_, a_pow = self._powers(strategy or 3)
|
||||||
|
cut = max(0.0, (self.price - c) / max(self.price, 1.0)) # 인하 요구폭 (현 제시가 대비)
|
||||||
|
prev_c = self._last_c
|
||||||
|
repeated = prev_c is not None and abs(c - prev_c) < 1e-6 # 같은 숫자 반복
|
||||||
|
self._last_c = c
|
||||||
|
# 양보 상호성(v3.3): 직전 제안보다 올려 부르면(성의 있는 양보) 호의적으로 반응한다.
|
||||||
|
# 이 신호가 있어야 '상대가 내리면 우리도 조금 올리는' tit-for-tat 이 학습으로 나온다.
|
||||||
|
warm = 0.0
|
||||||
|
if prev_c is not None and c > prev_c + 1e-9:
|
||||||
|
warm = float(np.clip((c - prev_c) / max(self.price - self.floor, 1.0), 0.0, 0.35))
|
||||||
|
if c >= self.floor:
|
||||||
|
margin = (c - self.floor) / max(self.floor, 1.0)
|
||||||
|
p_acc = float(np.clip(0.20 + 0.9 * margin / 0.08, 0.0, 0.95)) * (0.75 + 0.35 * a_pow)
|
||||||
|
p_acc *= float(np.clip(1.0 - (cut - 0.05) / 0.20, 0.0, 1.0)) # 컷 5% 초과부터 반발, 25%면 수락 0
|
||||||
|
if repeated:
|
||||||
|
p_acc *= 0.25 # 이미 거절한 숫자를 또 내밀면 설득력 급감
|
||||||
|
p_acc *= 1.0 + warm
|
||||||
|
if self.rng.random() < min(p_acc, 0.97):
|
||||||
|
return "accept", c
|
||||||
|
# 모욕적 요구(하한 미달·과도한 원샷 컷·앵무새 반복) → 이탈 위험
|
||||||
|
low = max(0.0, (self.floor - c) / max(self.floor, 1.0))
|
||||||
|
p_walk = min(0.5, 2.0 * low) + 0.35 * max(0.0, cut - 0.20) / 0.20 + (0.15 if repeated else 0.0)
|
||||||
|
if self.rng.random() < min(p_walk * (1.0 - warm), 0.7):
|
||||||
|
return "walk", self.price
|
||||||
|
self.price = max(self.floor, c + (self.price - c) * float(self.rng.uniform(0.30, 0.60) + warm))
|
||||||
|
return "counter", self.price
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 에피소드 실행 (룰 없음 — 종료는 행동 또는 상대 특성으로만) ------------------------
|
||||||
|
def make_snapshot(sup, price, turn, p0, anchor, outcome=NegotiationOutcome.ONGOING):
|
||||||
|
return NegotiationSnapshot(
|
||||||
|
revenue_amount=sup.revenue_amount, distribution_code=sup.distribution_code,
|
||||||
|
partner_count=sup.partner_count, acceptance_ratio=max(0.0, (p0 - price) / p0),
|
||||||
|
input_price=price, anchor_price=anchor, target_price=TARGET,
|
||||||
|
round_number=turn, outcome=outcome)
|
||||||
|
|
||||||
|
|
||||||
|
MIN_PRESS = int(os.getenv("AUTONOMY_MIN_PRESS", "2")) # 역제시 해금에 필요한 최소 설득 횟수
|
||||||
|
|
||||||
|
|
||||||
|
def available_actions(price: float, last_counter_q: Optional[float] = None,
|
||||||
|
counter_locked: bool = False) -> list:
|
||||||
|
"""행동 봉투 (serving autonomy_store 와 동일해야 한다):
|
||||||
|
① 목표가 초과 제시가는 '수락' 제외 — 매입 승인 범위(목표가 초과 수락 착취 방지)
|
||||||
|
② 직전 역제안 미만 금액의 역제안 제외 — 단조 양보 원칙(제안 철회 금지;
|
||||||
|
양보 '속도'는 정책이 배우고, 후퇴 '금지'만 구조로 보장)
|
||||||
|
③ counter_locked: 설득 MIN_PRESS 회 전에는 역제시 잠금 — 옛 제품 의미론
|
||||||
|
(일반 카드=설득, 역제시=와일드카드 성격의 마무리 수단) 복원
|
||||||
|
④ 마무리 국면(제시가 ≤ 목표가×1.005): 압박 제외 — 푼돈 차이에서 재검토 요청 반복 방지
|
||||||
|
⑤ 첫 역제안은 앵커 이하(q ≤ 0)만 — 낮게 개시해 사다리를 다 쓰며 올라간다"""
|
||||||
|
near_target = price <= TARGET * 1.005
|
||||||
|
return [a for a in ACTIONS
|
||||||
|
if not (a.kind == "accept" and price > TARGET)
|
||||||
|
and not (a.kind == "counter" and counter_locked and not near_target)
|
||||||
|
and not (a.kind == "walk" and counter_locked and not near_target)
|
||||||
|
and not (a.kind == "press" and near_target)
|
||||||
|
and not (a.kind == "counter" and last_counter_q is None and a.counter_q > 1e-9)
|
||||||
|
and not (a.kind == "counter" and last_counter_q is not None
|
||||||
|
and a.counter_q < last_counter_q - 1e-9)]
|
||||||
|
|
||||||
|
|
||||||
|
def action_feats(price: float, anchor: float, last_counter_q: Optional[float] = None,
|
||||||
|
counter_locked: bool = False):
|
||||||
|
"""현 제시가 기준 (가용 행동 리스트, 특징 [K, ACTION_DIM]). counter 는 컷폭 포함."""
|
||||||
|
span = max(TARGET - anchor, 1.0)
|
||||||
|
pos = (price - anchor) / span
|
||||||
|
acts = available_actions(price, last_counter_q, counter_locked)
|
||||||
|
rows = []
|
||||||
|
for a in acts:
|
||||||
|
cut = 0.0
|
||||||
|
if a.kind == "counter":
|
||||||
|
c = anchor + a.counter_q * span
|
||||||
|
cut = max(0.0, (price - c) / max(price, 1.0))
|
||||||
|
rows.append(a.feat(pos, cut))
|
||||||
|
return acts, np.stack(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def run_episode(policy_fn, sup, rc: RewardCalculator, tf: np.ndarray, seed: int,
|
||||||
|
learner: Optional[FeatureDQNPolicy] = None, trace: Optional[list] = None):
|
||||||
|
"""policy_fn(state_feat, price_pos) → Action. learner 지정 시 replay 저장+학습."""
|
||||||
|
buyer = AutonomousBuyer(sup, seed)
|
||||||
|
p0 = buyer.price
|
||||||
|
env_rng = np.random.default_rng(seed + 7)
|
||||||
|
# 앵커율 샘플링(v3.1): 실운영처럼 앵커가 목표가 바로 아래(0.8~6%) — 좁은 스팬 지형에서 학습.
|
||||||
|
anchor = TARGET * (1.0 - float(env_rng.uniform(*ANCHOR_RATE_RANGE)))
|
||||||
|
span = max(TARGET - anchor, 1.0)
|
||||||
|
turn, settled, walked = 0, None, False
|
||||||
|
last_kind, last_q = "", 0.0 # 직전 역제안 기억 (같은 숫자 반복 방지의 학습 근거)
|
||||||
|
press_n = 0 # 설득 횟수 — 역제시 해금(MIN_PRESS) 카운터
|
||||||
|
# 견적 마감(환경 사실): 마감 도달 시 협상은 미타결 종료된다 — 룰이 아니라 세상의 시계.
|
||||||
|
deadline_turns = int(env_rng.integers(3, 11))
|
||||||
|
# 관측성 마스크(v3.5): 실서빙은 마감·이력·최저가가 '없는' 세션이 흔하고 로더가 중립값
|
||||||
|
# (0.5/0)을 대입한다. 시뮬이 항상 다 아는 세계만 학습하면 그 중립 상태가 분포 밖이 된다
|
||||||
|
# — v3.4 가 라이브 소액 지형에서 첫 턴 결렬로 퇴화한 원인 추정. 세계(마감 종료·상대 특성)는
|
||||||
|
# 그대로 두고 관측만 가린다: 마감은 40% 미관측(0.5 고정), 15% 는 전부 미상(신규 견적의 전형).
|
||||||
|
deadline_known = env_rng.random() < 0.6
|
||||||
|
blind = env_rng.random() < 0.15
|
||||||
|
if blind:
|
||||||
|
deadline_known = False
|
||||||
|
# 협력사 이력·최저가 특징 (에피소드 내 불변)
|
||||||
|
known_hist = buyer.hist_n and not blind
|
||||||
|
fixed_extra = dict(
|
||||||
|
hist_n=min(buyer.hist_n, 5) / 5.0 if not blind else 0.0,
|
||||||
|
hist_success=buyer.hist_success if known_hist else 0.5,
|
||||||
|
hist_settle=extra_settle(buyer.hist_settle_ratio) if known_hist else 0.5,
|
||||||
|
internet_gap=internet_gap_feat(buyer.internet_lowest or 0.0, anchor) if not blind else 0.0,
|
||||||
|
)
|
||||||
|
pending = None # (state_feat, action_feat) — 최종 결과 시점만 채점, 중간 r=0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
turn += 1
|
||||||
|
price = buyer.price
|
||||||
|
deadline_remain = (max(0.0, (deadline_turns - turn + 1) / deadline_turns)
|
||||||
|
if deadline_known else 0.5) # 미관측 → 서빙 로더와 동일한 중립값
|
||||||
|
sf = np.concatenate([build_state_features(make_snapshot(sup, price, turn, p0, anchor)), tf,
|
||||||
|
extra_state(last_kind, last_q, deadline=deadline_remain, **fixed_extra)])
|
||||||
|
lcq = last_q if last_kind == "counter" else None
|
||||||
|
locked = lcq is None and press_n < MIN_PRESS
|
||||||
|
act = policy_fn(sf, price, anchor, lcq, locked)
|
||||||
|
if trace is not None:
|
||||||
|
trace.append((turn, int(price), act))
|
||||||
|
|
||||||
|
if act.kind == "accept":
|
||||||
|
settled = price
|
||||||
|
elif act.kind == "walk":
|
||||||
|
walked = True
|
||||||
|
elif act.kind == "counter":
|
||||||
|
c = anchor + act.counter_q * span
|
||||||
|
resp, val = buyer.on_counter(c, act.strategy, turn)
|
||||||
|
last_kind, last_q = "counter", act.counter_q # 역제안 기억 갱신
|
||||||
|
if resp == "accept":
|
||||||
|
settled = c
|
||||||
|
elif resp == "walk":
|
||||||
|
walked = True
|
||||||
|
else: # press
|
||||||
|
press_n += 1
|
||||||
|
left, _ = buyer.on_press(act.strategy, turn)
|
||||||
|
walked = walked or left
|
||||||
|
if not settled and not walked and turn >= buyer.patience:
|
||||||
|
walked = True # 인내심 소진(상대 특성) — 시스템 룰 아님
|
||||||
|
if not settled and not walked and turn >= deadline_turns:
|
||||||
|
walked = True # 견적 마감 도달(환경 사실) — 미타결 종료
|
||||||
|
|
||||||
|
done = settled is not None or walked
|
||||||
|
final_price = settled if settled is not None else buyer.price
|
||||||
|
# 성사 보너스는 목표가 이하 타결에만 — v3.1 이 '비싸게라도 성사'로 착취한 보상 구멍의
|
||||||
|
# 원인 차단(봉투 ① 의 마스크와 이중 방어: 유인 자체를 올바르게). 초과 타결 = 결렬 취급.
|
||||||
|
outcome = (NegotiationOutcome.SUCCESS if settled is not None and settled <= TARGET
|
||||||
|
else NegotiationOutcome.FAILURE if done else NegotiationOutcome.ONGOING)
|
||||||
|
r = rc.calculate(make_snapshot(sup, final_price, turn, p0, anchor, outcome)).total if done else 0.0
|
||||||
|
|
||||||
|
if learner is not None:
|
||||||
|
pos = (price - anchor) / span
|
||||||
|
cut = 0.0
|
||||||
|
if act.kind == "counter":
|
||||||
|
cut = max(0.0, (price - (anchor + act.counter_q * span)) / max(price, 1.0))
|
||||||
|
af = act.feat(pos, cut)
|
||||||
|
if pending:
|
||||||
|
nxt_lcq = last_q if last_kind == "counter" else None
|
||||||
|
learner.remember(*pending, 0.0, sf,
|
||||||
|
action_feats(price, anchor, nxt_lcq,
|
||||||
|
nxt_lcq is None and press_n < MIN_PRESS)[1], False)
|
||||||
|
pending = (sf, af)
|
||||||
|
if done:
|
||||||
|
learner.remember(sf, af, r, None, None, True)
|
||||||
|
learner.train_step()
|
||||||
|
if done:
|
||||||
|
return settled, turn, r
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 정책들 ------------------------------------------------------------------
|
||||||
|
def dqn_policy(policy: FeatureDQNPolicy):
|
||||||
|
def f(sf, price, anchor, last_counter_q=None, counter_locked=False):
|
||||||
|
acts, feats = action_feats(price, anchor, last_counter_q, counter_locked)
|
||||||
|
i, _, _ = policy.select(sf, feats)
|
||||||
|
return acts[i]
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
class RuleBaseline:
|
||||||
|
"""현행 시스템 룰 재현: 앵커 이하 수락 / 존내 1% 클로징 / 3회 압박 후 결렬."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.presses, self.closed = 0, False
|
||||||
|
|
||||||
|
def __call__(self, sf, price, anchor, last_counter_q=None, counter_locked=False) -> Action:
|
||||||
|
span = max(TARGET - anchor, 1.0)
|
||||||
|
if price <= anchor:
|
||||||
|
return Action("accept")
|
||||||
|
if price <= anchor * 1.02 and not self.closed:
|
||||||
|
self.closed = True
|
||||||
|
return Action("counter", (price * 0.99 - anchor) / span, 3)
|
||||||
|
if self.presses < 3:
|
||||||
|
self.presses += 1
|
||||||
|
return Action("press", 0.0, 3)
|
||||||
|
return Action("walk")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 학습/평가 ----------------------------------------------------------------
|
||||||
|
def evaluate(name, make_policy_fn, base_cfg, tcfg_state, episodes=3000, seed0=777):
|
||||||
|
rc = RewardCalculator(pref_config(base_cfg, 0.5), tcfg_state)
|
||||||
|
tf = build_tenant_features(pref_config(base_cfg, 0.5))
|
||||||
|
rng = np.random.default_rng(seed0)
|
||||||
|
rewards, settles, rounds = [], [], []
|
||||||
|
for i in range(episodes):
|
||||||
|
sup = sample_supplier(rng)
|
||||||
|
settled, turn, r = run_episode(make_policy_fn(), sup, rc, tf, seed0 * 91 + i)
|
||||||
|
rewards.append(r)
|
||||||
|
rounds.append(turn)
|
||||||
|
if settled is not None:
|
||||||
|
settles.append(settled / TARGET)
|
||||||
|
sr = len(settles) / episodes
|
||||||
|
print(f"{name:<14} 보상 {np.mean(rewards):.4f} ±{np.std(rewards)/np.sqrt(episodes):.4f}"
|
||||||
|
f" 성사율 {sr:.3f} 타결가/목표 {np.mean(settles):.3f} 평균라운드 {np.mean(rounds):.2f}")
|
||||||
|
return dict(reward=float(np.mean(rewards)), success=sr,
|
||||||
|
settle_ratio=float(np.mean(settles)) if settles else None, rounds=float(np.mean(rounds)))
|
||||||
|
|
||||||
|
|
||||||
|
def main(episodes=15000, seed=42):
|
||||||
|
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
|
||||||
|
tcfg = TenantConfigLoader().load("ktcommerce")
|
||||||
|
policy = FeatureDQNPolicy(state_dim=STATE_FEATURE_DIM + TENANT_FEATURE_DIM + EXTRA_STATE_DIM,
|
||||||
|
card_dim=ACTION_DIM, eps_decay=5000, gamma=0.97)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
print(f"=== 완전 자율 학습 {episodes}ep (행동 {len(ACTIONS)}개, 룰 0개) ===")
|
||||||
|
recent = []
|
||||||
|
for ep in range(1, episodes + 1):
|
||||||
|
sup = sample_supplier(rng)
|
||||||
|
rcfg, tf = sample_tenant_pref(rng, tcfg.reward)
|
||||||
|
rc = RewardCalculator(rcfg, tcfg.state)
|
||||||
|
_, _, r = run_episode(dqn_policy(policy), sup, rc, tf, seed * 131 + ep, learner=policy)
|
||||||
|
recent.append(r)
|
||||||
|
if ep % 3000 == 0:
|
||||||
|
print(f" ep {ep:>6} eps={policy.eps():.3f} 최근3000 평균보상={np.mean(recent[-3000:]):.4f}")
|
||||||
|
policy.save(CKPT_PATH)
|
||||||
|
|
||||||
|
print("\n=== 평가 3000ep (중립 성향 p=0.5, 동일 협력사 분포) ===")
|
||||||
|
policy.greedy = True
|
||||||
|
evaluate("룰시스템(현행)", lambda: RuleBaseline(), tcfg.reward, tcfg.state)
|
||||||
|
evaluate("완전자율 DQN", lambda: dqn_policy(policy), tcfg.reward, tcfg.state)
|
||||||
|
|
||||||
|
# 궤적 예시 — 에이전트가 룰 없이 뭘 하는지 눈으로
|
||||||
|
print("\n=== 궤적 예시 (완전자율) ===")
|
||||||
|
rc = RewardCalculator(pref_config(tcfg.reward, 0.5), tcfg.state)
|
||||||
|
tf = build_tenant_features(pref_config(tcfg.reward, 0.5))
|
||||||
|
rng2 = np.random.default_rng(7)
|
||||||
|
for k in range(3):
|
||||||
|
sup = sample_supplier(rng2)
|
||||||
|
trace = []
|
||||||
|
settled, turn, r = run_episode(dqn_policy(policy), sup, rc, tf, 5000 + k, trace=trace)
|
||||||
|
seg = "·".join(sup.segment)
|
||||||
|
print(f"[{seg}] " + " → ".join(
|
||||||
|
f"t{t}:{p:,}원 {a.kind}{'' if a.kind in ('accept', 'walk') else f'({a.counter_q:.2f},전략{a.strategy})' if a.kind == 'counter' else f'(전략{a.strategy})'}"
|
||||||
|
for t, p, a in trace) + f" ⇒ {'타결 ' + format(int(settled), ',') + '원' if settled else '결렬'} (r={r:.3f})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -58,21 +58,6 @@ 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
|
||||||
@ -138,7 +123,6 @@ 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")) # 소프트 삭제 여부
|
||||||
@ -173,7 +157,6 @@ 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)
|
||||||
@ -181,28 +164,6 @@ 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
|
||||||
@ -222,34 +183,6 @@ 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,41 +130,6 @@ 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, nego_cards, wild_cards
|
from common.database.model.models import chats, items, sessions
|
||||||
from common.enums import ErrorType, SessionStatus
|
from common.enums import ErrorType, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
@ -47,14 +47,6 @@ 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]:
|
||||||
@ -120,30 +112,6 @@ 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,40 +1,24 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update
|
from sqlalchemy import case, func, nulls_last, select, 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 chats, items, quotations, sessions
|
from common.database.model.models import items, quotations, sessions
|
||||||
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
|
from common.enums import ErrorType, 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, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
|
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -54,56 +38,23 @@ class ISessionCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_session_reject(
|
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||||
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, keyword=None, result=None):
|
def __filters(supplier_id, status, qt_type):
|
||||||
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:
|
||||||
# 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다.
|
conds.append(sessions.status == status)
|
||||||
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, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
|
||||||
try:
|
try:
|
||||||
conds = self.__filters(supplier_id, status, qt_type, keyword, result)
|
conds = self.__filters(supplier_id, status, qt_type)
|
||||||
|
|
||||||
# 정렬 규칙:
|
# 정렬 규칙:
|
||||||
# - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬).
|
# - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬).
|
||||||
@ -118,7 +69,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
else:
|
else:
|
||||||
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
||||||
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
||||||
actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
actionable = sessions.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()
|
||||||
@ -127,7 +78,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
sessions.session_id,
|
sessions.session_id,
|
||||||
_effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다
|
sessions.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 = 견적 마감 시각
|
||||||
@ -135,17 +86,6 @@ 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)
|
||||||
@ -162,9 +102,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, keyword=None, result=None) -> Tuple[ErrorType, int]:
|
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
|
||||||
try:
|
try:
|
||||||
conds = self.__filters(supplier_id, status, qt_type, keyword, result)
|
conds = self.__filters(supplier_id, status, qt_type)
|
||||||
query = (
|
query = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(sessions)
|
.select_from(sessions)
|
||||||
@ -222,58 +162,12 @@ 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(
|
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||||
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(**values)
|
.values(status=status, reject_reason=reject_reason)
|
||||||
)
|
|
||||||
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, companies, sessions
|
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers
|
||||||
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,14 +28,6 @@ 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
|
||||||
@ -125,43 +117,6 @@ 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, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
@ -20,7 +20,6 @@ from .protocol import (
|
|||||||
Res_Me,
|
Res_Me,
|
||||||
Res_PopupStatus,
|
Res_PopupStatus,
|
||||||
Res_RefreshToken,
|
Res_RefreshToken,
|
||||||
Res_SessionBranding,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
||||||
@ -107,16 +106,3 @@ 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,9 +48,6 @@ 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):
|
||||||
@ -67,9 +64,3 @@ 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,7 +63,6 @@ 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")
|
||||||
@ -78,10 +77,6 @@ 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,5 +1,3 @@
|
|||||||
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
|
||||||
@ -16,14 +14,6 @@ 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):
|
||||||
@ -39,27 +29,7 @@ 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,16 +6,7 @@ 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 (
|
from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList
|
||||||
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"}})
|
||||||
|
|
||||||
@ -35,11 +26,9 @@ 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, keyword, result)
|
await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -62,7 +51,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"),
|
||||||
@ -71,59 +60,4 @@ async def reject(
|
|||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
service: NegotiationService = Depends(),
|
service: NegotiationService = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(
|
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
||||||
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,7 +18,6 @@ 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
|
||||||
|
|
||||||
@ -251,35 +250,6 @@ 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,7 +24,6 @@ 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
|
||||||
@ -45,13 +44,11 @@ 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 불필요, 상단 집약) ----
|
||||||
@ -62,35 +59,6 @@ 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:
|
||||||
@ -135,18 +103,13 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
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:
|
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = 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,
|
||||||
@ -235,15 +198,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):
|
||||||
await self._ensure_in_progress(sess, quote)
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||||
|
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)
|
||||||
@ -258,65 +221,8 @@ 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()
|
||||||
@ -333,15 +239,6 @@ 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)
|
||||||
@ -474,43 +371,8 @@ 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, card_uuid=card_uuid, card_type=card_type)
|
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
|
||||||
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||||
@ -530,15 +392,10 @@ 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=parsed["reason"], reject_price=parsed["offer_price"],
|
reject_reason=(user_input or None), reject_price=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:
|
||||||
@ -648,17 +505,7 @@ 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
|
||||||
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
|
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
|
||||||
# 회사가 배송유형 보기를 자기 용어로 바꿨으면(settings.labels['delivery_type.N']) 그 단어를 쓴다 —
|
item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else ""
|
||||||
# 협상 중 공급사가 고른 보기와 요약 표기가 갈리지 않도록.
|
|
||||||
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,35 +1,14 @@
|
|||||||
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 chats, notifications, sessions
|
from common.database.model.models import sessions
|
||||||
from common.enums import (
|
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||||||
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 (
|
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@ -39,20 +18,11 @@ 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, keyword: str = None, result: int = None) -> Res_SessionList:
|
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList:
|
||||||
res = Res_SessionList()
|
res = Res_SessionList()
|
||||||
|
|
||||||
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
||||||
@ -68,7 +38,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, keyword, result),
|
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size),
|
||||||
)
|
)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
@ -78,274 +48,31 @@ 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, keyword, result),
|
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type),
|
||||||
)
|
)
|
||||||
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 = [
|
||||||
max_rounds: dict = {}
|
ListItem(
|
||||||
for number in {r[3] for r in rows if r[3]}:
|
session_id=str(r[0]),
|
||||||
_e, mx = await DB_SESSION_MNG.execute_lambda(
|
session_status=r[1],
|
||||||
sessions.DBType(), DBWRType.DB_READ.value,
|
qt_type=r[2],
|
||||||
lambda s, n=number: self.session_crud.chain_max_round(s, n),
|
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 "",
|
||||||
)
|
)
|
||||||
max_rounds[number] = mx or 0
|
for r in rows
|
||||||
|
]
|
||||||
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) 을 반환한다.
|
||||||
@ -438,10 +165,7 @@ class NegotiationService:
|
|||||||
res.session_id = str(sess.session_id)
|
res.session_id = str(sess.session_id)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def reject(
|
async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_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()
|
||||||
|
|
||||||
# 거부 사유 필수
|
# 거부 사유 필수
|
||||||
@ -461,19 +185,11 @@ class NegotiationService:
|
|||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장.
|
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
|
||||||
# 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리.
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
funcs = [
|
[sessions.DBType()],
|
||||||
lambda s: self.session_crud.update_session_reject(
|
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
|
||||||
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,58 +220,12 @@ 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)
|
||||||
@ -397,28 +351,23 @@ 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()
|
||||||
assert body["result"]["success"] is True
|
# 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다.
|
||||||
assert body["session_status"] == 4
|
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
||||||
assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이
|
assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨
|
||||||
|
|
||||||
|
|
||||||
async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine):
|
async def test_init_blocks_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"]["success"] is True and body["session_status"] == 5
|
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단
|
||||||
assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
|
|
||||||
|
|
||||||
|
|
||||||
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
||||||
|
|||||||
@ -16,35 +16,6 @@ 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 를 반환."""
|
||||||
@ -62,11 +33,6 @@ 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}%'"))
|
||||||
@ -86,9 +52,31 @@ 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 spec in specs:
|
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
|
||||||
code = spec[0]
|
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||||
sids[code], qids[code] = await _seed_case(conn, *spec)
|
sids[code], qids[code] = session_id, qt_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, 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}
|
||||||
|
|
||||||
@ -153,63 +141,6 @@ 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()
|
||||||
@ -246,56 +177,6 @@ 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)
|
||||||
@ -385,65 +266,6 @@ 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"], " ") # 공백만 → 사유 없음
|
||||||
@ -472,76 +294,3 @@ 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
|
|
||||||
|
|||||||
@ -1,215 +0,0 @@
|
|||||||
"""공급사 재협상 요청/철회(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
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
"""공급사 관점 협상 결과 파생(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
BIN
dev-settings.png
Binary file not shown.
|
Before 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