Agent 서버 구축: 멀티테넌트 협상 PoC (UCB Q-Table 학습 + /chat + 14 API + 학습검증 하네스), config 단일화(local.toml) + 빌드 경량화

This commit is contained in:
hbyang 2026-06-17 11:10:03 +09:00
parent 212725e5f9
commit 951d6eab84
140 changed files with 6689 additions and 214 deletions

3
.gitignore vendored
View File

@ -3,6 +3,9 @@
.env.*
!.env.example
*.toml
# Python 바이트코드/캐시 — 절대 커밋하지 않는다.
__pycache__/
*.py[cod]

7
agent/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
__pycache__/
*.pyc
.pytest_cache/
.git/
tests/
*.md
eval_harness/reports/

12
agent/.gitignore vendored Normal file
View 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
View 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
View 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"]

View File

@ -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×(10.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 비교표/리포트.
상세 계획·검증 기준은 실행 계획서 참조.

View File

View 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
View File

View File

View 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()

View File

View 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
View 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
View 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()

View File

View 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
View 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

View File

View 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
View File

View 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)

View 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 # 베이스 시드 학습 에피소드 수

View 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
View 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

View File

View 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

View 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]]

View 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

View 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

View 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"]

View 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()

View 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)

View File

View File

View 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

View File

View 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,
)

View 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])

View 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

View File

View 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

View 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", []))

View File

View 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)

View 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 폴백).
"""

View 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)

View 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")

View 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)

View 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

View 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

View File

View 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

View 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})

View 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

View 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,
)

View 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))

View 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])

9
agent/pytest.ini Normal file
View 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
View 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
View File

27
agent/router/deps.py Normal file
View 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()

View File

View 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
View 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)

View File

View File

View 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())]}

View File

View 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)

View 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

View File

View 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"}

View File

View 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],
}

View File

View 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)

View 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

View File

View 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

View 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

View File

179
agent/tenancy/config.py Normal file
View 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)

View 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
View 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()

View File

@ -0,0 +1,19 @@
{
"_comment": "내부 step → 클라이언트 표시 step 그룹 매핑(기능적 매핑, 브랜드 무관).",
"시작": "서비스안내",
"서비스안내": "서비스안내",
"담당자확인": "담당자확인",
"담당자확인_아니오": "담당자확인",
"정보변경_완료": "담당자확인",
"협상품목안내": "협상품목안내",
"기존가격제시": "가격협상",
"가격협상": "가격협상",
"가격협상_입력": "가격협상",
"가격협상_확인": "가격협상",
"가격협상_재입력": "가격협상",
"가격협상_확인_버짓": "가격협상",
"가격협상_와일드": "가격협상",
"협상완료": "협상종료",
"협상실패": "협상종료",
"협상종료": "협상종료"
}

View 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