카드 재설계("멘트 카드 → 전술 카드"):
- tactics.py 신설: 카드번호→전술(카운터 산식) 레지스트리, min(counter,target) 클램프
- 카운터 수락=즉시 타결(pending_counter_price 일반화, 구 offer_1pct 흡수)
- 목표가 초과 타결 금지(성공스텝 진입 가드) + "카드 소진=실패" 폐지→종결 국면
- 선택형 와일드카드(WC-*) 발동 + card.wild_cards 멘트 DB 어댑터
LLM 계층:
- Phase 2 표현층 ScriptNaturalizer(카드 멘트 자연화, 마커·치환자·숫자 보존 검증)
- Phase 3 이해층 InputInterpreter(자유발화 NLU→기대입력, 한국어 가격 파서)
- OPENAI_API_KEY env override(server_configs) + 전역 자격증명 게이트
결정 스택(Phase 1):
- 협상 규칙 데이터화(negotiation.wildcard_*_ratio/max_counter_rounds)
- 선택카드 우선순위 prior(UCB 방문수 감쇠, Q-table 오염 없음)
버그픽스:
- 인하율 음수 표기 제거 + 인상/동일/인하 구분(discount_phrase)
- 자연화 강조마커 보존(볼드/색 소실 시 원본 폴백)
- 카드 시드 가격변수(prev_partner_price·target_mid_price·middle_price 등) 치환
정리:
- ktcommerce 테넌트 삭제 + 테스트 21파일 imarketkorea/_base 로 마이그레이션
- 실 LLM 호출 차단 conftest 가드
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
193 lines
14 KiB
Markdown
193 lines
14 KiB
Markdown
# Negosium Agent
|
||
|
||
범용 멀티테넌트 협상 솔루션 PoC. 여러 회사(imarketkorea 등)가 **각자 데이터로 분기 학습**하는
|
||
협상 카드 선택 에이전트. Q-Learning(UCB) 기반 `Chat_server`(단일 테넌트)를 참고해 신규 구축한다.
|
||
|
||
PoC 목표 두 가지:
|
||
1. **학습 알고리즘이 실제 협상 성과를 개선하는지 정량 검증** (Q-Table vs LinUCB vs Offline RL 비교 하네스).
|
||
2. **멀티테넌트 분기 학습 + 기존 14개 API/step 체계 무손실 보존**.
|
||
|
||
## o2o-negosium 서버군 내 위치
|
||
|
||
`agent` 는 서버군의 한 서비스다. **backend 와 같은 컨벤션·같은 `negosium_db`** 를 공유한다.
|
||
|
||
| 서비스 | 포트 | DB | 역할 |
|
||
|---|---|---|---|
|
||
| backend | 9300 | negosium_db | 회사/공급사/견적/카드 도메인 + 인증 |
|
||
| negodata/backend | 9400 | negodata_db | 데이터 도구 |
|
||
| **agent** | **9500** | **negosium_db (learning 스키마 + 기존 스키마 읽기/쓰기)** | **협상 챗봇·학습** |
|
||
|
||
### 통합 토폴로지 (확정: 하이브리드)
|
||
|
||
- agent 는 **독립 FastAPI 서비스**(자체 포트)로 뜨되, backend 의 구조적 컨벤션을 그대로 채택한다:
|
||
- **MVC**: `router/`(컨트롤러) → `services/`(로직, 추가 예정) → `crud/`(쿼리, 추가 예정).
|
||
- **람다 DB**: `common/database/db_session_manager.py` — `execute_lambda` / `execute_lambda_run`, Read/Write 엔진 분리.
|
||
- **Protocol**: `common/models/gmodel.py` — `WebPacketProtocol`/`Req_*`/`Res_*`/`result: ErrorInfo`.
|
||
- **config**: `config/config.<APP_ENV>.toml` (`APP_ENV` 로 선택).
|
||
- 협상 엔진 내부는 **헥사고날 구조**(`negotiation/` 패키지)로 유지한다 — 도메인은 tenant-agnostic, config 주입.
|
||
- backend 가 `/chat` 을 agent 로 위임하거나, 프런트가 agent 를 직접 호출(인증 토큰은 backend 발급분 검증).
|
||
|
||
**장단점**
|
||
- (+) 무거운 RL 의존성(torch/d3rlpy/obp)이 인증 backend 를 오염시키지 않음 — 격리 + 독립 스케일.
|
||
- (+) backend 컨벤션·`negosium_db` 단일 소스 재사용 → 학습 곡선·중복 제거.
|
||
- (+) PoC 알고리즘 실험의 잦은 재학습이 운영 backend 배포에 묶이지 않음.
|
||
- (−) `common/` 을 backend 와 별도 유지(현재는 복제) → 추후 공유 라이브러리화 검토.
|
||
- (−) 서비스 간 Protocol 버전 정합 관리 필요.
|
||
|
||
### 테넌트 = company_id
|
||
|
||
계획서의 문자열 `tenant_id` 는 이 레포에서 **`company.companies.company_id`(uuid)** 로 매핑한다(이미 1급 시민).
|
||
공유 베이스 정책은 예약 식별자(`_base`)로 표현한다. 모든 학습 테이블은 `company_id` 로 논리 격리한다.
|
||
|
||
### RL 학습 자산 저장 위치 = learning 스키마
|
||
|
||
Q-Table·q_values·visit_counts·experience_logs(propensity/turn 포함)는 `negosium_db` 안의
|
||
**신설 `learning` 스키마**(7번째)에 둔다. 기존 `company`/`card`/`negotiation`/`quotation` 스키마는 재사용한다.
|
||
|
||
### 저작권 분리 (클린룸) — `CLEANROOM.md`
|
||
|
||
플랫폼은 특정 고객사 독점 자산과 분리해 독립 설계한다. 참고 엔진(`Chat_server`)은 **기능 구조(아이디어·방법)** 이해용으로만 열람하고, **독점 표현물(협상 스크립트·고유 카드 코드·verbatim 라벨·튜닝값)은 반입하지 않는다.** 레포의 테넌트 프로파일은 **합성/중립 데모값**이며, 실제 운영값은 테넌트 비공개 소스(YAML/DB)에서 런타임 주입한다. 카드 코드는 우리 중립 스킴(`NGC-*`). 상세는 [CLEANROOM.md](CLEANROOM.md).
|
||
|
||
## 디렉토리 구조
|
||
|
||
```
|
||
agent/
|
||
├── web_main.py # 엔트리포인트 (uvicorn, 포트 9500)
|
||
├── config/ # APP_ENV 별 toml (backend 컨벤션)
|
||
├── common/ # logger·enums·singleton·gmodel·gtime·db_session_manager (backend 이식)
|
||
├── router/ # FastAPI app + 미들웨어 + v1 라우터
|
||
│ ├── router.py # app + lifespan + TenantMiddleware
|
||
│ ├── middleware/tenant_middleware.py # X-Tenant-ID → request.state.tenant_id (P4 완성)
|
||
│ └── v1/health/health.py
|
||
├── negotiation/ # 협상 엔진 (Chat_server 이식, 헥사고날)
|
||
│ ├── chat/service/ # chat_engine·step 핸들러 (P7/P8)
|
||
│ ├── orchestrator/ # negotiation_orchestrator (P4 팩토리화)
|
||
│ ├── policy/ policies/ # UCBPolicy + 상위 NegotiationPolicy 추상 (H0)
|
||
│ ├── qtable/ # state·q_table·reward·usecase (P2 config 주입)
|
||
│ ├── cards/ # CardSourcePort + card.* 어댑터 (P6)
|
||
│ └── profiling/ # ← 구 N-profiling 개명 (동적 import 해킹 제거) ✅ P0 완료
|
||
├── tenancy/ # TenantConfig·로더·레지스트리 (P1/P4)
|
||
├── eval_harness/ # 알고리즘 비교 하네스 (PoC 본체, H1~H7)
|
||
├── tools/ # init_base·train·export 스크립트
|
||
└── tests/
|
||
```
|
||
|
||
## 실행 / 테스트
|
||
|
||
```bash
|
||
cd agent
|
||
pip install -r requirements.txt
|
||
pip install pytest pytest-asyncio httpx # 테스트 도구
|
||
|
||
# config 는 config.local.toml 하나만 사용(.gitignore — DB 비번·OpenAI 키 포함).
|
||
# 없으면 직접 생성(DB·OpenAI 값 입력). test/docker 도 이 파일로 폴백된다.
|
||
|
||
python web_main.py # APP_ENV 기본 local, http://localhost:9500/docs
|
||
APP_ENV=test python -m pytest # 테스트 (config.local.toml 사용)
|
||
```
|
||
|
||
### 브라우저 데모 (스크립트 기반 협상 채팅)
|
||
|
||
서버를 띄우고 **http://localhost:9500/demo** 접속 (`tests/negotiation_demo.html`, `/v1/chat` 구동).
|
||
테넌트/유형을 고르면 **서비스안내→담당자확인→협상품목안내→가격협상→와일드카드→협상완료** 대화가
|
||
스크립트로 진행되고, 버튼/가격 입력이 step 에 따라 동적 렌더링. **가격협상 턴에서 UCB Q-Table 이 카드를
|
||
선택하고 학습**(card_id·Q·visit 표시), 종료 시 성공/실패 보상 반영. 스크립트 브랜드는 테넌트별 치환(데모상사 A/B).
|
||
|
||
### 콘솔 테스트 (프론트 없이)
|
||
|
||
현재 구현된 부분을 콘솔로 확인하는 방법:
|
||
|
||
**1) 의사결정 루프 데모** — 테넌트별 config 주입·상태분류·보상·DB 격리를 눈으로 확인:
|
||
```bash
|
||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 기본 3턴 시나리오
|
||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 다른 테넌트(다른 카드셋·임계값)
|
||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --interactive # 직접 입력
|
||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 없이
|
||
```
|
||
> ⚠️ 카드선택은 임시 placeholder 정책(실제 UCB Q-Table 은 H1/P5). 학습은 아직 일어나지 않는다.
|
||
|
||
**2) 서버 헬스/테넌트 라우팅** — 서버를 띄우고 curl:
|
||
```bash
|
||
APP_ENV=local python web_main.py # 다른 터미널에서:
|
||
curl localhost:9500/healthz # 200
|
||
curl localhost:9500/v1/health # {"status":"ok",...}
|
||
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: imarketkorea' # 통과(라우트 미존재라 404 Not Found)
|
||
```
|
||
|
||
**2-1) 협상 한 라운드 (HTTP 프리뷰)** — `POST /v1/negotiation/step` (Swagger: http://localhost:9500/docs):
|
||
```bash
|
||
curl -s -X POST localhost:9500/v1/negotiation/step \
|
||
-H 'X-Tenant-ID: imarketkorea' -H 'Content-Type: application/json' \
|
||
-d '{"revenue_amount":20000000,"distribution_code":"A","partner_count":1,
|
||
"acceptance_ratio":0.11,"input_price":990,"anchor_price":800,"target_price":1000,
|
||
"round_number":3,"outcome":"success"}'
|
||
# → state_index / card_id(NGC-A*) / reward / logged. 테넌트를 imarketkorea 로 바꾸면 다른 상태·카드(NGC-B*).
|
||
```
|
||
> ⚠️ 카드선택은 임시 placeholder. 실제 대화 `/chat`·step 체계·학습형 정책은 P7/H1.
|
||
|
||
**3) DB 격리 확인** — 콘솔 데모 실행 후 company_id 별 로그 건수 조회:
|
||
```bash
|
||
APP_ENV=local python -m tools.show_logs # learning.experience_logs 를 company_id 별로 집계
|
||
```
|
||
|
||
## 진행 상황 (Phase)
|
||
|
||
- ✅ **P0 스캐폴딩**: 디렉토리/config/common/람다DB/Protocol 골격, profiling 개명(동적 import 제거),
|
||
앱 순환 import 없이 로드 + 테넌트 미들웨어 골격. (`tests/test_p0_scaffold.py` 5/5)
|
||
- ✅ **P1 TenantConfig & 로더**: pydantic config + YAML/`_base` deep-merge + TTL 캐시,
|
||
**클린룸 적용**(중립 데모값·`NGC-*` 카드). (`tests/test_p1_tenant_config.py` 7/7)
|
||
- ✅ **P2 State/Reward/Mapper config 주입**: `build_state`(IntEnum→주입, mixed-radix index),
|
||
`RewardCalculator`(자체 공식), `ActionCardMapper`(+중복방지 마스킹). 결정론·주입효과 검증. (`tests/test_p2_*` 8/8)
|
||
- ✅ **P3 learning 스키마**: `postgres-init/02-learning-schema.sql`(company_id 격리·복합유니크·propensity/turn),
|
||
ORM 모델, `LearningRepository`(company_id 생성자 박기·리셋 스코프). **실DB 검증**: 2테넌트 version_name 공존,
|
||
reset_all 타테넌트 무영향(파괴 테스트), company_id 위조방지. (`tests/test_p3_*` 5/5)
|
||
- ✅ **P4 Registry/Factory & 미들웨어**: 전역 싱글톤 제거 → `TenantEngineRegistry`(테넌트별 지연생성+lock 캐시),
|
||
`EngineFactory`, **episode_state 외부화**(`EpisodeState` 요청스코프 — 동시성 오염 방지), 미들웨어 미등록 404.
|
||
검증: 두 테넌트 다른 엔진/카드, 동시요청 1회조립, 헤더 400·미등록 404. (`tests/test_p4_*` 7/7)
|
||
- ✅ **H0 Policy 추상 + H1 UCB Q-Table 정책**: `NegotiationPolicy`(ABC), `QTable`(numpy, Q-learning),
|
||
`UCBQTablePolicy`(UCB 선택+propensity 근사+마스킹), `QTablePolicyStore`(learning 스키마 로드/영속).
|
||
**`/v1/negotiation/step` 이 실제 학습형으로 교체** — 반복 호출 시 UCB 탐색 + Q 갱신 + DB 누적 + 테넌트 격리.
|
||
(`tests/test_h1_*` 7/7)
|
||
- ✅ **대화 스크립트 + ScriptRepository**: Chat_server 스크립트 구조 이식(재협상/재견적/와일드카드/step·변수맵),
|
||
**KT 브랜드→`{company_name}`/`{service_name}` 변수화 + 표현 중립 재작성**(클린룸). (`tests/test_scripts_*` 7/7)
|
||
- ✅ **P7 슬라이스 `/v1/chat`**: 인메모리 세션 + `ChatEngine`(step 전이·조건분기·와일드카드 진입) +
|
||
**가격협상 턴 UCB 카드선택·학습 + 종료보상 역전파**. 브라우저 채팅 UI. (`tests/test_p7_chat.py` 5/5)
|
||
- ✅ **H5 학습검증 하네스 (PoC 본체)**: 카드별 효과가 다른 시뮬 구매자(`eval_harness/`) → 정책 비교.
|
||
**학습형(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)
|
||
- ✅ **P7 14개 API**: chat / q-table(versions·switch·current) / experience-logs / reset-learning ·
|
||
reset-all(타테넌트 무영향) / invalidate-session / **train**(오프라인 Q-learning) / verification-report /
|
||
card-update · card-search. 전부 X-Tenant-ID 격리. (`tests/test_p7_apis.py` 5/5)
|
||
- ✅ **P5 베이스 warm-start / cold-start 3단**: `warm_start_from_base`(차원 호환 시 base Q값/방문수 복제,
|
||
visit 감쇠·base_version_id 추적), cold-start 3단(warm-start→차원불일치 휴리스틱 폴백→첫 버전),
|
||
`tools/init_base.py`(시뮬레이터로 베이스 시드). 신규 테넌트 첫 협상 → `v000_warmstart_from_base`. (`tests/test_p5_*` 5/5)
|
||
- ✅ **P8-A 세션 상태 DB화**: `learning.chat_sessions` + `ChatSessionRepository`(인메모리 제거).
|
||
**서버 재시작/멀티워커에도 협상 진행 상태 복원**(라이브 검증: kill 후 재기동→같은 session_id 이어짐). (`tests/test_p8_*` 3/3)
|
||
- ⬜ H3 LinUCB / H4 OPE(IPS/DR/SNIPS) / H6 CQL+FQE / P8-B 채팅로그(negotiation.chats) 적재.
|
||
|
||
**누적 테스트 73/73** (`cd agent && APP_ENV=test python -m pytest`). DB 테스트는 로컬 postgres 필요(미가용 시 skip).
|
||
|
||
### 협상 경제 모델 (KT 구매자 관점)
|
||
- **KT커머스/아이마켓코리아 = 구매자(갑)**. 목표 = **싸게 매입**. 협력사(판매자)가 제시가를 낸다.
|
||
- **앵커링가(anchor) < 목표가(target).** 앵커링값은 **갑이 직접 입력**(UI 기본 제안 = `target×(1−0.01)`, 편집 가능).
|
||
- **협력사 제시가 ≤ 앵커가 → 우선협상(타결)**, 더 낮을수록 **KT 보상↑**. 앵커가 초과 → 카드로 인하 협상, **설정 카드 모두 소진 시 결렬**.
|
||
- 와일드카드: 앵커가 살짝 초과 구간에서 1% 인하/목표가 매칭 압박.
|
||
|
||
### PoC 본체 결과 (H5, 위 경제모델 기준 / target=10000·anchor=8000 시나리오)
|
||
```
|
||
python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
|
||
policy success settled/tgt turns mean_rwd ±95%CI good_hit
|
||
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
|
||
qtable_ucb 0.998 0.881 1.46 1.2686 0.0088 1.000 ← 학습형(최저 매입가)
|
||
판정 ✅ PASS: 학습형이 baseline 대비 평균보상 우위(95%CI 분리) + 좋은카드 적중 우위 → 학습 루프 유효
|
||
(settled/tgt 낮을수록 = 더 싸게 매입 = KT 이득. 학습형이 가장 낮음)
|
||
```
|
||
|
||
> **학습 한계(정직)**: 현재 보상은 snapshot 만으로 산출돼 **어떤 카드를 골랐는지에 무관**하다. 즉 UCB 탐색·Q갱신·영속·격리 '머신'은 실동작하지만, "어떤 카드가 더 좋은가"를 학습하려면 **행동-의존 보상**이 필요하다 → H5(LLM 구매자 시뮬레이터) 또는 실 협상결과 로그. 그게 PoC 본체의 다음 핵심.
|
||
- ⬜ 트랙2(하네스): H0 로깅 보강 → H1 QTable 어댑터 → … → H7 비교표/리포트.
|
||
|
||
상세 계획·검증 기준은 실행 계획서 참조.
|