Agent 서버 구축: 멀티테넌트 협상 PoC (UCB Q-Table 학습 + /chat + 14 API + 학습검증 하네스), config 단일화(local.toml) + 빌드 경량화
This commit is contained in:
parent
212725e5f9
commit
951d6eab84
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,6 +3,9 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
|
||||
*.toml
|
||||
|
||||
# Python 바이트코드/캐시 — 절대 커밋하지 않는다.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
7
agent/.dockerignore
Normal file
7
agent/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.git/
|
||||
tests/
|
||||
*.md
|
||||
eval_harness/reports/
|
||||
12
agent/.gitignore
vendored
Normal file
12
agent/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
logs/
|
||||
eval_harness/reports/*
|
||||
!eval_harness/reports/.gitkeep
|
||||
|
||||
# config 는 config.local.toml 하나만 사용
|
||||
config/*.toml
|
||||
29
agent/CLEANROOM.md
Normal file
29
agent/CLEANROOM.md
Normal file
@ -0,0 +1,29 @@
|
||||
# 클린룸 / 저작권 분리 정책
|
||||
|
||||
이 플랫폼(`agent`)은 특정 고객사(KT커머스 등)의 **독점 자산과 분리**되어 독립적으로 설계된다.
|
||||
참고용 엔진(`Chat_server`, 단일 테넌트)은 **기능 구조(아이디어·방법론)** 를 이해하기 위해서만 열람했고,
|
||||
그쪽의 **표현물(코드 문구·스크립트·고유 식별자·튜닝값)을 그대로 가져오지 않는다.**
|
||||
|
||||
## 보호 대상 vs 자유 이용 대상
|
||||
|
||||
| 구분 | 예시 | 우리 정책 |
|
||||
|---|---|---|
|
||||
| **독점 표현물 (반입 금지)** | 협상 스크립트(`scripts_*.json`의 한글 영업 카피), 고객 고유 카드 코드(`NC26-xxx`), verbatim 라벨/문구, 이식한 LLM 프롬프트 원문 | 레포에 **번들하지 않음**. 테넌트 비공개 소스(YAML/DB)에서 **런타임 주입**. |
|
||||
| **고유 식별자** | 카드 코드, 배포 ID, 회사 내부 코드 | 우리 **중립 스킴**(`NGC-*`)으로 대체. 실제 값은 테넌트가 자기 카탈로그(`card.nego_cards`)로 공급. |
|
||||
| **기능적 방법·아이디어 (자유)** | Q-Learning/UCB 알고리즘, state 차원 구성 방식, reward 공식 형태, 람다DB 패턴 | 자유 이용(저작권 비보호). 단, **값**은 우리 자체 기본값을 선택. |
|
||||
|
||||
## 적용 규칙
|
||||
|
||||
1. **카드 코드**: 플랫폼은 중립 데모 코드(`NGC-A001` 등)만 보유. 운영 시 각 테넌트가 자사 `card.nego_cards`로 매핑.
|
||||
2. **스크립트 콘텐츠**: KT 스크립트(`scripts_renegotiation/requote/wildcard.json`)는 **반입 금지**. 우리 자체 placeholder만 사용(P7).
|
||||
3. **임계값·가중치·하이퍼파라미터**: 우리가 선택한 **중립 플랫폼 기본값**. 특정 고객의 튜닝값을 복제하지 않음. 실제 튜닝은 테넌트 YAML/DB에서 주입.
|
||||
4. **라벨/설명 문구**: 우리 자체 중립 표기(영문 키 또는 일반 표현).
|
||||
5. **LLM 프롬프트**: 우리가 작성한 문구로 재작성.
|
||||
6. **테넌트 식별자**(`ktcommerce`, `imarketkorea`): 단순 라우팅 키(회사 라벨)로만 사용. 데모 프로파일의 **값은 합성/중립**이며 해당 회사의 실제 운영값이 아니다.
|
||||
|
||||
## 검증 기준 변경 (P1)
|
||||
|
||||
- (이전) "ktcommerce config == Chat_server 하드코딩 1:1" → **폐기** (독점값 복제를 의미하므로).
|
||||
- (변경) "우리 플랫폼 중립 기본값이 정확히 로드되고 deep-merge·차원 산출이 동작한다."
|
||||
|
||||
> 기능 동등성(behavior parity)은 알고리즘/구조 수준에서 유지하되, **값**은 우리 자체 설정으로 간다.
|
||||
17
agent/Dockerfile
Normal file
17
agent/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 의존성은 모두 wheel 제공(컴파일러 불필요) → build-essential 없이 경량 빌드.
|
||||
# 의존성 먼저 설치 (레이어 캐시 활용)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# 항상 APP_ENV=local 로 실행 → config.local.toml 사용.
|
||||
ENV APP_ENV=local
|
||||
|
||||
EXPOSE 9500
|
||||
|
||||
CMD ["python", "web_main.py"]
|
||||
193
agent/README.md
193
agent/README.md
@ -1 +1,192 @@
|
||||
마지막 내 도리는 하자 .
|
||||
# Negosium Agent
|
||||
|
||||
범용 멀티테넌트 협상 솔루션 PoC. 여러 회사(ktcommerce·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 ktcommerce # 기본 3턴 시나리오
|
||||
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 다른 테넌트(다른 카드셋·임계값)
|
||||
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive # 직접 입력
|
||||
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --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: ktcommerce' # 통과(라우트 미존재라 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: ktcommerce' -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 ktcommerce`. (`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 ktcommerce
|
||||
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 비교표/리포트.
|
||||
|
||||
상세 계획·검증 기준은 실행 계획서 참조.
|
||||
|
||||
0
agent/bootstrap/__init__.py
Normal file
0
agent/bootstrap/__init__.py
Normal file
36
agent/bootstrap/lifespan.py
Normal file
36
agent/bootstrap/lifespan.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""서버 기동 부트스트랩 (운영 자동화).
|
||||
|
||||
ensure_base_seeded: 공유 베이스 정책(_base)이 없으면 자동 시드한다.
|
||||
- 이미 있으면 빠르게 스킵(매 재시작마다 가벼운 체크만).
|
||||
- DB 미가용 시 서버 기동을 막지 않고 경고만 남긴다.
|
||||
- 신규 테넌트는 첫 협상에서 이 베이스를 cold-start warm-start 복제한다(별도 작업 불필요).
|
||||
"""
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import BASE_COMPANY_ID
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from config.server_configs import agent_config
|
||||
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
||||
|
||||
|
||||
async def ensure_base_seeded():
|
||||
if not agent_config.auto_seed_base:
|
||||
LOG.i("[bootstrap] auto_seed_base=false — skip base seeding")
|
||||
return
|
||||
try:
|
||||
repo = LearningRepository(BASE_COMPANY_ID)
|
||||
err, active = await DB_SESSION_MNG.execute_lambda(
|
||||
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: repo.get_active_version(s))
|
||||
if err == ErrorType.SUCCESS and active is not None:
|
||||
LOG.i(f"[bootstrap] base policy 존재 (v={active.version_name}, {active.state_space_size}x{active.action_space_size}) — 시드 스킵")
|
||||
return
|
||||
|
||||
LOG.i("[bootstrap] base policy 없음 — _base 자동 시드 시작 ...")
|
||||
from tools.init_base import seed_base
|
||||
info = await seed_base(action_space=agent_config.base_action_space,
|
||||
episodes=agent_config.base_seed_episodes)
|
||||
LOG.i(f"[bootstrap] base 시드 완료: {info['state_space']}x{info['action_space']} cells={info['cells']} "
|
||||
f"(신규 테넌트는 첫 협상에서 자동 warm-start)")
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[bootstrap] base 시드 스킵 (DB 미가용?): {type(ex).__name__}: {ex}")
|
||||
0
agent/common/__init__.py
Normal file
0
agent/common/__init__.py
Normal file
0
agent/common/database/__init__.py
Normal file
0
agent/common/database/__init__.py
Normal file
188
agent/common/database/db_session_manager.py
Normal file
188
agent/common/database/db_session_manager.py
Normal file
@ -0,0 +1,188 @@
|
||||
from asyncio import current_task
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_scoped_session
|
||||
from sqlalchemy.util._collections import immutabledict
|
||||
|
||||
from common.database.model.models import MAIN_BASE
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from common.singleton import Singleton
|
||||
from config.server_configs import main_db_config
|
||||
|
||||
|
||||
class DBSessionManager(Singleton):
|
||||
"""DB 세션/엔진 관리자 (싱글톤). backend/common/database/db_session_manager.py 와 동일 패턴.
|
||||
|
||||
- DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진.
|
||||
- 비즈니스 로직(service)은 세션을 직접 열지 않고 "람다"를 넘긴다.
|
||||
execute_lambda : 단일 쿼리 (주로 조회)
|
||||
execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if DBSessionManager.is_init():
|
||||
LOG.e_no_callstack("already init DBSessionManager")
|
||||
return
|
||||
DBSessionManager.set_init()
|
||||
|
||||
self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"}
|
||||
self.__engines = []
|
||||
self.__db_type_map = {
|
||||
DBType.MAIN.value: main_db_config,
|
||||
}
|
||||
|
||||
self.__write_session = {
|
||||
DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_WRITE.value),
|
||||
}
|
||||
self.__read_session = {
|
||||
DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_READ.value),
|
||||
}
|
||||
|
||||
def create_engine(self, db_type: int, db_wr_type: int):
|
||||
db_config = self.__db_type_map.get(db_type)
|
||||
if not db_config:
|
||||
raise ValueError("Invalid database type")
|
||||
|
||||
if db_wr_type == DBWRType.DB_READ.value:
|
||||
pw = (":" + db_config.read_pw) if len(db_config.read_pw) > 0 else ""
|
||||
db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.read_id}{pw}@{db_config.read_host}:{db_config.read_port}/{db_config.name}"
|
||||
LOG.i(f"Read DB create engine url : {db_url}")
|
||||
else:
|
||||
pw = (":" + db_config.write_pw) if len(db_config.write_pw) > 0 else ""
|
||||
db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}"
|
||||
LOG.i(f"Write DB create engine url : {db_url}")
|
||||
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
echo=db_config.show_log,
|
||||
pool_size=db_config.pool_size,
|
||||
max_overflow=db_config.max_overflow,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=600,
|
||||
)
|
||||
self.__engines.append(engine)
|
||||
scoped_session = async_scoped_session(
|
||||
sessionmaker(engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False),
|
||||
scopefunc=current_task,
|
||||
)
|
||||
return scoped_session
|
||||
|
||||
async def dispose_all(self):
|
||||
for engine in self.__engines:
|
||||
await engine.dispose()
|
||||
|
||||
# ---- 세션 lifecycle -------------------------------------------------
|
||||
async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession:
|
||||
if db_wr_type == DBWRType.DB_WRITE.value:
|
||||
return self.__write_session[db_type]()
|
||||
return self.__read_session[db_type]()
|
||||
|
||||
async def end_session(self, db_type: int, db_wr_type: int):
|
||||
if db_wr_type == DBWRType.DB_WRITE.value:
|
||||
await self.__write_session[db_type].remove()
|
||||
else:
|
||||
await self.__read_session[db_type].remove()
|
||||
|
||||
# ---- 저수준 DB 연산 (crud 에서 호출) --------------------------------
|
||||
async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType:
|
||||
try:
|
||||
await db.commit()
|
||||
return ErrorType.SUCCESS
|
||||
except IntegrityError as ex:
|
||||
await db.rollback()
|
||||
LOG.e_no_callstack(f"duplicated. {ex}")
|
||||
return ErrorType.DB_ALREADY_SAME_KEY
|
||||
except Exception as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type
|
||||
|
||||
async def insert(self, db: AsyncSession, obj, err_msg="DB Failed", raise_error=True) -> ErrorType:
|
||||
try:
|
||||
if isinstance(obj, MAIN_BASE):
|
||||
db.add(obj)
|
||||
elif isinstance(obj, list):
|
||||
db.add_all(obj)
|
||||
else:
|
||||
raise RuntimeError("DO NOT USE QUERY IN DBJOB")
|
||||
return ErrorType.SUCCESS
|
||||
except Exception as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type
|
||||
|
||||
async def add(self, db: AsyncSession, query, err_msg="DB Operation Failed", raise_error=True) -> ErrorType:
|
||||
"""update/delete 등 비-select 쿼리 실행."""
|
||||
try:
|
||||
if hasattr(query, "column_descriptions"):
|
||||
raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB")
|
||||
await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"}))
|
||||
return ErrorType.SUCCESS
|
||||
except IntegrityError as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_ALREADY_SAME_KEY
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
return err_type
|
||||
except Exception as ex:
|
||||
await db.rollback()
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type
|
||||
|
||||
async def execute(self, db: AsyncSession, query, err_msg="DB Query Execution Failed", raise_error=True) -> tuple[ErrorType, list]:
|
||||
"""select 쿼리 실행 후 결과 리스트 반환."""
|
||||
try:
|
||||
if not hasattr(query, "column_descriptions"):
|
||||
raise RuntimeError("DO NOT USE NON-SELECT QUERY IN DBJOB")
|
||||
res = await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"}))
|
||||
return ErrorType.SUCCESS, res.scalars().fetchall() if 1 == len(query.column_descriptions) else res.all()
|
||||
except Exception as ex:
|
||||
err_type = ErrorType.DB_RUN_FAILED
|
||||
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
|
||||
if raise_error:
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type, []
|
||||
|
||||
# ---- 람다 실행 진입점 (service 에서 호출) ---------------------------
|
||||
async def execute_lambda(self, db_type: int, db_wr_type: int, func):
|
||||
"""단일 쿼리 호출. func(session) 한 개를 실행하고 결과를 그대로 반환."""
|
||||
s = await self.start_session(db_type, db_wr_type)
|
||||
try:
|
||||
return await func(s)
|
||||
finally:
|
||||
await self.end_session(db_type, db_wr_type)
|
||||
|
||||
async def execute_lambda_run(self, db_type_list: list[int], func_list: list):
|
||||
"""동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit.
|
||||
하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다.
|
||||
"""
|
||||
temp_list = list(set(db_type_list))
|
||||
if len(temp_list) != 1:
|
||||
return ErrorType.DB_INVALID_TYPE
|
||||
|
||||
db_type = temp_list[0]
|
||||
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
||||
try:
|
||||
for func in func_list:
|
||||
err_type = await func(s)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type
|
||||
return await self.run(s)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
finally:
|
||||
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
||||
|
||||
|
||||
DB_SESSION_MNG = DBSessionManager()
|
||||
0
agent/common/database/model/__init__.py
Normal file
0
agent/common/database/model/__init__.py
Normal file
144
agent/common/database/model/models.py
Normal file
144
agent/common/database/model/models.py
Normal file
@ -0,0 +1,144 @@
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
String,
|
||||
Float,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from common.enums import DBType
|
||||
|
||||
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. (backend 와 동일 패턴)
|
||||
#
|
||||
# agent 는 negosium_db 를 backend 와 공유한다.
|
||||
# - 기존 스키마(company/card/negotiation 등)는 postgres-init/01-schema.sql 이 소유 → 읽기/쓰기만.
|
||||
# - RL 학습 자산은 신설 `learning` 스키마(postgres-init/02-learning-schema.sql)에 둔다.
|
||||
MAIN_BASE = declarative_base()
|
||||
|
||||
LEARNING_SCHEMA = "learning"
|
||||
|
||||
# 공유 베이스 정책의 예약 테넌트 키 (company_id 컬럼에 저장). company uuid 와 혼용되므로 VARCHAR.
|
||||
BASE_COMPANY_ID = "_base"
|
||||
|
||||
|
||||
class _DBTypeMixin:
|
||||
"""모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택)."""
|
||||
|
||||
@staticmethod
|
||||
def DBType():
|
||||
return DBType.MAIN.value
|
||||
|
||||
|
||||
# ============================================================
|
||||
# learning 스키마 ORM (02-learning-schema.sql 과 1:1)
|
||||
# 모든 테이블에 company_id(테넌트 키) — 논리 격리.
|
||||
# ============================================================
|
||||
class QTableVersion(_DBTypeMixin, MAIN_BASE):
|
||||
__tablename__ = "q_table_versions"
|
||||
__table_args__ = {"schema": LEARNING_SCHEMA}
|
||||
|
||||
version_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
||||
company_id = Column(String(64), nullable=False)
|
||||
version_name = Column(String(50), nullable=False)
|
||||
scope = Column(SmallInteger, nullable=False, default=2) # 1=base, 2=tenant
|
||||
base_version_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
state_space_size = Column(Integer, nullable=False)
|
||||
action_space_size = Column(Integer, nullable=False)
|
||||
learning_rate = Column(Numeric(6, 4), nullable=False, default=0.1)
|
||||
discount_factor = Column(Numeric(6, 4), nullable=False, default=0.95)
|
||||
epochs = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
|
||||
deleted = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class QValue(_DBTypeMixin, MAIN_BASE):
|
||||
__tablename__ = "q_values"
|
||||
__table_args__ = {"schema": LEARNING_SCHEMA}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
company_id = Column(String(64), nullable=False)
|
||||
version_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
state_index = Column(Integer, nullable=False)
|
||||
action_id = Column(Integer, nullable=False)
|
||||
q_value = Column(Float, nullable=False, default=0.0)
|
||||
|
||||
|
||||
class VisitCount(_DBTypeMixin, MAIN_BASE):
|
||||
__tablename__ = "visit_counts"
|
||||
__table_args__ = {"schema": LEARNING_SCHEMA}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
company_id = Column(String(64), nullable=False)
|
||||
version_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
state_index = Column(Integer, nullable=False)
|
||||
action_id = Column(Integer, nullable=False)
|
||||
count = Column(BigInteger, nullable=False, default=0)
|
||||
|
||||
|
||||
class ExperienceLog(_DBTypeMixin, MAIN_BASE):
|
||||
__tablename__ = "experience_logs"
|
||||
__table_args__ = {"schema": LEARNING_SCHEMA}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
company_id = Column(String(64), nullable=False)
|
||||
transition_id = Column(UUID(as_uuid=True), nullable=False, server_default=text("gen_random_uuid()"))
|
||||
session_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
state_index = Column(Integer, nullable=False)
|
||||
action_id = Column(Integer, nullable=False)
|
||||
card_id = Column(String(40), nullable=True)
|
||||
q_value_at_selection = Column(Float, nullable=True)
|
||||
reward = Column(Float, nullable=True)
|
||||
next_state_index = Column(Integer, nullable=True)
|
||||
done = Column(Boolean, nullable=False, default=False)
|
||||
snapshot = Column(JSONB, nullable=True)
|
||||
propensity = Column(Float, nullable=True) # OPE 필수
|
||||
turn = Column(Integer, nullable=True)
|
||||
available_actions = Column(JSONB, nullable=True)
|
||||
settled_price = Column(BigInteger, nullable=True)
|
||||
visit_count_at_selection = Column(BigInteger, nullable=True)
|
||||
total_visits_at_selection = Column(BigInteger, nullable=True)
|
||||
ucb_score_at_selection = Column(Float, nullable=True)
|
||||
is_new_quote = Column(Boolean, nullable=False, default=False)
|
||||
is_invalidated = Column(Boolean, nullable=False, default=False)
|
||||
invalidated_reason = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
|
||||
|
||||
|
||||
class TenantActionCard(_DBTypeMixin, MAIN_BASE):
|
||||
__tablename__ = "tenant_action_cards"
|
||||
__table_args__ = {"schema": LEARNING_SCHEMA}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
company_id = Column(String(64), nullable=False)
|
||||
action_id = Column(Integer, nullable=False)
|
||||
card_id = Column(String(40), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
|
||||
updated_at = Column(DateTime(timezone=True), server_default=text("now()"))
|
||||
deleted = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class ChatSessionRow(_DBTypeMixin, MAIN_BASE):
|
||||
"""/chat 진행 상태 영속화 (P8-A). 채팅 메시지 로그가 아니라 step 머신 상태."""
|
||||
|
||||
__tablename__ = "chat_sessions"
|
||||
__table_args__ = {"schema": LEARNING_SCHEMA}
|
||||
|
||||
session_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||
company_id = Column(String(64), nullable=False)
|
||||
tenant_id = Column(String(64), nullable=False)
|
||||
rq_type = Column(String(10), nullable=False, default="재협상")
|
||||
step = Column(String(40), nullable=False, default="시작")
|
||||
context = Column(JSONB, nullable=False, default=dict)
|
||||
used_action_ids = Column(JSONB, nullable=False, default=list)
|
||||
action_space_size = Column(Integer, nullable=False, default=0)
|
||||
ended = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
|
||||
updated_at = Column(DateTime(timezone=True), server_default=text("now()"))
|
||||
70
agent/common/enums.py
Normal file
70
agent/common/enums.py
Normal file
@ -0,0 +1,70 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class ErrorType(Enum):
|
||||
"""서버 전역 결과 코드. backend/common/enums.py 와 구간을 공유하되,
|
||||
협상 에이전트 전용 코드를 2000 구간에 추가한다.
|
||||
"""
|
||||
|
||||
SUCCESS = 0
|
||||
FAIL = 1
|
||||
|
||||
# DB / Redis 에러
|
||||
DB_RUN_FAILED = 10
|
||||
DB_ALREADY_SAME_KEY = auto()
|
||||
DB_INVALID_KEY = auto()
|
||||
DB_EMPTY_DATA = auto()
|
||||
DB_INVALID_TYPE = auto()
|
||||
|
||||
# 요청/직렬화 에러
|
||||
JSON_PARSE_ERROR = 100
|
||||
INVALID_REQUEST_DATA = auto()
|
||||
INTERNAL_EXCEPTION = auto()
|
||||
|
||||
# http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션
|
||||
HTTP_INVALID_CLIENT_REQUEST = 419
|
||||
HTTP_TO_MANY_REQUEST = 429
|
||||
HTTP_INVALID_CLIENT_ACCESS = 433
|
||||
HTTP_ACCESS_TOKEN_EXPIRED = 434
|
||||
HTTP_REFRESH_TOKEN_EXPIRED = 435
|
||||
HTTP_INVALID_TOKEN_ACCESS = 436
|
||||
|
||||
# 테넌트 라우팅 에러 (P4 tenant_middleware)
|
||||
TENANT_HEADER_MISSING = 1400 # X-Tenant-ID 부재 -> 400
|
||||
TENANT_NOT_REGISTERED = auto() # 미등록 테넌트 -> 404
|
||||
|
||||
# 협상 에이전트 에러
|
||||
NEGO_INVALID_STEP = 2000
|
||||
NEGO_SESSION_NOT_FOUND = auto()
|
||||
NEGO_QTABLE_NOT_LOADED = auto()
|
||||
NEGO_DIM_MISMATCH = auto() # state/action 차원 불일치 (warm-start 폴백 트리거)
|
||||
|
||||
|
||||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||||
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
|
||||
EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name)
|
||||
EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name)
|
||||
EXCEPTION_ACCESS_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.name)
|
||||
EXCEPTION_REFRESH_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.name)
|
||||
EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_TOKEN_ACCESS.value, detail=ErrorType.HTTP_INVALID_TOKEN_ACCESS.name)
|
||||
|
||||
# 테넌트 라우팅 전용 예외 (P4 에서 미들웨어가 raise)
|
||||
EXCEPTION_TENANT_HEADER_MISSING = HTTPException(status_code=400, detail=ErrorType.TENANT_HEADER_MISSING.name)
|
||||
EXCEPTION_TENANT_NOT_REGISTERED = HTTPException(status_code=404, detail=ErrorType.TENANT_NOT_REGISTERED.name)
|
||||
|
||||
|
||||
class DBType(Enum):
|
||||
"""논리 DB 구분. backend 와 동일하게 단일 negosium_db(MAIN)를 공유한다.
|
||||
agent 전용 RL 학습 자산은 negosium_db 안의 `learning` 스키마에 둔다(별도 DB 아님).
|
||||
"""
|
||||
|
||||
MAIN = 1
|
||||
|
||||
|
||||
class DBWRType(Enum):
|
||||
"""Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다."""
|
||||
|
||||
DB_READ = 1
|
||||
DB_WRITE = 2
|
||||
43
agent/common/logger.py
Normal file
43
agent/common/logger.py
Normal file
@ -0,0 +1,43 @@
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class _Logger:
|
||||
"""backend/common/logger.py 와 동일 인터페이스 (서버군 컨벤션 정합).
|
||||
LOG.i / LOG.d / LOG.w / LOG.e / LOG.e_no_callstack / LOG.SetPrefix 제공.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._prefix = ""
|
||||
|
||||
def SetPrefix(self, prefix: str):
|
||||
self._prefix = prefix
|
||||
|
||||
def _now(self) -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _write(self, level: str, msg):
|
||||
head = f"{self._now()} [{level}]"
|
||||
if self._prefix:
|
||||
head += f"[{self._prefix}]"
|
||||
print(f"{head} {msg}", file=sys.stderr if level in ("WARN", "ERROR") else sys.stdout)
|
||||
|
||||
def d(self, msg):
|
||||
self._write("DEBUG", msg)
|
||||
|
||||
def i(self, msg):
|
||||
self._write("INFO", msg)
|
||||
|
||||
def w(self, msg):
|
||||
self._write("WARN", msg)
|
||||
|
||||
def e(self, msg):
|
||||
self._write("ERROR", msg)
|
||||
traceback.print_stack()
|
||||
|
||||
def e_no_callstack(self, msg):
|
||||
self._write("ERROR", msg)
|
||||
|
||||
|
||||
LOG = _Logger()
|
||||
0
agent/common/models/__init__.py
Normal file
0
agent/common/models/__init__.py
Normal file
42
agent/common/models/gmodel.py
Normal file
42
agent/common/models/gmodel.py
Normal file
@ -0,0 +1,42 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common.enums import ErrorType
|
||||
|
||||
|
||||
class StructModel:
|
||||
"""프로토콜/구조체 식별용 마커 클래스. (backend 와 동일 규약)"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ErrorInfo(BaseModel, StructModel):
|
||||
"""모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다."""
|
||||
|
||||
success: Optional[bool] = True
|
||||
code: Optional[int] = ErrorType.SUCCESS.value
|
||||
desc: Optional[str] = ErrorType.SUCCESS.name
|
||||
|
||||
def SetResult(self, enum: ErrorType):
|
||||
if enum is not None:
|
||||
self.success = ErrorType.SUCCESS.value == enum.value
|
||||
self.code = enum.value
|
||||
self.desc = enum.name
|
||||
|
||||
|
||||
# ---- Protocol 규약 (backend/common/models/gmodel.py 와 동일) ----------------
|
||||
# 모든 통신 패킷은 WebPacketProtocol 을 상속한다.
|
||||
# 요청 : Req_xxx (WebPacketProtocol) / 응답 : Res_xxx (Res_WebPacketProtocol, 항상 result)
|
||||
class WebPacketProtocol(BaseModel, StructModel):
|
||||
pass
|
||||
|
||||
|
||||
class Req_WebPacketProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Res_WebPacketProtocol(WebPacketProtocol):
|
||||
# default_factory 로 인스턴스마다 새 ErrorInfo 를 생성한다 (mutable default 공유 방지).
|
||||
result: ErrorInfo = Field(default_factory=ErrorInfo)
|
||||
msg: Optional[str] = None
|
||||
16
agent/common/singleton.py
Normal file
16
agent/common/singleton.py
Normal file
@ -0,0 +1,16 @@
|
||||
class Singleton:
|
||||
_init = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not hasattr(cls, "instance"):
|
||||
cls.instance = super(Singleton, cls).__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
@classmethod
|
||||
def is_init(cls):
|
||||
return cls._init
|
||||
|
||||
@classmethod
|
||||
def set_init(cls):
|
||||
cls._init = True
|
||||
0
agent/common/utils/__init__.py
Normal file
0
agent/common/utils/__init__.py
Normal file
21
agent/common/utils/gtime.py
Normal file
21
agent/common/utils/gtime.py
Normal file
@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
|
||||
class GTime:
|
||||
"""서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (backend 와 동일)"""
|
||||
|
||||
@staticmethod
|
||||
def UTC() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def UTCStr(fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
return GTime.UTC().strftime(fmt)
|
||||
|
||||
@staticmethod
|
||||
def AddMinutes(minutes: int) -> datetime:
|
||||
return GTime.UTC() + timedelta(minutes=minutes)
|
||||
|
||||
@staticmethod
|
||||
def AddDays(days: int) -> datetime:
|
||||
return GTime.UTC() + timedelta(days=days)
|
||||
0
agent/config/__init__.py
Normal file
0
agent/config/__init__.py
Normal file
32
agent/config/config_loader.py
Normal file
32
agent/config/config_loader.py
Normal file
@ -0,0 +1,32 @@
|
||||
import tomllib
|
||||
from typing import Optional, Type, Dict, TypeVar
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConfigModel(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
# APP_ENV (backend 와 동일 규약)
|
||||
# local : 로컬 환경(개인 pc) / dev : 개발환경 / prod : 서비스 환경
|
||||
# 실행 시: export APP_ENV=dev (linux) / set APP_ENV=dev (windows)
|
||||
class Configs:
|
||||
ConfigType = TypeVar("ConfigType", bound=ConfigModel)
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self._settings: Dict[Type["Configs.ConfigType"], "Configs.ConfigType"] = self._load_settings_from_toml(file_path)
|
||||
|
||||
def _load_settings_from_toml(self, file_path: str) -> Dict[Type[ConfigType], ConfigType]:
|
||||
with open(file_path, "rb") as f:
|
||||
toml_content = tomllib.load(f)
|
||||
|
||||
config_subclasses = ConfigModel.__subclasses__()
|
||||
configs = {
|
||||
config_class: config_class.model_validate(toml_content[config_class.__name__])
|
||||
for config_class in config_subclasses
|
||||
if config_class.__name__ in toml_content
|
||||
}
|
||||
return configs
|
||||
|
||||
def get(self, config_class: Type[ConfigType]) -> Optional[ConfigType]:
|
||||
return self._settings.get(config_class)
|
||||
62
agent/config/config_models.py
Normal file
62
agent/config/config_models.py
Normal file
@ -0,0 +1,62 @@
|
||||
from config.config_loader import ConfigModel
|
||||
|
||||
|
||||
class WebServerConfig(ConfigModel):
|
||||
server_name: str = ""
|
||||
port: int = 0
|
||||
process_count: int = 1
|
||||
is_ssl: bool = False
|
||||
is_test: bool = False
|
||||
|
||||
|
||||
class LogConfig(ConfigModel):
|
||||
print_console: bool = True
|
||||
log_level: str = "debug"
|
||||
|
||||
|
||||
# DB Read/Write 분리 설정. backend 와 동일 구조 (negosium_db 공유).
|
||||
class MainDBConfig(ConfigModel):
|
||||
db_type: str = "postgresql"
|
||||
name: str = ""
|
||||
write_host: str = ""
|
||||
write_port: int = 5432
|
||||
write_id: str = ""
|
||||
write_pw: str = ""
|
||||
read_host: str = ""
|
||||
read_port: int = 5432
|
||||
read_id: str = ""
|
||||
read_pw: str = ""
|
||||
show_log: bool = False
|
||||
pool_size: int = 10
|
||||
max_overflow: int = 20
|
||||
|
||||
|
||||
class OpenAIConfig(ConfigModel):
|
||||
"""LLM(OpenAI/Azure) 접속 설정. 프로젝트 컨벤션대로 toml 로 관리.
|
||||
⚠️ api_key 는 시크릿 — config.local.toml 은 외부 공개 저장소에 push 하지 말 것.
|
||||
"""
|
||||
|
||||
provider: str = "openai" # "openai" | "azure"
|
||||
api_key: str = ""
|
||||
model: str = "gpt-4o-mini" # openai: 모델명 / azure: 배포(deployment) 이름
|
||||
base_url: str = "" # openai 호환 게이트웨이용(옵션)
|
||||
# azure 전용
|
||||
azure_endpoint: str = ""
|
||||
api_version: str = ""
|
||||
|
||||
|
||||
class AgentConfig(ConfigModel):
|
||||
"""협상 에이전트 전역 설정. 테넌트별 세부 설정은 tenants/<id>/tenant.yaml (TenantConfig, P1).
|
||||
여기에는 서버군 공통값만 둔다.
|
||||
"""
|
||||
|
||||
# 테넌트 정적 리소스 루트 (tenants/<id>/...)
|
||||
tenants_dir: str = "tenants"
|
||||
# TenantConfig TTL 캐시(초). 0 이면 매 요청 로드.
|
||||
config_cache_ttl_seconds: int = 300
|
||||
# default tenant 금지 (KT 사고 방지). 미들웨어가 헤더 부재 시 400.
|
||||
allow_default_tenant: bool = False
|
||||
# 서버 기동 시 공유 베이스(_base)가 없으면 자동 시드 (운영 자동화).
|
||||
auto_seed_base: bool = True
|
||||
base_action_space: int = 9 # 베이스 Q-Table action 차원 (신규 테넌트와 호환돼야 복제됨)
|
||||
base_seed_episodes: int = 300 # 베이스 시드 학습 에피소드 수
|
||||
22
agent/config/server_configs.py
Normal file
22
agent/config/server_configs.py
Normal file
@ -0,0 +1,22 @@
|
||||
import os
|
||||
|
||||
from config.config_loader import Configs
|
||||
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, AgentConfig, OpenAIConfig
|
||||
|
||||
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. (backend 와 동일)
|
||||
APP_ENV = os.environ.get("APP_ENV", "local")
|
||||
|
||||
_config_dir = os.path.dirname(__file__)
|
||||
_config_file = os.path.join(_config_dir, f"config.{APP_ENV}.toml")
|
||||
|
||||
# 운영 전제: 항상 APP_ENV=local 로 띄운다 → config.local.toml 사용 (test/docker 도 local 로 실행).
|
||||
if not os.path.exists(_config_file):
|
||||
raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). APP_ENV=local 로 실행하세요.")
|
||||
|
||||
configs = Configs(_config_file)
|
||||
|
||||
web_server_config: WebServerConfig = configs.get(WebServerConfig)
|
||||
log_config: LogConfig = configs.get(LogConfig)
|
||||
main_db_config: MainDBConfig = configs.get(MainDBConfig)
|
||||
agent_config: AgentConfig = configs.get(AgentConfig)
|
||||
openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig()
|
||||
65
agent/conftest.py
Normal file
65
agent/conftest.py
Normal file
@ -0,0 +1,65 @@
|
||||
# 테스트도 APP_ENV=local 로 실행한다 (config.local.toml 사용).
|
||||
# config.server_configs 가 import 되는 순간 config.<APP_ENV>.toml 을 읽으므로 가장 먼저 설정.
|
||||
import os
|
||||
|
||||
os.environ.setdefault("APP_ENV", "local")
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _dispose_app_engines():
|
||||
"""테스트 세션 종료 시 앱 싱글톤 엔진 정리 ('Event loop is closed' 경고 제거)."""
|
||||
yield
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
def _write_url(cfg) -> str:
|
||||
pw = f":{cfg.write_pw}" if cfg.write_pw else ""
|
||||
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/{cfg.name}"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_engine():
|
||||
"""learning 스키마 테이블을 보장하고, 매 테스트 시작 시 비워 격리한다.
|
||||
|
||||
DB 미가용(로컬 postgres 없음) 시 해당 테스트를 skip 한다.
|
||||
앱(DB_SESSION_MNG)은 같은 config 로 같은 DB 에 접속하므로 스키마를 공유한다.
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from common.database.model.models import MAIN_BASE, LEARNING_SCHEMA
|
||||
from config.server_configs import main_db_config
|
||||
|
||||
engine = create_async_engine(_write_url(main_db_config))
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
|
||||
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {LEARNING_SCHEMA}"))
|
||||
await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip
|
||||
for tbl in ("experience_logs", "q_values", "visit_counts", "q_table_versions", "tenant_action_cards", "chat_sessions"):
|
||||
await conn.execute(text(f"TRUNCATE TABLE {LEARNING_SCHEMA}.{tbl} RESTART IDENTITY CASCADE"))
|
||||
except Exception as ex:
|
||||
await engine.dispose()
|
||||
pytest.skip(f"DB 미가용 — P3 DB 테스트 skip: {type(ex).__name__}: {str(ex)[:80]}")
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
"""앱을 실제 네트워크 없이 호출하는 httpx 클라이언트 (ASGITransport).
|
||||
|
||||
P0 스모크는 DB 테이블을 요구하지 않는 경로(healthz/health)만 검증한다.
|
||||
learning 스키마 테이블·DB 의존 테스트는 P3 이후 db_engine 픽스처를 추가해 다룬다.
|
||||
"""
|
||||
from router.router import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
0
agent/eval_harness/__init__.py
Normal file
0
agent/eval_harness/__init__.py
Normal file
56
agent/eval_harness/baselines.py
Normal file
56
agent/eval_harness/baselines.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""비교용 baseline 정책 (NegotiationPolicy 구현). 학습 정책과 같은 인터페이스로 하네스에 등록."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from negotiation.policies.base import ActionDecision, NegotiationPolicy, PolicyContext, Transition
|
||||
|
||||
|
||||
class RandomPolicy(NegotiationPolicy):
|
||||
"""가용 액션 중 무작위 선택. 학습하지 않음(update no-op). 학습 정책의 하한 비교군."""
|
||||
|
||||
name = "random"
|
||||
|
||||
def __init__(self, seed: int = 0):
|
||||
self.rng = np.random.default_rng(seed)
|
||||
|
||||
def _available(self, ctx: PolicyContext):
|
||||
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) -> ActionDecision:
|
||||
avail = self._available(ctx)
|
||||
a = int(self.rng.choice(avail))
|
||||
if ctx.episode:
|
||||
ctx.episode.mark_used(a)
|
||||
return ActionDecision(action_id=a, propensity=1.0 / len(avail), available_actions=avail)
|
||||
|
||||
def update(self, transition: Transition) -> None:
|
||||
pass
|
||||
|
||||
def predict_action_dist(self, ctx: PolicyContext) -> np.ndarray:
|
||||
avail = self._available(ctx)
|
||||
dist = np.zeros(ctx.action_space_size)
|
||||
for a in avail:
|
||||
dist[a] = 1.0 / len(avail)
|
||||
return dist
|
||||
|
||||
|
||||
class StaticPolicy(NegotiationPolicy):
|
||||
"""항상 고정 카드(기본 action 0). '정적 운영'(학습 없음) 비교군."""
|
||||
|
||||
name = "static"
|
||||
|
||||
def __init__(self, fixed_action: int = 0):
|
||||
self.fixed = fixed_action
|
||||
|
||||
def select(self, ctx: PolicyContext) -> ActionDecision:
|
||||
used = ctx.episode.used_action_ids if ctx.episode else set()
|
||||
a = self.fixed if self.fixed not in used else next(
|
||||
(x for x in range(ctx.action_space_size) if x not in used), self.fixed)
|
||||
if ctx.episode:
|
||||
ctx.episode.mark_used(a)
|
||||
return ActionDecision(action_id=a, propensity=1.0, available_actions=[a])
|
||||
|
||||
def update(self, transition: Transition) -> None:
|
||||
pass
|
||||
79
agent/eval_harness/buyer.py
Normal file
79
agent/eval_harness/buyer.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""시뮬레이션 구매자 (계획서 G, H5).
|
||||
|
||||
PoC 본체의 핵심: **카드(action) 선택에 따라 협상 결과가 달라지는** 환경. 그래야 에이전트가
|
||||
"어떤 카드가 좋은가"를 보상으로 학습할 수 있고, 학습 정책이 random 보다 성과가 오르는지 검증 가능.
|
||||
|
||||
- HeuristicBuyer: LLM 없이 결정론적(seed)으로 동작. 각 카드에 숨은 효과(effectiveness)를 부여해
|
||||
수락확률·타결가에 반영. 에이전트는 이를 모르고 보상으로만 추정한다.
|
||||
- LLMBuyer(옵션): Azure OpenAI 가상 구매자(페르소나/예산). 자격증명(llm.enabled) 있을 때만.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
"""KT 구매자 관점. anchor=협력사 기준가(높음) ≥ target=KT 목표 매입가(낮음)."""
|
||||
|
||||
anchor_price: float # 협력사 기준가(앵커). 제시가 ≤ anchor → 우선협상
|
||||
target_price: float # KT 목표 매입가(낮음). 낮게 타결할수록 보상↑
|
||||
revenue_amount: float = 20_000_000
|
||||
distribution_code: str = "A"
|
||||
partner_count: int = 1
|
||||
acceptance_ratio: float = 0.05
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuyerResponse:
|
||||
accept: bool
|
||||
new_price: float # 협력사가 양보한 새 제시가(이번 턴 후)
|
||||
walked: bool = False # 협상 이탈(결렬)
|
||||
|
||||
|
||||
class HeuristicBuyer:
|
||||
"""카드 효과 기반 결정론적 '협력사(판매자)' 모델 (KT 구매자가 상대).
|
||||
|
||||
card_effectiveness[action] ∈ [0,1] 가 클수록: 협력사가 더 크게 양보(가격 인하)하고 수락확률↑
|
||||
→ KT 에게 유리(낮은 타결가). 효과는 비공개이며 에이전트는 보상으로만 추정.
|
||||
"""
|
||||
|
||||
def __init__(self, card_effectiveness: Dict[int, float], seed: int = 0,
|
||||
accept_base: float = 0.10, accept_gain: float = 0.75, max_turns: int = 5):
|
||||
self.eff = card_effectiveness
|
||||
self.rng = np.random.default_rng(seed)
|
||||
self.accept_base = accept_base
|
||||
self.accept_gain = accept_gain
|
||||
self.max_turns = max_turns
|
||||
|
||||
def reseed(self, seed: int):
|
||||
self.rng = np.random.default_rng(seed)
|
||||
|
||||
def respond(self, action_id: int, scenario: Scenario, turn: int, current_price: float) -> BuyerResponse:
|
||||
eff = float(self.eff.get(action_id, 0.1))
|
||||
# 협력사 양보: 효과 클수록 앵커가 쪽으로 더 많이 내려온다(KT 이득). 앵커가 이하까지 도달 가능.
|
||||
floor = scenario.anchor_price * 0.95
|
||||
concession = (current_price - floor) * (0.15 + 0.55 * eff)
|
||||
new_price = max(floor, current_price - concession)
|
||||
# 수락(현 가격에 합의)확률: 효과 + 후반 라운드 압박.
|
||||
p_accept = min(0.97, self.accept_base + self.accept_gain * eff + 0.06 * (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)
|
||||
|
||||
|
||||
def make_card_effectiveness(action_space_size: int, seed: int = 0,
|
||||
n_good: int = 3) -> Dict[int, float]:
|
||||
"""카드 효과 벡터 생성: n_good 개의 '좋은 카드'(0.75~0.95) + 나머지 약효(0.05~0.35)."""
|
||||
rng = np.random.default_rng(seed)
|
||||
eff = {a: float(rng.uniform(0.05, 0.35)) for a in range(action_space_size)}
|
||||
good = rng.choice(action_space_size, size=min(n_good, action_space_size), replace=False)
|
||||
for a in good:
|
||||
eff[int(a)] = float(rng.uniform(0.75, 0.95))
|
||||
return eff
|
||||
|
||||
|
||||
def best_actions(card_effectiveness: Dict[int, float], k: int = 3) -> List[int]:
|
||||
return [a for a, _ in sorted(card_effectiveness.items(), key=lambda kv: -kv[1])[:k]]
|
||||
18
agent/eval_harness/configs/exp_default.yaml
Normal file
18
agent/eval_harness/configs/exp_default.yaml
Normal file
@ -0,0 +1,18 @@
|
||||
# 알고리즘 비교 실험 기본 설정 (H5). E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
||||
episodes: 400 # 정책당 협상 에피소드 수
|
||||
seed: 42 # 재현용 (구매자 randomness 페어드)
|
||||
max_turns: 5 # 협상 라운드 상한
|
||||
n_good_cards: 3 # 시뮬 구매자: 효과 좋은 카드 수
|
||||
curve_buckets: 10 # 학습곡선 구간 수
|
||||
policies: # 비교 대상 (random/static = 비학습 비교군, qtable_ucb = 학습)
|
||||
- random
|
||||
- static
|
||||
- qtable_ucb
|
||||
scenario: # KT 구매자(갑): anchor(앵커링) < target(목표 매입가). 갑이 직접 입력.
|
||||
target_price: 10000 # KT 목표 매입가
|
||||
anchor_price: 8000 # KT 앵커링가(공격적으로 낮게 설정). 제시가 ≤ 8000 → 우선협상
|
||||
# ↑ 0.99×target(9900)도 가능하나, 카드 효과가 드러나려면 협상 여지(갭)가 있어야 함
|
||||
revenue_amount: 20000000
|
||||
distribution_code: A
|
||||
partner_count: 1
|
||||
acceptance_ratio: 0.05
|
||||
77
agent/eval_harness/metrics.py
Normal file
77
agent/eval_harness/metrics.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""지표 집계 + 학습곡선 (H5, 계획서 G 지표)."""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from eval_harness.simulator import EpisodeResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class Aggregate:
|
||||
n: int
|
||||
success_rate: float
|
||||
mean_settled_ratio: float # 평균 타결가 / 목표가
|
||||
mean_turns: float
|
||||
mean_reward: float
|
||||
reward_ci95: float # 평균보상 95% 신뢰구간 반폭
|
||||
|
||||
def as_row(self) -> dict:
|
||||
return {
|
||||
"n": self.n,
|
||||
"success_rate": round(self.success_rate, 3),
|
||||
"mean_settled_ratio": round(self.mean_settled_ratio, 3),
|
||||
"mean_turns": round(self.mean_turns, 2),
|
||||
"mean_reward": round(self.mean_reward, 4),
|
||||
"reward_ci95": round(self.reward_ci95, 4),
|
||||
}
|
||||
|
||||
|
||||
def aggregate(results: List[EpisodeResult]) -> Aggregate:
|
||||
n = len(results)
|
||||
if n == 0:
|
||||
return Aggregate(0, 0, 0, 0, 0, 0)
|
||||
succ = sum(1 for r in results if r.success) / n
|
||||
settled = sum(r.settled_ratio for r in results) / n
|
||||
turns = sum(r.turns for r in results) / n
|
||||
rewards = [r.total_reward for r in results]
|
||||
mean_r = sum(rewards) / n
|
||||
var = sum((x - mean_r) ** 2 for x in rewards) / n if n > 1 else 0.0
|
||||
ci = 1.96 * math.sqrt(var / n) if n > 1 else 0.0
|
||||
return Aggregate(n, succ, settled, turns, mean_r, ci)
|
||||
|
||||
|
||||
def learning_curve(results: List[EpisodeResult], buckets: int = 10) -> List[float]:
|
||||
"""에피소드를 buckets 구간으로 나눠 구간별 평균 보상 (우상향이면 학습)."""
|
||||
n = len(results)
|
||||
if n == 0:
|
||||
return []
|
||||
size = max(1, n // buckets)
|
||||
curve = []
|
||||
for i in range(0, n, size):
|
||||
chunk = results[i:i + size]
|
||||
curve.append(round(sum(r.total_reward for r in chunk) / len(chunk), 4))
|
||||
return curve
|
||||
|
||||
|
||||
def good_card_hit_rate(first_actions: List[int], good_actions: List[int], last_frac: float = 0.3) -> float:
|
||||
"""후반 구간에서 첫 카드가 '좋은 카드'였던 비율 (학습 수렴 지표)."""
|
||||
if not first_actions:
|
||||
return 0.0
|
||||
tail = first_actions[max(0, int(len(first_actions) * (1 - last_frac))):]
|
||||
good = set(good_actions)
|
||||
return sum(1 for a in tail if a in good) / len(tail)
|
||||
|
||||
|
||||
def hit_curve(first_actions: List[int], good_actions: List[int], buckets: int = 10) -> List[float]:
|
||||
"""구간별 '좋은 카드 선택' 비율 (학습 전/후 개선곡선 — 세일즈용)."""
|
||||
n = len(first_actions)
|
||||
if n == 0:
|
||||
return []
|
||||
good = set(good_actions)
|
||||
size = max(1, n // buckets)
|
||||
curve = []
|
||||
for i in range(0, n, size):
|
||||
chunk = first_actions[i:i + size]
|
||||
curve.append(round(sum(1 for a in chunk if a in good) / len(chunk), 3))
|
||||
return curve
|
||||
33
agent/eval_harness/registry.py
Normal file
33
agent/eval_harness/registry.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""정책 레지스트리 — 하네스에서 비교할 정책 인스턴스 팩토리 (H5).
|
||||
|
||||
현재: random / static / qtable_ucb. LinUCB(H3)·CQL(H6)은 같은 NegotiationPolicy 로 추후 등록.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from eval_harness.baselines import RandomPolicy, StaticPolicy
|
||||
from negotiation.policies.qtable_policy import UCBQTablePolicy
|
||||
from negotiation.qtable.domain.model.q_table import QTable
|
||||
from tenancy.config import PolicyConfig, StateConfig
|
||||
|
||||
|
||||
def build_policy(name: str, state_cfg: StateConfig, action_space_size: int,
|
||||
policy_cfg: PolicyConfig, seed: int = 0):
|
||||
"""이름으로 새 정책 인스턴스 생성 (메모리 QTable, DB 미사용 — 시뮬 속도/격리)."""
|
||||
if name == "random":
|
||||
return RandomPolicy(seed=seed)
|
||||
if name == "static":
|
||||
return StaticPolicy(fixed_action=0)
|
||||
if name in ("qtable_ucb", "qtable", "ucb"):
|
||||
qt = QTable(state_cfg.state_space_size, action_space_size,
|
||||
learning_rate=policy_cfg.learning_rate, discount_factor=policy_cfg.gamma)
|
||||
params = policy_cfg.params or {}
|
||||
return UCBQTablePolicy(
|
||||
qt,
|
||||
exploration_constant=params.get("exploration_constant", math.sqrt(2.0)),
|
||||
epsilon=params.get("propensity_epsilon", 0.1),
|
||||
)
|
||||
raise ValueError(f"unknown policy: {name} (지원: random|static|qtable_ucb; LinUCB/CQL 은 H3/H6)")
|
||||
|
||||
|
||||
AVAILABLE = ["random", "static", "qtable_ucb"]
|
||||
162
agent/eval_harness/runner.py
Normal file
162
agent/eval_harness/runner.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""eval_harness 러너 — 정책 비교 + 학습곡선 (H5, PoC 본체).
|
||||
|
||||
E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
|
||||
|
||||
판정: 학습형(qtable_ucb)이 random/static 대비 평균보상·성공률 우상향이면 "학습 루프 유효".
|
||||
구매자는 카드별 효과가 다른 시뮬(HeuristicBuyer) — 학습 정책만 좋은 카드를 알아내 성과가 오른다.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List
|
||||
|
||||
import yaml
|
||||
|
||||
from eval_harness.buyer import HeuristicBuyer, Scenario, best_actions, make_card_effectiveness
|
||||
from eval_harness.metrics import aggregate, good_card_hit_rate, hit_curve, learning_curve
|
||||
from eval_harness.registry import build_policy
|
||||
from eval_harness.simulator import run_episode
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _resolve_config(path: str) -> str:
|
||||
if os.path.isabs(path) and os.path.exists(path):
|
||||
return path
|
||||
for cand in (path, os.path.join(_HERE, path), os.path.join(_HERE, "configs", os.path.basename(path))):
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
raise FileNotFoundError(f"config not found: {path}")
|
||||
|
||||
|
||||
def run(config_path: str, tenant_id: str) -> dict:
|
||||
with open(_resolve_config(config_path), "r", encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
|
||||
tcfg = TenantConfigLoader().load(tenant_id)
|
||||
A = tcfg.action_mapping.action_space_size
|
||||
if A == 0:
|
||||
raise ValueError(f"tenant {tenant_id} 에 action 매핑이 없습니다 (action_space_size=0)")
|
||||
scn_raw = cfg.get("scenario", {})
|
||||
scenario = Scenario(
|
||||
anchor_price=scn_raw.get("anchor_price", 8000), target_price=scn_raw.get("target_price", 10000),
|
||||
revenue_amount=scn_raw.get("revenue_amount", 20_000_000),
|
||||
distribution_code=scn_raw.get("distribution_code", "A"),
|
||||
partner_count=scn_raw.get("partner_count", 1), acceptance_ratio=scn_raw.get("acceptance_ratio", 0.05),
|
||||
)
|
||||
episodes = int(cfg.get("episodes", 400))
|
||||
seed = int(cfg.get("seed", 42))
|
||||
max_turns = int(cfg.get("max_turns", 5))
|
||||
buckets = int(cfg.get("curve_buckets", 10))
|
||||
n_good = int(cfg.get("n_good_cards", 3))
|
||||
policy_names = cfg.get("policies", ["random", "qtable_ucb"])
|
||||
|
||||
# 카드 효과(숨김) + 좋은 카드 — 구매자는 테넌트/seed 로 고정. 정책은 모른다.
|
||||
eff = make_card_effectiveness(A, seed=seed, n_good=n_good)
|
||||
good = best_actions(eff, k=n_good)
|
||||
|
||||
report = {"tenant": tenant_id, "episodes": episodes, "seed": seed,
|
||||
"action_space_size": A, "good_cards": good, "policies": {}}
|
||||
|
||||
for name in policy_names:
|
||||
policy = build_policy(name, tcfg.state, A, tcfg.policy, seed=seed)
|
||||
buyer = HeuristicBuyer(eff, seed=seed, max_turns=max_turns)
|
||||
results = []
|
||||
for i in range(episodes):
|
||||
buyer.reseed(seed * 100_000 + i) # 페어드: 정책 간 동일 구매자 randomness
|
||||
learn = name in ("qtable_ucb", "qtable", "ucb")
|
||||
results.append(run_episode(policy, buyer, scenario, tcfg.state, tcfg.reward, A,
|
||||
max_turns=max_turns, learn=learn))
|
||||
agg = aggregate(results)
|
||||
firsts = [r.first_action for r in results]
|
||||
# 초반 구간 미세 곡선(첫 50 에피소드, 5개씩) — 빠른 수렴 시 cold→warm 상승을 드러냄.
|
||||
early = hit_curve(firsts[:50], good, buckets=10)
|
||||
report["policies"][name] = {
|
||||
**agg.as_row(),
|
||||
"learning_curve": learning_curve(results, buckets=buckets),
|
||||
"good_card_hit_rate": round(good_card_hit_rate(firsts, good), 3),
|
||||
"hit_curve": hit_curve(firsts, good, buckets=buckets),
|
||||
"early_hit_curve": early,
|
||||
}
|
||||
|
||||
report["verdict"] = _verdict(report)
|
||||
return report
|
||||
|
||||
|
||||
def _verdict(report: dict) -> dict:
|
||||
pols = report["policies"]
|
||||
learner = pols.get("qtable_ucb")
|
||||
baseline = pols.get("random") or pols.get("static")
|
||||
if not learner or not baseline:
|
||||
return {"pass": None, "note": "학습/비교군 부재"}
|
||||
# PoC 판정(계획서 G): 학습형이 random/정적 대비 성과 우상향.
|
||||
# - 평균보상 우위 + 95%CI 비중첩(통계적 분리)
|
||||
# - '좋은 카드' 적중 우위(학습으로 카드 우열을 알아냄)
|
||||
# - cold→warm: 초반 미세곡선이 baseline 수준에서 상승
|
||||
beats_reward = learner["mean_reward"] - learner["reward_ci95"] > baseline["mean_reward"] + baseline["reward_ci95"]
|
||||
beats_hit = learner["good_card_hit_rate"] >= baseline["good_card_hit_rate"] + 0.2
|
||||
ec = learner.get("early_hit_curve") or [0, 0]
|
||||
cold_warm = len(ec) >= 2 and ec[-1] > ec[0]
|
||||
passed = beats_reward and beats_hit
|
||||
return {
|
||||
"pass": bool(passed),
|
||||
"ci_separated": bool(beats_reward),
|
||||
"learner_mean_reward": learner["mean_reward"],
|
||||
"baseline_mean_reward": baseline["mean_reward"],
|
||||
"reward_uplift": round(learner["mean_reward"] - baseline["mean_reward"], 4),
|
||||
"learner_good_hit": learner["good_card_hit_rate"],
|
||||
"baseline_good_hit": baseline["good_card_hit_rate"],
|
||||
"early_cold_to_warm": [ec[0], ec[-1]] if ec else [],
|
||||
"cold_warm_rising": bool(cold_warm),
|
||||
"note": ("학습형이 baseline 대비 평균보상 우위(95%CI 분리) + '좋은 카드' 적중 우위 → 학습 루프 유효"
|
||||
if passed else "개선 미확인"),
|
||||
}
|
||||
|
||||
|
||||
def _print(report: dict):
|
||||
print("=" * 70)
|
||||
print(f" 알고리즘 비교 — tenant={report['tenant']} episodes={report['episodes']} seed={report['seed']}")
|
||||
print(f" (숨은) 좋은 카드 action: {report['good_cards']} / 총 {report['action_space_size']}개")
|
||||
print("=" * 70)
|
||||
hdr = f"{'policy':<12} {'success':>8} {'settled/tgt':>12} {'turns':>7} {'mean_rwd':>10} {'±95%CI':>9} {'good_hit':>9}"
|
||||
print(hdr); print("-" * len(hdr))
|
||||
for name, p in report["policies"].items():
|
||||
print(f"{name:<12} {p['success_rate']:>8.3f} {p['mean_settled_ratio']:>12.3f} "
|
||||
f"{p['mean_turns']:>7.2f} {p['mean_reward']:>10.4f} {p['reward_ci95']:>9.4f} {p['good_card_hit_rate']:>9.3f}")
|
||||
print("-" * len(hdr))
|
||||
print("\n학습 개선곡선 ('좋은 카드' 선택률 구간별, 세일즈용):")
|
||||
for name, p in report["policies"].items():
|
||||
print(f" {name:<12} 전구간 {p.get('hit_curve')}")
|
||||
print(f"\n qtable_ucb 초반 cold→warm (첫 50ep, 5개씩): {report['policies'].get('qtable_ucb', {}).get('early_hit_curve')}")
|
||||
v = report["verdict"]
|
||||
flag = "✅ PASS" if v.get("pass") else ("— " if v.get("pass") is None else "❌ FAIL")
|
||||
print(f"\n판정 {flag}: {v.get('note')}")
|
||||
if v.get("pass") is not None:
|
||||
print(f" 평균보상 학습형 {v['learner_mean_reward']} vs baseline {v['baseline_mean_reward']} "
|
||||
f"(uplift {v['reward_uplift']}, 95%CI 분리={v['ci_separated']})")
|
||||
print(f" 좋은카드 적중 학습형 {v['learner_good_hit']} vs baseline {v['baseline_good_hit']} · "
|
||||
f"cold→warm {v['early_cold_to_warm']}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="협상 정책 비교 하네스 (H5)")
|
||||
ap.add_argument("--config", default="configs/exp_default.yaml")
|
||||
ap.add_argument("--tenant", default="ktcommerce")
|
||||
ap.add_argument("--save", action="store_true", help="reports/ 에 JSON 저장")
|
||||
args = ap.parse_args()
|
||||
|
||||
report = run(args.config, args.tenant)
|
||||
_print(report)
|
||||
if args.save:
|
||||
out_dir = os.path.join(_HERE, "reports")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out = os.path.join(out_dir, f"report_{args.tenant}.json")
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n[저장] {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
agent/eval_harness/simulator.py
Normal file
83
agent/eval_harness/simulator.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""에피소드 시뮬레이터 — 정책 vs 구매자의 한 협상 시퀀스 (H5).
|
||||
|
||||
한 에피소드: 앵커에서 시작, 매 턴 정책이 카드 선택 → 구매자 반응(수락/계속/이탈) → 보상.
|
||||
수락 시 타결가로 성공 종료, 마지막 턴까지 미수락이면 결렬. 보상은 RewardCalculator(테넌트 config)로 채점.
|
||||
정책의 update(Transition)로 온라인 학습(시퀀스 보상). 동일 seed 시 재현 가능(페어드 비교).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from eval_harness.buyer import HeuristicBuyer, Scenario
|
||||
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||
from negotiation.qtable.domain.service.state_calculator import state_index
|
||||
from tenancy.config import RewardConfig, StateConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeResult:
|
||||
success: bool
|
||||
settled_price: float
|
||||
turns: int
|
||||
total_reward: float
|
||||
target_price: float
|
||||
first_action: int = -1 # 첫 턴 선택 카드(학습 수렴 측정용)
|
||||
|
||||
@property
|
||||
def settled_ratio(self) -> float:
|
||||
return self.settled_price / self.target_price if self.target_price else 0.0
|
||||
|
||||
|
||||
def run_episode(policy, buyer: HeuristicBuyer, scenario: Scenario,
|
||||
state_cfg: StateConfig, reward_cfg: RewardConfig, action_space_size: int,
|
||||
max_turns: int = 5, learn: bool = True) -> EpisodeResult:
|
||||
rc = RewardCalculator(reward_cfg)
|
||||
episode = EpisodeState()
|
||||
total_r = 0.0
|
||||
# 협력사는 목표가보다 높게 시작(협상 여지). KT 가 카드로 앵커가 이하까지 끌어내린다.
|
||||
price = scenario.target_price * 1.15
|
||||
last_idx = last_action = None
|
||||
first_action = -1
|
||||
|
||||
def snap(p, turn, outcome):
|
||||
return NegotiationSnapshot(
|
||||
revenue_amount=scenario.revenue_amount, distribution_code=scenario.distribution_code,
|
||||
partner_count=scenario.partner_count, acceptance_ratio=scenario.acceptance_ratio,
|
||||
input_price=p, anchor_price=scenario.anchor_price, target_price=scenario.target_price,
|
||||
round_number=turn, outcome=outcome,
|
||||
)
|
||||
|
||||
for turn in range(1, max_turns + 1):
|
||||
s = snap(price, turn, NegotiationOutcome.ONGOING)
|
||||
idx = state_index(s, state_cfg)
|
||||
ctx = PolicyContext(state_index=idx, snapshot=s, action_space_size=action_space_size, episode=episode)
|
||||
decision = policy.select(ctx)
|
||||
last_idx, last_action = idx, decision.action_id
|
||||
if turn == 1:
|
||||
first_action = decision.action_id
|
||||
|
||||
resp = buyer.respond(decision.action_id, scenario, turn, price)
|
||||
price = resp.new_price # 협력사가 양보한 새 가격
|
||||
# 우선협상(제시가 ≤ anchor) 또는 협력사 수락 → 타결
|
||||
if resp.accept or price <= scenario.anchor_price:
|
||||
fs = snap(price, turn, NegotiationOutcome.SUCCESS)
|
||||
r = rc.calculate(fs).total # 낮은 타결가일수록 보상↑
|
||||
if learn:
|
||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=r, done=True))
|
||||
total_r += r
|
||||
return EpisodeResult(True, price, turn, total_r, scenario.target_price, first_action)
|
||||
|
||||
# 미타결: 진행 보상 후 다음 턴(가격은 계속 내려간 상태)
|
||||
r = rc.calculate(s).total
|
||||
if learn:
|
||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=r, done=False))
|
||||
total_r += r
|
||||
|
||||
# 카드 소진/라운드 종료까지 우선협상 미달 → 결렬
|
||||
fs = snap(price, max_turns, NegotiationOutcome.FAILURE)
|
||||
r = rc.calculate(fs).total
|
||||
if learn and last_idx is not None:
|
||||
policy.update(Transition(state_index=last_idx, action_id=last_action, reward=r, done=True))
|
||||
total_r += r
|
||||
return EpisodeResult(False, price, max_turns, total_r, scenario.target_price, first_action)
|
||||
0
agent/negotiation/__init__.py
Normal file
0
agent/negotiation/__init__.py
Normal file
0
agent/negotiation/cards/__init__.py
Normal file
0
agent/negotiation/cards/__init__.py
Normal file
56
agent/negotiation/cards/action_card_mapper.py
Normal file
56
agent/negotiation/cards/action_card_mapper.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""ActionCardMapper — action_id ↔ card_id 매핑 (config 주입형).
|
||||
|
||||
Chat_server 의 매퍼는 JSON 파일에 결합돼 있었다. 여기서는 TenantConfig.action_mapping
|
||||
(ActionMappingConfig)에서 주입받아 테넌트별로 다른 카드셋을 지원한다.
|
||||
|
||||
PoC 는 카드 매핑을 고정한다(action_space_size ↔ Q-Table 차원 정합성 리스크 회피, 계획서).
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from tenancy.config import ActionMappingConfig
|
||||
|
||||
|
||||
class ActionCardMapper:
|
||||
def __init__(self, config: ActionMappingConfig):
|
||||
self._config = config
|
||||
self._action_to_card: Dict[int, str] = {}
|
||||
self._card_to_action: Dict[str, int] = {}
|
||||
self._rebuild()
|
||||
|
||||
def _rebuild(self):
|
||||
self._action_to_card = {int(a): c for a, c in self._config.action_to_card.items()}
|
||||
self._card_to_action = {c: a for a, c in self._action_to_card.items()}
|
||||
|
||||
def reload(self, config: ActionMappingConfig):
|
||||
"""카드 동기화/테넌트 reload 시 매핑 교체 (P6)."""
|
||||
self._config = config
|
||||
self._rebuild()
|
||||
|
||||
@property
|
||||
def action_space_size(self) -> int:
|
||||
return len(self._action_to_card)
|
||||
|
||||
def get_card_id(self, action_id: int) -> Optional[str]:
|
||||
return self._action_to_card.get(action_id)
|
||||
|
||||
def get_action_id(self, card_id: str) -> Optional[int]:
|
||||
return self._card_to_action.get(card_id)
|
||||
|
||||
def action_ids(self) -> List[int]:
|
||||
return sorted(self._action_to_card.keys())
|
||||
|
||||
def available_mask(self, used_action_ids: Optional[set] = None) -> np.ndarray:
|
||||
"""중복 방지 마스킹: 이미 사용한 action 은 False. 정책 select 시 곱해 제외한다.
|
||||
|
||||
길이 = action_space_size, dtype=bool. used_action_ids 가 None 이면 전부 True.
|
||||
"""
|
||||
n = self.action_space_size
|
||||
mask = np.ones(n, dtype=bool)
|
||||
if used_action_ids:
|
||||
for a in used_action_ids:
|
||||
if 0 <= a < n:
|
||||
mask[a] = False
|
||||
return mask
|
||||
0
agent/negotiation/cards/adapters/__init__.py
Normal file
0
agent/negotiation/cards/adapters/__init__.py
Normal file
0
agent/negotiation/cards/domain/__init__.py
Normal file
0
agent/negotiation/cards/domain/__init__.py
Normal file
0
agent/negotiation/cards/ports/__init__.py
Normal file
0
agent/negotiation/cards/ports/__init__.py
Normal file
0
agent/negotiation/chat/__init__.py
Normal file
0
agent/negotiation/chat/__init__.py
Normal file
0
agent/negotiation/chat/service/__init__.py
Normal file
0
agent/negotiation/chat/service/__init__.py
Normal file
184
agent/negotiation/chat/service/chat_engine.py
Normal file
184
agent/negotiation/chat/service/chat_engine.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""ChatEngine — 대화 step 전이 엔진 (동기 순수 로직, P7 슬라이스).
|
||||
|
||||
Chat_server 의 step 체계(서비스안내→담당자확인→협상품목안내→가격협상→협상완료/실패→협상종료)와
|
||||
조건 분기(check_wildcard_entry/price_match/iteration_limit)·와일드카드 진입을 우리 구현으로 재작성.
|
||||
|
||||
DB/정책(카드선택·학습)은 여기 두지 않는다 — ChatService(async)가 StepView 의 신호를 보고 처리한다.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
|
||||
MAX_ROUNDS = 3
|
||||
_PRICE_MODES = ("price",)
|
||||
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatSession:
|
||||
session_id: str
|
||||
tenant_id: str
|
||||
company_id: str
|
||||
rq_type: str = "재협상"
|
||||
step: str = "시작"
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
used_action_ids: set = field(default_factory=set)
|
||||
action_space_size: int = 0 # 카드 소진 판정용 (ChatService 가 주입)
|
||||
ended: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepView:
|
||||
step: str
|
||||
script: str
|
||||
input_mode: str
|
||||
input_options: List[str]
|
||||
chat_end: bool
|
||||
client_step: Optional[str] = None
|
||||
needs_card_selection: bool = False # 가격협상(에이전트 카운터) → UCB 카드선택+학습
|
||||
outcome: Optional[str] = None # 협상완료="success" / 협상실패="failure" → 종료보상
|
||||
wildcard: Optional[str] = None # 발동한 와일드카드 key
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ChatEngine:
|
||||
def __init__(self, scripts_repo: ScriptRepository, rq_type: str = "재협상"):
|
||||
self.repo = scripts_repo
|
||||
self.rq_type = rq_type
|
||||
self.scripts = scripts_repo.load_scripts(rq_type)
|
||||
self.step_map = scripts_repo.client_step_mapping()
|
||||
|
||||
# ---- public --------------------------------------------------------
|
||||
def start(self, session: ChatSession) -> StepView:
|
||||
first = self._default_next(self.scripts.get("시작", {})) or "서비스안내"
|
||||
return self._render(session, first)
|
||||
|
||||
def advance(self, session: ChatSession, user_input: Optional[str]) -> StepView:
|
||||
if session.ended:
|
||||
return self._error(session, "협상이 종료되었습니다. 새 협상을 시작하세요.")
|
||||
node = self.scripts.get(session.step, {})
|
||||
mode = node.get("next_input_mode", "null")
|
||||
|
||||
if mode in _PRICE_MODES:
|
||||
try:
|
||||
price = float(str(user_input).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
||||
session.context["input_price"] = price
|
||||
session.context["round"] = session.context.get("round", 0) + 1
|
||||
nxt = self._default_next(node)
|
||||
elif mode in _CHOICE_MODES:
|
||||
nxt = self._choice_next(node, user_input, session)
|
||||
else:
|
||||
nxt = self._default_next(node)
|
||||
|
||||
nxt = self._resolve(nxt, session)
|
||||
return self._render(session, nxt)
|
||||
|
||||
# ---- transition ----------------------------------------------------
|
||||
def _default_next(self, node: dict) -> Optional[str]:
|
||||
ns = node.get("next_step")
|
||||
if isinstance(ns, dict):
|
||||
return ns.get("default") or next(iter(ns.values()), None)
|
||||
return ns
|
||||
|
||||
def _choice_next(self, node: dict, choice: Optional[str], session: ChatSession):
|
||||
ns = node.get("next_step") or {}
|
||||
if not isinstance(ns, dict):
|
||||
return ns
|
||||
val = ns.get(choice)
|
||||
if val is None:
|
||||
val = ns.get("default") or next(iter(ns.values()), None)
|
||||
return val
|
||||
|
||||
def _resolve(self, nxt, session: ChatSession) -> Optional[str]:
|
||||
"""조건 리스트 평가 + 가격협상_와일드 가상스텝 → 실제 와일드카드 key 로 해석."""
|
||||
if isinstance(nxt, list):
|
||||
nxt = self._eval_conditions(nxt, session)
|
||||
if nxt == "가격협상_와일드":
|
||||
nxt = self._pick_wildcard(session)
|
||||
return nxt
|
||||
|
||||
def _eval_conditions(self, conds: List[dict], session: ChatSession) -> Optional[str]:
|
||||
"""KT 구매자 관점 조건 평가.
|
||||
- 협력사 제시가 ≤ anchor → 우선협상(협상완료).
|
||||
- anchor 살짝 초과(≤ anchor*1.05) + 와일드카드 미사용 → 와일드카드로 인하 압박.
|
||||
- 설정 카드(action_space) 모두 소진 → 협상실패.
|
||||
- 그 외 → 가격협상(카드 1장 플레이 후 재제안).
|
||||
"""
|
||||
ctx = session.context
|
||||
price = ctx.get("input_price", 0)
|
||||
anchor = ctx.get("anchor_price", 0)
|
||||
cards_used = len(session.used_action_ids)
|
||||
cards_total = session.action_space_size or 0
|
||||
for c in conds:
|
||||
cond = c.get("condition")
|
||||
ok = False
|
||||
if cond == "check_wildcard_entry":
|
||||
ok = (not ctx.get("wildcard_used")) and anchor < price <= anchor * 1.05
|
||||
elif cond == "check_is_supplier_type_c":
|
||||
ok = False # 공급사 유형 미보유 (PoC 단순화)
|
||||
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
|
||||
ok = anchor > 0 and price <= anchor
|
||||
elif cond == "check_iteration_limit": # = 카드 소진
|
||||
ok = cards_total > 0 and cards_used >= cards_total
|
||||
elif cond == "default":
|
||||
ok = True
|
||||
if ok:
|
||||
return c.get("next")
|
||||
return "가격협상"
|
||||
|
||||
def _pick_wildcard(self, session: ChatSession) -> str:
|
||||
"""앵커가 살짝 초과 구간에서 인하 압박 카드 선택.
|
||||
- 앵커가에 아주 근접(≤ anchor*1.02): 1% 인하 요청(wild_card_1pct) → 앵커가 이하로 유도.
|
||||
- 그 외: 목표 매입가 맞춰달라(wild_card_budget).
|
||||
"""
|
||||
ctx = session.context
|
||||
price = ctx.get("input_price", 0)
|
||||
anchor = ctx.get("anchor_price", 0)
|
||||
ctx["wildcard_used"] = True
|
||||
if anchor > 0 and price <= anchor * 1.02:
|
||||
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가
|
||||
return "wild_card_1pct"
|
||||
return "wild_card_budget"
|
||||
|
||||
# ---- render --------------------------------------------------------
|
||||
def _vars(self, session: ChatSession) -> Dict[str, Any]:
|
||||
ctx = session.context
|
||||
out = {}
|
||||
if "input_price" in ctx:
|
||||
out["input_price"] = int(ctx["input_price"])
|
||||
if "target_price" in ctx:
|
||||
out["target"] = int(ctx["target_price"])
|
||||
if "offer_1pct" in ctx:
|
||||
out["offer_1pct"] = int(ctx["offer_1pct"])
|
||||
return out
|
||||
|
||||
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
|
||||
if not step_key or step_key not in self.scripts:
|
||||
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
||||
node = self.scripts[step_key]
|
||||
session.step = step_key
|
||||
session.ended = bool(node.get("chat_end"))
|
||||
outcome = "success" if step_key == "협상완료" else "failure" if step_key == "협상실패" else None
|
||||
return StepView(
|
||||
step=step_key,
|
||||
script=self.repo.format_script(node.get("script", ""), self._vars(session)),
|
||||
input_mode=node.get("next_input_mode", "null"),
|
||||
input_options=node.get("input_options", []),
|
||||
chat_end=bool(node.get("chat_end")),
|
||||
client_step=self.step_map.get(step_key, step_key),
|
||||
needs_card_selection=(step_key == "가격협상"),
|
||||
outcome=outcome,
|
||||
wildcard=step_key if step_key.startswith("wild_card_") else None,
|
||||
)
|
||||
|
||||
def _error(self, session: ChatSession, msg: str) -> StepView:
|
||||
node = self.scripts.get(session.step, {})
|
||||
return StepView(
|
||||
step=session.step, script=node.get("script", ""),
|
||||
input_mode=node.get("next_input_mode", "null"), input_options=node.get("input_options", []),
|
||||
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
|
||||
)
|
||||
62
agent/negotiation/chat/service/chat_session_repository.py
Normal file
62
agent/negotiation/chat/service/chat_session_repository.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""ChatSessionRepository — /chat 세션 상태 DB 영속화 (P8-A).
|
||||
|
||||
인메모리 대신 learning.chat_sessions 에 진행 상태를 저장 → 서버 재시작/멀티워커 안전.
|
||||
company_id 스코프. ChatSession(dataclass) ↔ row 직렬화.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import ChatSessionRow
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.utils.gtime import GTime
|
||||
from negotiation.chat.service.chat_engine import ChatSession
|
||||
|
||||
|
||||
class ChatSessionRepository:
|
||||
def __init__(self, company_id: str):
|
||||
self.company_id = company_id
|
||||
|
||||
async def get(self, session_id: Optional[str]) -> Optional[ChatSession]:
|
||||
if not session_id:
|
||||
return None
|
||||
|
||||
def _q(s: AsyncSession):
|
||||
stmt = select(ChatSessionRow).where(
|
||||
ChatSessionRow.session_id == session_id,
|
||||
ChatSessionRow.company_id == self.company_id,
|
||||
).limit(1)
|
||||
return DB_SESSION_MNG.execute(s, stmt)
|
||||
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
if err != ErrorType.SUCCESS or not rows:
|
||||
return None
|
||||
r = rows[0]
|
||||
return ChatSession(
|
||||
session_id=str(r.session_id), tenant_id=r.tenant_id, company_id=r.company_id,
|
||||
rq_type=r.rq_type, step=r.step, context=dict(r.context or {}),
|
||||
used_action_ids=set(r.used_action_ids or []), action_space_size=r.action_space_size,
|
||||
ended=r.ended,
|
||||
)
|
||||
|
||||
async def save(self, session: ChatSession) -> ErrorType:
|
||||
values = dict(
|
||||
session_id=session.session_id, company_id=session.company_id, tenant_id=session.tenant_id,
|
||||
rq_type=session.rq_type, step=session.step, context=session.context,
|
||||
used_action_ids=sorted(session.used_action_ids), action_space_size=session.action_space_size,
|
||||
ended=session.ended, updated_at=GTime.UTC(),
|
||||
)
|
||||
|
||||
def _do(s: AsyncSession):
|
||||
stmt = pg_insert(ChatSessionRow.__table__).values(**values).on_conflict_do_update(
|
||||
index_elements=[ChatSessionRow.session_id],
|
||||
set_={k: values[k] for k in ("step", "context", "used_action_ids", "ended", "updated_at")},
|
||||
)
|
||||
return DB_SESSION_MNG.add(s, stmt)
|
||||
|
||||
return await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_do])
|
||||
105
agent/negotiation/chat/service/script_repository.py
Normal file
105
agent/negotiation/chat/service/script_repository.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""ScriptRepository — 테넌트 대화 스크립트 로드 + 치환 (P7 대화엔진의 데이터 계층).
|
||||
|
||||
- rq_type("재협상"|"재견적")별 스크립트 + 와일드카드 스크립트 + step 매핑 + 변수 매핑 로드.
|
||||
- 탐색 순서: tenants/<id>/resources/ → 없으면 tenants/_base/resources/ 폴백.
|
||||
- 브랜드({company_name}/{service_name})는 TenantConfig.resources 에서 주입(클린룸: 특정사 브랜드 비포함).
|
||||
- 협상 변수({input_price}, {target}, {offer_1pct} 등)는 format_script 에서 치환.
|
||||
|
||||
Chat_server 구조 참고, 동적 import 해킹/특정사 표현은 제거(CLEANROOM.md).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from common.logger import LOG
|
||||
from tenancy.config import TenantConfig
|
||||
|
||||
_RQ_FILES = {"재협상": "scripts_renegotiation.json", "재견적": "scripts_requote.json"}
|
||||
|
||||
|
||||
class ScriptRepository:
|
||||
def __init__(self, config: TenantConfig, tenants_dir: str):
|
||||
self._config = config
|
||||
self._tenants_dir = tenants_dir
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
# ---- 경로 해석 (_base 폴백) ---------------------------------------
|
||||
def _resource_path(self, filename: str) -> Optional[str]:
|
||||
scripts_dir = self._config.resources.scripts_dir
|
||||
candidates = [
|
||||
os.path.join(self._tenants_dir, self._config.tenant_id, scripts_dir, filename),
|
||||
os.path.join(self._tenants_dir, "_base", scripts_dir, filename),
|
||||
]
|
||||
for p in candidates:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
def _load_json(self, filename: str) -> dict:
|
||||
if filename in self._cache:
|
||||
return self._cache[filename]
|
||||
path = self._resource_path(filename)
|
||||
if not path:
|
||||
LOG.w(f"[ScriptRepository] resource not found: {filename} (tenant={self._config.tenant_id})")
|
||||
self._cache[filename] = {}
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.pop("_comment", None)
|
||||
self._cache[filename] = data
|
||||
return data
|
||||
|
||||
# ---- 로드 ----------------------------------------------------------
|
||||
def load_scripts(self, rq_type: str = "재협상") -> dict:
|
||||
"""rq_type 스크립트 + 와일드카드 병합(가격협상_와일드 진입용)."""
|
||||
filename = _RQ_FILES.get(rq_type, _RQ_FILES["재협상"])
|
||||
scripts = dict(self._load_json(filename))
|
||||
# 와일드카드는 재협상 흐름에 병합 (Chat_server 와 동일 동작).
|
||||
if rq_type == "재협상":
|
||||
for card_id, cfg in self._load_json("scripts_wildcard.json").items():
|
||||
scripts[card_id] = cfg
|
||||
return scripts
|
||||
|
||||
def wildcard_scripts(self) -> dict:
|
||||
return self._load_json("scripts_wildcard.json")
|
||||
|
||||
def client_step_mapping(self) -> dict:
|
||||
return self._load_json("client_step_mapping.json")
|
||||
|
||||
def variable_mapping(self) -> dict:
|
||||
return self._load_json("variable_mapping.json")
|
||||
|
||||
# ---- 치환 ----------------------------------------------------------
|
||||
def _brand_vars(self) -> Dict[str, str]:
|
||||
return {
|
||||
"company_name": self._config.resources.company_name,
|
||||
"service_name": self._config.resources.service_name,
|
||||
}
|
||||
|
||||
def format_script(self, text: str, variables: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""{company_name}/{service_name} + 협상 변수 치환. 누락 변수는 원형 유지(KeyError 방지)."""
|
||||
if not text:
|
||||
return text
|
||||
ctx = self._brand_vars()
|
||||
if variables:
|
||||
ctx.update({k: v for k, v in variables.items() if v is not None})
|
||||
|
||||
class _Safe(dict):
|
||||
def __missing__(self, key):
|
||||
return "{" + key + "}"
|
||||
|
||||
try:
|
||||
return text.format_map(_Safe(ctx))
|
||||
except (ValueError, IndexError):
|
||||
return text # 형식 토큰 충돌 시 원형
|
||||
|
||||
def get_step(self, step: str, rq_type: str = "재협상", variables: Optional[Dict[str, Any]] = None) -> Optional[dict]:
|
||||
"""step 정의를 반환하되 script 를 치환해서 돌려준다."""
|
||||
scripts = self.load_scripts(rq_type)
|
||||
node = scripts.get(step)
|
||||
if node is None:
|
||||
return None
|
||||
node = dict(node)
|
||||
node["script"] = self.format_script(node.get("script", ""), variables)
|
||||
return node
|
||||
0
agent/negotiation/orchestrator/__init__.py
Normal file
0
agent/negotiation/orchestrator/__init__.py
Normal file
0
agent/negotiation/policies/__init__.py
Normal file
0
agent/negotiation/policies/__init__.py
Normal file
108
agent/negotiation/policies/base.py
Normal file
108
agent/negotiation/policies/base.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""정책 컨텍스트/결정 + 요청 스코프 episode 상태 (계획서 B 동시성, F 추상화 출발점).
|
||||
|
||||
핵심(계획서 리스크): Chat_server 의 UCBPolicy._episode_actions 는 **인스턴스 멤버**라,
|
||||
테넌트 엔진을 여러 요청이 공유하면 세션 간 오염이 발생한다. 여기서는 episode 상태를
|
||||
**요청 스코프 객체(EpisodeState)** 로 외부화하고, 정책은 stateless 하게 이를 주입받는다.
|
||||
|
||||
NegotiationPolicy(상위 추상)의 train/predict_action_dist 등 전체 인터페이스는 H0 에서 확장한다.
|
||||
P4 는 동시성에 필요한 PolicyContext/ActionDecision/EpisodeState 만 정의한다.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Set
|
||||
|
||||
import numpy as np
|
||||
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeState:
|
||||
"""한 협상 세션(요청 흐름)의 가변 상태. 절대 정책/엔진 인스턴스에 두지 않는다.
|
||||
|
||||
used_action_ids: 이 에피소드에서 이미 제시한 action(중복 방지 마스킹용).
|
||||
"""
|
||||
|
||||
used_action_ids: Set[int] = field(default_factory=set)
|
||||
|
||||
def mark_used(self, action_id: int):
|
||||
self.used_action_ids.add(action_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyContext:
|
||||
"""정책이 행동을 고르기 위해 받는 입력. state_index(이산)와 snapshot(연속) 모두 포함
|
||||
→ Q-Table/LinUCB/Offline RL 이 같은 컨텍스트를 공유한다(계획서 F).
|
||||
"""
|
||||
|
||||
state_index: int
|
||||
snapshot: NegotiationSnapshot
|
||||
action_space_size: int
|
||||
episode: EpisodeState
|
||||
available_mask: Optional[np.ndarray] = None # None 이면 used_action_ids 로 산출
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionDecision:
|
||||
"""정책의 출력. propensity(행동확률)는 OPE 의 전제(계획서 H0)."""
|
||||
|
||||
action_id: int
|
||||
propensity: float
|
||||
card_id: Optional[str] = None
|
||||
q_value: Optional[float] = None
|
||||
ucb_score: Optional[float] = None
|
||||
available_actions: Optional[List[int]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transition:
|
||||
"""학습용 (s, a, r, s', done) + OPE 메타. experience_logs 한 row 에 대응."""
|
||||
|
||||
state_index: int
|
||||
action_id: int
|
||||
reward: float
|
||||
next_state_index: Optional[int] = None
|
||||
done: bool = False
|
||||
propensity: Optional[float] = None
|
||||
|
||||
|
||||
class NegotiationPolicy(ABC):
|
||||
"""상위 정책 추상 (계획서 F). Q-Table/LinUCB/Offline RL 이 모두 이 인터페이스를 구현한다.
|
||||
|
||||
Chat_server 의 Policy.select_action(state_index, q_values: np.ndarray, ...) 는 Q값 배열에
|
||||
결합돼 LinUCB/CQL 을 감쌀 수 없었다. 여기서는 PolicyContext(state_index+snapshot)를 받아
|
||||
ActionDecision(propensity 포함)을 돌려주는 알고리즘-중립 인터페이스로 둔다.
|
||||
"""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def select(self, ctx: PolicyContext) -> ActionDecision:
|
||||
"""행동 선택. propensity(선택확률)를 반드시 채운다(OPE 전제)."""
|
||||
|
||||
@abstractmethod
|
||||
def update(self, transition: Transition) -> None:
|
||||
"""온라인 1-스텝 갱신 (Q-learning 등). 배치 학습은 train()."""
|
||||
|
||||
def train(self, transitions: List[Transition]) -> None:
|
||||
"""오프라인 배치 학습 (기본: update 반복). 알고리즘별 override."""
|
||||
for t in transitions:
|
||||
self.update(t)
|
||||
|
||||
def predict_action_dist(self, ctx: PolicyContext) -> np.ndarray:
|
||||
"""상태에서의 행동 분포 (OPE/시뮬레이터용). 기본: 선택 액션에 1.0."""
|
||||
dist = np.zeros(ctx.action_space_size)
|
||||
dist[self.select(ctx).action_id] = 1.0
|
||||
return dist
|
||||
|
||||
def warm_start(self, other: "NegotiationPolicy") -> None:
|
||||
"""베이스 정책으로부터 초기화 (계획서 D). 기본: 미지원."""
|
||||
raise NotImplementedError
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""직렬화 가능한 파라미터 스냅샷(저장용). 알고리즘별 구현."""
|
||||
raise NotImplementedError
|
||||
|
||||
def load_snapshot(self, data: dict) -> None:
|
||||
raise NotImplementedError
|
||||
103
agent/negotiation/policies/qtable_policy.py
Normal file
103
agent/negotiation/policies/qtable_policy.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""UCBQTablePolicy — UCB 탐색 기반 Q-Table 정책 (NegotiationPolicy 구현, 우리 자체 구현).
|
||||
|
||||
select: 가용 액션 중 UCB 점수 최대 선택 (중복방지 마스킹 + propensity 산출).
|
||||
update: Q-learning 1-스텝.
|
||||
|
||||
propensity(계획서 G): UCB 는 결정론적이라 그대로면 IPS 지지(support)가 0 이 된다.
|
||||
과거 로그를 ε-greedy 근사로 본다 — 선택(greedy) 액션에 (1-ε)+ε/n, 나머지 ε/n.
|
||||
이렇게 로깅된 propensity 가 OPE(IPS/DR/SNIPS)의 입력이 된다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from negotiation.policies.base import ActionDecision, NegotiationPolicy, PolicyContext, Transition
|
||||
from negotiation.qtable.domain.model.q_table import QTable
|
||||
|
||||
|
||||
class UCBQTablePolicy(NegotiationPolicy):
|
||||
name = "qtable_ucb"
|
||||
|
||||
def __init__(self, qtable: QTable, exploration_constant: float = math.sqrt(2.0),
|
||||
epsilon: float = 0.1, mark_visits: bool = True):
|
||||
self.qtable = qtable
|
||||
self.c = exploration_constant
|
||||
self.epsilon = epsilon # propensity 근사용 ε (로깅 전용, 선택 자체는 결정론적 UCB)
|
||||
self.mark_visits = mark_visits
|
||||
|
||||
# ---- 선택 ----------------------------------------------------------
|
||||
def _available(self, ctx: PolicyContext) -> List[int]:
|
||||
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 _ucb_scores(self, state_index: int, available: List[int]) -> np.ndarray:
|
||||
q = self.qtable.row(state_index)
|
||||
visits = self.qtable.visit_row(state_index)
|
||||
total = self.qtable.state_visits(state_index)
|
||||
ln = math.log(total + 1.0)
|
||||
scores = np.full(self.qtable.action_space_size, -np.inf)
|
||||
for a in available:
|
||||
bonus = self.c * math.sqrt(ln / (visits[a] + 1e-6))
|
||||
scores[a] = q[a] + bonus
|
||||
return scores
|
||||
|
||||
def select(self, ctx: PolicyContext) -> ActionDecision:
|
||||
available = self._available(ctx)
|
||||
scores = self._ucb_scores(ctx.state_index, available)
|
||||
action_id = int(np.argmax(scores))
|
||||
n = len(available)
|
||||
# ε-greedy 근사 propensity (greedy 액션)
|
||||
propensity = (1.0 - self.epsilon) + self.epsilon / n
|
||||
if self.mark_visits:
|
||||
self.qtable.mark_visit(ctx.state_index, action_id)
|
||||
if ctx.episode:
|
||||
ctx.episode.mark_used(action_id)
|
||||
return ActionDecision(
|
||||
action_id=action_id,
|
||||
propensity=propensity,
|
||||
q_value=float(self.qtable.row(ctx.state_index)[action_id]),
|
||||
ucb_score=float(scores[action_id]),
|
||||
available_actions=available,
|
||||
)
|
||||
|
||||
# ---- 학습 ----------------------------------------------------------
|
||||
def update(self, transition: Transition) -> None:
|
||||
self.qtable.update(
|
||||
transition.state_index, transition.action_id, transition.reward,
|
||||
next_state_index=transition.next_state_index, done=transition.done,
|
||||
)
|
||||
|
||||
def predict_action_dist(self, ctx: PolicyContext) -> np.ndarray:
|
||||
"""ε-greedy 근사 분포 (OPE/시뮬레이터용)."""
|
||||
available = self._available(ctx)
|
||||
scores = self._ucb_scores(ctx.state_index, available)
|
||||
greedy = int(np.argmax(scores))
|
||||
n = len(available)
|
||||
dist = np.zeros(ctx.action_space_size)
|
||||
for a in available:
|
||||
dist[a] = self.epsilon / n
|
||||
dist[greedy] += (1.0 - self.epsilon)
|
||||
return dist
|
||||
|
||||
# ---- warm-start / 직렬화 ------------------------------------------
|
||||
def warm_start(self, other: "UCBQTablePolicy") -> None:
|
||||
if (other.qtable.state_space_size != self.qtable.state_space_size
|
||||
or other.qtable.action_space_size != self.qtable.action_space_size):
|
||||
raise ValueError("dimension mismatch — warm-start 불가 (휴리스틱 init 폴백 필요)")
|
||||
self.qtable.q = other.qtable.q.copy()
|
||||
# 탐색 여지를 위해 visit 은 감쇠 복제 (계획서 D)
|
||||
self.qtable.visits = (other.qtable.visits * 0.5).astype(np.int64)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return {"cells": self.qtable.nonzero_cells(),
|
||||
"state_space_size": self.qtable.state_space_size,
|
||||
"action_space_size": self.qtable.action_space_size}
|
||||
|
||||
def load_snapshot(self, data: dict) -> None:
|
||||
self.qtable.load_cells(data.get("cells", []))
|
||||
0
agent/negotiation/policy/__init__.py
Normal file
0
agent/negotiation/policy/__init__.py
Normal file
67
agent/negotiation/policy/model_store.py
Normal file
67
agent/negotiation/policy/model_store.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""QTablePolicyStore — learning 스키마에서 테넌트 UCBQTablePolicy 를 로드/영속화 (H1).
|
||||
|
||||
요청마다 활성 버전의 q_values/visit_counts 를 numpy QTable 로 적재해 정책을 조립한다(상태는 DB 가
|
||||
단일 소스). 갱신은 touched 셀만 write-through → 재시작/요청 간 학습이 보존된다.
|
||||
|
||||
PoC 단순화: 온라인 single-step 갱신(다음상태 부트스트랩은 outcome 종료 시 생략). 시퀀스 보상링크는
|
||||
P5/H 트랙에서 transition_id 기반으로 정교화한다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
from negotiation.policies.qtable_policy import UCBQTablePolicy
|
||||
from negotiation.qtable.domain.model.q_table import QTable
|
||||
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
|
||||
class QTablePolicyStore:
|
||||
@staticmethod
|
||||
async def load(engine: TenantEngine) -> Tuple[UCBQTablePolicy, object, LearningRepository]:
|
||||
repo = LearningRepository(engine.company_id)
|
||||
S = engine.state_space_size
|
||||
A = engine.action_space_size
|
||||
pol_cfg = engine.config.policy
|
||||
lr = pol_cfg.learning_rate
|
||||
gamma = pol_cfg.gamma
|
||||
|
||||
# cold-start 3단 (계획서 D):
|
||||
# ① 활성 버전 있으면 그대로 ② 없고 inherits_base 면 base warm-start 복제(차원 호환 시)
|
||||
# ③ 차원 불일치/base 없음 → 휴리스틱 빈 버전
|
||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||
if active is not None:
|
||||
version_id = active.version_id
|
||||
else:
|
||||
version_id = None
|
||||
if engine.config.inherits_base:
|
||||
version_id = await repo.warm_start_from_base(
|
||||
state_space_size=S, action_space_size=A, learning_rate=lr, discount_factor=gamma)
|
||||
if version_id is None:
|
||||
version_id = await repo.get_or_create_active_version(
|
||||
state_space_size=S, action_space_size=A, learning_rate=lr, discount_factor=gamma)
|
||||
|
||||
qtable = QTable(S, A, learning_rate=lr, discount_factor=gamma)
|
||||
qrows, vrows = await repo.load_cells(version_id)
|
||||
for st, a, q in qrows:
|
||||
if 0 <= st < S and 0 <= a < A:
|
||||
qtable.q[st, a] = q
|
||||
for st, a, c in vrows:
|
||||
if 0 <= st < S and 0 <= a < A:
|
||||
qtable.visits[st, a] = c
|
||||
|
||||
params = pol_cfg.params or {}
|
||||
policy = UCBQTablePolicy(
|
||||
qtable,
|
||||
exploration_constant=params.get("exploration_constant", math.sqrt(2.0)),
|
||||
epsilon=params.get("propensity_epsilon", 0.1),
|
||||
)
|
||||
return policy, version_id, repo
|
||||
|
||||
@staticmethod
|
||||
async def persist_cell(repo: LearningRepository, version_id, policy: UCBQTablePolicy,
|
||||
state_index: int, action_id: int):
|
||||
"""(state, action) 셀 write-through (select 의 visit 증가 + update 의 Q 변화 반영)."""
|
||||
q_value = float(policy.qtable.q[state_index, action_id])
|
||||
count = int(policy.qtable.visits[state_index, action_id])
|
||||
await repo.upsert_cell(version_id, state_index, action_id, q_value, count)
|
||||
12
agent/negotiation/profiling/__init__.py
Normal file
12
agent/negotiation/profiling/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""profiling 패키지 (구 N-profiling).
|
||||
|
||||
Chat_server 의 `N-profiling` 은 패키지명에 하이픈(`-`)이 들어가 정상 import 가 불가능했고,
|
||||
`chat_engine.py` 에서 `importlib.util.spec_from_file_location` + `sys.modules` 수동 등록으로
|
||||
동적 로드하는 해킹(`_load_dynamic_module`)으로 우회했다.
|
||||
|
||||
여기서는 `profiling` 으로 개명하여 정식 패키지 import 로 전환한다.
|
||||
before: ScriptModifier = _load_dynamic_module("script_modifier", "ScriptModifier")
|
||||
after : from negotiation.profiling.script_modifier import ScriptModifier
|
||||
|
||||
LLM 자격증명은 P7 에서 TenantConfig.llm 주입으로 전환한다(현재는 env 폴백).
|
||||
"""
|
||||
34
agent/negotiation/profiling/config.py
Normal file
34
agent/negotiation/profiling/config.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""LLM 자격증명 — OpenAIConfig(toml)에서 주입.
|
||||
|
||||
프로젝트 컨벤션대로 toml 로 관리한다. P7 에서 TenantConfig.llm 로 테넌트별 오버라이드를 얹을 수 있다.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmCredentials:
|
||||
"""LLM 접속 정보. provider 로 openai/azure 분기."""
|
||||
|
||||
provider: str = "openai" # "openai" | "azure"
|
||||
api_key: Optional[str] = None
|
||||
model: Optional[str] = None # openai 모델명 / azure 배포 이름
|
||||
base_url: Optional[str] = None
|
||||
azure_endpoint: Optional[str] = None
|
||||
api_version: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_config(cls) -> "LlmCredentials":
|
||||
"""server_configs.openai_config 에서 생성 (전역 기본값)."""
|
||||
from config.server_configs import openai_config as c
|
||||
return cls(
|
||||
provider=c.provider, api_key=c.api_key or None, model=c.model or None,
|
||||
base_url=c.base_url or None, azure_endpoint=c.azure_endpoint or None,
|
||||
api_version=c.api_version or None,
|
||||
)
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
if self.provider == "azure":
|
||||
return bool(self.api_key and self.azure_endpoint and self.model)
|
||||
return bool(self.api_key and self.model)
|
||||
0
agent/negotiation/profiling/domain/__init__.py
Normal file
0
agent/negotiation/profiling/domain/__init__.py
Normal file
34
agent/negotiation/profiling/domain/graph.py
Normal file
34
agent/negotiation/profiling/domain/graph.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""LangGraph 기반 대화 그래프 (구 N-profiling/domain/graph.py 이식).
|
||||
|
||||
원본 import 버그 수정: `n_profiling.llm`(존재하지 않음) → `negotiation.profiling.infra.llm_adapter`.
|
||||
chat_engine 은 이 모듈을 사용하지 않으며(script_modifier/verifier 만 사용), langgraph 는 선택 의존성이다.
|
||||
직접 import 할 때만 langgraph 가 필요하다.
|
||||
"""
|
||||
|
||||
from typing import List, TypedDict
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
|
||||
from negotiation.profiling.infra.llm_adapter import get_llm
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
"""대화 상태. messages: 대화 히스토리."""
|
||||
|
||||
messages: List[BaseMessage]
|
||||
|
||||
|
||||
def call_model(state: AgentState):
|
||||
response = get_llm().invoke(state["messages"])
|
||||
return {"messages": [response]}
|
||||
|
||||
|
||||
# 대화 상태를 메모리에 임시 저장하는 체크포인터.
|
||||
memory = SqliteSaver.from_conn_string(":memory:")
|
||||
|
||||
graph = StateGraph(AgentState)
|
||||
graph.add_node("llm", call_model)
|
||||
graph.set_entry_point("llm")
|
||||
graph.set_finish_point("llm")
|
||||
0
agent/negotiation/profiling/infra/__init__.py
Normal file
0
agent/negotiation/profiling/infra/__init__.py
Normal file
51
agent/negotiation/profiling/infra/llm_adapter.py
Normal file
51
agent/negotiation/profiling/infra/llm_adapter.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""LLM 어댑터 — openai SDK 직접 호출 (langchain 미사용).
|
||||
|
||||
provider 로 OpenAI / Azure OpenAI 분기. 무거운 import 는 호출 시점에 한다.
|
||||
chat_complete: messages → 응답 텍스트. json_mode 면 JSON 객체 강제(response_format).
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from negotiation.profiling.config import LlmCredentials
|
||||
|
||||
|
||||
def _client(creds: LlmCredentials):
|
||||
if creds.provider == "azure":
|
||||
from openai import AzureOpenAI
|
||||
return AzureOpenAI(api_key=creds.api_key, azure_endpoint=creds.azure_endpoint,
|
||||
api_version=creds.api_version or "2024-06-01")
|
||||
from openai import OpenAI
|
||||
kwargs = {"api_key": creds.api_key}
|
||||
if creds.base_url:
|
||||
kwargs["base_url"] = creds.base_url
|
||||
return OpenAI(**kwargs)
|
||||
|
||||
|
||||
def chat_complete(messages: List[dict], creds: Optional[LlmCredentials] = None,
|
||||
json_mode: bool = False, temperature: float = 0.7, max_tokens: int = 1024) -> str:
|
||||
"""OpenAI(또는 Azure) chat completion 호출 → 응답 텍스트.
|
||||
|
||||
Args:
|
||||
messages: [{"role": "system"|"user"|"assistant", "content": "..."}]
|
||||
creds: 자격증명(없으면 OpenAIConfig 전역값).
|
||||
json_mode: True 면 JSON 객체 응답 강제.
|
||||
"""
|
||||
if creds is None:
|
||||
creds = LlmCredentials.from_config()
|
||||
if not creds.is_configured():
|
||||
raise RuntimeError("LLM 미설정 (OpenAIConfig.api_key/model 확인)")
|
||||
|
||||
client = _client(creds)
|
||||
kwargs = {"model": creds.model, "messages": messages,
|
||||
"temperature": temperature, "max_tokens": max_tokens}
|
||||
if json_mode:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
resp = client.chat.completions.create(**kwargs)
|
||||
return resp.choices[0].message.content or ""
|
||||
|
||||
|
||||
def chat_json(messages: List[dict], creds: Optional[LlmCredentials] = None, **kw) -> dict:
|
||||
"""JSON 응답을 파싱해 dict 로 반환."""
|
||||
text = chat_complete(messages, creds=creds, json_mode=True, **kw)
|
||||
return json.loads(text)
|
||||
52
agent/negotiation/profiling/script_modifier.py
Normal file
52
agent/negotiation/profiling/script_modifier.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""ScriptModifier — LLM 으로 협상 스크립트 어조/내용을 다듬되 구조·치환자·스타일 보존.
|
||||
|
||||
openai SDK 직접 호출(langchain 미사용). 프롬프트 문구는 우리 자체 작성(CLEANROOM.md).
|
||||
LLM 미설정/실패 시 원본을 그대로 반환(안전 폴백).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from negotiation.profiling.config import LlmCredentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYSTEM = """역할: 협상 스크립트 문장 다듬기 도우미.
|
||||
입력은 JSON 블록 리스트다. 각 블록의 text 만 다듬고 그 외 모든 것은 그대로 둔다.
|
||||
규칙:
|
||||
- 출력은 입력과 동일 구조의 유효한 JSON 이어야 한다. {"script": [...]} 형태로 반환한다.
|
||||
- {price} 같은 치환자는 추가/삭제/변경하지 않는다. 입력에 있던 것만 유지한다.
|
||||
- color/bold 등 스타일 키와 children 배열 구조는 변경하지 않는다.
|
||||
- text 값만 요청된 어조/맥락에 맞게 자연스럽게 다시 쓴다(한국어).
|
||||
- 새로운 키를 추가하지 않는다."""
|
||||
|
||||
|
||||
class ScriptModifier:
|
||||
def __init__(self, creds: Optional[LlmCredentials] = None):
|
||||
self._creds = creds
|
||||
|
||||
def is_available(self) -> bool:
|
||||
creds = self._creds or LlmCredentials.from_config()
|
||||
return creds.is_configured()
|
||||
|
||||
def modify_script(self, original_script: list, context: dict = None) -> list:
|
||||
"""context(예: {'tone': 'polite'})에 맞춰 스크립트 텍스트 수정. 실패 시 원본 반환."""
|
||||
if not original_script or not isinstance(original_script, list):
|
||||
return original_script
|
||||
try:
|
||||
from negotiation.profiling.infra.llm_adapter import chat_json
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM},
|
||||
{"role": "user", "content":
|
||||
"원본 스크립트:\n" + json.dumps(original_script, ensure_ascii=False) +
|
||||
"\n\n맥락/지시:\n" + json.dumps(context or {}, ensure_ascii=False) +
|
||||
"\n\n위 규칙대로 다듬어 {\"script\": [...]} 로 출력."},
|
||||
]
|
||||
result = chat_json(messages, creds=self._creds)
|
||||
modified = result.get("script", result) if isinstance(result, dict) else result
|
||||
return modified if isinstance(modified, list) else original_script
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to modify script via LLM: {e}")
|
||||
return original_script
|
||||
47
agent/negotiation/profiling/script_verifier.py
Normal file
47
agent/negotiation/profiling/script_verifier.py
Normal file
@ -0,0 +1,47 @@
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScriptVerifier:
|
||||
"""수정된 스크립트가 원본의 필수 구조/데이터(파라미터 placeholder)를 유지하는지 검증.
|
||||
|
||||
구 N-profiling/script_verifier.py 이식 (로직 동일, 외부 의존성 없음).
|
||||
"""
|
||||
|
||||
def verify_script(self, original_script: list, modified_script: list) -> bool:
|
||||
if not isinstance(modified_script, list):
|
||||
logger.warning("Verification Failed: Modified script is not a list.")
|
||||
return False
|
||||
|
||||
original_params = self._extract_params(original_script)
|
||||
modified_params = self._extract_params(modified_script)
|
||||
|
||||
# 원본의 모든 파라미터가 수정본에 존재해야 한다(데이터 주입 보장).
|
||||
missing_params = original_params - modified_params
|
||||
if missing_params:
|
||||
logger.warning(f"Verification Failed: Missing parameters {missing_params}")
|
||||
return False
|
||||
|
||||
# 환각으로 추가된 파라미터(원본에 없던 변수) 금지.
|
||||
added_params = modified_params - original_params
|
||||
if added_params:
|
||||
logger.warning(f"Verification Failed: Added unknown parameters {added_params}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _extract_params(self, script: list) -> set:
|
||||
"""스크립트 구조에서 {param_name} 형태 파라미터를 추출한다."""
|
||||
params = set()
|
||||
for block in script:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
children = block.get("children", [])
|
||||
for child in children:
|
||||
text = child.get("text", "")
|
||||
matches = re.findall(r"\{([^}]+)\}", text)
|
||||
for match in matches:
|
||||
params.add(match)
|
||||
return params
|
||||
0
agent/negotiation/qtable/__init__.py
Normal file
0
agent/negotiation/qtable/__init__.py
Normal file
0
agent/negotiation/qtable/domain/__init__.py
Normal file
0
agent/negotiation/qtable/domain/__init__.py
Normal file
0
agent/negotiation/qtable/domain/model/__init__.py
Normal file
0
agent/negotiation/qtable/domain/model/__init__.py
Normal file
66
agent/negotiation/qtable/domain/model/q_table.py
Normal file
66
agent/negotiation/qtable/domain/model/q_table.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Q-Table — 이산 (state, action) 가치표 + 방문횟수 (우리 자체 numpy 구현).
|
||||
|
||||
클린룸: Q-learning(off-policy TD) 과 UCB 는 표준 알고리즘(아이디어)이고, 아래 구현은 우리 작성이다.
|
||||
state_space_size x action_space_size 밀집 행렬. 차원은 TenantConfig 에서 산출된 값을 주입.
|
||||
"""
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class QTable:
|
||||
def __init__(self, state_space_size: int, action_space_size: int,
|
||||
learning_rate: float = 0.1, discount_factor: float = 0.95):
|
||||
self.state_space_size = state_space_size
|
||||
self.action_space_size = action_space_size
|
||||
self.lr = learning_rate
|
||||
self.gamma = discount_factor
|
||||
self.q = np.zeros((state_space_size, action_space_size), dtype=float)
|
||||
self.visits = np.zeros((state_space_size, action_space_size), dtype=np.int64)
|
||||
|
||||
# ---- 접근 ----------------------------------------------------------
|
||||
def row(self, state_index: int) -> np.ndarray:
|
||||
return self.q[state_index]
|
||||
|
||||
def visit_row(self, state_index: int) -> np.ndarray:
|
||||
return self.visits[state_index]
|
||||
|
||||
def state_visits(self, state_index: int) -> int:
|
||||
return int(self.visits[state_index].sum())
|
||||
|
||||
def best_action(self, state_index: int) -> int:
|
||||
return int(np.argmax(self.q[state_index]))
|
||||
|
||||
# ---- 갱신 ----------------------------------------------------------
|
||||
def mark_visit(self, state_index: int, action_id: int):
|
||||
self.visits[state_index, action_id] += 1
|
||||
|
||||
def update(self, state_index: int, action_id: int, reward: float,
|
||||
next_state_index: int = None, done: bool = False) -> float:
|
||||
"""Q-learning 1-스텝 갱신. 반환: 갱신 후 Q[s,a].
|
||||
|
||||
target = reward + (0 if done/next 없음 else gamma * max_a' Q[s',a'])
|
||||
Q[s,a] += lr * (target - Q[s,a])
|
||||
"""
|
||||
bootstrap = 0.0
|
||||
if not done and next_state_index is not None:
|
||||
bootstrap = self.gamma * float(np.max(self.q[next_state_index]))
|
||||
td_target = reward + bootstrap
|
||||
self.q[state_index, action_id] += self.lr * (td_target - self.q[state_index, action_id])
|
||||
return float(self.q[state_index, action_id])
|
||||
|
||||
# ---- 직렬화 (희소: 0 아닌 셀만) ------------------------------------
|
||||
def nonzero_cells(self) -> List[Tuple[int, int, float, int]]:
|
||||
"""(state_index, action_id, q_value, visit_count) — q 또는 visit 가 0 이 아닌 셀."""
|
||||
out = []
|
||||
nz = np.argwhere((self.q != 0) | (self.visits != 0))
|
||||
for s, a in nz:
|
||||
out.append((int(s), int(a), float(self.q[s, a]), int(self.visits[s, a])))
|
||||
return out
|
||||
|
||||
def load_cells(self, cells: List[Tuple[int, int, float, int]]):
|
||||
for s, a, q, v in cells:
|
||||
if 0 <= s < self.state_space_size and 0 <= a < self.action_space_size:
|
||||
self.q[s, a] = q
|
||||
self.visits[s, a] = v
|
||||
49
agent/negotiation/qtable/domain/model/snapshot.py
Normal file
49
agent/negotiation/qtable/domain/model/snapshot.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""NegotiationSnapshot — 한 협상 의사결정 시점의 관측치 (우리 자체 스키마).
|
||||
|
||||
이산 상태(state_index)와 연속 feature 가 한 곳에 공존한다. experience_logs.snapshot(JSON)에
|
||||
저장되어 3개 알고리즘(Q-Table/LinUCB/Offline RL)이 같은 데이터를 공유한다(계획서 핵심통찰).
|
||||
|
||||
클린룸: 필드 구성은 우리 설계다. 상태 산출에 필요한 관측치 + reward 계산 입력을 담는다.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class NegotiationOutcome(str, Enum):
|
||||
"""협상 라운드 결과 (reward 계산 입력)."""
|
||||
|
||||
ONGOING = "ongoing"
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NegotiationSnapshot:
|
||||
# --- 이산 상태 산출 입력 ---
|
||||
revenue_amount: float # 매출액(원)
|
||||
distribution_code: str # 유통 구조 외부 코드 (테넌트 code_map 으로 해석)
|
||||
partner_count: int # 파트너사 수
|
||||
acceptance_ratio: float # 가격 수용률 (0~1)
|
||||
input_price: float # 현재 제시/입력 가격
|
||||
anchor_price: float # 앵커(시작) 가격
|
||||
target_price: float # 목표 가격
|
||||
|
||||
# --- 시퀀스/보상 컨텍스트 ---
|
||||
round_number: int = 0 # 협상 라운드(turn)
|
||||
outcome: NegotiationOutcome = NegotiationOutcome.ONGOING
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = asdict(self)
|
||||
d["outcome"] = self.outcome.value
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "NegotiationSnapshot":
|
||||
d = dict(d)
|
||||
outcome = d.get("outcome", NegotiationOutcome.ONGOING.value)
|
||||
d["outcome"] = NegotiationOutcome(outcome) if not isinstance(outcome, NegotiationOutcome) else outcome
|
||||
# 알 수 없는 키는 무시(스키마 진화 내성).
|
||||
allowed = cls.__dataclass_fields__.keys()
|
||||
return cls(**{k: v for k, v in d.items() if k in allowed})
|
||||
44
agent/negotiation/qtable/domain/model/state.py
Normal file
44
agent/negotiation/qtable/domain/model/state.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""이산 협상 State 모델 (우리 자체 구현, config 주입형).
|
||||
|
||||
Chat_server 의 state.py 는 임계값/가중치를 IntEnum 클래스변수로 박아 config 주입이 불가능했다.
|
||||
여기서는 임계값/가중치/코드맵을 모두 TenantConfig.state(StateConfig)에서 주입받고,
|
||||
State 는 차원 인덱스 튜플만 보유하는 순수 값객체로 둔다.
|
||||
|
||||
state_index 는 mixed-radix(혼합 진법) 인코딩으로 차원 곱 공간에 매핑한다 — 차원 개수가
|
||||
회사마다 달라도 일반적으로 동작한다(기능적 방법).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class State:
|
||||
"""5개 이산 차원 인덱스. 각 인덱스는 해당 차원의 [0, dim) 범위."""
|
||||
|
||||
revenue_idx: int
|
||||
distribution_idx: int
|
||||
partner_idx: int
|
||||
acceptance_idx: int
|
||||
price_zone_idx: int
|
||||
|
||||
def to_tuple(self) -> tuple[int, int, int, int, int]:
|
||||
return (self.revenue_idx, self.distribution_idx, self.partner_idx, self.acceptance_idx, self.price_zone_idx)
|
||||
|
||||
|
||||
def encode_index(indices: List[int], dims: List[int]) -> int:
|
||||
"""mixed-radix 인코딩: indices 를 dims 진법으로 단일 정수에 매핑.
|
||||
|
||||
idx = ((...(i0)*d1 + i1)*d2 + i2)...)*d_{n-1} + i_{n-1}
|
||||
범위/차원 검증 포함 — 잘못된 입력은 즉시 실패(학습 차원 오염 방지).
|
||||
"""
|
||||
if len(indices) != len(dims):
|
||||
raise ValueError(f"indices/dims length mismatch: {len(indices)} vs {len(dims)}")
|
||||
idx = 0
|
||||
for i, (val, dim) in enumerate(zip(indices, dims)):
|
||||
if dim <= 0:
|
||||
raise ValueError(f"dim[{i}] must be positive, got {dim}")
|
||||
if not (0 <= val < dim):
|
||||
raise ValueError(f"index[{i}]={val} out of range [0,{dim})")
|
||||
idx = idx * dim + val
|
||||
return idx
|
||||
0
agent/negotiation/qtable/domain/service/__init__.py
Normal file
0
agent/negotiation/qtable/domain/service/__init__.py
Normal file
74
agent/negotiation/qtable/domain/service/reward_calculator.py
Normal file
74
agent/negotiation/qtable/domain/service/reward_calculator.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""RewardCalculator — 협상 라운드 보상 계산 (우리 자체 공식, RewardConfig 주입).
|
||||
|
||||
클린룸: 보상 공식의 '형태'(가격보상 + 종료보상 - 페널티, 동적 가중치)는 기능적 아이디어이고,
|
||||
아래 산식은 우리 자체 설계다(특정 고객 산식 복제 아님). 모든 계수는 RewardConfig 에서 주입.
|
||||
|
||||
PoC 의도: 학습 루프가 협상 성과(타결 여부·타결가·턴 수)를 보상으로 흡수하는지 검증.
|
||||
산식은 결정론적이며 RewardConfig 로 튜닝 가능하다.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||
from tenancy.config import RewardConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RewardBreakdown:
|
||||
"""보상 구성요소(투명성/디버깅용). total 이 학습에 쓰인다."""
|
||||
|
||||
price_reward: float
|
||||
end_reward: float
|
||||
penalty: float
|
||||
weight: float
|
||||
total: float
|
||||
|
||||
|
||||
def _clip(v: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
class RewardCalculator:
|
||||
def __init__(self, config: RewardConfig):
|
||||
self._cfg = config
|
||||
|
||||
def _price_reward(self, snapshot: NegotiationSnapshot) -> float:
|
||||
"""KT 구매자 관점: 협력사 제시가가 낮을수록 보상↑ (anchor 앵커링가 < target 목표 매입가).
|
||||
|
||||
progress = clip((target - input) / (target - anchor), 0, 1).
|
||||
제시가가 목표가면 0, 앵커링가까지 내려오면 1, 더 낮으면 1로 캡.
|
||||
"""
|
||||
band = snapshot.target_price - snapshot.anchor_price
|
||||
if band <= 0:
|
||||
return 0.0
|
||||
return _clip((snapshot.target_price - snapshot.input_price) / band, 0.0, 1.0)
|
||||
|
||||
def _end_reward(self, outcome: NegotiationOutcome) -> float:
|
||||
if outcome == NegotiationOutcome.SUCCESS:
|
||||
return self._cfg.success_reward
|
||||
if outcome == NegotiationOutcome.FAILURE:
|
||||
return self._cfg.failure_penalty
|
||||
return self._cfg.ongoing_reward
|
||||
|
||||
def _dynamic_weight(self, snapshot: NegotiationSnapshot) -> float:
|
||||
"""라운드가 진행될수록 가중치를 beta 만큼 감쇠해 [min_weight, max_weight] 로 클립.
|
||||
|
||||
w = max_weight - beta * round_number → 초반 라운드일수록 가격보상을 더 크게 본다.
|
||||
(w1~w5 는 차원별 가중치로 P-후속 단계에서 state 차원 중요도에 결합 예정.)
|
||||
"""
|
||||
w = self._cfg.max_weight - self._cfg.beta * max(0, snapshot.round_number)
|
||||
return _clip(w, self._cfg.min_weight, self._cfg.max_weight)
|
||||
|
||||
def calculate(self, snapshot: NegotiationSnapshot) -> RewardBreakdown:
|
||||
price_reward = self._price_reward(snapshot)
|
||||
end_reward = self._end_reward(snapshot.outcome)
|
||||
weight = self._dynamic_weight(snapshot)
|
||||
penalty = self._cfg.penalty_lambda * max(0, snapshot.round_number)
|
||||
total = weight * price_reward + end_reward - penalty
|
||||
return RewardBreakdown(
|
||||
price_reward=price_reward,
|
||||
end_reward=end_reward,
|
||||
penalty=penalty,
|
||||
weight=weight,
|
||||
total=total,
|
||||
)
|
||||
89
agent/negotiation/qtable/domain/service/state_calculator.py
Normal file
89
agent/negotiation/qtable/domain/service/state_calculator.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""build_state — NegotiationSnapshot + StateConfig → State/state_index (우리 자체 구현).
|
||||
|
||||
각 차원 인덱스 산출 규칙(기능적 방법)은 우리 설계이며, 임계값/코드맵은 config 주입이다.
|
||||
도메인은 tenant-agnostic: 같은 config + 같은 snapshot 이면 항상 같은 출력(결정론).
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
|
||||
from negotiation.qtable.domain.model.state import State, encode_index
|
||||
from tenancy.config import StateConfig
|
||||
|
||||
|
||||
def _threshold_bucket(value: float, thresholds: List[float]) -> int:
|
||||
"""thresholds 경계로 구간 인덱스 산출. value <= thresholds[i] 이면 i, 모두 초과면 마지막 구간.
|
||||
|
||||
경계 포함 규칙: value <= threshold → 해당 구간(하한). (예: th=[10,30] → ≤10:0, ≤30:1, else:2)
|
||||
"""
|
||||
for i, th in enumerate(thresholds):
|
||||
if value <= th:
|
||||
return i
|
||||
return len(thresholds) # 마지막 구간 (= dim-1, dim = len(thresholds)+1)
|
||||
|
||||
|
||||
def _acceptance_bucket(ratio: float, thresholds: List[float]) -> int:
|
||||
"""수용률 구간: < thresholds[0] → 0, <= thresholds[1] → 1, ... else 마지막.
|
||||
|
||||
하한은 strict-less, 이후 경계는 inclusive (low 는 미만, mid 이상은 이하).
|
||||
"""
|
||||
if ratio < thresholds[0]:
|
||||
return 0
|
||||
for i in range(1, len(thresholds)):
|
||||
if ratio <= thresholds[i]:
|
||||
return i
|
||||
return len(thresholds)
|
||||
|
||||
|
||||
def _partner_bucket(count: int) -> int:
|
||||
"""파트너 수 → 인덱스. 규약: single=0, multiple=1, none=2."""
|
||||
if count <= 0:
|
||||
return 2 # none
|
||||
if count == 1:
|
||||
return 0 # single
|
||||
return 1 # multiple
|
||||
|
||||
|
||||
def _price_zone_bucket(input_price: float, anchor_price: float, target_price: float) -> int:
|
||||
"""입력가격 구간 (KT 구매자 관점, anchor=협력사 기준가 ≥ target=KT 목표 매입가).
|
||||
|
||||
협력사 제시가가 앵커가 이하면 우선협상 가능 구간(0), 초과면 추가 협상 구간(1).
|
||||
"""
|
||||
if anchor_price <= 0 or target_price <= 0:
|
||||
raise ValueError("anchor_price/target_price must be positive")
|
||||
if input_price <= anchor_price:
|
||||
return 0 # at_or_below_anchor (우선협상 가능)
|
||||
return 1 # above_anchor (협상 지속)
|
||||
|
||||
|
||||
def state_dims(cfg: StateConfig) -> List[int]:
|
||||
"""각 차원의 크기. config 의 weights/code_map 길이로 결정."""
|
||||
return [
|
||||
len(cfg.revenue.weights),
|
||||
len(cfg.distribution.weights),
|
||||
len(cfg.partner.weights),
|
||||
len(cfg.acceptance.weights),
|
||||
len(cfg.price_zone.weights),
|
||||
]
|
||||
|
||||
|
||||
def build_state(snapshot: NegotiationSnapshot, cfg: StateConfig) -> State:
|
||||
"""snapshot 을 config 기준으로 이산 State 로 변환."""
|
||||
revenue_idx = _threshold_bucket(snapshot.revenue_amount, cfg.revenue.thresholds)
|
||||
|
||||
code = (snapshot.distribution_code or "").strip()
|
||||
if code not in cfg.distribution.code_map:
|
||||
raise ValueError(f"unknown distribution code: {snapshot.distribution_code!r} (code_map keys={list(cfg.distribution.code_map)})")
|
||||
distribution_idx = cfg.distribution.code_map[code]
|
||||
|
||||
partner_idx = _partner_bucket(snapshot.partner_count)
|
||||
acceptance_idx = _acceptance_bucket(snapshot.acceptance_ratio, cfg.acceptance.thresholds)
|
||||
price_zone_idx = _price_zone_bucket(snapshot.input_price, snapshot.anchor_price, snapshot.target_price)
|
||||
|
||||
return State(revenue_idx, distribution_idx, partner_idx, acceptance_idx, price_zone_idx)
|
||||
|
||||
|
||||
def state_index(snapshot: NegotiationSnapshot, cfg: StateConfig) -> int:
|
||||
"""snapshot → 단일 정수 state_index (mixed-radix)."""
|
||||
st = build_state(snapshot, cfg)
|
||||
return encode_index(list(st.to_tuple()), state_dims(cfg))
|
||||
0
agent/negotiation/qtable/infra/__init__.py
Normal file
0
agent/negotiation/qtable/infra/__init__.py
Normal file
339
agent/negotiation/qtable/infra/repository/learning_repository.py
Normal file
339
agent/negotiation/qtable/infra/repository/learning_repository.py
Normal file
@ -0,0 +1,339 @@
|
||||
"""LearningRepository — learning.* 테이블 접근 (company_id 스코프, backend CRUD 컨벤션).
|
||||
|
||||
핵심 안전장치(계획서 리스크): company_id 를 **생성자에 박아** 모든 쿼리에 자동 주입한다.
|
||||
삭제/리셋도 ORM core where(company_id=...)로만 수행 → "raw SQL 전체삭제로 타사 데이터 파괴" 불가.
|
||||
메서드는 backend crud 처럼 (session, ...) 을 받고, service 는 DB_SESSION_MNG 람다로 호출한다.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import (
|
||||
BASE_COMPANY_ID,
|
||||
ExperienceLog,
|
||||
QTableVersion,
|
||||
QValue,
|
||||
TenantActionCard,
|
||||
VisitCount,
|
||||
)
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
class LearningRepository:
|
||||
def __init__(self, company_id: str):
|
||||
if not company_id:
|
||||
raise ValueError("company_id is required (테넌트 스코프 필수)")
|
||||
self.company_id = company_id
|
||||
|
||||
# ---- 버전 ----------------------------------------------------------
|
||||
async def create_version(self, s: AsyncSession, *, version_name: str, scope: int,
|
||||
state_space_size: int, action_space_size: int,
|
||||
learning_rate: float = 0.1, discount_factor: float = 0.95,
|
||||
base_version_id=None, is_active: bool = False) -> ErrorType:
|
||||
obj = QTableVersion(
|
||||
company_id=self.company_id,
|
||||
version_name=version_name,
|
||||
scope=scope,
|
||||
base_version_id=base_version_id,
|
||||
state_space_size=state_space_size,
|
||||
action_space_size=action_space_size,
|
||||
learning_rate=learning_rate,
|
||||
discount_factor=discount_factor,
|
||||
is_active=is_active,
|
||||
)
|
||||
return await DB_SESSION_MNG.insert(s, obj)
|
||||
|
||||
async def get_version_by_name(self, s: AsyncSession, version_name: str) -> Tuple[ErrorType, Optional[QTableVersion]]:
|
||||
q = (
|
||||
select(QTableVersion)
|
||||
.where(QTableVersion.company_id == self.company_id, QTableVersion.version_name == version_name, QTableVersion.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(s, q)
|
||||
if err != ErrorType.SUCCESS:
|
||||
return err, None
|
||||
return ErrorType.SUCCESS, rows[0] if rows else None
|
||||
|
||||
async def list_versions(self, s: AsyncSession) -> Tuple[ErrorType, List[QTableVersion]]:
|
||||
q = (
|
||||
select(QTableVersion)
|
||||
.where(QTableVersion.company_id == self.company_id, QTableVersion.deleted == False) # noqa: E712
|
||||
.order_by(QTableVersion.created_at.asc())
|
||||
)
|
||||
return await DB_SESSION_MNG.execute(s, q)
|
||||
|
||||
async def get_active_version(self, s: AsyncSession) -> Tuple[ErrorType, Optional[QTableVersion]]:
|
||||
q = (
|
||||
select(QTableVersion)
|
||||
.where(QTableVersion.company_id == self.company_id, QTableVersion.is_active == True, QTableVersion.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(s, q)
|
||||
if err != ErrorType.SUCCESS:
|
||||
return err, None
|
||||
return ErrorType.SUCCESS, rows[0] if rows else None
|
||||
|
||||
# ---- 경험 로그 -----------------------------------------------------
|
||||
async def log_transition(self, s: AsyncSession, data: Dict[str, Any]) -> ErrorType:
|
||||
"""data 의 알려진 필드만 채워 ExperienceLog insert. company_id 는 항상 강제 주입."""
|
||||
allowed = {c.name for c in ExperienceLog.__table__.columns}
|
||||
payload = {k: v for k, v in data.items() if k in allowed}
|
||||
payload["company_id"] = self.company_id # 위조 방지: 항상 덮어쓴다
|
||||
return await DB_SESSION_MNG.insert(s, ExperienceLog(**payload))
|
||||
|
||||
async def update_transition_reward(self, s: AsyncSession, transition_id, *, reward: float,
|
||||
next_state_index: Optional[int] = None, done: bool = False,
|
||||
settled_price: Optional[int] = None) -> ErrorType:
|
||||
q = (
|
||||
update(ExperienceLog)
|
||||
.where(ExperienceLog.company_id == self.company_id, ExperienceLog.transition_id == transition_id)
|
||||
.values(reward=reward, next_state_index=next_state_index, done=done, settled_price=settled_price)
|
||||
)
|
||||
return await DB_SESSION_MNG.add(s, q)
|
||||
|
||||
async def count_experience(self, s: AsyncSession) -> Tuple[ErrorType, int]:
|
||||
q = select(ExperienceLog).where(ExperienceLog.company_id == self.company_id)
|
||||
err, rows = await DB_SESSION_MNG.execute(s, q)
|
||||
if err != ErrorType.SUCCESS:
|
||||
return err, 0
|
||||
return ErrorType.SUCCESS, len(rows)
|
||||
|
||||
async def invalidate_by_session(self, s: AsyncSession, session_id, reason: str = "") -> ErrorType:
|
||||
q = (
|
||||
update(ExperienceLog)
|
||||
.where(ExperienceLog.company_id == self.company_id, ExperienceLog.session_id == session_id)
|
||||
.values(is_invalidated=True, invalidated_reason=reason)
|
||||
)
|
||||
return await DB_SESSION_MNG.add(s, q)
|
||||
|
||||
# ---- 리셋 (스코프 강제, 타테넌트 무영향) ---------------------------
|
||||
async def _delete_experience(self, s: AsyncSession) -> ErrorType:
|
||||
# 계획서 리스크: 구 코드의 'DELETE FROM experience_logs' (WHERE 없음)를 대체.
|
||||
return await DB_SESSION_MNG.add(s, delete(ExperienceLog).where(ExperienceLog.company_id == self.company_id))
|
||||
|
||||
async def _delete_learning_rows(self, s: AsyncSession) -> ErrorType:
|
||||
for model in (QValue, VisitCount):
|
||||
err = await DB_SESSION_MNG.add(s, delete(model).where(model.company_id == self.company_id))
|
||||
if err != ErrorType.SUCCESS:
|
||||
return err
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
async def _delete_nonactive_versions(self, s: AsyncSession) -> ErrorType:
|
||||
return await DB_SESSION_MNG.add(
|
||||
s,
|
||||
delete(QTableVersion).where(QTableVersion.company_id == self.company_id, QTableVersion.is_active == False), # noqa: E712
|
||||
)
|
||||
|
||||
async def reset_learning(self) -> ErrorType:
|
||||
"""학습 데이터(q_values/visit_counts/experience)만 초기화. 자사 스코프."""
|
||||
return await DB_SESSION_MNG.execute_lambda_run(
|
||||
[DBType.MAIN.value],
|
||||
[self._delete_learning_rows, self._delete_experience],
|
||||
)
|
||||
|
||||
async def reset_all(self) -> ErrorType:
|
||||
"""완전 초기화: 자사 experience 전체 + q_values/visit + 비활성 버전 삭제. 타테넌트 무영향."""
|
||||
return await DB_SESSION_MNG.execute_lambda_run(
|
||||
[DBType.MAIN.value],
|
||||
[self._delete_experience, self._delete_learning_rows, self._delete_nonactive_versions],
|
||||
)
|
||||
|
||||
# ---- 람다 facade (service/test 용) ---------------------------------
|
||||
async def read(self, func):
|
||||
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, func)
|
||||
|
||||
async def write_one(self, func):
|
||||
return await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [func])
|
||||
|
||||
# ---- Q-Table 영속화 (H1) -------------------------------------------
|
||||
async def get_or_create_active_version(self, *, state_space_size: int, action_space_size: int,
|
||||
learning_rate: float, discount_factor: float,
|
||||
scope: int = 2, version_name: str = "v000") -> Optional[uuid.UUID]:
|
||||
"""활성 버전 version_id 반환. 없으면 v000 을 활성으로 생성. (company_id 스코프)"""
|
||||
err, existing = await DB_SESSION_MNG.execute_lambda(
|
||||
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: self.get_active_version(s)
|
||||
)
|
||||
if err == ErrorType.SUCCESS and existing is not None:
|
||||
return existing.version_id
|
||||
|
||||
vid = uuid.uuid4()
|
||||
|
||||
async def _create(s: AsyncSession) -> ErrorType:
|
||||
obj = QTableVersion(
|
||||
version_id=vid, company_id=self.company_id, version_name=version_name, scope=scope,
|
||||
state_space_size=state_space_size, action_space_size=action_space_size,
|
||||
learning_rate=learning_rate, discount_factor=discount_factor, is_active=True,
|
||||
)
|
||||
return await DB_SESSION_MNG.insert(s, obj)
|
||||
|
||||
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_create])
|
||||
if err != ErrorType.SUCCESS:
|
||||
# 동시 생성 경합 등 → 다시 읽어 활성 버전 반환
|
||||
err2, existing2 = await DB_SESSION_MNG.execute_lambda(
|
||||
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: self.get_active_version(s)
|
||||
)
|
||||
return existing2.version_id if existing2 else None
|
||||
return vid
|
||||
|
||||
async def warm_start_from_base(self, *, state_space_size: int, action_space_size: int,
|
||||
learning_rate: float, discount_factor: float, visit_decay: float = 0.5,
|
||||
version_name: str = "v000_warmstart_from_base") -> Optional[uuid.UUID]:
|
||||
"""공유 베이스(_base)의 Q값/방문수를 자사로 복제해 활성 버전 생성 (cold-start ①, 계획서 D).
|
||||
|
||||
차원 불일치/베이스 없음 → None (호출자가 휴리스틱 init 폴백). visit 은 감쇠 복제(탐색 여지).
|
||||
"""
|
||||
base_repo = LearningRepository(BASE_COMPANY_ID)
|
||||
err, base_ver = await DB_SESSION_MNG.execute_lambda(
|
||||
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: base_repo.get_active_version(s))
|
||||
if err != ErrorType.SUCCESS or base_ver is None:
|
||||
return None
|
||||
if base_ver.state_space_size != state_space_size or base_ver.action_space_size != action_space_size:
|
||||
return None # 차원 불일치 → 휴리스틱 폴백
|
||||
|
||||
qcells, vcells = await base_repo.load_cells(base_ver.version_id)
|
||||
vid = uuid.uuid4()
|
||||
|
||||
async def _create(s: AsyncSession) -> ErrorType:
|
||||
ver = QTableVersion(
|
||||
version_id=vid, company_id=self.company_id, version_name=version_name, scope=2,
|
||||
base_version_id=base_ver.version_id, state_space_size=state_space_size,
|
||||
action_space_size=action_space_size, learning_rate=learning_rate,
|
||||
discount_factor=discount_factor, is_active=True,
|
||||
)
|
||||
e = await DB_SESSION_MNG.insert(s, ver)
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
qobjs = [QValue(company_id=self.company_id, version_id=vid, state_index=st, action_id=a, q_value=q)
|
||||
for st, a, q in qcells]
|
||||
vobjs = [VisitCount(company_id=self.company_id, version_id=vid, state_index=st, action_id=a,
|
||||
count=int(c * visit_decay)) for st, a, c in vcells]
|
||||
if qobjs:
|
||||
e = await DB_SESSION_MNG.insert(s, qobjs)
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
if vobjs:
|
||||
e = await DB_SESSION_MNG.insert(s, vobjs)
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_create])
|
||||
return vid if err == ErrorType.SUCCESS else None
|
||||
|
||||
async def load_cells(self, version_id) -> Tuple[List[tuple], List[tuple]]:
|
||||
"""(q_cells, visit_cells) — q_cells: (state,action,q), visit_cells: (state,action,count). 자사 스코프."""
|
||||
def _q(s):
|
||||
q = select(QValue.state_index, QValue.action_id, QValue.q_value).where(
|
||||
QValue.company_id == self.company_id, QValue.version_id == version_id)
|
||||
return DB_SESSION_MNG.execute(s, q)
|
||||
|
||||
def _v(s):
|
||||
q = select(VisitCount.state_index, VisitCount.action_id, VisitCount.count).where(
|
||||
VisitCount.company_id == self.company_id, VisitCount.version_id == version_id)
|
||||
return DB_SESSION_MNG.execute(s, q)
|
||||
|
||||
err, qrows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
err, vrows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _v)
|
||||
return list(qrows), list(vrows)
|
||||
|
||||
# ---- 버전 전환 / 요약 (관리 API) ----------------------------------
|
||||
async def set_active_version(self, version_name: str) -> ErrorType:
|
||||
"""version_name 을 활성으로 전환(나머지 비활성). 자사 스코프."""
|
||||
err, target = await DB_SESSION_MNG.execute_lambda(
|
||||
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: self.get_version_by_name(s, version_name))
|
||||
if err != ErrorType.SUCCESS or target is None:
|
||||
return ErrorType.DB_INVALID_KEY
|
||||
|
||||
async def _deactivate(s):
|
||||
return await DB_SESSION_MNG.add(s, update(QTableVersion)
|
||||
.where(QTableVersion.company_id == self.company_id).values(is_active=False))
|
||||
|
||||
async def _activate(s):
|
||||
return await DB_SESSION_MNG.add(s, update(QTableVersion)
|
||||
.where(QTableVersion.company_id == self.company_id, QTableVersion.version_name == version_name)
|
||||
.values(is_active=True))
|
||||
|
||||
return await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_deactivate, _activate])
|
||||
|
||||
async def qvalue_count(self, version_id) -> int:
|
||||
def _q(s):
|
||||
return DB_SESSION_MNG.execute(s, select(QValue.id).where(
|
||||
QValue.company_id == self.company_id, QValue.version_id == version_id))
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
return len(rows)
|
||||
|
||||
async def recent_experience(self, limit: int = 50) -> List[dict]:
|
||||
def _q(s):
|
||||
return DB_SESSION_MNG.execute(s, select(
|
||||
ExperienceLog.transition_id, ExperienceLog.session_id, ExperienceLog.state_index,
|
||||
ExperienceLog.action_id, ExperienceLog.card_id, ExperienceLog.reward, ExperienceLog.turn,
|
||||
ExperienceLog.propensity, ExperienceLog.done, ExperienceLog.created_at,
|
||||
).where(ExperienceLog.company_id == self.company_id).order_by(ExperienceLog.id.desc()).limit(limit))
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
return [dict(state_index=r[2], action_id=r[3], card_id=r[4], reward=r[5], turn=r[6],
|
||||
propensity=r[7], done=r[8], session_id=str(r[1]) if r[1] else None,
|
||||
transition_id=str(r[0]), created_at=str(r[9])) for r in rows]
|
||||
|
||||
async def load_transitions(self) -> List[tuple]:
|
||||
"""학습용 (state_index, action_id, reward, next_state_index, done). reward 있는 row 만."""
|
||||
def _q(s):
|
||||
return DB_SESSION_MNG.execute(s, select(
|
||||
ExperienceLog.state_index, ExperienceLog.action_id, ExperienceLog.reward,
|
||||
ExperienceLog.next_state_index, ExperienceLog.done,
|
||||
).where(ExperienceLog.company_id == self.company_id, ExperienceLog.reward.isnot(None),
|
||||
ExperienceLog.is_invalidated == False)) # noqa: E712
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
return [(r[0], r[1], r[2], r[3], r[4]) for r in rows]
|
||||
|
||||
# ---- 카드 매핑 (card-update/search) -------------------------------
|
||||
async def upsert_action_card(self, action_id: int, card_id: str) -> ErrorType:
|
||||
async def _do(s):
|
||||
q = select(TenantActionCard).where(
|
||||
TenantActionCard.company_id == self.company_id, TenantActionCard.action_id == action_id,
|
||||
TenantActionCard.deleted == False) # noqa: E712
|
||||
err, rows = await DB_SESSION_MNG.execute(s, q)
|
||||
if rows:
|
||||
return await DB_SESSION_MNG.add(s, update(TenantActionCard)
|
||||
.where(TenantActionCard.company_id == self.company_id, TenantActionCard.action_id == action_id)
|
||||
.values(card_id=card_id, updated_at=GTime.UTC()))
|
||||
return await DB_SESSION_MNG.insert(s, TenantActionCard(
|
||||
company_id=self.company_id, action_id=action_id, card_id=card_id))
|
||||
return await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_do])
|
||||
|
||||
async def get_action_cards(self) -> List[dict]:
|
||||
def _q(s):
|
||||
return DB_SESSION_MNG.execute(s, select(TenantActionCard.action_id, TenantActionCard.card_id)
|
||||
.where(TenantActionCard.company_id == self.company_id, TenantActionCard.deleted == False)) # noqa: E712
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
return [dict(action_id=r[0], card_id=r[1]) for r in rows]
|
||||
|
||||
async def upsert_cell(self, version_id, state_index: int, action_id: int, q_value: float, count: int) -> ErrorType:
|
||||
"""단일 (state, action) 셀 write-through (q_values + visit_counts). company_id 스코프."""
|
||||
def _q(s):
|
||||
stmt = pg_insert(QValue.__table__).values(
|
||||
company_id=self.company_id, version_id=version_id,
|
||||
state_index=state_index, action_id=action_id, q_value=q_value,
|
||||
).on_conflict_do_update(
|
||||
index_elements=[QValue.company_id, QValue.version_id, QValue.state_index, QValue.action_id],
|
||||
set_={"q_value": q_value},
|
||||
)
|
||||
return DB_SESSION_MNG.add(s, stmt)
|
||||
|
||||
def _v(s):
|
||||
stmt = pg_insert(VisitCount.__table__).values(
|
||||
company_id=self.company_id, version_id=version_id,
|
||||
state_index=state_index, action_id=action_id, count=count,
|
||||
).on_conflict_do_update(
|
||||
index_elements=[VisitCount.company_id, VisitCount.version_id, VisitCount.state_index, VisitCount.action_id],
|
||||
set_={"count": count},
|
||||
)
|
||||
return DB_SESSION_MNG.add(s, stmt)
|
||||
|
||||
return await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_q, _v])
|
||||
0
agent/negotiation/qtable/usecase/__init__.py
Normal file
0
agent/negotiation/qtable/usecase/__init__.py
Normal file
9
agent/pytest.ini
Normal file
9
agent/pytest.ini
Normal file
@ -0,0 +1,9 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
# DB_SESSION_MNG(싱글톤)의 커넥션 풀이 첫 이벤트 루프에 묶이므로,
|
||||
# 모든 테스트/픽스처가 단일 session 루프를 공유하게 한다. (backend 와 동일)
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
asyncio_default_test_loop_scope = session
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore::pytest.PytestUnraisableExceptionWarning
|
||||
18
agent/requirements.txt
Normal file
18
agent/requirements.txt
Normal file
@ -0,0 +1,18 @@
|
||||
# --- API / web (backend 컨벤션 정합: async FastAPI + asyncpg + 람다DB) ---
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy>=2.0
|
||||
asyncpg
|
||||
orjson
|
||||
pydantic>=2.0
|
||||
pyyaml # tenant.yaml 로더 (TenantConfig)
|
||||
|
||||
# --- 협상 엔진 + 학습 하네스 ---
|
||||
numpy>=2.0 # Q-Table 행렬 연산 + eval_harness(시뮬레이터/지표)
|
||||
openai>=1.40 # ScriptModifier / LLM 호출 (OpenAI/Azure, langchain 미사용)
|
||||
|
||||
# 참고: Offline RL(CQL/FQE, d3rlpy·torch)·OPE(obp)는 계획서 H4/H6 단계로 현재 미구현 →
|
||||
# 의존성 추가하지 않음(빌드 경량 유지). 해당 단계 진행 시 그때 추가한다.
|
||||
|
||||
# --- 테스트 ---
|
||||
# pip install 시 함께: pytest pytest-asyncio httpx
|
||||
0
agent/router/__init__.py
Normal file
0
agent/router/__init__.py
Normal file
27
agent/router/deps.py
Normal file
27
agent/router/deps.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""FastAPI 의존성 — 요청에서 테넌트 엔진을 해석한다 (계획서 B).
|
||||
|
||||
미들웨어가 검증/주입한 request.state.tenant_id 로 레지스트리에서 엔진을 가져온다.
|
||||
요청 본문엔 tenant 가 없다(위조 방지) — 항상 헤더 경로로만 결정된다.
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from common.enums import EXCEPTION_TENANT_HEADER_MISSING, EXCEPTION_TENANT_NOT_REGISTERED
|
||||
from tenancy.registry import TenantEngine, tenant_registry
|
||||
|
||||
|
||||
async def get_tenant_engine(request: Request) -> TenantEngine:
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
if not tenant_id:
|
||||
raise EXCEPTION_TENANT_HEADER_MISSING
|
||||
try:
|
||||
return await tenant_registry.get_engine(tenant_id)
|
||||
except KeyError:
|
||||
raise EXCEPTION_TENANT_NOT_REGISTERED
|
||||
|
||||
|
||||
def new_episode_state():
|
||||
"""요청 스코프 EpisodeState 생성 (정책 인스턴스에 두지 않기 위함)."""
|
||||
from negotiation.policies.base import EpisodeState
|
||||
|
||||
return EpisodeState()
|
||||
0
agent/router/middleware/__init__.py
Normal file
0
agent/router/middleware/__init__.py
Normal file
64
agent/router/middleware/tenant_middleware.py
Normal file
64
agent/router/middleware/tenant_middleware.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""테넌트 라우팅 미들웨어.
|
||||
|
||||
규약 (계획서 B):
|
||||
- 헤더 `X-Tenant-ID`(1순위) / 경로 `/t/{id}/...`(폴백)로 테넌트 식별.
|
||||
- 부재 시 400(TENANT_HEADER_MISSING), 미등록 404(TENANT_NOT_REGISTERED).
|
||||
- default tenant 금지 (KT 사고 방지) — allow_default_tenant=false.
|
||||
- 요청 본문엔 tenant 미포함(위조 방지). 식별된 tenant_id 는 request.state.tenant_id 로만 전달.
|
||||
|
||||
NOTE(P0): 여기서는 헤더 추출 + 부재 검증까지만 한다.
|
||||
미등록 404 판정(레지스트리 조회)·company_id 매핑·엔진 resolve 는 P4(TenantEngineRegistry)에서 채운다.
|
||||
health/docs 등 화이트리스트 경로는 검사를 건너뛴다.
|
||||
"""
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from common.enums import ErrorType
|
||||
from config.server_configs import agent_config
|
||||
|
||||
_TENANT_HEADER = "X-Tenant-ID"
|
||||
|
||||
# 테넌트 식별이 필요 없는 경로 (헬스/문서/스키마/데모 UI).
|
||||
_WHITELIST_PREFIXES = ("/healthz", "/health", "/v1/health", "/docs", "/redoc", "/openapi.json", "/demo")
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# CORS preflight(OPTIONS)는 테넌트 헤더 없이 오므로 통과시킨다(CORSMiddleware 가 처리).
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in _WHITELIST_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
tenant_id = request.headers.get(_TENANT_HEADER)
|
||||
|
||||
# 경로 폴백: /t/{tenant_id}/...
|
||||
if not tenant_id and path.startswith("/t/"):
|
||||
parts = path.split("/", 3)
|
||||
if len(parts) >= 3 and parts[2]:
|
||||
tenant_id = parts[2]
|
||||
|
||||
if not tenant_id:
|
||||
if agent_config.allow_default_tenant:
|
||||
tenant_id = "_base"
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"result": {"success": False, "code": ErrorType.TENANT_HEADER_MISSING.value, "desc": ErrorType.TENANT_HEADER_MISSING.name}},
|
||||
)
|
||||
|
||||
# 미등록 테넌트는 404 (default tenant 금지 — KT 사고 방지).
|
||||
from tenancy.registry import tenant_registry
|
||||
|
||||
if not tenant_registry.is_registered(tenant_id):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"result": {"success": False, "code": ErrorType.TENANT_NOT_REGISTERED.value, "desc": ErrorType.TENANT_NOT_REGISTERED.name}},
|
||||
)
|
||||
|
||||
request.state.tenant_id = tenant_id
|
||||
return await call_next(request)
|
||||
77
agent/router/router.py
Normal file
77
agent/router/router.py
Normal file
@ -0,0 +1,77 @@
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
from router.middleware.tenant_middleware import TenantMiddleware
|
||||
import router.v1.health.health
|
||||
import router.v1.negotiation.negotiation
|
||||
import router.v1.chat.chat
|
||||
import router.v1.learning.learning
|
||||
import router.v1.card.card
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화, P5)
|
||||
from bootstrap.lifespan import ensure_base_seeded
|
||||
await ensure_base_seeded()
|
||||
yield
|
||||
# shutdown: DB 엔진 커넥션 풀 정리
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
app = FastAPI(title="Negosium Agent Server", lifespan=lifespan)
|
||||
|
||||
# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축.
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
# 테넌트 라우팅 미들웨어 (헤더 X-Tenant-ID → request.state.tenant_id).
|
||||
app.add_middleware(TenantMiddleware)
|
||||
# 데모 HTML(file:// 또는 타 출처)에서 호출 가능하도록 CORS 허용 (개발용 — 운영은 출처 제한 권장).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_time(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
elapsed = time.time() - start_time
|
||||
LOG.d(f"took: {elapsed:.4f} - {request.url.path}")
|
||||
return response
|
||||
|
||||
|
||||
@app.get(path="/healthz", responses={404: {"description": "Not found"}})
|
||||
async def healthz():
|
||||
return API_SERVER_START_TIME
|
||||
|
||||
|
||||
# 간단 테스트 프론트 (tests/negotiation_demo.html). 같은 출처로 서빙 → CORS 불필요.
|
||||
_DEMO_HTML = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "negotiation_demo.html")
|
||||
|
||||
|
||||
@app.get(path="/demo", include_in_schema=False)
|
||||
async def demo():
|
||||
return FileResponse(_DEMO_HTML)
|
||||
|
||||
|
||||
# 각 도메인 라우터 등록. 새 기능 추가 시 router.v1.<domain>.<file> import 후 include.
|
||||
# (chat / card / learning / qtable 라우터는 P7 에서 Chat_server 14개 API 이식하며 추가)
|
||||
app.include_router(router.v1.health.health.router)
|
||||
app.include_router(router.v1.negotiation.negotiation.router)
|
||||
app.include_router(router.v1.chat.chat.router)
|
||||
app.include_router(router.v1.learning.learning.router)
|
||||
app.include_router(router.v1.card.card.router)
|
||||
0
agent/router/v1/__init__.py
Normal file
0
agent/router/v1/__init__.py
Normal file
0
agent/router/v1/card/__init__.py
Normal file
0
agent/router/v1/card/__init__.py
Normal file
51
agent/router/v1/card/card.py
Normal file
51
agent/router/v1/card/card.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""카드 매핑 API (card-update / card-search, tenant 스코프).
|
||||
|
||||
PoC: action_id ↔ card_id 매핑을 learning.tenant_action_cards 에 둔다(config 기본 + DB override).
|
||||
카드 스크립트 본문은 ScriptRepository(tenants/<id>/resources)가 담당 — 여기선 매핑만.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common.enums import ErrorType
|
||||
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
||||
from router.deps import get_tenant_engine
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["Card"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
async def _merged_mapping(engine: TenantEngine) -> dict:
|
||||
"""config 기본 매핑 위에 DB override 를 얹은 action→card 최종 매핑."""
|
||||
mapping = {int(a): c for a, c in engine.config.action_mapping.action_to_card.items()}
|
||||
overrides = await LearningRepository(engine.company_id).get_action_cards()
|
||||
for o in overrides:
|
||||
mapping[o["action_id"]] = o["card_id"]
|
||||
return mapping
|
||||
|
||||
|
||||
class CardUpdateReq(BaseModel):
|
||||
action_id: int
|
||||
card_id: str
|
||||
|
||||
|
||||
@router.post("/card-update", summary="카드 매핑 갱신(action→card)")
|
||||
async def card_update(req: CardUpdateReq, engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
if not (0 <= req.action_id < engine.action_space_size):
|
||||
return {"success": False, "desc": "action_id out of range",
|
||||
"action_space_size": engine.action_space_size}
|
||||
err = await LearningRepository(engine.company_id).upsert_action_card(req.action_id, req.card_id)
|
||||
return {"success": err == ErrorType.SUCCESS, "company_id": engine.company_id,
|
||||
"action_id": req.action_id, "card_id": req.card_id, "desc": err.name}
|
||||
|
||||
|
||||
@router.get("/card-search", summary="카드 검색(전체 매핑 또는 card_id 조회)")
|
||||
async def card_search(card_id: Optional[str] = None, engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
mapping = await _merged_mapping(engine)
|
||||
if card_id:
|
||||
hits = [a for a, c in mapping.items() if c == card_id]
|
||||
return {"company_id": engine.company_id, "card_id": card_id, "action_ids": hits, "found": bool(hits)}
|
||||
return {"company_id": engine.company_id,
|
||||
"mapping": [{"action_id": a, "card_id": c} for a, c in sorted(mapping.items())]}
|
||||
0
agent/router/v1/chat/__init__.py
Normal file
0
agent/router/v1/chat/__init__.py
Normal file
24
agent/router/v1/chat/chat.py
Normal file
24
agent/router/v1/chat/chat.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""chat 라우터 (대화형 협상, P7 슬라이스). 엔진 주입(헤더→레지스트리) → ChatService."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from router.deps import get_tenant_engine
|
||||
from router.v1.chat.protocol import Req_Chat, Res_Chat
|
||||
from services.chat_service import ChatService
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
router = APIRouter(prefix="/v1/chat", tags=["Chat (negotiation)"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.post(
|
||||
path="",
|
||||
response_model=Res_Chat,
|
||||
summary="협상 대화 한 턴",
|
||||
description="session_id 없으면 새 협상 시작. user_input 으로 진행(버튼 텍스트/가격). X-Tenant-ID 헤더 필수.",
|
||||
)
|
||||
async def chat(
|
||||
req: Req_Chat,
|
||||
engine: TenantEngine = Depends(get_tenant_engine),
|
||||
service: ChatService = Depends(),
|
||||
):
|
||||
return await service.chat(engine, req)
|
||||
41
agent/router/v1/chat/protocol.py
Normal file
41
agent/router/v1/chat/protocol.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""chat 라우터 프로토콜 (대화형 /chat, P7 슬라이스)."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol
|
||||
|
||||
|
||||
class Req_Chat(Req_WebPacketProtocol):
|
||||
"""대화 한 턴. session_id 없으면 새 협상 시작(아래 컨텍스트로). tenant 는 헤더로만."""
|
||||
|
||||
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성")
|
||||
rq_type: str = Field("재협상", description="재협상 | 재견적")
|
||||
user_input: Optional[str] = Field(None, description="버튼 선택 텍스트 또는 가격(price 모드)")
|
||||
# 새 세션 시작 시 협상 컨텍스트 (옵션, 기본값 제공)
|
||||
revenue_amount: float = 20_000_000
|
||||
distribution_code: str = "A"
|
||||
partner_count: int = 1
|
||||
acceptance_ratio: float = 0.05
|
||||
# 갑(KT/iMK)이 직접 입력. anchor < target. anchor 기본 제안값 = target*(1-0.01).
|
||||
target_price: float = 10000 # KT 목표 매입가
|
||||
anchor_price: float = 9900 # KT 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
|
||||
|
||||
|
||||
class Res_Chat(Res_WebPacketProtocol):
|
||||
session_id: Optional[str] = None
|
||||
step: Optional[str] = None
|
||||
client_step: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
input_mode: Optional[str] = None
|
||||
input_options: Optional[List[str]] = None
|
||||
chat_end: bool = False
|
||||
outcome: Optional[str] = None
|
||||
# 가격협상 턴에서 선택된 협상 카드 + 학습 메타
|
||||
card_id: Optional[str] = None
|
||||
policy: Optional[str] = None
|
||||
q_value: Optional[float] = None
|
||||
updated_q: Optional[float] = None
|
||||
visit_count: Optional[int] = None
|
||||
reward_total: Optional[float] = None
|
||||
0
agent/router/v1/health/__init__.py
Normal file
0
agent/router/v1/health/__init__.py
Normal file
9
agent/router/v1/health/health.py
Normal file
9
agent/router/v1/health/health.py
Normal file
@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
# 협상 엔진 헬스체크 (Chat_server /health 이식). MVC 컨벤션: 라우터는 얇게.
|
||||
router = APIRouter(prefix="/v1", tags=["Health"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(path="/health", summary="협상 에이전트 헬스체크")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "negosium-agent"}
|
||||
0
agent/router/v1/learning/__init__.py
Normal file
0
agent/router/v1/learning/__init__.py
Normal file
149
agent/router/v1/learning/learning.py
Normal file
149
agent/router/v1/learning/learning.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""learning/qtable 관리 API (Chat_server 14개 API 보존, tenant 스코프).
|
||||
|
||||
엔드포인트: /q-table/versions·switch·current, /experience-logs, /reset-learning, /reset-all,
|
||||
/invalidate-session, /train. 모두 X-Tenant-ID 헤더로 company_id 격리.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common.enums import ErrorType
|
||||
from negotiation.policies.base import Transition
|
||||
from negotiation.policies.qtable_policy import UCBQTablePolicy
|
||||
from negotiation.policy.model_store import QTablePolicyStore
|
||||
from negotiation.qtable.domain.model.q_table import QTable
|
||||
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
||||
from router.deps import get_tenant_engine
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["Learning / Q-Table"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
def _repo(engine: TenantEngine) -> LearningRepository:
|
||||
return LearningRepository(engine.company_id)
|
||||
|
||||
|
||||
@router.get("/q-table/versions", summary="Q-Table 버전 목록")
|
||||
async def list_versions(engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
repo = _repo(engine)
|
||||
err, versions = await repo.read(lambda s: repo.list_versions(s))
|
||||
return {"company_id": engine.company_id, "versions": [
|
||||
{"version_name": v.version_name, "is_active": v.is_active, "scope": v.scope,
|
||||
"state_space_size": v.state_space_size, "action_space_size": v.action_space_size,
|
||||
"epochs": v.epochs, "created_at": str(v.created_at)} for v in (versions or [])]}
|
||||
|
||||
|
||||
class SwitchReq(BaseModel):
|
||||
version_name: str
|
||||
|
||||
|
||||
@router.post("/q-table/switch", summary="활성 Q-Table 버전 전환")
|
||||
async def switch_version(req: SwitchReq, engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
err = await _repo(engine).set_active_version(req.version_name)
|
||||
ok = err == ErrorType.SUCCESS
|
||||
return {"success": ok, "active_version": req.version_name if ok else None,
|
||||
"desc": err.name}
|
||||
|
||||
|
||||
@router.get("/q-table/current", summary="현재 활성 Q-Table 상태")
|
||||
async def current_qtable(engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
repo = _repo(engine)
|
||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||
if not active:
|
||||
return {"company_id": engine.company_id, "active_version": None, "q_value_rows": 0}
|
||||
rows = await repo.qvalue_count(active.version_id)
|
||||
return {"company_id": engine.company_id, "active_version": active.version_name,
|
||||
"state_space_size": active.state_space_size, "action_space_size": active.action_space_size,
|
||||
"q_value_rows": rows}
|
||||
|
||||
|
||||
@router.get("/experience-logs", summary="최근 경험 로그")
|
||||
async def experience_logs(limit: int = 50, engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
repo = _repo(engine)
|
||||
err, total = await repo.read(lambda s: repo.count_experience(s))
|
||||
logs = await repo.recent_experience(limit=min(limit, 500))
|
||||
return {"company_id": engine.company_id, "total": total, "logs": logs}
|
||||
|
||||
|
||||
@router.post("/reset-learning", summary="[관리] 학습 데이터 초기화(자사 스코프)")
|
||||
async def reset_learning(engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
err = await _repo(engine).reset_learning()
|
||||
return {"success": err == ErrorType.SUCCESS, "company_id": engine.company_id, "desc": err.name}
|
||||
|
||||
|
||||
@router.post("/reset-all", summary="[관리] 완전 초기화(자사 스코프, 타테넌트 무영향)")
|
||||
async def reset_all(engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
err = await _repo(engine).reset_all()
|
||||
return {"success": err == ErrorType.SUCCESS, "company_id": engine.company_id, "desc": err.name}
|
||||
|
||||
|
||||
class InvalidateReq(BaseModel):
|
||||
session_id: str
|
||||
reason: Optional[str] = "invalidated"
|
||||
|
||||
|
||||
@router.post("/invalidate-session", summary="세션 학습 데이터 무효화")
|
||||
async def invalidate_session(req: InvalidateReq, engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
repo = _repo(engine)
|
||||
err = await repo.write_one(lambda s: repo.invalidate_by_session(s, req.session_id, req.reason))
|
||||
return {"success": err == ErrorType.SUCCESS, "session_id": req.session_id, "desc": err.name}
|
||||
|
||||
|
||||
class TrainReq(BaseModel):
|
||||
epochs: int = 1
|
||||
|
||||
|
||||
@router.post("/train", summary="오프라인 Q-Learning 학습(경험로그 기반)")
|
||||
async def train(req: TrainReq, engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
repo = _repo(engine)
|
||||
transitions = await repo.load_transitions()
|
||||
if not transitions:
|
||||
return {"success": True, "trained_transitions": 0, "note": "경험 로그 없음 (먼저 /chat 진행)"}
|
||||
|
||||
S, A = engine.state_space_size, engine.action_space_size
|
||||
qt = QTable(S, A, learning_rate=engine.config.policy.learning_rate, discount_factor=engine.config.policy.gamma)
|
||||
# 활성 버전에서 현재 Q 적재
|
||||
version_id = await repo.get_or_create_active_version(
|
||||
state_space_size=S, action_space_size=A,
|
||||
learning_rate=engine.config.policy.learning_rate, discount_factor=engine.config.policy.gamma)
|
||||
qrows, vrows = await repo.load_cells(version_id)
|
||||
for st, a, q in qrows:
|
||||
if 0 <= st < S and 0 <= a < A:
|
||||
qt.q[st, a] = q
|
||||
policy = UCBQTablePolicy(qt, mark_visits=False)
|
||||
|
||||
updates = 0
|
||||
touched = set()
|
||||
for _ in range(max(1, req.epochs)):
|
||||
for st, a, r, ns, done in transitions:
|
||||
if st is None or a is None or not (0 <= st < S and 0 <= a < A):
|
||||
continue
|
||||
policy.update(Transition(state_index=st, action_id=a, reward=r, next_state_index=ns, done=bool(done)))
|
||||
touched.add((st, a))
|
||||
updates += 1
|
||||
# 갱신된 셀 영속화
|
||||
for (st, a) in touched:
|
||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, st, a)
|
||||
|
||||
return {"success": True, "trained_transitions": len(transitions), "epochs": req.epochs,
|
||||
"updates": updates, "cells_persisted": len(touched), "company_id": engine.company_id}
|
||||
|
||||
|
||||
@router.get("/verification-report", summary="Q-Table 검증 리포트(요약)")
|
||||
async def verification_report(engine: TenantEngine = Depends(get_tenant_engine)):
|
||||
repo = _repo(engine)
|
||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||
err, total_exp = await repo.read(lambda s: repo.count_experience(s))
|
||||
if not active:
|
||||
return {"company_id": engine.company_id, "active_version": None, "experience_total": total_exp}
|
||||
qrows, vrows = await repo.load_cells(active.version_id)
|
||||
nonzero = [(s, a, q) for s, a, q in qrows if q != 0]
|
||||
top = sorted(nonzero, key=lambda x: -x[2])[:10]
|
||||
return {
|
||||
"company_id": engine.company_id, "active_version": active.version_name,
|
||||
"experience_total": total_exp, "q_value_rows": len(qrows), "nonzero_q": len(nonzero),
|
||||
"visited_cells": len(vrows),
|
||||
"top_q": [{"state_index": s, "action_id": a, "q_value": round(q, 4)} for s, a, q in top],
|
||||
}
|
||||
0
agent/router/v1/negotiation/__init__.py
Normal file
0
agent/router/v1/negotiation/__init__.py
Normal file
28
agent/router/v1/negotiation/negotiation.py
Normal file
28
agent/router/v1/negotiation/negotiation.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""negotiation 라우터 (컨트롤러). 엔진 주입(헤더→레지스트리) → service 호출만 담당.
|
||||
|
||||
NOTE: 현재는 P2 의사결정 루프의 HTTP 프리뷰(/v1/negotiation/step).
|
||||
실제 대화 /chat + 14개 API + step 체계는 P7 에서 이식한다.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from router.deps import get_tenant_engine
|
||||
from router.v1.negotiation.protocol import Req_NegotiationStep, Res_NegotiationStep
|
||||
from services.negotiation_service import NegotiationService
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation (preview)"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/step",
|
||||
response_model=Res_NegotiationStep,
|
||||
summary="협상 한 라운드 (프리뷰)",
|
||||
description="관측치 → 상태분류 → (임시)카드선택 → 보상 → DB로깅. X-Tenant-ID 헤더 필수. 실제 학습/대화는 미구현.",
|
||||
)
|
||||
async def step(
|
||||
req: Req_NegotiationStep,
|
||||
engine: TenantEngine = Depends(get_tenant_engine),
|
||||
service: NegotiationService = Depends(),
|
||||
):
|
||||
return await service.step(engine, req)
|
||||
66
agent/router/v1/negotiation/protocol.py
Normal file
66
agent/router/v1/negotiation/protocol.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""negotiation 라우터 프로토콜 (backend Protocol 규약: Req_/Res_ + result: ErrorInfo)."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol
|
||||
|
||||
|
||||
class Req_NegotiationStep(Req_WebPacketProtocol):
|
||||
"""한 협상 라운드 관측치. tenant 는 본문에 없음(헤더 X-Tenant-ID 로만, 위조 방지)."""
|
||||
|
||||
# 이산 상태 산출 입력
|
||||
revenue_amount: float = Field(..., description="매출액(원)")
|
||||
distribution_code: str = Field("A", description="유통 구조 외부 코드 (테넌트 code_map)")
|
||||
partner_count: int = Field(1, description="파트너사 수")
|
||||
acceptance_ratio: float = Field(..., description="가격 수용률 0~1")
|
||||
input_price: float = Field(..., description="현재 제시/입력 가격")
|
||||
anchor_price: float = Field(..., description="앵커(시작) 가격")
|
||||
target_price: float = Field(..., description="목표 가격")
|
||||
# 시퀀스/보상
|
||||
round_number: int = Field(1, description="협상 라운드(turn)")
|
||||
outcome: str = Field("ongoing", description="ongoing | success | failure")
|
||||
# 세션/에피소드 (HTTP 무상태 — 클라이언트가 사용한 action 을 전달해 중복 방지)
|
||||
session_id: Optional[str] = Field(None, description="협상 세션 uuid (없으면 생성)")
|
||||
used_action_ids: Optional[List[int]] = Field(None, description="이미 제시한 action(중복방지 마스킹)")
|
||||
log: bool = Field(True, description="learning.experience_logs 에 기록할지")
|
||||
learn: bool = Field(True, description="Q-Table 온라인 갱신 + 영속화 여부")
|
||||
|
||||
|
||||
# 중첩 뷰는 result/msg 가 필요 없으므로 순수 BaseModel.
|
||||
class StateView(BaseModel):
|
||||
revenue_idx: int = 0
|
||||
distribution_idx: int = 0
|
||||
partner_idx: int = 0
|
||||
acceptance_idx: int = 0
|
||||
price_zone_idx: int = 0
|
||||
|
||||
|
||||
class RewardView(BaseModel):
|
||||
price_reward: float = 0.0
|
||||
end_reward: float = 0.0
|
||||
penalty: float = 0.0
|
||||
weight: float = 0.0
|
||||
total: float = 0.0
|
||||
|
||||
|
||||
class Res_NegotiationStep(Res_WebPacketProtocol):
|
||||
tenant_id: Optional[str] = None
|
||||
company_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
state_index: Optional[int] = None
|
||||
state: Optional[StateView] = None
|
||||
action_id: Optional[int] = None
|
||||
card_id: Optional[str] = None
|
||||
propensity: Optional[float] = None
|
||||
available_actions: Optional[List[int]] = None
|
||||
reward: Optional[RewardView] = None
|
||||
logged: bool = False
|
||||
# 학습 가시화: 선택 시점 Q, UCB 점수, 갱신 후 Q, 이 state-action 누적 방문수, 정책명
|
||||
policy: Optional[str] = None
|
||||
q_value: Optional[float] = None
|
||||
ucb_score: Optional[float] = None
|
||||
updated_q: Optional[float] = None
|
||||
visit_count: Optional[int] = None
|
||||
learned: bool = False
|
||||
0
agent/services/__init__.py
Normal file
0
agent/services/__init__.py
Normal file
143
agent/services/chat_service.py
Normal file
143
agent/services/chat_service.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""ChatService — 대화형 /chat 오케스트레이션 (P7 슬라이스).
|
||||
|
||||
ChatEngine(동기 step 전이) + UCB Q-Table(가격협상 카드선택·학습) + DB(experience_logs) 결합.
|
||||
세션 상태는 인메모리 스토어(PoC; 단일 워커). 종료 outcome 은 마지막 (state,action)에 종료보상을 역전파.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from common.enums import DBType, ErrorType
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from config.server_configs import agent_config
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView
|
||||
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
|
||||
from negotiation.policy.model_store import QTablePolicyStore
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||
from negotiation.qtable.domain.service.state_calculator import state_index
|
||||
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
||||
from router.v1.chat.protocol import Req_Chat, Res_Chat
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
|
||||
class ChatService:
|
||||
async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat:
|
||||
res = Res_Chat()
|
||||
repo = ScriptRepository(engine.config, agent_config.tenants_dir)
|
||||
sess_repo = ChatSessionRepository(engine.company_id)
|
||||
|
||||
# 1) 세션 확보 / 시작 (DB 영속 — 재시작/멀티워커 안전, P8-A)
|
||||
session = await sess_repo.get(req.session_id) if req.session_id else None
|
||||
chat_engine = ChatEngine(repo, rq_type=(session.rq_type if session else req.rq_type))
|
||||
|
||||
if session is None:
|
||||
session = ChatSession(
|
||||
session_id=str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id,
|
||||
rq_type=req.rq_type, action_space_size=engine.action_space_size,
|
||||
context={
|
||||
"revenue_amount": req.revenue_amount, "distribution_code": req.distribution_code,
|
||||
"partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio,
|
||||
# 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)).
|
||||
"anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0,
|
||||
},
|
||||
)
|
||||
view = chat_engine.start(session)
|
||||
else:
|
||||
view = chat_engine.advance(session, req.user_input)
|
||||
|
||||
# 2) 학습 결합 (가격협상 카드선택 / 종료 보상)
|
||||
if view.error is None and engine.action_space_size > 0:
|
||||
if view.needs_card_selection:
|
||||
await self._select_and_learn(engine, session, res)
|
||||
elif view.outcome is not None:
|
||||
await self._terminal_learn(engine, session, view.outcome, res)
|
||||
|
||||
# 3) 응답
|
||||
res.session_id = session.session_id
|
||||
res.step = view.step
|
||||
res.client_step = view.client_step
|
||||
res.script = view.script
|
||||
res.input_mode = view.input_mode
|
||||
res.input_options = view.input_options
|
||||
res.chat_end = view.chat_end
|
||||
res.outcome = view.outcome
|
||||
if view.error:
|
||||
res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
|
||||
res.msg = view.error
|
||||
|
||||
await sess_repo.save(session) # 진행 상태 영속화 (재시작/멀티워커 안전)
|
||||
return res
|
||||
|
||||
# ---- 학습 ----------------------------------------------------------
|
||||
def _snapshot(self, session: ChatSession, outcome: NegotiationOutcome) -> NegotiationSnapshot:
|
||||
c = session.context
|
||||
return NegotiationSnapshot(
|
||||
revenue_amount=c["revenue_amount"], distribution_code=c["distribution_code"],
|
||||
partner_count=c["partner_count"], acceptance_ratio=c["acceptance_ratio"],
|
||||
input_price=c.get("input_price", c["anchor_price"]), anchor_price=c["anchor_price"],
|
||||
target_price=c["target_price"], round_number=c.get("round", 0), outcome=outcome,
|
||||
)
|
||||
|
||||
async def _select_and_learn(self, engine: TenantEngine, session: ChatSession, res: Res_Chat):
|
||||
snap = self._snapshot(session, NegotiationOutcome.ONGOING)
|
||||
try:
|
||||
idx = state_index(snap, engine.config.state)
|
||||
except ValueError as ex:
|
||||
LOG.e_no_callstack(f"[ChatService] state error: {ex}")
|
||||
return
|
||||
policy, version_id, repo = await QTablePolicyStore.load(engine)
|
||||
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size,
|
||||
episode=EpisodeState(used_action_ids=set(session.used_action_ids)))
|
||||
decision = policy.select(ctx)
|
||||
session.used_action_ids.add(decision.action_id)
|
||||
card_id = engine.mapper.get_card_id(decision.action_id)
|
||||
reward = RewardCalculator(engine.config.reward).calculate(snap)
|
||||
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)
|
||||
session.context["last_state"] = idx
|
||||
session.context["last_action"] = decision.action_id
|
||||
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
|
||||
|
||||
res.card_id = card_id
|
||||
res.policy = policy.name
|
||||
res.q_value = decision.q_value
|
||||
res.updated_q = float(policy.qtable.q[idx, decision.action_id])
|
||||
res.visit_count = int(policy.qtable.visits[idx, decision.action_id])
|
||||
res.reward_total = reward.total
|
||||
|
||||
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
||||
oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE
|
||||
snap = self._snapshot(session, oc)
|
||||
reward = RewardCalculator(engine.config.reward).calculate(snap)
|
||||
res.reward_total = reward.total
|
||||
last_state = session.context.get("last_state")
|
||||
last_action = session.context.get("last_action")
|
||||
if last_state is None or last_action is None:
|
||||
return # 카드선택 없이 종료된 경우(예: 담당자확인 단계 이탈)
|
||||
policy, version_id, repo = await QTablePolicyStore.load(engine)
|
||||
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 self._log(repo, session, last_state, last_action,
|
||||
engine.mapper.get_card_id(last_action), snap, reward, None, done=True)
|
||||
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
||||
|
||||
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
|
||||
data = {
|
||||
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
||||
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
|
||||
"turn": snap.round_number, "reward": reward.total, "done": done,
|
||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||
}
|
||||
try:
|
||||
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[ChatService] log failed: {ex}")
|
||||
|
||||
|
||||
def reset_sessions():
|
||||
"""세션은 DB(learning.chat_sessions)에 영속화된다(P8-A). 테스트는 db_engine 픽스처가 TRUNCATE 하므로 no-op."""
|
||||
pass
|
||||
118
agent/services/negotiation_service.py
Normal file
118
agent/services/negotiation_service.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""NegotiationService — 협상 한 라운드 (실제 UCB Q-Table 학습 정책, H1).
|
||||
|
||||
흐름: 관측치 → build_state → 정책 로드(learning 스키마) → UCB 선택(propensity) → reward
|
||||
→ Q-learning 온라인 갱신 + touched 셀 write-through → experience_logs 기록.
|
||||
|
||||
반복 호출하면 visit/Q 가 DB 에 누적되어 학습이 진행된다(같은 state 를 칠수록 탐색 보너스↓, Q 수렴).
|
||||
대화형 /chat·step 체계·시퀀스 보상링크는 P5/P7. 여기는 단일 라운드 단위.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from common.enums import DBType, ErrorType
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
|
||||
from negotiation.policy.model_store import QTablePolicyStore
|
||||
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||
from negotiation.qtable.domain.service.state_calculator import build_state, state_index
|
||||
from router.v1.negotiation.protocol import Req_NegotiationStep, Res_NegotiationStep, RewardView, StateView
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
|
||||
class NegotiationService:
|
||||
async def step(self, engine: TenantEngine, req: Req_NegotiationStep) -> Res_NegotiationStep:
|
||||
res = Res_NegotiationStep(tenant_id=engine.tenant_id, company_id=engine.company_id)
|
||||
|
||||
# 1) 관측치 → snapshot
|
||||
try:
|
||||
outcome = NegotiationOutcome(req.outcome)
|
||||
except ValueError:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
res.msg = f"outcome must be ongoing|success|failure, got {req.outcome!r}"
|
||||
return res
|
||||
|
||||
# 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용.
|
||||
snap = NegotiationSnapshot(
|
||||
revenue_amount=req.revenue_amount, distribution_code=req.distribution_code,
|
||||
partner_count=req.partner_count, acceptance_ratio=req.acceptance_ratio,
|
||||
input_price=req.input_price, anchor_price=req.anchor_price, target_price=req.target_price,
|
||||
round_number=req.round_number, outcome=outcome,
|
||||
)
|
||||
|
||||
# 2) 상태 산출 (config 주입)
|
||||
try:
|
||||
st = build_state(snap, engine.config.state)
|
||||
idx = state_index(snap, engine.config.state)
|
||||
except ValueError as ex:
|
||||
res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
|
||||
res.msg = str(ex)
|
||||
return res
|
||||
|
||||
# 3) 정책 로드 (learning 스키마 활성 버전) → UCB 선택
|
||||
policy, version_id, repo = await QTablePolicyStore.load(engine)
|
||||
episode = EpisodeState(used_action_ids=set(req.used_action_ids or []))
|
||||
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size, episode=episode)
|
||||
decision = policy.select(ctx)
|
||||
decision.card_id = engine.mapper.get_card_id(decision.action_id)
|
||||
|
||||
# 4) 보상
|
||||
reward = RewardCalculator(engine.config.reward).calculate(snap)
|
||||
|
||||
# 5) 학습: Q-learning 온라인 갱신 + touched 셀 영속화
|
||||
updated_q = decision.q_value
|
||||
if req.learn:
|
||||
done = outcome != NegotiationOutcome.ONGOING
|
||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=done))
|
||||
updated_q = float(policy.qtable.q[idx, decision.action_id])
|
||||
try:
|
||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||
res.learned = True
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[NegotiationService] persist failed: {ex}")
|
||||
|
||||
# 6) 응답 채우기
|
||||
session_id = req.session_id or str(uuid.uuid4())
|
||||
res.session_id = session_id
|
||||
res.state_index = idx
|
||||
res.state = StateView(
|
||||
revenue_idx=st.revenue_idx, distribution_idx=st.distribution_idx, partner_idx=st.partner_idx,
|
||||
acceptance_idx=st.acceptance_idx, price_zone_idx=st.price_zone_idx,
|
||||
)
|
||||
res.action_id = decision.action_id
|
||||
res.card_id = decision.card_id
|
||||
res.propensity = decision.propensity
|
||||
res.available_actions = decision.available_actions
|
||||
res.reward = RewardView(
|
||||
price_reward=reward.price_reward, end_reward=reward.end_reward,
|
||||
penalty=reward.penalty, weight=reward.weight, total=reward.total,
|
||||
)
|
||||
res.policy = policy.name
|
||||
res.q_value = decision.q_value
|
||||
res.ucb_score = decision.ucb_score
|
||||
res.updated_q = updated_q
|
||||
res.visit_count = int(policy.qtable.visits[idx, decision.action_id])
|
||||
|
||||
# 7) experience_logs 기록
|
||||
if req.log:
|
||||
res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
|
||||
return res
|
||||
|
||||
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool:
|
||||
repo = LearningRepository(engine.company_id)
|
||||
data = {
|
||||
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
||||
"card_id": decision.card_id, "snapshot": snap.to_dict(), "propensity": decision.propensity,
|
||||
"turn": snap.round_number, "available_actions": decision.available_actions,
|
||||
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
||||
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
|
||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||
}
|
||||
try:
|
||||
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
||||
return err == ErrorType.SUCCESS
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[NegotiationService] log failed: {ex}")
|
||||
return False
|
||||
0
agent/tenancy/__init__.py
Normal file
0
agent/tenancy/__init__.py
Normal file
179
agent/tenancy/config.py
Normal file
179
agent/tenancy/config.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""TenantConfig — 테넌트별 협상 설정 단일 소스 (계획서 설계 A).
|
||||
|
||||
Chat_server 에서 23곳에 하드코딩돼 있던 KT 가정(state 임계값·가중치·유통코드, reward,
|
||||
policy, 카드 매핑, LLM 배포)을 한 pydantic 모델로 외부화한다. 도메인 코드는 이 config 를
|
||||
주입받아 tenant-agnostic 하게 동작한다(P2).
|
||||
|
||||
저장 위치 (YAML + DB 하이브리드, 계획서 A):
|
||||
- YAML(tenants/<id>/tenant.yaml, git 형상관리): state·reward·policy·language·resource 경로.
|
||||
- env/시크릿(llm.api_key_ref): LLM api_key (YAML 평문 금지).
|
||||
- DB: action_to_card 매핑(P6 tenant_action_cards), 활성 Q-Table 버전 포인터(P5).
|
||||
|
||||
테넌트 식별자는 company.companies.company_id(uuid)에 매핑된다. 공유 베이스는 예약어 "_base".
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---- state: 이산 상태 차원 정의 -------------------------------------------
|
||||
# 모든 기본값은 우리 플랫폼의 중립 데모 기본값이다(CLEANROOM.md). 특정 고객의 튜닝값을
|
||||
# 복제하지 않으며, 실제 운영값은 테넌트 YAML/DB 에서 주입한다. 차원 구성(개수)은 기능적
|
||||
# 설계이고, 값/라벨은 우리 자체 선택이다.
|
||||
class RevenueConfig(BaseModel):
|
||||
"""매출 가격구간. thresholds[i] 이하이면 i번째 구간. 마지막 구간은 초과분."""
|
||||
|
||||
thresholds: List[float] = [10_000_000, 50_000_000] # 플랫폼 중립 기본값
|
||||
weights: List[float] = [0.3, 0.6, 1.0]
|
||||
descriptions: List[str] = ["low", "mid", "high"]
|
||||
|
||||
|
||||
class DistributionConfig(BaseModel):
|
||||
"""유통 구조. code_map: 테넌트 외부 코드 → 구간 인덱스(테넌트가 자사 코드로 정의)."""
|
||||
|
||||
code_map: Dict[str, int] = {"A": 0, "B": 1, "C": 2} # 중립 예시 코드(테넌트가 오버라이드)
|
||||
weights: List[float] = [0.3, 0.6, 1.0]
|
||||
descriptions: List[str] = ["channel_a", "channel_b", "channel_c"]
|
||||
|
||||
|
||||
class PartnerConfig(BaseModel):
|
||||
"""파트너사 수 → 구간. count==0:none, ==1:single, >=2:multiple.
|
||||
인덱스 규약: SINGLE=0, MULTIPLE=1, NONE=2.
|
||||
"""
|
||||
|
||||
weights: List[float] = [0.5, 1.0, 0.3] # single, multiple, none
|
||||
descriptions: List[str] = ["single", "multiple", "none"]
|
||||
|
||||
|
||||
class AcceptanceConfig(BaseModel):
|
||||
"""가격 수용률 구간(0~1). < thresholds[0]: low, <= thresholds[1]: mid, else high."""
|
||||
|
||||
thresholds: List[float] = [0.03, 0.09]
|
||||
weights: List[float] = [0.3, 0.6, 1.0]
|
||||
descriptions: List[str] = ["low", "mid", "high"]
|
||||
|
||||
|
||||
class PriceZoneConfig(BaseModel):
|
||||
"""입력가격 구간 (KT 구매자: anchor=협력사 기준가 ≥ target=목표 매입가).
|
||||
zone0: 제시가 ≤ anchor(우선협상 가능), zone1: > anchor(협상 지속)."""
|
||||
|
||||
weights: List[float] = [1.0, 0.5] # at_or_below_anchor, above_anchor
|
||||
descriptions: List[str] = ["at_or_below_anchor", "above_anchor"]
|
||||
|
||||
|
||||
class StateConfig(BaseModel):
|
||||
revenue: RevenueConfig = Field(default_factory=RevenueConfig)
|
||||
distribution: DistributionConfig = Field(default_factory=DistributionConfig)
|
||||
partner: PartnerConfig = Field(default_factory=PartnerConfig)
|
||||
acceptance: AcceptanceConfig = Field(default_factory=AcceptanceConfig)
|
||||
price_zone: PriceZoneConfig = Field(default_factory=PriceZoneConfig)
|
||||
|
||||
@property
|
||||
def state_space_size(self) -> int:
|
||||
"""차원 곱으로 자동 산출 (KT: 3×3×3×3×2 = 162). 회사별 차원이 다르면 값이 달라진다.
|
||||
→ warm-start 차원 호환 체크의 기준이 된다(P5).
|
||||
"""
|
||||
revenue_dim = len(self.revenue.weights)
|
||||
dist_dim = len(self.distribution.weights)
|
||||
partner_dim = len(self.partner.weights)
|
||||
accept_dim = len(self.acceptance.weights)
|
||||
price_dim = len(self.price_zone.weights)
|
||||
return revenue_dim * dist_dim * partner_dim * accept_dim * price_dim
|
||||
|
||||
|
||||
# ---- reward: 보상 공식 파라미터 (공식 형태는 기능적, 값은 우리 자체 중립 기본값) ----------
|
||||
class RewardConfig(BaseModel):
|
||||
"""보상 계산 파라미터. 필드 구성은 P2 RewardCalculator 주입용이며,
|
||||
기본값은 플랫폼 중립값(균등 가중치)이다 — 특정 고객 튜닝값 복제 아님(CLEANROOM.md).
|
||||
"""
|
||||
|
||||
beta: float = 0.2
|
||||
success_reward: float = 1.0
|
||||
ongoing_reward: float = 0.0
|
||||
failure_penalty: float = -0.5
|
||||
penalty_lambda: float = 0.02
|
||||
# 동적 가중치 기본값: 균등 분배(0.2×5). 테넌트가 자사 특성에 맞게 오버라이드.
|
||||
w1: float = 0.2
|
||||
w2: float = 0.2
|
||||
w3: float = 0.2
|
||||
w4: float = 0.2
|
||||
w5: float = 0.2
|
||||
min_weight: float = 0.2
|
||||
max_weight: float = 0.8
|
||||
|
||||
|
||||
# ---- policy / cards / llm / resources / action_mapping ----------------------
|
||||
class ActionMappingConfig(BaseModel):
|
||||
"""action_id ↔ card_id 매핑. PoC 는 카드 매핑 고정(차원 정합성 리스크 회피)."""
|
||||
|
||||
type: str = "file" # "file" | "db"(P6 tenant_action_cards)
|
||||
action_to_card: Dict[str, str] = {}
|
||||
|
||||
@property
|
||||
def action_space_size(self) -> int:
|
||||
return len(self.action_to_card)
|
||||
|
||||
|
||||
class PolicyConfig(BaseModel):
|
||||
"""정책 종류 + 하이퍼파라미터. params 는 알고리즘별 불투명 dict(LinUCB/CQL 등)."""
|
||||
|
||||
type: str = "ucb" # ucb | linucb | cql (eval_harness registry 키)
|
||||
learning_rate: float = 0.1
|
||||
gamma: float = 0.95 # discount_factor
|
||||
params: Dict[str, Any] = {"exploration_constant": 1.4142135623730951, "epsilon": 1e-6}
|
||||
|
||||
|
||||
class CardsConfig(BaseModel):
|
||||
source_type: str = "file" # file | backoffice_db (card.* 스키마)
|
||||
sync_interval_seconds: int = 300
|
||||
connection: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class LlmConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
endpoint: Optional[str] = None
|
||||
deployment: Optional[str] = None
|
||||
api_version: Optional[str] = None
|
||||
api_key_ref: Optional[str] = None # env 변수명 (평문 금지). P7 에서 LlmCredentials 로 해석.
|
||||
|
||||
|
||||
class NegotiationConfig(BaseModel):
|
||||
"""협상 가격 정책. 앵커링값은 목표가에서 자동 산출한다(KT 구매자: anchor < target).
|
||||
|
||||
anchor = round(target * (1 - anchor_rate)). 예) target=10000, rate=0.01 → anchor=9900.
|
||||
협력사 제시가 ≤ anchor → 우선협상.
|
||||
"""
|
||||
|
||||
anchor_rate: float = 0.01 # 목표가 대비 앵커링 인하율 (기본 1%)
|
||||
max_rounds: int = 5 # 라운드 상한(보조). 실제 종료는 '카드 소진' 기준.
|
||||
|
||||
def anchor_for(self, target_price: float) -> float:
|
||||
return round(target_price * (1.0 - self.anchor_rate))
|
||||
|
||||
|
||||
class ResourcesConfig(BaseModel):
|
||||
language: str = "ko"
|
||||
scripts_dir: str = "resources" # tenants/<id>/resources/ (없으면 _base/resources/ 폴백)
|
||||
variable_mapping: Optional[str] = None
|
||||
# 스크립트 템플릿의 브랜드 치환값 (클린룸: 특정사 브랜드 대신 테넌트별 주입).
|
||||
company_name: str = "당사"
|
||||
service_name: str = "Negosium"
|
||||
|
||||
|
||||
class TenantConfig(BaseModel):
|
||||
"""한 테넌트의 협상 설정 전체."""
|
||||
|
||||
tenant_id: str
|
||||
company_id: Optional[str] = None # company.companies.company_id (uuid). _base 는 None.
|
||||
name: str = ""
|
||||
inherits_base: bool = True
|
||||
|
||||
state: StateConfig = Field(default_factory=StateConfig)
|
||||
reward: RewardConfig = Field(default_factory=RewardConfig)
|
||||
negotiation: NegotiationConfig = Field(default_factory=NegotiationConfig)
|
||||
action_mapping: ActionMappingConfig = Field(default_factory=ActionMappingConfig)
|
||||
policy: PolicyConfig = Field(default_factory=PolicyConfig)
|
||||
cards: CardsConfig = Field(default_factory=CardsConfig)
|
||||
llm: LlmConfig = Field(default_factory=LlmConfig)
|
||||
resources: ResourcesConfig = Field(default_factory=ResourcesConfig)
|
||||
85
agent/tenancy/config_loader.py
Normal file
85
agent/tenancy/config_loader.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""TenantConfig 로더 (계획서 A: YAML → env 주입 → DB 오버레이 merge, inherits_base deep-merge, TTL 캐시).
|
||||
|
||||
P1 범위: YAML 로드 + _base deep-merge + TTL 캐시.
|
||||
DB 오버레이(action_to_card, 활성 Q-Table 버전 포인터)는 P5/P6 에서 추가한다.
|
||||
env 주입(llm.api_key_ref → 실제 키)은 P7 에서 LlmCredentials 해석으로 처리한다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from common.logger import LOG
|
||||
from config.server_configs import agent_config
|
||||
from tenancy.config import TenantConfig
|
||||
|
||||
_BASE_TENANT_ID = "_base"
|
||||
|
||||
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""override 를 base 위에 재귀 병합. dict 는 키 단위 병합, 그 외(리스트/스칼라)는 override 우선.
|
||||
|
||||
리스트는 통째로 교체한다(임계값/가중치 배열은 부분 병합이 의미 없으므로).
|
||||
"""
|
||||
result = dict(base)
|
||||
for key, ov in override.items():
|
||||
bv = result.get(key)
|
||||
if isinstance(bv, dict) and isinstance(ov, dict):
|
||||
result[key] = _deep_merge(bv, ov)
|
||||
else:
|
||||
result[key] = ov
|
||||
return result
|
||||
|
||||
|
||||
class TenantConfigLoader:
|
||||
"""tenants/<id>/tenant.yaml 을 읽어 TenantConfig 로 만든다. TTL 캐시."""
|
||||
|
||||
def __init__(self, tenants_dir: Optional[str] = None, cache_ttl_seconds: Optional[int] = None):
|
||||
self._tenants_dir = tenants_dir or agent_config.tenants_dir
|
||||
self._ttl = agent_config.config_cache_ttl_seconds if cache_ttl_seconds is None else cache_ttl_seconds
|
||||
self._cache: Dict[str, tuple[float, TenantConfig]] = {}
|
||||
|
||||
def _yaml_path(self, tenant_id: str) -> str:
|
||||
return os.path.join(self._tenants_dir, tenant_id, "tenant.yaml")
|
||||
|
||||
def _read_yaml(self, tenant_id: str) -> Dict[str, Any]:
|
||||
path = self._yaml_path(tenant_id)
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"tenant.yaml 없음: {path} (tenant_id={tenant_id})")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
def is_registered(self, tenant_id: str) -> bool:
|
||||
return os.path.exists(self._yaml_path(tenant_id))
|
||||
|
||||
def load(self, tenant_id: str, use_cache: bool = True) -> TenantConfig:
|
||||
if use_cache and self._ttl > 0:
|
||||
hit = self._cache.get(tenant_id)
|
||||
if hit and (time.monotonic() - hit[0]) < self._ttl:
|
||||
return hit[1]
|
||||
|
||||
raw = self._read_yaml(tenant_id)
|
||||
|
||||
# inherits_base 이면 _base 를 deep-merge 한다 (자기 자신이 _base 면 스킵).
|
||||
inherits = raw.get("inherits_base", True)
|
||||
if inherits and tenant_id != _BASE_TENANT_ID and self.is_registered(_BASE_TENANT_ID):
|
||||
base_raw = self._read_yaml(_BASE_TENANT_ID)
|
||||
# _base 의 식별 필드는 병합하지 않는다.
|
||||
base_raw = {k: v for k, v in base_raw.items() if k not in ("tenant_id", "company_id", "name")}
|
||||
raw = _deep_merge(base_raw, raw)
|
||||
|
||||
raw.setdefault("tenant_id", tenant_id)
|
||||
config = TenantConfig.model_validate(raw)
|
||||
|
||||
if self._ttl > 0:
|
||||
self._cache[tenant_id] = (time.monotonic(), config)
|
||||
LOG.i(f"[TenantConfigLoader] loaded tenant_id={tenant_id} state_space_size={config.state.state_space_size} action_space_size={config.action_mapping.action_space_size}")
|
||||
return config
|
||||
|
||||
def invalidate(self, tenant_id: Optional[str] = None):
|
||||
if tenant_id is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(tenant_id, None)
|
||||
96
agent/tenancy/registry.py
Normal file
96
agent/tenancy/registry.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""TenantEngineRegistry & EngineFactory (계획서 B: 싱글톤 제거, 테넌트별 엔진 지연생성+캐시).
|
||||
|
||||
Chat_server 는 `chat_engine = ChatEngine()` 전역 무인자 싱글톤이라 멀티테넌트가 불가능했다.
|
||||
여기서는 테넌트별로 config 를 주입해 엔진을 조립하고(lock 보호 지연생성), 캐시한다.
|
||||
카드/모델 갱신 시 reload(tenant_id)로 해당 테넌트만 재조립한다.
|
||||
|
||||
엔진(TenantEngine)은 요청 간 공유되지만 **가변 episode 상태를 갖지 않는다**(EpisodeState 외부화).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from common.logger import LOG
|
||||
from negotiation.cards.action_card_mapper import ActionCardMapper
|
||||
from tenancy.config import TenantConfig
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
|
||||
class TenantEngine:
|
||||
"""한 테넌트의 협상 엔진 조립체 (불변 협력자 보관).
|
||||
|
||||
P4 범위: config + ActionCardMapper. policy/orchestrator/chat_engine 은 후속 단계에서
|
||||
이 팩토리 조립 라인에 추가된다(P5 ModelRepository warm-start, P7 ChatEngine).
|
||||
"""
|
||||
|
||||
def __init__(self, config: TenantConfig, mapper: ActionCardMapper):
|
||||
self.tenant_id = config.tenant_id
|
||||
self.company_id = config.company_id or config.tenant_id # _base/미시드 시 tenant_id 사용
|
||||
self.config = config
|
||||
self.mapper = mapper
|
||||
|
||||
@property
|
||||
def action_space_size(self) -> int:
|
||||
return self.mapper.action_space_size
|
||||
|
||||
@property
|
||||
def state_space_size(self) -> int:
|
||||
return self.config.state.state_space_size
|
||||
|
||||
|
||||
class EngineFactory:
|
||||
"""TenantConfig → TenantEngine 조립. (PolicyFactory/ModelRepository 는 후속 단계 결합)"""
|
||||
|
||||
@staticmethod
|
||||
def build(config: TenantConfig) -> TenantEngine:
|
||||
mapper = ActionCardMapper(config.action_mapping)
|
||||
return TenantEngine(config, mapper)
|
||||
|
||||
|
||||
class TenantEngineRegistry:
|
||||
def __init__(self, loader: Optional[TenantConfigLoader] = None, factory: type[EngineFactory] = EngineFactory):
|
||||
self._loader = loader or TenantConfigLoader()
|
||||
self._factory = factory
|
||||
self._engines: Dict[str, TenantEngine] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
|
||||
def is_registered(self, tenant_id: str) -> bool:
|
||||
return self._loader.is_registered(tenant_id)
|
||||
|
||||
async def get_engine(self, tenant_id: str) -> TenantEngine:
|
||||
cached = self._engines.get(tenant_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# 테넌트별 lock 으로 동시 첫 요청에서 1회만 조립 (double-checked).
|
||||
async with self._locks[tenant_id]:
|
||||
cached = self._engines.get(tenant_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not self.is_registered(tenant_id):
|
||||
raise KeyError(f"unregistered tenant: {tenant_id}")
|
||||
config = self._loader.load(tenant_id)
|
||||
engine = self._factory.build(config)
|
||||
self._engines[tenant_id] = engine
|
||||
LOG.i(f"[TenantEngineRegistry] built engine tenant_id={tenant_id} "
|
||||
f"state={engine.state_space_size} action={engine.action_space_size}")
|
||||
return engine
|
||||
|
||||
async def reload(self, tenant_id: str) -> Optional[TenantEngine]:
|
||||
"""해당 테넌트만 재조립 (카드/모델 갱신 시). 미등록이면 None."""
|
||||
async with self._locks[tenant_id]:
|
||||
self._loader.invalidate(tenant_id)
|
||||
self._engines.pop(tenant_id, None)
|
||||
if not self.is_registered(tenant_id):
|
||||
return None
|
||||
config = self._loader.load(tenant_id)
|
||||
engine = self._factory.build(config)
|
||||
self._engines[tenant_id] = engine
|
||||
return engine
|
||||
|
||||
def cached_tenants(self) -> list[str]:
|
||||
return list(self._engines.keys())
|
||||
|
||||
|
||||
# 앱 전역 레지스트리 (backend 의 모듈 싱글톤 컨벤션). 미들웨어/deps 가 참조한다.
|
||||
tenant_registry = TenantEngineRegistry()
|
||||
19
agent/tenants/_base/resources/client_step_mapping.json
Normal file
19
agent/tenants/_base/resources/client_step_mapping.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"_comment": "내부 step → 클라이언트 표시 step 그룹 매핑(기능적 매핑, 브랜드 무관).",
|
||||
"시작": "서비스안내",
|
||||
"서비스안내": "서비스안내",
|
||||
"담당자확인": "담당자확인",
|
||||
"담당자확인_아니오": "담당자확인",
|
||||
"정보변경_완료": "담당자확인",
|
||||
"협상품목안내": "협상품목안내",
|
||||
"기존가격제시": "가격협상",
|
||||
"가격협상": "가격협상",
|
||||
"가격협상_입력": "가격협상",
|
||||
"가격협상_확인": "가격협상",
|
||||
"가격협상_재입력": "가격협상",
|
||||
"가격협상_확인_버짓": "가격협상",
|
||||
"가격협상_와일드": "가격협상",
|
||||
"협상완료": "협상종료",
|
||||
"협상실패": "협상종료",
|
||||
"협상종료": "협상종료"
|
||||
}
|
||||
138
agent/tenants/_base/resources/scripts_renegotiation.json
Normal file
138
agent/tenants/_base/resources/scripts_renegotiation.json
Normal file
@ -0,0 +1,138 @@
|
||||
{
|
||||
"_comment": "재협상(renegotiation) 대화 스크립트. Chat_server 구조를 참고하되 브랜드/표현은 중립 재작성(CLEANROOM.md). {company_name}/{service_name} 및 변수는 ScriptRepository 가 치환.",
|
||||
"시작": {
|
||||
"script": "",
|
||||
"editor_script_id": "시작",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
"next_step": { "default": "서비스안내" },
|
||||
"type": "null",
|
||||
"chat_end": false
|
||||
},
|
||||
"서비스안내": {
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
||||
"editor_script_id": "서비스안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": ["확인"],
|
||||
"next_step": { "default": "담당자확인" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"담당자확인": {
|
||||
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"editor_script_id": "담당자확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
"next_step": { "예": "협상품목안내", "아니오": "담당자확인_아니오" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"담당자확인_아니오": {
|
||||
"script": "[아니오]를 선택하셨습니다. 담당자가 변경되어 정보를 수정하시려면 [정보변경]을, 실수로 선택하신 경우 다시 진행하려면 [돌아가기]를 선택해 주세요.",
|
||||
"editor_script_id": "담당자확인_아니오",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["돌아가기", "정보변경"],
|
||||
"next_step": { "돌아가기": "담당자확인", "정보변경": "정보변경_완료" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"정보변경_완료": {
|
||||
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
||||
"editor_script_id": "정보변경_완료",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
"next_step": null,
|
||||
"type": "text",
|
||||
"chat_end": true
|
||||
},
|
||||
"협상품목안내": {
|
||||
"script": "{company_name}는 귀사의 협력에 진심으로 감사드립니다. 이번 가격 협상 품목과 기본 정보를 안내드립니다. 좌측의 상품 정보를 확인해 주세요. 협상이 원만히 마무리되면 더 많은 협력 기회가 마련될 수 있습니다.",
|
||||
"editor_script_id": "협상품목안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": ["확인"],
|
||||
"next_step": { "확인": "기존가격제시" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"기존가격제시": {
|
||||
"script": "현재 기준 가격 정보를 바탕으로 협상을 시작하겠습니다. 제안하실 가격을 입력해 주세요.",
|
||||
"editor_script_id": "기존가격제시",
|
||||
"next_input_mode": "price",
|
||||
"input_options": [],
|
||||
"next_step": { "default": "가격협상_확인" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"가격협상_재입력": {
|
||||
"script": "다시 제안하실 가격을 입력해 주세요.",
|
||||
"editor_script_id": "가격협상_재입력",
|
||||
"next_input_mode": "price",
|
||||
"input_options": [],
|
||||
"next_step": { "default": "가격협상_확인" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"가격협상_확인": {
|
||||
"script": "{input_price}원으로 제안하시겠습니까?",
|
||||
"editor_script_id": "가격협상_확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
"next_step": {
|
||||
"예": [
|
||||
{ "condition": "check_wildcard_entry", "next": "가격협상_와일드" },
|
||||
{ "condition": "check_is_supplier_type_c", "next": "협상완료" },
|
||||
{ "condition": "check_price_match", "next": "협상완료" },
|
||||
{ "condition": "check_iteration_limit", "next": "협상실패" },
|
||||
{ "condition": "default", "next": "가격협상" }
|
||||
],
|
||||
"아니오": "가격협상_재입력"
|
||||
},
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"가격협상_확인_버짓": {
|
||||
"script": "{target}원으로 제안하시겠습니까?",
|
||||
"editor_script_id": "가격협상_확인_버짓",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
"next_step": { "예": "협상완료", "아니오": "가격협상_재입력" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"가격협상": {
|
||||
"script": "제안 감사합니다. 내부 검토 결과 추가 조정이 필요합니다. 다시 제안하실 가격을 입력해 주세요.",
|
||||
"editor_script_id": "가격협상",
|
||||
"next_input_mode": "price",
|
||||
"input_options": [],
|
||||
"next_step": { "default": "가격협상_확인" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"협상완료": {
|
||||
"script": "협조해 주신 덕분에 원만히 협상이 완료되었습니다. 협상 결과를 확인하신 뒤 동의해 주세요. 거래 약정에 따라 일부 조건이 조정될 수 있는 점 참고 부탁드립니다. 성실히 응해 주셔서 감사합니다.",
|
||||
"editor_script_id": "협상완료",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": ["협상 내용을 확인했으며, 이의가 없음에 동의합니다."],
|
||||
"next_step": { "default": "협상종료" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"협상실패": {
|
||||
"script": "이번 협상은 합의에 이르지 못했습니다. 시간 내어 참여해 주셔서 감사합니다.",
|
||||
"editor_script_id": "협상실패",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
"next_step": { "default": "협상종료" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
},
|
||||
"협상종료": {
|
||||
"script": "지금까지 {company_name} {service_name}를 통해 협상에 참여해 주셔서 감사합니다. 협상이 종료되었습니다.",
|
||||
"editor_script_id": "협상종료",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
"next_step": null,
|
||||
"type": "text",
|
||||
"chat_end": true
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user