diff --git a/.gitignore b/.gitignore index 4383163..f2c6e78 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ .env.* !.env.example + +*.toml + # Python 바이트코드/캐시 — 절대 커밋하지 않는다. __pycache__/ *.py[cod] diff --git a/agent/.dockerignore b/agent/.dockerignore new file mode 100644 index 0000000..4b8d742 --- /dev/null +++ b/agent/.dockerignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.git/ +tests/ +*.md +eval_harness/reports/ diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..b5e3bab --- /dev/null +++ b/agent/.gitignore @@ -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 diff --git a/agent/CLEANROOM.md b/agent/CLEANROOM.md new file mode 100644 index 0000000..aba1694 --- /dev/null +++ b/agent/CLEANROOM.md @@ -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)은 알고리즘/구조 수준에서 유지하되, **값**은 우리 자체 설정으로 간다. diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..1dd0d02 --- /dev/null +++ b/agent/Dockerfile @@ -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"] diff --git a/agent/README.md b/agent/README.md index 53a5f28..1f587c2 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1 +1,192 @@ -마지막 내 도리는 하자 . \ No newline at end of file +# 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..toml` (`APP_ENV` 로 선택). +- 협상 엔진 내부는 **헥사고날 구조**(`negotiation/` 패키지)로 유지한다 — 도메인은 tenant-agnostic, config 주입. +- backend 가 `/chat` 을 agent 로 위임하거나, 프런트가 agent 를 직접 호출(인증 토큰은 backend 발급분 검증). + +**장단점** +- (+) 무거운 RL 의존성(torch/d3rlpy/obp)이 인증 backend 를 오염시키지 않음 — 격리 + 독립 스케일. +- (+) backend 컨벤션·`negosium_db` 단일 소스 재사용 → 학습 곡선·중복 제거. +- (+) PoC 알고리즘 실험의 잦은 재학습이 운영 backend 배포에 묶이지 않음. +- (−) `common/` 을 backend 와 별도 유지(현재는 복제) → 추후 공유 라이브러리화 검토. +- (−) 서비스 간 Protocol 버전 정합 관리 필요. + +### 테넌트 = company_id + +계획서의 문자열 `tenant_id` 는 이 레포에서 **`company.companies.company_id`(uuid)** 로 매핑한다(이미 1급 시민). +공유 베이스 정책은 예약 식별자(`_base`)로 표현한다. 모든 학습 테이블은 `company_id` 로 논리 격리한다. + +### RL 학습 자산 저장 위치 = learning 스키마 + +Q-Table·q_values·visit_counts·experience_logs(propensity/turn 포함)는 `negosium_db` 안의 +**신설 `learning` 스키마**(7번째)에 둔다. 기존 `company`/`card`/`negotiation`/`quotation` 스키마는 재사용한다. + +### 저작권 분리 (클린룸) — `CLEANROOM.md` + +플랫폼은 특정 고객사 독점 자산과 분리해 독립 설계한다. 참고 엔진(`Chat_server`)은 **기능 구조(아이디어·방법)** 이해용으로만 열람하고, **독점 표현물(협상 스크립트·고유 카드 코드·verbatim 라벨·튜닝값)은 반입하지 않는다.** 레포의 테넌트 프로파일은 **합성/중립 데모값**이며, 실제 운영값은 테넌트 비공개 소스(YAML/DB)에서 런타임 주입한다. 카드 코드는 우리 중립 스킴(`NGC-*`). 상세는 [CLEANROOM.md](CLEANROOM.md). + +## 디렉토리 구조 + +``` +agent/ +├── web_main.py # 엔트리포인트 (uvicorn, 포트 9500) +├── config/ # APP_ENV 별 toml (backend 컨벤션) +├── common/ # logger·enums·singleton·gmodel·gtime·db_session_manager (backend 이식) +├── router/ # FastAPI app + 미들웨어 + v1 라우터 +│ ├── router.py # app + lifespan + TenantMiddleware +│ ├── middleware/tenant_middleware.py # X-Tenant-ID → request.state.tenant_id (P4 완성) +│ └── v1/health/health.py +├── negotiation/ # 협상 엔진 (Chat_server 이식, 헥사고날) +│ ├── chat/service/ # chat_engine·step 핸들러 (P7/P8) +│ ├── orchestrator/ # negotiation_orchestrator (P4 팩토리화) +│ ├── policy/ policies/ # UCBPolicy + 상위 NegotiationPolicy 추상 (H0) +│ ├── qtable/ # state·q_table·reward·usecase (P2 config 주입) +│ ├── cards/ # CardSourcePort + card.* 어댑터 (P6) +│ └── profiling/ # ← 구 N-profiling 개명 (동적 import 해킹 제거) ✅ P0 완료 +├── tenancy/ # TenantConfig·로더·레지스트리 (P1/P4) +├── eval_harness/ # 알고리즘 비교 하네스 (PoC 본체, H1~H7) +├── tools/ # init_base·train·export 스크립트 +└── tests/ +``` + +## 실행 / 테스트 + +```bash +cd agent +pip install -r requirements.txt +pip install pytest pytest-asyncio httpx # 테스트 도구 + +# config 는 config.local.toml 하나만 사용(.gitignore — DB 비번·OpenAI 키 포함). +# 없으면 직접 생성(DB·OpenAI 값 입력). test/docker 도 이 파일로 폴백된다. + +python web_main.py # APP_ENV 기본 local, http://localhost:9500/docs +APP_ENV=test python -m pytest # 테스트 (config.local.toml 사용) +``` + +### 브라우저 데모 (스크립트 기반 협상 채팅) + +서버를 띄우고 **http://localhost:9500/demo** 접속 (`tests/negotiation_demo.html`, `/v1/chat` 구동). +테넌트/유형을 고르면 **서비스안내→담당자확인→협상품목안내→가격협상→와일드카드→협상완료** 대화가 +스크립트로 진행되고, 버튼/가격 입력이 step 에 따라 동적 렌더링. **가격협상 턴에서 UCB Q-Table 이 카드를 +선택하고 학습**(card_id·Q·visit 표시), 종료 시 성공/실패 보상 반영. 스크립트 브랜드는 테넌트별 치환(데모상사 A/B). + +### 콘솔 테스트 (프론트 없이) + +현재 구현된 부분을 콘솔로 확인하는 방법: + +**1) 의사결정 루프 데모** — 테넌트별 config 주입·상태분류·보상·DB 격리를 눈으로 확인: +```bash +APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 3턴 시나리오 +APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 다른 테넌트(다른 카드셋·임계값) +APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive # 직접 입력 +APP_ENV=local python -m tools.console_demo --tenant ktcommerce --no-db # DB 없이 +``` +> ⚠️ 카드선택은 임시 placeholder 정책(실제 UCB Q-Table 은 H1/P5). 학습은 아직 일어나지 않는다. + +**2) 서버 헬스/테넌트 라우팅** — 서버를 띄우고 curl: +```bash +APP_ENV=local python web_main.py # 다른 터미널에서: +curl localhost:9500/healthz # 200 +curl localhost:9500/v1/health # {"status":"ok",...} +curl localhost:9500/v1/foo # 400 TENANT_HEADER_MISSING (헤더 없음) +curl localhost:9500/v1/foo -H 'X-Tenant-ID: nonexistent' # 404 TENANT_NOT_REGISTERED +curl localhost:9500/v1/foo -H 'X-Tenant-ID: ktcommerce' # 통과(라우트 미존재라 404 Not Found) +``` + +**2-1) 협상 한 라운드 (HTTP 프리뷰)** — `POST /v1/negotiation/step` (Swagger: http://localhost:9500/docs): +```bash +curl -s -X POST localhost:9500/v1/negotiation/step \ + -H 'X-Tenant-ID: ktcommerce' -H 'Content-Type: application/json' \ + -d '{"revenue_amount":20000000,"distribution_code":"A","partner_count":1, + "acceptance_ratio":0.11,"input_price":990,"anchor_price":800,"target_price":1000, + "round_number":3,"outcome":"success"}' +# → state_index / card_id(NGC-A*) / reward / logged. 테넌트를 imarketkorea 로 바꾸면 다른 상태·카드(NGC-B*). +``` +> ⚠️ 카드선택은 임시 placeholder. 실제 대화 `/chat`·step 체계·학습형 정책은 P7/H1. + +**3) DB 격리 확인** — 콘솔 데모 실행 후 company_id 별 로그 건수 조회: +```bash +APP_ENV=local python -m tools.show_logs # learning.experience_logs 를 company_id 별로 집계 +``` + +## 진행 상황 (Phase) + +- ✅ **P0 스캐폴딩**: 디렉토리/config/common/람다DB/Protocol 골격, profiling 개명(동적 import 제거), + 앱 순환 import 없이 로드 + 테넌트 미들웨어 골격. (`tests/test_p0_scaffold.py` 5/5) +- ✅ **P1 TenantConfig & 로더**: pydantic config + YAML/`_base` deep-merge + TTL 캐시, + **클린룸 적용**(중립 데모값·`NGC-*` 카드). (`tests/test_p1_tenant_config.py` 7/7) +- ✅ **P2 State/Reward/Mapper config 주입**: `build_state`(IntEnum→주입, mixed-radix index), + `RewardCalculator`(자체 공식), `ActionCardMapper`(+중복방지 마스킹). 결정론·주입효과 검증. (`tests/test_p2_*` 8/8) +- ✅ **P3 learning 스키마**: `postgres-init/02-learning-schema.sql`(company_id 격리·복합유니크·propensity/turn), + ORM 모델, `LearningRepository`(company_id 생성자 박기·리셋 스코프). **실DB 검증**: 2테넌트 version_name 공존, + reset_all 타테넌트 무영향(파괴 테스트), company_id 위조방지. (`tests/test_p3_*` 5/5) +- ✅ **P4 Registry/Factory & 미들웨어**: 전역 싱글톤 제거 → `TenantEngineRegistry`(테넌트별 지연생성+lock 캐시), + `EngineFactory`, **episode_state 외부화**(`EpisodeState` 요청스코프 — 동시성 오염 방지), 미들웨어 미등록 404. + 검증: 두 테넌트 다른 엔진/카드, 동시요청 1회조립, 헤더 400·미등록 404. (`tests/test_p4_*` 7/7) +- ✅ **H0 Policy 추상 + H1 UCB Q-Table 정책**: `NegotiationPolicy`(ABC), `QTable`(numpy, Q-learning), + `UCBQTablePolicy`(UCB 선택+propensity 근사+마스킹), `QTablePolicyStore`(learning 스키마 로드/영속). + **`/v1/negotiation/step` 이 실제 학습형으로 교체** — 반복 호출 시 UCB 탐색 + Q 갱신 + DB 누적 + 테넌트 격리. + (`tests/test_h1_*` 7/7) +- ✅ **대화 스크립트 + ScriptRepository**: Chat_server 스크립트 구조 이식(재협상/재견적/와일드카드/step·변수맵), + **KT 브랜드→`{company_name}`/`{service_name}` 변수화 + 표현 중립 재작성**(클린룸). (`tests/test_scripts_*` 7/7) +- ✅ **P7 슬라이스 `/v1/chat`**: 인메모리 세션 + `ChatEngine`(step 전이·조건분기·와일드카드 진입) + + **가격협상 턴 UCB 카드선택·학습 + 종료보상 역전파**. 브라우저 채팅 UI. (`tests/test_p7_chat.py` 5/5) +- ✅ **H5 학습검증 하네스 (PoC 본체)**: 카드별 효과가 다른 시뮬 구매자(`eval_harness/`) → 정책 비교. + **학습형(qtable_ucb)이 random/static 대비 평균보상 우위(95%CI 분리)·좋은카드 적중 0.9 vs 0.32** → + "학습하면 성과가 오른다" 정량 입증. `python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce`. (`tests/test_h5_*` 5/5) +- ✅ **P7 14개 API**: chat / q-table(versions·switch·current) / experience-logs / reset-learning · + reset-all(타테넌트 무영향) / invalidate-session / **train**(오프라인 Q-learning) / verification-report / + card-update · card-search. 전부 X-Tenant-ID 격리. (`tests/test_p7_apis.py` 5/5) +- ✅ **P5 베이스 warm-start / cold-start 3단**: `warm_start_from_base`(차원 호환 시 base Q값/방문수 복제, + visit 감쇠·base_version_id 추적), cold-start 3단(warm-start→차원불일치 휴리스틱 폴백→첫 버전), + `tools/init_base.py`(시뮬레이터로 베이스 시드). 신규 테넌트 첫 협상 → `v000_warmstart_from_base`. (`tests/test_p5_*` 5/5) +- ✅ **P8-A 세션 상태 DB화**: `learning.chat_sessions` + `ChatSessionRepository`(인메모리 제거). + **서버 재시작/멀티워커에도 협상 진행 상태 복원**(라이브 검증: kill 후 재기동→같은 session_id 이어짐). (`tests/test_p8_*` 3/3) +- ⬜ H3 LinUCB / H4 OPE(IPS/DR/SNIPS) / H6 CQL+FQE / P8-B 채팅로그(negotiation.chats) 적재. + +**누적 테스트 73/73** (`cd agent && APP_ENV=test python -m pytest`). DB 테스트는 로컬 postgres 필요(미가용 시 skip). + +### 협상 경제 모델 (KT 구매자 관점) +- **KT커머스/아이마켓코리아 = 구매자(갑)**. 목표 = **싸게 매입**. 협력사(판매자)가 제시가를 낸다. +- **앵커링가(anchor) < 목표가(target).** 앵커링값은 **갑이 직접 입력**(UI 기본 제안 = `target×(1−0.01)`, 편집 가능). +- **협력사 제시가 ≤ 앵커가 → 우선협상(타결)**, 더 낮을수록 **KT 보상↑**. 앵커가 초과 → 카드로 인하 협상, **설정 카드 모두 소진 시 결렬**. +- 와일드카드: 앵커가 살짝 초과 구간에서 1% 인하/목표가 매칭 압박. + +### PoC 본체 결과 (H5, 위 경제모델 기준 / target=10000·anchor=8000 시나리오) +``` +python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce +policy success settled/tgt turns mean_rwd ±95%CI good_hit +random 0.988 0.904 2.02 1.1176 0.0206 0.350 +static 1.000 0.923 2.56 0.9995 0.0020 0.000 +qtable_ucb 0.998 0.881 1.46 1.2686 0.0088 1.000 ← 학습형(최저 매입가) +판정 ✅ PASS: 학습형이 baseline 대비 평균보상 우위(95%CI 분리) + 좋은카드 적중 우위 → 학습 루프 유효 + (settled/tgt 낮을수록 = 더 싸게 매입 = KT 이득. 학습형이 가장 낮음) +``` + +> **학습 한계(정직)**: 현재 보상은 snapshot 만으로 산출돼 **어떤 카드를 골랐는지에 무관**하다. 즉 UCB 탐색·Q갱신·영속·격리 '머신'은 실동작하지만, "어떤 카드가 더 좋은가"를 학습하려면 **행동-의존 보상**이 필요하다 → H5(LLM 구매자 시뮬레이터) 또는 실 협상결과 로그. 그게 PoC 본체의 다음 핵심. +- ⬜ 트랙2(하네스): H0 로깅 보강 → H1 QTable 어댑터 → … → H7 비교표/리포트. + +상세 계획·검증 기준은 실행 계획서 참조. diff --git a/agent/bootstrap/__init__.py b/agent/bootstrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/bootstrap/lifespan.py b/agent/bootstrap/lifespan.py new file mode 100644 index 0000000..1d27c2f --- /dev/null +++ b/agent/bootstrap/lifespan.py @@ -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}") diff --git a/agent/common/__init__.py b/agent/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/common/database/__init__.py b/agent/common/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/common/database/db_session_manager.py b/agent/common/database/db_session_manager.py new file mode 100644 index 0000000..b61efd3 --- /dev/null +++ b/agent/common/database/db_session_manager.py @@ -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() diff --git a/agent/common/database/model/__init__.py b/agent/common/database/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/common/database/model/models.py b/agent/common/database/model/models.py new file mode 100644 index 0000000..7ed8e49 --- /dev/null +++ b/agent/common/database/model/models.py @@ -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()")) diff --git a/agent/common/enums.py b/agent/common/enums.py new file mode 100644 index 0000000..6366dae --- /dev/null +++ b/agent/common/enums.py @@ -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 diff --git a/agent/common/logger.py b/agent/common/logger.py new file mode 100644 index 0000000..9a090e4 --- /dev/null +++ b/agent/common/logger.py @@ -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() diff --git a/agent/common/models/__init__.py b/agent/common/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/common/models/gmodel.py b/agent/common/models/gmodel.py new file mode 100644 index 0000000..2a4063d --- /dev/null +++ b/agent/common/models/gmodel.py @@ -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 diff --git a/agent/common/singleton.py b/agent/common/singleton.py new file mode 100644 index 0000000..47d87e6 --- /dev/null +++ b/agent/common/singleton.py @@ -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 diff --git a/agent/common/utils/__init__.py b/agent/common/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/common/utils/gtime.py b/agent/common/utils/gtime.py new file mode 100644 index 0000000..985e396 --- /dev/null +++ b/agent/common/utils/gtime.py @@ -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) diff --git a/agent/config/__init__.py b/agent/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/config/config_loader.py b/agent/config/config_loader.py new file mode 100644 index 0000000..0f53643 --- /dev/null +++ b/agent/config/config_loader.py @@ -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) diff --git a/agent/config/config_models.py b/agent/config/config_models.py new file mode 100644 index 0000000..2ae3a0d --- /dev/null +++ b/agent/config/config_models.py @@ -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//tenant.yaml (TenantConfig, P1). + 여기에는 서버군 공통값만 둔다. + """ + + # 테넌트 정적 리소스 루트 (tenants//...) + 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 # 베이스 시드 학습 에피소드 수 diff --git a/agent/config/server_configs.py b/agent/config/server_configs.py new file mode 100644 index 0000000..fab2fe6 --- /dev/null +++ b/agent/config/server_configs.py @@ -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() diff --git a/agent/conftest.py b/agent/conftest.py new file mode 100644 index 0000000..4215f6e --- /dev/null +++ b/agent/conftest.py @@ -0,0 +1,65 @@ +# 테스트도 APP_ENV=local 로 실행한다 (config.local.toml 사용). +# config.server_configs 가 import 되는 순간 config..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 diff --git a/agent/eval_harness/__init__.py b/agent/eval_harness/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/eval_harness/baselines.py b/agent/eval_harness/baselines.py new file mode 100644 index 0000000..aa6cbe5 --- /dev/null +++ b/agent/eval_harness/baselines.py @@ -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 diff --git a/agent/eval_harness/buyer.py b/agent/eval_harness/buyer.py new file mode 100644 index 0000000..3851e09 --- /dev/null +++ b/agent/eval_harness/buyer.py @@ -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]] diff --git a/agent/eval_harness/configs/exp_default.yaml b/agent/eval_harness/configs/exp_default.yaml new file mode 100644 index 0000000..6d0df5e --- /dev/null +++ b/agent/eval_harness/configs/exp_default.yaml @@ -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 diff --git a/agent/eval_harness/metrics.py b/agent/eval_harness/metrics.py new file mode 100644 index 0000000..1b4babe --- /dev/null +++ b/agent/eval_harness/metrics.py @@ -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 diff --git a/agent/eval_harness/registry.py b/agent/eval_harness/registry.py new file mode 100644 index 0000000..276fb74 --- /dev/null +++ b/agent/eval_harness/registry.py @@ -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"] diff --git a/agent/eval_harness/runner.py b/agent/eval_harness/runner.py new file mode 100644 index 0000000..1d6f516 --- /dev/null +++ b/agent/eval_harness/runner.py @@ -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() diff --git a/agent/eval_harness/simulator.py b/agent/eval_harness/simulator.py new file mode 100644 index 0000000..5b89dc1 --- /dev/null +++ b/agent/eval_harness/simulator.py @@ -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) diff --git a/agent/negotiation/__init__.py b/agent/negotiation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/cards/__init__.py b/agent/negotiation/cards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/cards/action_card_mapper.py b/agent/negotiation/cards/action_card_mapper.py new file mode 100644 index 0000000..7496f12 --- /dev/null +++ b/agent/negotiation/cards/action_card_mapper.py @@ -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 diff --git a/agent/negotiation/cards/adapters/__init__.py b/agent/negotiation/cards/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/cards/domain/__init__.py b/agent/negotiation/cards/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/cards/ports/__init__.py b/agent/negotiation/cards/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/chat/__init__.py b/agent/negotiation/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/chat/service/__init__.py b/agent/negotiation/chat/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py new file mode 100644 index 0000000..7c36961 --- /dev/null +++ b/agent/negotiation/chat/service/chat_engine.py @@ -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, + ) diff --git a/agent/negotiation/chat/service/chat_session_repository.py b/agent/negotiation/chat/service/chat_session_repository.py new file mode 100644 index 0000000..5f1fc22 --- /dev/null +++ b/agent/negotiation/chat/service/chat_session_repository.py @@ -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]) diff --git a/agent/negotiation/chat/service/script_repository.py b/agent/negotiation/chat/service/script_repository.py new file mode 100644 index 0000000..9683395 --- /dev/null +++ b/agent/negotiation/chat/service/script_repository.py @@ -0,0 +1,105 @@ +"""ScriptRepository — 테넌트 대화 스크립트 로드 + 치환 (P7 대화엔진의 데이터 계층). + +- rq_type("재협상"|"재견적")별 스크립트 + 와일드카드 스크립트 + step 매핑 + 변수 매핑 로드. +- 탐색 순서: tenants//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 diff --git a/agent/negotiation/orchestrator/__init__.py b/agent/negotiation/orchestrator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/policies/__init__.py b/agent/negotiation/policies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/policies/base.py b/agent/negotiation/policies/base.py new file mode 100644 index 0000000..fbb4cfa --- /dev/null +++ b/agent/negotiation/policies/base.py @@ -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 diff --git a/agent/negotiation/policies/qtable_policy.py b/agent/negotiation/policies/qtable_policy.py new file mode 100644 index 0000000..d0622d5 --- /dev/null +++ b/agent/negotiation/policies/qtable_policy.py @@ -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", [])) diff --git a/agent/negotiation/policy/__init__.py b/agent/negotiation/policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/policy/model_store.py b/agent/negotiation/policy/model_store.py new file mode 100644 index 0000000..f4aa200 --- /dev/null +++ b/agent/negotiation/policy/model_store.py @@ -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) diff --git a/agent/negotiation/profiling/__init__.py b/agent/negotiation/profiling/__init__.py new file mode 100644 index 0000000..844189e --- /dev/null +++ b/agent/negotiation/profiling/__init__.py @@ -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 폴백). +""" diff --git a/agent/negotiation/profiling/config.py b/agent/negotiation/profiling/config.py new file mode 100644 index 0000000..17f7487 --- /dev/null +++ b/agent/negotiation/profiling/config.py @@ -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) diff --git a/agent/negotiation/profiling/domain/__init__.py b/agent/negotiation/profiling/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/profiling/domain/graph.py b/agent/negotiation/profiling/domain/graph.py new file mode 100644 index 0000000..3d984f0 --- /dev/null +++ b/agent/negotiation/profiling/domain/graph.py @@ -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") diff --git a/agent/negotiation/profiling/infra/__init__.py b/agent/negotiation/profiling/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/profiling/infra/llm_adapter.py b/agent/negotiation/profiling/infra/llm_adapter.py new file mode 100644 index 0000000..63c53ec --- /dev/null +++ b/agent/negotiation/profiling/infra/llm_adapter.py @@ -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) diff --git a/agent/negotiation/profiling/script_modifier.py b/agent/negotiation/profiling/script_modifier.py new file mode 100644 index 0000000..e5c7ead --- /dev/null +++ b/agent/negotiation/profiling/script_modifier.py @@ -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 diff --git a/agent/negotiation/profiling/script_verifier.py b/agent/negotiation/profiling/script_verifier.py new file mode 100644 index 0000000..d23d2db --- /dev/null +++ b/agent/negotiation/profiling/script_verifier.py @@ -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 diff --git a/agent/negotiation/qtable/__init__.py b/agent/negotiation/qtable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/qtable/domain/__init__.py b/agent/negotiation/qtable/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/qtable/domain/model/__init__.py b/agent/negotiation/qtable/domain/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/qtable/domain/model/q_table.py b/agent/negotiation/qtable/domain/model/q_table.py new file mode 100644 index 0000000..85f51ee --- /dev/null +++ b/agent/negotiation/qtable/domain/model/q_table.py @@ -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 diff --git a/agent/negotiation/qtable/domain/model/snapshot.py b/agent/negotiation/qtable/domain/model/snapshot.py new file mode 100644 index 0000000..cda5ea0 --- /dev/null +++ b/agent/negotiation/qtable/domain/model/snapshot.py @@ -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}) diff --git a/agent/negotiation/qtable/domain/model/state.py b/agent/negotiation/qtable/domain/model/state.py new file mode 100644 index 0000000..965e89d --- /dev/null +++ b/agent/negotiation/qtable/domain/model/state.py @@ -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 diff --git a/agent/negotiation/qtable/domain/service/__init__.py b/agent/negotiation/qtable/domain/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/qtable/domain/service/reward_calculator.py b/agent/negotiation/qtable/domain/service/reward_calculator.py new file mode 100644 index 0000000..641d852 --- /dev/null +++ b/agent/negotiation/qtable/domain/service/reward_calculator.py @@ -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, + ) diff --git a/agent/negotiation/qtable/domain/service/state_calculator.py b/agent/negotiation/qtable/domain/service/state_calculator.py new file mode 100644 index 0000000..cd6a171 --- /dev/null +++ b/agent/negotiation/qtable/domain/service/state_calculator.py @@ -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)) diff --git a/agent/negotiation/qtable/infra/__init__.py b/agent/negotiation/qtable/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/qtable/infra/repository/__init__.py b/agent/negotiation/qtable/infra/repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/qtable/infra/repository/learning_repository.py b/agent/negotiation/qtable/infra/repository/learning_repository.py new file mode 100644 index 0000000..e08c063 --- /dev/null +++ b/agent/negotiation/qtable/infra/repository/learning_repository.py @@ -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]) diff --git a/agent/negotiation/qtable/usecase/__init__.py b/agent/negotiation/qtable/usecase/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/pytest.ini b/agent/pytest.ini new file mode 100644 index 0000000..05e0c77 --- /dev/null +++ b/agent/pytest.ini @@ -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 diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..ee60076 --- /dev/null +++ b/agent/requirements.txt @@ -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 diff --git a/agent/router/__init__.py b/agent/router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/deps.py b/agent/router/deps.py new file mode 100644 index 0000000..9099be5 --- /dev/null +++ b/agent/router/deps.py @@ -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() diff --git a/agent/router/middleware/__init__.py b/agent/router/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/middleware/tenant_middleware.py b/agent/router/middleware/tenant_middleware.py new file mode 100644 index 0000000..70d97eb --- /dev/null +++ b/agent/router/middleware/tenant_middleware.py @@ -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) diff --git a/agent/router/router.py b/agent/router/router.py new file mode 100644 index 0000000..a26c354 --- /dev/null +++ b/agent/router/router.py @@ -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.. 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) diff --git a/agent/router/v1/__init__.py b/agent/router/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/v1/card/__init__.py b/agent/router/v1/card/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/v1/card/card.py b/agent/router/v1/card/card.py new file mode 100644 index 0000000..a015e2d --- /dev/null +++ b/agent/router/v1/card/card.py @@ -0,0 +1,51 @@ +"""카드 매핑 API (card-update / card-search, tenant 스코프). + +PoC: action_id ↔ card_id 매핑을 learning.tenant_action_cards 에 둔다(config 기본 + DB override). +카드 스크립트 본문은 ScriptRepository(tenants//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())]} diff --git a/agent/router/v1/chat/__init__.py b/agent/router/v1/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/v1/chat/chat.py b/agent/router/v1/chat/chat.py new file mode 100644 index 0000000..02f2fad --- /dev/null +++ b/agent/router/v1/chat/chat.py @@ -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) diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py new file mode 100644 index 0000000..56f02fa --- /dev/null +++ b/agent/router/v1/chat/protocol.py @@ -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 diff --git a/agent/router/v1/health/__init__.py b/agent/router/v1/health/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/v1/health/health.py b/agent/router/v1/health/health.py new file mode 100644 index 0000000..93579db --- /dev/null +++ b/agent/router/v1/health/health.py @@ -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"} diff --git a/agent/router/v1/learning/__init__.py b/agent/router/v1/learning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/v1/learning/learning.py b/agent/router/v1/learning/learning.py new file mode 100644 index 0000000..64bb44c --- /dev/null +++ b/agent/router/v1/learning/learning.py @@ -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], + } diff --git a/agent/router/v1/negotiation/__init__.py b/agent/router/v1/negotiation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/router/v1/negotiation/negotiation.py b/agent/router/v1/negotiation/negotiation.py new file mode 100644 index 0000000..59378dd --- /dev/null +++ b/agent/router/v1/negotiation/negotiation.py @@ -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) diff --git a/agent/router/v1/negotiation/protocol.py b/agent/router/v1/negotiation/protocol.py new file mode 100644 index 0000000..ff6326e --- /dev/null +++ b/agent/router/v1/negotiation/protocol.py @@ -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 diff --git a/agent/services/__init__.py b/agent/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py new file mode 100644 index 0000000..5f3bd80 --- /dev/null +++ b/agent/services/chat_service.py @@ -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 diff --git a/agent/services/negotiation_service.py b/agent/services/negotiation_service.py new file mode 100644 index 0000000..0450536 --- /dev/null +++ b/agent/services/negotiation_service.py @@ -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 diff --git a/agent/tenancy/__init__.py b/agent/tenancy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/tenancy/config.py b/agent/tenancy/config.py new file mode 100644 index 0000000..dc419e2 --- /dev/null +++ b/agent/tenancy/config.py @@ -0,0 +1,179 @@ +"""TenantConfig — 테넌트별 협상 설정 단일 소스 (계획서 설계 A). + +Chat_server 에서 23곳에 하드코딩돼 있던 KT 가정(state 임계값·가중치·유통코드, reward, +policy, 카드 매핑, LLM 배포)을 한 pydantic 모델로 외부화한다. 도메인 코드는 이 config 를 +주입받아 tenant-agnostic 하게 동작한다(P2). + +저장 위치 (YAML + DB 하이브리드, 계획서 A): +- YAML(tenants//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//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) diff --git a/agent/tenancy/config_loader.py b/agent/tenancy/config_loader.py new file mode 100644 index 0000000..c2d803a --- /dev/null +++ b/agent/tenancy/config_loader.py @@ -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//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) diff --git a/agent/tenancy/registry.py b/agent/tenancy/registry.py new file mode 100644 index 0000000..5e9e648 --- /dev/null +++ b/agent/tenancy/registry.py @@ -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() diff --git a/agent/tenants/_base/resources/client_step_mapping.json b/agent/tenants/_base/resources/client_step_mapping.json new file mode 100644 index 0000000..4655848 --- /dev/null +++ b/agent/tenants/_base/resources/client_step_mapping.json @@ -0,0 +1,19 @@ +{ + "_comment": "내부 step → 클라이언트 표시 step 그룹 매핑(기능적 매핑, 브랜드 무관).", + "시작": "서비스안내", + "서비스안내": "서비스안내", + "담당자확인": "담당자확인", + "담당자확인_아니오": "담당자확인", + "정보변경_완료": "담당자확인", + "협상품목안내": "협상품목안내", + "기존가격제시": "가격협상", + "가격협상": "가격협상", + "가격협상_입력": "가격협상", + "가격협상_확인": "가격협상", + "가격협상_재입력": "가격협상", + "가격협상_확인_버짓": "가격협상", + "가격협상_와일드": "가격협상", + "협상완료": "협상종료", + "협상실패": "협상종료", + "협상종료": "협상종료" +} diff --git a/agent/tenants/_base/resources/scripts_renegotiation.json b/agent/tenants/_base/resources/scripts_renegotiation.json new file mode 100644 index 0000000..a4a0322 --- /dev/null +++ b/agent/tenants/_base/resources/scripts_renegotiation.json @@ -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 + } +} diff --git a/agent/tenants/_base/resources/scripts_requote.json b/agent/tenants/_base/resources/scripts_requote.json new file mode 100644 index 0000000..2b654aa --- /dev/null +++ b/agent/tenants/_base/resources/scripts_requote.json @@ -0,0 +1,147 @@ +{ + "_comment": "재견적(requote) 대화 스크립트. 신규 공급사 선정 흐름. 구조 보존 + 브랜드/표현 중립 재작성(CLEANROOM.md).", + "시작": { + "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": { "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": "null", + "input_options": [], + "next_step": { "default": "협상종료" }, + "type": "text", + "chat_end": false + }, + "배송형태선택": { + "script": "배송 형태를 선택해 주세요.", + "editor_script_id": "배송형태선택", + "next_input_mode": "delivery_type", + "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": { "예": "추가할인요청", "아니오": "가격재입력" }, + "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": "yes_no", + "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": "confirm", + "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 + } +} diff --git a/agent/tenants/_base/resources/scripts_wildcard.json b/agent/tenants/_base/resources/scripts_wildcard.json new file mode 100644 index 0000000..82cfb30 --- /dev/null +++ b/agent/tenants/_base/resources/scripts_wildcard.json @@ -0,0 +1,21 @@ +{ + "_comment": "와일드카드 분기 스크립트(1%인하 / 재원부족). 가격협상_확인의 check_wildcard_entry 조건에서 진입. 중립 재작성(CLEANROOM.md).", + "wild_card_1pct": { + "script": "제안해 주신 가격에 감사드립니다. 적극적으로 협조해 주신 덕분에 긍정적으로 검토되고 있습니다. 다만 내부 승인을 위해 조금 더 명분이 필요한 상황입니다. 약 1% 미만 추가 인하하여 {offer_1pct}원에 가능하실까요? 수락해 주신다면 즉시 우선협상 대상으로 검토하겠습니다.", + "type": "text", + "chat_end": false, + "next_input_mode": "yes_no", + "input_options": ["예", "아니오"], + "next_step": { "default": "협상완료" }, + "editor_script_id": "wild_card_1pct" + }, + "wild_card_budget": { + "script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 {target}원입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.", + "type": "text", + "chat_end": false, + "next_input_mode": "price", + "input_options": [], + "next_step": { "default": "가격협상_확인_버짓" }, + "editor_script_id": "wild_card_budget" + } +} diff --git a/agent/tenants/_base/resources/variable_mapping.json b/agent/tenants/_base/resources/variable_mapping.json new file mode 100644 index 0000000..9f2cb60 --- /dev/null +++ b/agent/tenants/_base/resources/variable_mapping.json @@ -0,0 +1,15 @@ +{ + "_comment": "표시 라벨 → 내부 변수 키 매핑(협상 feature 명, 기능적). 테넌트가 자사 라벨로 override 가능.", + "인터넷 최저가": "internet_min_price", + "앵커링 적용가": "anchoring_price", + "경쟁사 가격반영": "competitor_price_diff", + "거래기간": "trading_period", + "누적 거래 금액": "cumulative_amount", + "최근 1년간 총 거래 금액": "item_amount", + "1년간 거래액 비율": "item_ratio", + "고객 불만 발생 건수": "voc_count", + "협상 참여 응답 비율": "response_rate", + "상품 판매 기간": "product_sales_period", + "공급사 순위": "supplier_ranking", + "대금 지급 조건": "payment_terms" +} diff --git a/agent/tenants/_base/tenant.yaml b/agent/tenants/_base/tenant.yaml new file mode 100644 index 0000000..2156b66 --- /dev/null +++ b/agent/tenants/_base/tenant.yaml @@ -0,0 +1,65 @@ +# 공유 베이스 정책 (계획서 D). 신규 테넌트 cold-start 의 기준값. +# 모든 값은 우리 플랫폼의 중립 기본값이다(CLEANROOM.md). 특정 고객 운영값 복제 아님. +# 실제 운영값은 각 테넌트 YAML/DB 에서 주입한다. 차원 개수만 기능적 설계, 값/라벨은 우리 선택. +tenant_id: _base +inherits_base: false # 베이스는 자기 자신을 상속하지 않음 +name: "Shared Base Policy" + +state: + revenue: + thresholds: [10000000, 50000000] # 플랫폼 중립 기본 구간 + weights: [0.3, 0.6, 1.0] + descriptions: ["low", "mid", "high"] + distribution: + code_map: {A: 0, B: 1, C: 2} # 중립 예시 코드(테넌트가 자사 코드로 오버라이드) + weights: [0.3, 0.6, 1.0] + descriptions: ["channel_a", "channel_b", "channel_c"] + partner: + weights: [0.5, 1.0, 0.3] # single, multiple, none + descriptions: ["single", "multiple", "none"] + acceptance: + thresholds: [0.03, 0.09] + weights: [0.3, 0.6, 1.0] + descriptions: ["low", "mid", "high"] + price_zone: + weights: [1.0, 0.5] # at_or_below_anchor(우선협상), above_anchor(협상지속) + descriptions: ["at_or_below_anchor", "above_anchor"] + # state_space_size 는 차원 곱으로 자동 산출: 3×3×3×3×2 = 162 + +reward: + beta: 0.2 + success_reward: 1.0 + ongoing_reward: 0.0 + failure_penalty: -0.5 + penalty_lambda: 0.02 + w1: 0.2 + w2: 0.2 + w3: 0.2 + w4: 0.2 + w5: 0.2 + min_weight: 0.2 + max_weight: 0.8 + +policy: + type: ucb + learning_rate: 0.1 # 표준 기본값 + gamma: 0.95 + params: + exploration_constant: 1.4142135623730951 # sqrt(2) — 표준 UCB 상수 + epsilon: 1.0e-6 + +action_mapping: + type: file + action_to_card: {} # 베이스는 카드 매핑 없음 — 테넌트가 자사 카탈로그로 supply + +cards: + source_type: file + sync_interval_seconds: 300 + connection: {} + +llm: + enabled: false + +resources: + language: ko + scripts_dir: resources diff --git a/agent/tenants/imarketkorea/tenant.yaml b/agent/tenants/imarketkorea/tenant.yaml new file mode 100644 index 0000000..144f869 --- /dev/null +++ b/agent/tenants/imarketkorea/tenant.yaml @@ -0,0 +1,45 @@ +# 데모 테넌트 프로파일 (합성/중립값 — CLEANROOM.md). +# 멀티테넌트 분기 학습 시연용. 두 번째 테넌트는 차원(162)은 동일하게 두되 비즈니스 값만 달리한다 +# → base warm-start 호환(P5). 아래 값은 합성이며 특정 회사의 실제 운영값이 아니다. +tenant_id: imarketkorea +inherits_base: true +name: "Demo Tenant B" +company_id: null + +state: + revenue: + # 다른 거래 규모 가정(데모): 구간 임계값 상향 (차원 수는 3으로 동일) + thresholds: [30000000, 100000000] + weights: [0.3, 0.6, 1.0] + descriptions: ["low", "mid", "high"] + acceptance: + thresholds: [0.02, 0.07] + weights: [0.3, 0.6, 1.0] + descriptions: ["low", "mid", "high"] + +reward: + failure_penalty: -0.7 # 결렬 비용을 더 크게 둔 데모 프로파일 + beta: 0.25 + +action_mapping: + type: file + action_to_card: # 동일 차원(9) 유지 → warm-start 가능. 합성 데모 코드(테넌트 B). + "0": "NGC-B001" + "1": "NGC-B002" + "2": "NGC-B003" + "3": "NGC-B004" + "4": "NGC-B005" + "5": "NGC-B006" + "6": "NGC-B007" + "7": "NGC-B008" + "8": "NGC-B009" + +llm: + enabled: false + api_key_ref: TENANT_B_OPENAI_API_KEY + +resources: + language: ko + scripts_dir: resources + company_name: "데모상사 B" + service_name: "Negosium" diff --git a/agent/tenants/ktcommerce/tenant.yaml b/agent/tenants/ktcommerce/tenant.yaml new file mode 100644 index 0000000..3a98e95 --- /dev/null +++ b/agent/tenants/ktcommerce/tenant.yaml @@ -0,0 +1,30 @@ +# 데모 테넌트 프로파일 (합성/중립값 — CLEANROOM.md). +# tenant_id 는 라우팅 키일 뿐이며, 아래 값은 해당 회사의 실제 운영값이 아니다. +# 실제 운영 시 카드 카탈로그(card.nego_cards)·튜닝값은 테넌트 비공개 소스에서 주입한다. +tenant_id: ktcommerce +inherits_base: true +name: "Demo Tenant A" +company_id: null # P3 시드 시 company.companies.company_id(uuid) 로 채움 + +action_mapping: + type: file + action_to_card: # 우리 중립 데모 카드 코드(합성). 실제 카드 코드 아님. + "0": "NGC-A001" + "1": "NGC-A002" + "2": "NGC-A003" + "3": "NGC-A004" + "4": "NGC-A005" + "5": "NGC-A006" + "6": "NGC-A007" + "7": "NGC-A008" + "8": "NGC-A009" + +llm: + enabled: false # P7 에서 테넌트별 자격증명 주입 + api_key_ref: TENANT_A_OPENAI_API_KEY + +resources: + language: ko + scripts_dir: resources # 없으면 _base/resources 폴백 + company_name: "데모상사 A" # 스크립트 {company_name} 치환값 (합성) + service_name: "Negosium" diff --git a/agent/tests/__init__.py b/agent/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/tests/negotiation_demo.html b/agent/tests/negotiation_demo.html new file mode 100644 index 0000000..ff7d852 --- /dev/null +++ b/agent/tests/negotiation_demo.html @@ -0,0 +1,142 @@ + + + + + +Negosium Agent — 협상 채팅 (테스트) + + + +
+

Negosium Agent — 협상 채팅 /v1/chat

+

KT 구매자 관점: 협력사(판매자)가 제시가를 입력 → 앵커가 이하면 우선협상(타결), 초과면 카드로 인하 협상(카드 소진까지). 낮게 매입할수록 KT 이득.

+
+ +
+
+ + + + + +
+ +
+
+
+
+ + + + diff --git a/agent/tests/test_h1_qtable_policy.py b/agent/tests/test_h1_qtable_policy.py new file mode 100644 index 0000000..c04f075 --- /dev/null +++ b/agent/tests/test_h1_qtable_policy.py @@ -0,0 +1,145 @@ +"""H1 검증 — 실제 UCB Q-Table 정책 + learning 스키마 영속화 + 온라인 학습. + +1. UCB select: 가용 중 UCB 최대 선택, 사용 액션 마스킹, propensity = (1-ε)+ε/n. +2. Q-learning update: Q 가 보상 방향으로 이동, done 시 부트스트랩 없음. +3. predict_action_dist: ε-greedy 분포 합=1, greedy 에 질량. +4. warm_start: 차원 일치 시 Q 복제·visit 감쇠, 불일치 시 ValueError. +5. (DB) 버전 확보 idempotent + 셀 upsert/load 라운드트립. +6. (DB) service.step 반복 호출 시 visit/Q 누적 → 학습 진행 + 테넌트 격리. +""" + +import math +import os + +import numpy as np +import pytest + +from negotiation.policies.base import EpisodeState, PolicyContext, Transition +from negotiation.policies.qtable_policy import UCBQTablePolicy +from negotiation.qtable.domain.model.q_table import QTable +from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot +from negotiation.qtable.infra.repository.learning_repository import LearningRepository +from services.negotiation_service import NegotiationService +from router.v1.negotiation.protocol import Req_NegotiationStep +from tenancy.config_loader import TenantConfigLoader +from tenancy.registry import TenantEngineRegistry + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + + +def _snap(**o): + base = dict(revenue_amount=1, distribution_code="A", partner_count=1, acceptance_ratio=0.1, + input_price=900, anchor_price=800, target_price=1000) + base.update(o) + return NegotiationSnapshot(**base) + + +def _ctx(state_index, n=4, used=None): + return PolicyContext(state_index=state_index, snapshot=_snap(), action_space_size=n, + episode=EpisodeState(used_action_ids=set(used or []))) + + +# ---- 유닛 ---------------------------------------------------------------- +def test_ucb_selects_highest_and_masks_used(): + qt = QTable(2, 4) + qt.q[0] = np.array([1.0, 9.0, 2.0, 0.0]) + qt.visits[0] = np.array([5, 5, 5, 5]) # 방문 균등 → Q 가 지배 + p = UCBQTablePolicy(qt, exploration_constant=0.1, epsilon=0.1) + d = p.select(_ctx(0)) + assert d.action_id == 1 # 최고 Q + assert d.propensity == pytest.approx(0.9 + 0.1 / 4) + # 1 을 사용처리하면 다음은 다른 액션 + d2 = p.select(_ctx(0, used=[1])) + assert d2.action_id != 1 + + +def test_ucb_exploration_bonus_prefers_unvisited(): + qt = QTable(1, 3) + qt.q[0] = np.array([1.0, 0.0, 0.0]) + qt.visits[0] = np.array([100, 0, 0]) # action0 Q 높지만 과방문 + p = UCBQTablePolicy(qt, exploration_constant=math.sqrt(2), epsilon=0.1) + d = p.select(_ctx(0, n=3)) + assert d.action_id in (1, 2) # 미방문 액션의 탐색 보너스가 이긴다 + + +def test_q_update_moves_toward_reward(): + qt = QTable(2, 2, learning_rate=0.5) + new = qt.update(0, 0, reward=10.0, done=True) # 0 + 0.5*(10-0)=5 + assert new == pytest.approx(5.0) + # done=False + next state 부트스트랩 + qt.q[1] = np.array([4.0, 0.0]) + new2 = qt.update(0, 1, reward=1.0, next_state_index=1, done=False) # 0+0.5*(1+0.95*4-0) + assert new2 == pytest.approx(0.5 * (1 + 0.95 * 4)) + + +def test_predict_action_dist_is_epsilon_greedy(): + qt = QTable(1, 4); qt.q[0] = np.array([0, 9.0, 0, 0]); qt.visits[0] = np.array([5, 5, 5, 5]) + p = UCBQTablePolicy(qt, exploration_constant=0.1, epsilon=0.2) + dist = p.predict_action_dist(_ctx(0)) + assert dist.sum() == pytest.approx(1.0) + assert dist[1] == pytest.approx(0.8 + 0.2 / 4) # greedy + + +def test_warm_start_dimension_check(): + base = UCBQTablePolicy(QTable(162, 9)); base.qtable.q[0, 0] = 3.0; base.qtable.visits[0, 0] = 10 + tgt = UCBQTablePolicy(QTable(162, 9)) + tgt.warm_start(base) + assert tgt.qtable.q[0, 0] == 3.0 + assert tgt.qtable.visits[0, 0] == 5 # 감쇠(0.5) + with pytest.raises(ValueError): + UCBQTablePolicy(QTable(10, 9)).warm_start(base) # 차원 불일치 + + +# ---- DB ------------------------------------------------------------------ +@pytest.mark.asyncio +async def test_version_and_cell_persistence(db_engine): + repo = LearningRepository("co-h1") + v1 = await repo.get_or_create_active_version(state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95) + v2 = await repo.get_or_create_active_version(state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95) + assert v1 == v2 # idempotent (활성 버전 재사용) + + await repo.upsert_cell(v1, state_index=5, action_id=2, q_value=1.5, count=3) + await repo.upsert_cell(v1, state_index=5, action_id=2, q_value=2.5, count=4) # 갱신 + qcells, vcells = await repo.load_cells(v1) + qmap = {(s, a): q for s, a, q in qcells} + vmap = {(s, a): c for s, a, c in vcells} + assert qmap[(5, 2)] == 2.5 + assert vmap[(5, 2)] == 4 + + +@pytest.mark.asyncio +async def test_service_step_learns_and_isolates(db_engine): + reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + eng = await reg.get_engine("ktcommerce") + svc = NegotiationService() + + def req(): + return Req_NegotiationStep(revenue_amount=20_000_000, distribution_code="A", partner_count=1, + acceptance_ratio=0.11, input_price=990, anchor_price=800, target_price=1000, + round_number=3, outcome="success", log=True, learn=True) + + r1 = await svc.step(eng, req()) + r2 = await svc.step(eng, req()) + r3 = await svc.step(eng, req()) + # UCB 는 미방문 액션을 탐색하므로 매 호출 다른 액션을 고른다(정상). → state 전체 방문이 누적된다. + assert {r1.action_id, r2.action_id, r3.action_id} == {r1.action_id} or len({r1.action_id, r2.action_id, r3.action_id}) >= 2 + assert r1.learned is True and r1.policy == "qtable_ucb" + assert r1.updated_q > 0.0 # 성공 보상으로 Q 상승 + + # DB 에서 state 전체 방문 누적 확인 (state 58 = ktcommerce 의 이 snapshot) + from negotiation.qtable.domain.service.state_calculator import state_index + sidx = state_index(_snap(revenue_amount=20_000_000, acceptance_ratio=0.11, input_price=990, round_number=3), eng.config.state) + repo_kt = LearningRepository("ktcommerce") + vid = await repo_kt.get_or_create_active_version(state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95) + _, vcells = await repo_kt.load_cells(vid) + state_total = sum(c for s, a, c in vcells if s == sidx) + assert state_total == 3 # 3회 호출 → state 누적 방문 3 + + # 테넌트 격리: imarketkorea 는 별도 학습/별도 state + eng2 = await reg.get_engine("imarketkorea") + ri = await svc.step(eng2, req()) + assert ri.visit_count == 1 + + err, ck = await repo_kt.read(lambda s: repo_kt.count_experience(s)) + err, ci = await LearningRepository("imarketkorea").read(lambda s: LearningRepository("imarketkorea").count_experience(s)) + assert ck == 3 and ci == 1 # experience 격리 diff --git a/agent/tests/test_h5_harness.py b/agent/tests/test_h5_harness.py new file mode 100644 index 0000000..29437c0 --- /dev/null +++ b/agent/tests/test_h5_harness.py @@ -0,0 +1,59 @@ +"""H5 검증 — 학습 검증 하네스 (PoC 본체). + +핵심 판정(계획서 G): 학습형(qtable_ucb)이 random/static 대비 성과 우상향. +- 평균보상 우위(95%CI 분리) + '좋은 카드' 적중 우위 → 학습 루프 유효. +- 시뮬레이터 결정론(동일 seed 재현). +- 멀티테넌트 분리 실행(--tenant). +""" + +import pytest + +from eval_harness.buyer import HeuristicBuyer, Scenario, make_card_effectiveness +from eval_harness.runner import run +from eval_harness.simulator import run_episode +from negotiation.qtable.domain.model.q_table import QTable +from negotiation.policies.qtable_policy import UCBQTablePolicy +from tenancy.config import RewardConfig, StateConfig + + +def test_simulator_deterministic(): + eff = make_card_effectiveness(9, seed=1) + scn = Scenario(anchor_price=800, target_price=1000) + sc, rc = StateConfig(), RewardConfig() + + def one(): + b = HeuristicBuyer(eff, seed=7) + p = UCBQTablePolicy(QTable(sc.state_space_size, 9)) + return run_episode(p, b, scn, sc, rc, 9, learn=False).total_reward + + assert one() == one() # 동일 seed → 동일 결과 + + +def test_card_effectiveness_has_good_cards(): + eff = make_card_effectiveness(9, seed=42, n_good=3) + good = [a for a, e in eff.items() if e >= 0.7] + assert len(good) >= 3 # 효과 좋은 카드 존재 → 학습 대상 신호 + + +@pytest.mark.parametrize("tenant", ["ktcommerce", "imarketkorea"]) +def test_learning_beats_baseline(tenant): + report = run("configs/exp_default.yaml", tenant) + pols = report["policies"] + q = pols["qtable_ucb"] + rnd = pols["random"] + + # 평균보상 우위 + 95%CI 비중첩 + assert q["mean_reward"] > rnd["mean_reward"] + assert q["mean_reward"] - q["reward_ci95"] > rnd["mean_reward"] + rnd["reward_ci95"] + # 좋은 카드 적중 우위 (학습으로 카드 우열 파악) + assert q["good_card_hit_rate"] >= rnd["good_card_hit_rate"] + 0.2 + assert q["good_card_hit_rate"] >= 0.7 + # 종합 판정 + assert report["verdict"]["pass"] is True + + +def test_static_does_not_learn(): + report = run("configs/exp_default.yaml", "ktcommerce") + static = report["policies"]["static"] + # 정적 정책은 항상 고정 카드 → 좋은카드 적중 학습 없음(우연 일치만) + assert static["good_card_hit_rate"] <= report["policies"]["qtable_ucb"]["good_card_hit_rate"] diff --git a/agent/tests/test_negotiation_step_preview.py b/agent/tests/test_negotiation_step_preview.py new file mode 100644 index 0000000..1fb2c98 --- /dev/null +++ b/agent/tests/test_negotiation_step_preview.py @@ -0,0 +1,60 @@ +"""/v1/negotiation/step (P2 루프 HTTP 프리뷰) 회귀 스모크. + +서버 기동 후 실제로 칠 수 있는 유일한 업무 엔드포인트 — 테넌트 라우팅 + config 주입 + 응답 형태를 검증. +DB 로깅은 db_engine 픽스처 유무와 무관하게 log=false 로 끄고 검증(순수 응답 형태). +""" + +import pytest + +_BODY = { + "revenue_amount": 20_000_000, + "distribution_code": "A", + "partner_count": 1, + "acceptance_ratio": 0.11, + "input_price": 9950, + "anchor_price": 9900, # KT 앵커링가 (anchor < target) + "target_price": 10000, # KT 목표 매입가 + "round_number": 3, + "outcome": "success", + "log": False, +} + + +@pytest.mark.asyncio +async def test_step_requires_tenant_header(client): + r = await client.post("/v1/negotiation/step", json=_BODY) + assert r.status_code == 400 + assert r.json()["result"]["desc"] == "TENANT_HEADER_MISSING" + + +@pytest.mark.asyncio +async def test_step_tenant_divergence(client): + rk = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=_BODY) + ri = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=_BODY) + assert rk.status_code == 200 and ri.status_code == 200 + dk, di = rk.json(), ri.json() + # 같은 입력이 테넌트 config 에 따라 다른 상태/카드로 갈린다 + assert dk["card_id"].startswith("NGC-A") + assert di["card_id"].startswith("NGC-B") + assert dk["state_index"] != di["state_index"] + # 응답 형태 + assert dk["result"]["success"] is True + # input 9950: price_reward=(10000-9950)/(10000-9900)=0.5, round3 weight=0.2, + # penalty=0.06, end(success)=1.0 → 0.2*0.5+1.0-0.06 = 1.04 + assert dk["reward"]["total"] == pytest.approx(1.04, abs=1e-6) + assert dk["logged"] is False # log=false + + +@pytest.mark.asyncio +async def test_step_invalid_distribution_code_is_domain_error(client): + body = dict(_BODY, distribution_code="Z") + r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=body) + assert r.status_code == 200 # HTTP 는 200, 결과코드로 에러 전달(backend 규약) + assert r.json()["result"]["desc"] == "NEGO_INVALID_STEP" + + +@pytest.mark.asyncio +async def test_step_invalid_outcome(client): + body = dict(_BODY, outcome="maybe") + r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=body) + assert r.json()["result"]["desc"] == "INVALID_REQUEST_DATA" diff --git a/agent/tests/test_p0_scaffold.py b/agent/tests/test_p0_scaffold.py new file mode 100644 index 0000000..8866489 --- /dev/null +++ b/agent/tests/test_p0_scaffold.py @@ -0,0 +1,66 @@ +"""P0 스캐폴딩 검증 (계획서 P0 _검증_). + +1. 앱이 순환 import 없이 로드된다 (`import router.router`). +2. N-profiling→profiling 개명: 정식 패키지 import 가 동작한다 (동적 import 해킹 제거). +3. 테넌트 미들웨어: 헤더 부재 시 400, 화이트리스트(healthz/health)는 통과. +""" + +import importlib + +import pytest + + +def test_app_imports_without_circular(): + # 순환 import 없이 FastAPI app 로드. + mod = importlib.import_module("router.router") + assert mod.app is not None + + +def test_profiling_is_proper_package(): + # 동적 import 해킹 없이 정식 패키지로 import 된다. + from negotiation.profiling.script_verifier import ScriptVerifier + from negotiation.profiling.config import LlmCredentials + + verifier = ScriptVerifier() + original = [{"type": "p", "children": [{"text": "가격은 {price} 입니다"}]}] + # 파라미터 보존 검증: 동일 스크립트는 통과. + assert verifier.verify_script(original, original) is True + # 파라미터 누락 검증: {price} 가 사라지면 실패. + modified = [{"type": "p", "children": [{"text": "가격 안내"}]}] + assert verifier.verify_script(original, modified) is False + + # config 는 toml(OpenAIConfig) 주입 구조 (.env 트리 탐색 해킹 제거). + creds = LlmCredentials.from_config() + assert isinstance(creds, LlmCredentials) + + +def test_no_dynamic_import_hack_in_profiling(): + # script_modifier 가 _load_dynamic_module/spec_from_file_location 해킹 없이 import 된다. + import negotiation.profiling.script_modifier as sm + + src = importlib.util.find_spec("negotiation.profiling.script_modifier") + assert src is not None + assert "n_profiling" not in __import__("sys").modules # 수동 등록한 가짜 패키지가 없어야 함 + assert hasattr(sm, "ScriptModifier") + + +@pytest.mark.asyncio +async def test_healthz_and_health(client): + r = await client.get("/healthz") + assert r.status_code == 200 + + r = await client.get("/v1/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +@pytest.mark.asyncio +async def test_tenant_header_required(client): + # 화이트리스트가 아닌 경로는 X-Tenant-ID 부재 시 400. + r = await client.get("/v1/some-protected-path") + assert r.status_code == 400 + assert r.json()["result"]["desc"] == "TENANT_HEADER_MISSING" + + # 헤더가 있으면 미들웨어 통과 (라우트 미존재라 404). + r = await client.get("/v1/some-protected-path", headers={"X-Tenant-ID": "ktcommerce"}) + assert r.status_code == 404 diff --git a/agent/tests/test_p1_tenant_config.py b/agent/tests/test_p1_tenant_config.py new file mode 100644 index 0000000..4f5e908 --- /dev/null +++ b/agent/tests/test_p1_tenant_config.py @@ -0,0 +1,105 @@ +"""P1 검증 (계획서 P1, CLEANROOM.md 반영). + +검증 기준 변경: "Chat_server 하드코딩과 1:1 일치"(독점값 복제)를 폐기하고, +"우리 플랫폼 중립 기본값이 정확히 로드 + deep-merge + 차원 산출이 동작"으로 대체한다. + +1. 데모 테넌트 config 가 플랫폼 중립 기본값으로 로드된다(합성 카드 코드/중립 라벨). +2. _base deep-merge 단위테스트 (상속 + 부분 오버라이드). +3. state_space_size 자동 산출 (3×3×3×3×2 = 162), action_space_size = 9. +4. 두 번째 테넌트 오버라이드가 base 위에 정확히 병합 + 차원 동일(warm-start 호환). +5. 독점 카드 코드(NC26-*)·verbatim 라벨이 레포 config 에 없다(클린룸 가드). +""" + +import os + +from tenancy.config_loader import TenantConfigLoader, _deep_merge + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + + +def _loader() -> TenantConfigLoader: + return TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0) + + +def test_platform_neutral_defaults_load(): + cfg = _loader().load("ktcommerce") + + # 우리 플랫폼 중립 기본값 (CLEANROOM.md) + assert cfg.state.revenue.thresholds == [10_000_000, 50_000_000] + assert cfg.state.revenue.weights == [0.3, 0.6, 1.0] + assert cfg.state.revenue.descriptions == ["low", "mid", "high"] + assert cfg.state.distribution.code_map == {"A": 0, "B": 1, "C": 2} + assert cfg.state.partner.weights == [0.5, 1.0, 0.3] + assert cfg.state.acceptance.thresholds == [0.03, 0.09] + assert cfg.state.price_zone.weights == [1.0, 0.5] + + # reward: 균등 가중치 중립 기본값 + assert cfg.reward.beta == 0.2 + assert cfg.reward.success_reward == 1.0 + assert cfg.reward.failure_penalty == -0.5 + assert (cfg.reward.w1, cfg.reward.w2, cfg.reward.w3, cfg.reward.w4, cfg.reward.w5) == (0.2, 0.2, 0.2, 0.2, 0.2) + + # policy: 표준 UCB 기본값 + assert cfg.policy.type == "ucb" + assert cfg.policy.learning_rate == 0.1 + assert cfg.policy.gamma == 0.95 + assert abs(cfg.policy.params["exploration_constant"] - 2 ** 0.5) < 1e-12 + + +def test_state_space_and_action_space_size(): + cfg = _loader().load("ktcommerce") + assert cfg.state.state_space_size == 162 # 3×3×3×3×2 (차원 구성은 기능적 설계) + assert cfg.action_mapping.action_space_size == 9 + # 합성 데모 카드 코드 (우리 스킴) + assert cfg.action_mapping.action_to_card["0"] == "NGC-A001" + assert cfg.action_mapping.action_to_card["8"] == "NGC-A009" + + +def test_base_deep_merge_unit(): + base = {"a": 1, "nested": {"x": 1, "y": 2}, "list": [1, 2]} + override = {"b": 2, "nested": {"y": 20, "z": 30}, "list": [9]} + merged = _deep_merge(base, override) + assert merged["a"] == 1 + assert merged["b"] == 2 + assert merged["nested"] == {"x": 1, "y": 20, "z": 30} # dict 키 단위 병합 + assert merged["list"] == [9] # 리스트는 통째 교체 + + +def test_second_tenant_overrides_merged_on_base(): + cfg = _loader().load("imarketkorea") + # 오버라이드된 값 + assert cfg.state.revenue.thresholds == [30_000_000, 100_000_000] + assert cfg.reward.failure_penalty == -0.7 + assert cfg.reward.beta == 0.25 + # 오버라이드 안 한 값은 base 상속 + assert cfg.state.distribution.code_map == {"A": 0, "B": 1, "C": 2} + assert cfg.reward.success_reward == 1.0 + assert cfg.policy.type == "ucb" + # 차원은 데모 테넌트 A 와 동일(162) → base warm-start 호환(P5) + assert cfg.state.state_space_size == 162 + assert cfg.action_mapping.action_space_size == 9 + assert cfg.action_mapping.action_to_card["0"] == "NGC-B001" + + +def test_base_self_does_not_inherit(): + cfg = _loader().load("_base") + assert cfg.tenant_id == "_base" + assert cfg.action_mapping.action_space_size == 0 + + +def test_is_registered(): + loader = _loader() + assert loader.is_registered("ktcommerce") is True + assert loader.is_registered("imarketkorea") is True + assert loader.is_registered("nonexistent_tenant") is False + + +def test_no_proprietary_card_codes_or_labels_in_repo(): + """클린룸 가드: 독점 카드 코드/ verbatim 라벨이 로드된 config 에 존재하지 않는다.""" + for tid in ("_base", "ktcommerce", "imarketkorea"): + cfg = _loader().load(tid) + cards = " ".join(cfg.action_mapping.action_to_card.values()) + assert "NC26" not in cards # 참고 엔진의 고유 카드 코드 + # verbatim 한글 라벨이 아닌 중립 라벨 사용 + for d in cfg.state.revenue.descriptions: + assert "원" not in d diff --git a/agent/tests/test_p2_state_reward_mapper.py b/agent/tests/test_p2_state_reward_mapper.py new file mode 100644 index 0000000..5e93c95 --- /dev/null +++ b/agent/tests/test_p2_state_reward_mapper.py @@ -0,0 +1,144 @@ +"""P2 검증 (계획서 P2 _검증_, 클린룸 반영). + +기준 변경: "신규 결과 == Chat_server 결과"(독점 복제)가 아니라 +"동일 config + 동일 입력 → 결정론적 동일 출력 + config 주입이 실제로 반영"으로 검증한다. + +1. build_state/state_index 결정론 + 범위 [0, state_space_size). +2. mixed-radix 인코딩이 전 차원조합에 대해 [0,162) 전단사(bijection). +3. config 주입 효과: 테넌트별 임계값이 다르면 같은 입력이 다른 상태로 분류된다. +4. reward 결정론 + config(failure_penalty 등) 반영. +5. ActionCardMapper 라운드트립 + 중복방지 마스킹. +""" + +import itertools +import os + +import numpy as np + +from negotiation.cards.action_card_mapper import ActionCardMapper +from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot +from negotiation.qtable.domain.model.state import encode_index +from negotiation.qtable.domain.service.reward_calculator import RewardCalculator +from negotiation.qtable.domain.service.state_calculator import build_state, state_dims, state_index +from tenancy.config_loader import TenantConfigLoader + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + + +def _cfg(tid: str): + return TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load(tid) + + +def _snapshot(**over) -> NegotiationSnapshot: + base = dict( + revenue_amount=5_000_000, + distribution_code="A", + partner_count=1, + acceptance_ratio=0.05, + input_price=9800, + anchor_price=9900, # KT 앵커링가 (anchor < target) + target_price=10000, # KT 목표 매입가 + round_number=1, + outcome=NegotiationOutcome.ONGOING, + ) + base.update(over) + return NegotiationSnapshot(**base) + + +def test_build_state_deterministic_and_in_range(): + cfg = _cfg("ktcommerce") + snap = _snapshot() + s1 = build_state(snap, cfg.state) + s2 = build_state(snap, cfg.state) + assert s1 == s2 # 결정론 + idx = state_index(snap, cfg.state) + assert idx == state_index(snap, cfg.state) + assert 0 <= idx < cfg.state.state_space_size + + +def test_encode_index_known_example(): + # dims=[3,3,3,3,2], 인덱스 (1,0,0,0,1) → ((((1)*3+0)*3+0)*3+0)*2+1 = 54*1 +1 = 55 + assert encode_index([1, 0, 0, 0, 1], [3, 3, 3, 3, 2]) == 55 + # 최소/최대 + assert encode_index([0, 0, 0, 0, 0], [3, 3, 3, 3, 2]) == 0 + assert encode_index([2, 2, 2, 2, 1], [3, 3, 3, 3, 2]) == 161 + + +def test_mixed_radix_bijection_over_full_space(): + cfg = _cfg("ktcommerce") + dims = state_dims(cfg.state) + assert dims == [3, 3, 3, 3, 2] + seen = set() + for combo in itertools.product(*[range(d) for d in dims]): + idx = encode_index(list(combo), dims) + seen.add(idx) + # 162개 조합이 0..161 에 1:1 + assert seen == set(range(cfg.state.state_space_size)) + + +def test_config_injection_changes_classification(): + # revenue=20,000,000 원: ktcommerce(th=[10M,50M]) → mid(1), imarketkorea(th=[30M,100M]) → low(0) + snap = _snapshot(revenue_amount=20_000_000) + kt = build_state(snap, _cfg("ktcommerce").state) + imk = build_state(snap, _cfg("imarketkorea").state) + assert kt.revenue_idx == 1 + assert imk.revenue_idx == 0 + assert kt != imk # 같은 입력이 테넌트 config 에 따라 다른 상태 + + +def test_distribution_unknown_code_raises(): + cfg = _cfg("ktcommerce") + snap = _snapshot(distribution_code="Z") # code_map 에 없음 + try: + build_state(snap, cfg.state) + assert False, "unknown distribution code should raise" + except ValueError: + pass + + +def test_price_zone_and_partner_buckets(): + cfg = _cfg("ktcommerce").state + # 제시가 ≤ 앵커가(9900) → 우선협상 구간(0) + assert build_state(_snapshot(input_price=9800), cfg).price_zone_idx == 0 + # 제시가 > 앵커가 → 협상 지속 구간(1) + assert build_state(_snapshot(input_price=10500), cfg).price_zone_idx == 1 + # partner: 0->none(2), 1->single(0), 3->multiple(1) + assert build_state(_snapshot(partner_count=0), cfg).partner_idx == 2 + assert build_state(_snapshot(partner_count=1), cfg).partner_idx == 0 + assert build_state(_snapshot(partner_count=3), cfg).partner_idx == 1 + + +def test_reward_deterministic_and_config_driven(): + snap = _snapshot(outcome=NegotiationOutcome.FAILURE, round_number=2) + kt_rc = RewardCalculator(_cfg("ktcommerce").reward) # failure_penalty -0.5 + imk_rc = RewardCalculator(_cfg("imarketkorea").reward) # failure_penalty -0.7 + + r1 = kt_rc.calculate(snap) + r2 = kt_rc.calculate(snap) + assert r1 == r2 # 결정론 + assert r1.end_reward == -0.5 # config 반영 + assert imk_rc.calculate(snap).end_reward == -0.7 + + # 성공 라운드 보상이 실패보다 크다 (방향성) + success = _snapshot(outcome=NegotiationOutcome.SUCCESS, round_number=0) + assert kt_rc.calculate(success).total > kt_rc.calculate(_snapshot(outcome=NegotiationOutcome.FAILURE, round_number=0)).total + + +def test_action_card_mapper_roundtrip_and_mask(): + cfg = _cfg("ktcommerce") + mapper = ActionCardMapper(cfg.action_mapping) + assert mapper.action_space_size == 9 + assert mapper.get_card_id(0) == "NGC-A001" + assert mapper.get_action_id("NGC-A001") == 0 + assert mapper.get_card_id(99) is None + + # 중복방지 마스킹: 사용한 action 제외 + mask = mapper.available_mask(used_action_ids={0, 3}) + assert mask.dtype == np.bool_ + assert mask[0] == False and mask[3] == False + assert mask[1] == True + assert mask.sum() == 7 + + # reload 로 다른 테넌트 카드셋 교체 + mapper.reload(_cfg("imarketkorea").action_mapping) + assert mapper.get_card_id(0) == "NGC-B001" diff --git a/agent/tests/test_p3_learning_schema.py b/agent/tests/test_p3_learning_schema.py new file mode 100644 index 0000000..6bfad47 --- /dev/null +++ b/agent/tests/test_p3_learning_schema.py @@ -0,0 +1,126 @@ +"""P3 검증 (계획서 P3 _검증_). + +1. learning 스키마 테이블/제약 무결성 (create + 컬럼 propensity/turn/company_id 등 존재). +2. 2테넌트 동일 version_name 공존 (UNIQUE(company_id, version_name) 덕분). +3. company_id 자동 주입 + 위조 방지(log_transition 이 company_id 강제). +4. reset_all 이 자사 데이터만 삭제, 타테넌트 무영향 (파괴 테스트). +5. experience_logs 신규 컬럼(propensity/turn/available_actions/settled_price) 채워짐. + +DB 미가용 시 db_engine 픽스처가 skip 한다. +""" + +import uuid + +import pytest + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType, ErrorType +from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot +from negotiation.qtable.infra.repository.learning_repository import LearningRepository + +COMPANY_A = "company-aaaa" +COMPANY_B = "company-bbbb" + + +async def _read(func): + return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, func) + + +@pytest.mark.asyncio +async def test_two_tenants_same_version_name_coexist(db_engine): + repo_a = LearningRepository(COMPANY_A) + repo_b = LearningRepository(COMPANY_B) + + # 동일 version_name 을 두 회사가 각각 생성 — 공존해야 한다. + err_a = await DB_SESSION_MNG.execute_lambda_run( + [DBType.MAIN.value], + [lambda s: repo_a.create_version(s, version_name="v000", scope=2, state_space_size=162, action_space_size=9, is_active=True)], + ) + err_b = await DB_SESSION_MNG.execute_lambda_run( + [DBType.MAIN.value], + [lambda s: repo_b.create_version(s, version_name="v000", scope=2, state_space_size=162, action_space_size=9, is_active=True)], + ) + assert err_a == ErrorType.SUCCESS + assert err_b == ErrorType.SUCCESS + + err, va = await _read(lambda s: repo_a.get_version_by_name(s, "v000")) + assert err == ErrorType.SUCCESS and va is not None and va.company_id == COMPANY_A + err, vb = await _read(lambda s: repo_b.get_version_by_name(s, "v000")) + assert vb is not None and vb.company_id == COMPANY_B + assert va.version_id != vb.version_id + + +@pytest.mark.asyncio +async def test_duplicate_version_same_company_rejected(db_engine): + repo = LearningRepository(COMPANY_A) + mk = lambda s: repo.create_version(s, version_name="dup", scope=2, state_space_size=162, action_space_size=9) + assert await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [mk]) == ErrorType.SUCCESS + # 같은 회사 + 같은 version_name → 유니크 위반 + err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [mk]) + assert err == ErrorType.DB_ALREADY_SAME_KEY + + +@pytest.mark.asyncio +async def test_log_transition_forces_company_id_and_new_columns(db_engine): + repo = LearningRepository(COMPANY_A) + snap = NegotiationSnapshot( + revenue_amount=5_000_000, distribution_code="A", partner_count=1, acceptance_ratio=0.05, + input_price=900, anchor_price=800, target_price=1000, round_number=2, outcome=NegotiationOutcome.ONGOING, + ) + data = { + "company_id": "ATTACKER", # 위조 시도 — repo 가 자사 company_id 로 덮어써야 함 + "state_index": 55, "action_id": 3, "card_id": "NGC-A004", + "snapshot": snap.to_dict(), + "propensity": 0.2, "turn": 2, "available_actions": [0, 1, 2, 3], + "settled_price": 950, "q_value_at_selection": 0.1, + } + err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)]) + assert err == ErrorType.SUCCESS + + err, cnt_a = await _read(lambda s: repo.count_experience(s)) + assert cnt_a == 1 + # 위조한 company_id 로는 조회되지 않는다 + err, cnt_atk = await _read(lambda s: LearningRepository("ATTACKER").count_experience(s)) + assert cnt_atk == 0 + + # 신규 컬럼 값 확인 + from sqlalchemy import select + from common.database.model.models import ExperienceLog + + err, rows = await _read(lambda s: DB_SESSION_MNG.execute(s, select(ExperienceLog).where(ExperienceLog.company_id == COMPANY_A))) + log = rows[0] + assert log.propensity == 0.2 + assert log.turn == 2 + assert log.available_actions == [0, 1, 2, 3] + assert log.settled_price == 950 + assert log.snapshot["round_number"] == 2 + + +@pytest.mark.asyncio +async def test_reset_all_isolates_tenants(db_engine): + """파괴 테스트: company A 의 reset_all 이 company B 데이터를 건드리면 안 된다.""" + repo_a = LearningRepository(COMPANY_A) + repo_b = LearningRepository(COMPANY_B) + + base = dict(state_index=10, action_id=1, card_id="x") + for repo, cid in ((repo_a, COMPANY_A), (repo_b, COMPANY_B)): + for _ in range(3): + await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s, r=repo: r.log_transition(s, dict(base))]) + + err, ca = await _read(lambda s: repo_a.count_experience(s)) + err, cb = await _read(lambda s: repo_b.count_experience(s)) + assert ca == 3 and cb == 3 + + # A 만 리셋 + assert await repo_a.reset_all() == ErrorType.SUCCESS + + err, ca2 = await _read(lambda s: repo_a.count_experience(s)) + err, cb2 = await _read(lambda s: repo_b.count_experience(s)) + assert ca2 == 0 # A 의 데이터는 사라짐 + assert cb2 == 3 # B 의 데이터는 그대로 (타테넌트 무영향) + + +@pytest.mark.asyncio +async def test_repo_requires_company_id(): + with pytest.raises(ValueError): + LearningRepository("") diff --git a/agent/tests/test_p4_registry_middleware.py b/agent/tests/test_p4_registry_middleware.py new file mode 100644 index 0000000..51dd027 --- /dev/null +++ b/agent/tests/test_p4_registry_middleware.py @@ -0,0 +1,112 @@ +"""P4 검증 (계획서 P4 _검증_). + +1. 두 테넌트가 서로 다른 엔진/카드매핑(q_table 차원·action) 사용. +2. 엔진 지연생성 + 캐시(동일 테넌트는 동일 인스턴스). +3. 동시 첫 요청에서 lock 으로 1회만 조립 (동시성). +4. 미등록 테넌트 get_engine → KeyError. +5. 미들웨어: 헤더 누락 400, 미등록 404, 등록 테넌트는 통과. +6. episode 상태 외부화: EpisodeState 는 요청 스코프(엔진에 없음). +""" + +import asyncio +import os + +import pytest + +from negotiation.policies.base import EpisodeState +from tenancy.config_loader import TenantConfigLoader +from tenancy.registry import EngineFactory, TenantEngine, TenantEngineRegistry + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + + +def _registry() -> TenantEngineRegistry: + return TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + + +@pytest.mark.asyncio +async def test_two_tenants_distinct_engines(): + reg = _registry() + e1 = await reg.get_engine("ktcommerce") + e2 = await reg.get_engine("imarketkorea") + assert e1 is not e2 + assert e1.tenant_id == "ktcommerce" and e2.tenant_id == "imarketkorea" + # 서로 다른 카드매핑 (다른 카드셋) + assert e1.mapper.get_card_id(0) == "NGC-A001" + assert e2.mapper.get_card_id(0) == "NGC-B001" + # 차원 + assert e1.state_space_size == 162 and e1.action_space_size == 9 + + +@pytest.mark.asyncio +async def test_engine_cached(): + reg = _registry() + a = await reg.get_engine("ktcommerce") + b = await reg.get_engine("ktcommerce") + assert a is b # 캐시 — 동일 인스턴스 + + +@pytest.mark.asyncio +async def test_concurrent_first_build_once(): + # 조립 횟수 카운트용 팩토리 + builds = {"n": 0} + + class CountingFactory(EngineFactory): + @staticmethod + def build(config): + builds["n"] += 1 + return EngineFactory.build(config) + + reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), factory=CountingFactory) + results = await asyncio.gather(*[reg.get_engine("ktcommerce") for _ in range(12)]) + # 모두 같은 인스턴스 + 1회만 조립 + assert all(r is results[0] for r in results) + assert builds["n"] == 1 + + +@pytest.mark.asyncio +async def test_unregistered_raises(): + reg = _registry() + with pytest.raises(KeyError): + await reg.get_engine("nonexistent_tenant") + assert reg.is_registered("ktcommerce") is True + assert reg.is_registered("nonexistent_tenant") is False + + +@pytest.mark.asyncio +async def test_reload_rebuilds_only_that_tenant(): + reg = _registry() + a = await reg.get_engine("ktcommerce") + b = await reg.get_engine("imarketkorea") + reloaded = await reg.reload("ktcommerce") + assert reloaded is not a # 재조립됨 + assert await reg.get_engine("imarketkorea") is b # 타테넌트는 그대로 + + +def test_episode_state_is_request_scoped(): + # 엔진은 episode 상태를 갖지 않는다 — EpisodeState 는 독립 객체. + es1 = EpisodeState() + es2 = EpisodeState() + es1.mark_used(3) + assert es1.used_action_ids == {3} + assert es2.used_action_ids == set() # 서로 오염 없음 + assert not hasattr(TenantEngine, "used_action_ids") + + +@pytest.mark.asyncio +async def test_middleware_header_missing_unregistered_registered(client): + # 헤더 누락 → 400 + r = await client.get("/v1/protected") + assert r.status_code == 400 + assert r.json()["result"]["desc"] == "TENANT_HEADER_MISSING" + + # 미등록 → 404 (TENANT_NOT_REGISTERED) + r = await client.get("/v1/protected", headers={"X-Tenant-ID": "nonexistent_tenant"}) + assert r.status_code == 404 + assert r.json()["result"]["desc"] == "TENANT_NOT_REGISTERED" + + # 등록 테넌트 → 미들웨어 통과 (라우트 미존재라 404지만 TENANT_NOT_REGISTERED 아님) + r = await client.get("/v1/protected", headers={"X-Tenant-ID": "ktcommerce"}) + assert r.status_code == 404 + body = r.json() + assert body.get("result", {}).get("desc") != "TENANT_NOT_REGISTERED" diff --git a/agent/tests/test_p5_warmstart.py b/agent/tests/test_p5_warmstart.py new file mode 100644 index 0000000..7118ff3 --- /dev/null +++ b/agent/tests/test_p5_warmstart.py @@ -0,0 +1,98 @@ +"""P5 검증 — 베이스 warm-start / cold-start 3단 (계획서 D). + +1. warm_start_from_base: 차원 호환 시 base Q값/방문수 복제(visit 감쇠), base_version_id 추적. +2. cold-start: 신규 테넌트 첫 정책 로드 → v000_warmstart_from_base 생성. +3. 차원 불일치 → warm-start None → 휴리스틱 빈 버전 폴백. +4. 활성 버전 있으면 warm-start 안 함(기존 사용). +""" + +import os + +import pytest + +from common.database.model.models import BASE_COMPANY_ID +from negotiation.policy.model_store import QTablePolicyStore +from negotiation.qtable.infra.repository.learning_repository import LearningRepository +from tenancy.config_loader import TenantConfigLoader +from tenancy.registry import TenantEngineRegistry + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + + +def _reg(): + return TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + + +async def _seed_base(S=162, A=9): + """_base 활성 버전 + 셀 시드 (state 5 의 action 2·3).""" + base = LearningRepository(BASE_COMPANY_ID) + vid = await base.get_or_create_active_version( + state_space_size=S, action_space_size=A, learning_rate=0.1, discount_factor=0.95, + scope=1, version_name="base_v000") + await base.reset_learning() + await base.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=10) + await base.upsert_cell(vid, state_index=5, action_id=3, q_value=0.5, count=4) + return vid + + +@pytest.mark.asyncio +async def test_warm_start_copies_base_with_decayed_visits(db_engine): + base_vid = await _seed_base() + repo = LearningRepository("co-new") + vid = await repo.warm_start_from_base(state_space_size=162, action_space_size=9, + learning_rate=0.1, discount_factor=0.95, visit_decay=0.5) + assert vid is not None + + # 버전 메타: scope=tenant, base_version_id 추적, 활성 + err, ver = await repo.read(lambda s: repo.get_version_by_name(s, "v000_warmstart_from_base")) + assert ver is not None and ver.scope == 2 and ver.is_active + assert str(ver.base_version_id) == str(base_vid) + + # Q값 복제 + visit 감쇠 복제 + qcells, vcells = await repo.load_cells(vid) + qmap = {(s, a): q for s, a, q in qcells} + vmap = {(s, a): c for s, a, c in vcells} + assert qmap[(5, 2)] == 0.9 and qmap[(5, 3)] == 0.5 + assert vmap[(5, 2)] == 5 and vmap[(5, 3)] == 2 # 10*0.5, 4*0.5 + + +@pytest.mark.asyncio +async def test_cold_start_creates_warmstart_version(db_engine): + await _seed_base() + eng = await _reg().get_engine("ktcommerce") # 활성 버전 없음 → cold-start + policy, version_id, repo = await QTablePolicyStore.load(eng) + err, ver = await repo.read(lambda s: repo.get_active_version(s)) + assert ver.version_name == "v000_warmstart_from_base" + # 복제된 Q가 정책에 적재됨 + assert policy.qtable.q[5, 2] == 0.9 + + +@pytest.mark.asyncio +async def test_dimension_mismatch_falls_back_to_heuristic(db_engine): + await _seed_base(S=162, A=9) + repo = LearningRepository("co-mismatch") + # 다른 차원 요청 → 복제 불가 + vid = await repo.warm_start_from_base(state_space_size=100, action_space_size=9, + learning_rate=0.1, discount_factor=0.95) + assert vid is None # 호출자가 휴리스틱 폴백 + + +@pytest.mark.asyncio +async def test_no_base_returns_none(db_engine): + # base 미시드 → warm-start None + repo = LearningRepository("co-nobase") + vid = await repo.warm_start_from_base(state_space_size=162, action_space_size=9, + learning_rate=0.1, discount_factor=0.95) + assert vid is None + + +@pytest.mark.asyncio +async def test_existing_version_not_warmstarted(db_engine): + await _seed_base() + eng = await _reg().get_engine("ktcommerce") + # 첫 로드 → warm-start 버전 생성 + await QTablePolicyStore.load(eng) + # 둘째 로드 → 기존 활성 버전 재사용(중복 warm-start 안 함) + _, _, repo = await QTablePolicyStore.load(eng) + err, vers = await repo.read(lambda s: repo.list_versions(s)) + assert len(vers) == 1 diff --git a/agent/tests/test_p7_apis.py b/agent/tests/test_p7_apis.py new file mode 100644 index 0000000..a261ff7 --- /dev/null +++ b/agent/tests/test_p7_apis.py @@ -0,0 +1,94 @@ +"""P7 — 14개 API 보존 스모크 (tenant 헤더 격리). + +Chat_server 14개 API 대응: health, chat, invalidate-session, card-update, card-search, +reset-learning, reset-all, q-table/{versions,switch,current}, experience-logs, train, verification-report. +모두 X-Tenant-ID 헤더 필요(누락 400). 동작 + company_id 격리 확인. +""" + +import pytest + +H = {"X-Tenant-ID": "ktcommerce"} + + +@pytest.mark.asyncio +async def test_health_no_tenant(client): + assert (await client.get("/v1/health")).status_code == 200 + + +@pytest.mark.asyncio +async def test_tenant_header_required_on_management(client): + for path in ["/v1/q-table/versions", "/v1/experience-logs", "/v1/card-search"]: + r = await client.get(path) + assert r.status_code == 400, path + + +@pytest.mark.asyncio +async def test_qtable_lifecycle_and_logs(client, db_engine): + # 활성 버전 없음 → current 빈 상태 + r = await client.get("/v1/q-table/current", headers=H) + assert r.status_code == 200 + + # /chat 한 번 돌려 학습 데이터 생성 (가격협상까지) + sid = None + for ui in [None, "확인", "예", "확인", "11000", "예", "10200", "예", "9800", "예", + "협상 내용을 확인했으며, 이의가 없음에 동의합니다."]: + cr = await client.post("/v1/chat", headers=H, json={"session_id": sid, "user_input": ui, "rq_type": "재협상"}) + sid = cr.json()["session_id"] + if cr.json().get("chat_end"): + break + + # 버전 생성됨 + 활성 + versions = (await client.get("/v1/q-table/versions", headers=H)).json() + assert versions["versions"] and any(v["is_active"] for v in versions["versions"]) + cur = (await client.get("/v1/q-table/current", headers=H)).json() + assert cur["active_version"] is not None and cur["q_value_rows"] >= 1 + + # 경험 로그 + logs = (await client.get("/v1/experience-logs?limit=10", headers=H)).json() + assert logs["total"] >= 1 and len(logs["logs"]) >= 1 + + # 검증 리포트 + rep = (await client.get("/v1/verification-report", headers=H)).json() + assert rep["experience_total"] >= 1 + + # 오프라인 학습 + tr = (await client.post("/v1/train", headers=H, json={"epochs": 2})).json() + assert tr["success"] and tr["trained_transitions"] >= 1 + + +@pytest.mark.asyncio +async def test_card_update_search(client, db_engine): + up = (await client.post("/v1/card-update", headers=H, json={"action_id": 0, "card_id": "CUSTOM-X"})).json() + assert up["success"] + s = (await client.get("/v1/card-search?card_id=CUSTOM-X", headers=H)).json() + assert s["found"] and 0 in s["action_ids"] + allm = (await client.get("/v1/card-search", headers=H)).json() + assert any(m["card_id"] == "CUSTOM-X" for m in allm["mapping"]) + + +@pytest.mark.asyncio +async def test_invalidate_and_reset_scoped(client, db_engine): + # 가격협상 카드선택이 일어나는 긴 경로(850→900→990)로 양 테넌트 데이터 생성 + convo = [None, "확인", "예", "확인", "11000", "예", "10200", "예", "9800", "예", + "협상 내용을 확인했으며, 이의가 없음에 동의합니다."] + sid = None + for ui in convo: + cr = await client.post("/v1/chat", headers=H, json={"session_id": sid, "user_input": ui}) + sid = cr.json()["session_id"] + if cr.json().get("chat_end"): + break + H2 = {"X-Tenant-ID": "imarketkorea"} + sid2 = None + for ui in convo: + cr = await client.post("/v1/chat", headers=H2, json={"session_id": sid2, "user_input": ui}) + sid2 = cr.json()["session_id"] + if cr.json().get("chat_end"): + break + + before2 = (await client.get("/v1/experience-logs", headers=H2)).json()["total"] + assert before2 >= 1 + + # ktcommerce reset-all → imarketkorea 무영향 + assert (await client.post("/v1/reset-all", headers=H)).json()["success"] + assert (await client.get("/v1/experience-logs", headers=H)).json()["total"] == 0 + assert (await client.get("/v1/experience-logs", headers=H2)).json()["total"] == before2 diff --git a/agent/tests/test_p7_chat.py b/agent/tests/test_p7_chat.py new file mode 100644 index 0000000..7b9c70d --- /dev/null +++ b/agent/tests/test_p7_chat.py @@ -0,0 +1,124 @@ +"""P7 슬라이스 검증 — 대화형 /chat (스크립트 구동 + 카드선택 학습 + 와일드카드). + +1. 전체 대화: 서비스안내→담당자확인→협상품목안내→가격협상→와일드카드→협상완료→협상종료(chat_end). +2. 가격협상 턴에서 카드 선택 + 학습(card_id, updated_q). +3. 와일드카드 발동(wild_card_budget) + 종료 보상(success). +4. 브랜드 치환 테넌트별(데모상사 A/B), 클린룸(스크립트에 KT 흔적 없음). +5. 경험로그 적재 + 종료 후 진행 시 에러. +6. (HTTP) 헤더로 새 세션 시작 + 한 턴 진행. +""" + +import os +import re + +import pytest + +from router.v1.chat.protocol import Req_Chat +from services.chat_service import ChatService, reset_sessions +from negotiation.qtable.infra.repository.learning_repository import LearningRepository +from tenancy.config_loader import TenantConfigLoader +from tenancy.registry import TenantEngineRegistry + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") +_KT = re.compile(r"kt\s*commerce|nego-?wiz", re.IGNORECASE) + + +def _reg(): + return TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + + +async def _run(svc, eng, turns): + """turns: user_input 리스트(첫 None=시작). 반환: 응답 리스트.""" + sid, out = None, [] + for ui in turns: + r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui, rq_type="재협상")) + sid = r.session_id + out.append(r) + if r.chat_end: + break + return out + + +@pytest.mark.asyncio +async def test_full_conversation_reaches_completion(db_engine): + reset_sessions() + eng = await _reg().get_engine("ktcommerce") + svc = ChatService() + # anchor=9900, target=10000(기본). 11000(>10395)→가격협상(카드), 10200(9900~10395)→와일드카드, 9800(≤anchor)→우선협상 + turns = [None, "확인", "예", "확인", "11000", "예", "10200", "예", "9800", "예", + "협상 내용을 확인했으며, 이의가 없음에 동의합니다."] + out = await _run(svc, eng, turns) + steps = [r.step for r in out] + + assert steps[0] == "서비스안내" + assert "협상완료" in steps + assert out[-1].step == "협상종료" and out[-1].chat_end is True + + # 가격협상 카드선택 + 학습 + nego = [r for r in out if r.step == "가격협상"] + assert nego and nego[0].card_id and nego[0].card_id.startswith("NGC-A") + assert nego[0].updated_q is not None + + # 와일드카드 발동 + assert any(r.step == "wild_card_budget" for r in out) + + # 종료 보상(success) + done = [r for r in out if r.outcome == "success"] + assert done and done[0].reward_total is not None + + # 브랜드 치환 + 클린룸 + assert "데모상사 A" in out[0].script + assert not _KT.search(out[0].script) + + # 경험로그 적재 + repo = LearningRepository("ktcommerce") + err, cnt = await repo.read(lambda s: repo.count_experience(s)) + assert cnt >= 1 + + +@pytest.mark.asyncio +async def test_priority_completes_without_wildcard(db_engine): + reset_sessions() + eng = await _reg().get_engine("ktcommerce") + svc = ChatService() + # 첫 제시가가 앵커가(9900) 이하 → 우선협상 → 바로 협상완료(카드/와일드카드 없이) + out = await _run(svc, eng, [None, "확인", "예", "확인", "9800", "예", + "협상 내용을 확인했으며, 이의가 없음에 동의합니다."]) + steps = [r.step for r in out] + assert "협상완료" in steps + assert "wild_card_budget" not in steps + assert "가격협상" not in steps # 우선협상이라 카드 협상 없이 타결 + + +@pytest.mark.asyncio +async def test_tenant_brand_isolation(db_engine): + reset_sessions() + svc = ChatService() + reg = _reg() + ik = await svc.chat(await reg.get_engine("imarketkorea"), Req_Chat(session_id=None, rq_type="재협상")) + assert "데모상사 B" in ik.script + + +@pytest.mark.asyncio +async def test_advance_after_end_errors(db_engine): + reset_sessions() + eng = await _reg().get_engine("ktcommerce") + svc = ChatService() + out = await _run(svc, eng, [None, "확인", "예", "확인", "1000", "예", + "협상 내용을 확인했으며, 이의가 없음에 동의합니다."]) + sid = out[-1].session_id + r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인")) + assert r.result.desc == "NEGO_INVALID_STEP" + + +@pytest.mark.asyncio +async def test_http_chat_start(client): + r = await client.post("/v1/chat", headers={"X-Tenant-ID": "ktcommerce"}, json={"rq_type": "재협상"}) + assert r.status_code == 200 + d = r.json() + assert d["step"] == "서비스안내" + assert d["input_options"] == ["확인"] + assert "데모상사 A" in d["script"] + # 헤더 없으면 400 + r2 = await client.post("/v1/chat", json={"rq_type": "재협상"}) + assert r2.status_code == 400 diff --git a/agent/tests/test_p8_session_db.py b/agent/tests/test_p8_session_db.py new file mode 100644 index 0000000..68f7c9b --- /dev/null +++ b/agent/tests/test_p8_session_db.py @@ -0,0 +1,69 @@ +"""P8-A 검증 — /chat 세션 상태 DB 영속화 (재시작/멀티워커 안전). + +1. 세션이 DB(learning.chat_sessions)에 저장된다. +2. 새 ChatService 인스턴스(=다른 워커/재시작 모사)로 같은 session_id 를 이어가도 진행 상태 복원. +3. company_id 격리(타테넌트 session_id 로는 조회 안 됨). +""" + +import pytest + +from negotiation.chat.service.chat_session_repository import ChatSessionRepository +from router.v1.chat.protocol import Req_Chat +from services.chat_service import ChatService +from tenancy.config_loader import TenantConfigLoader +from tenancy.registry import TenantEngineRegistry +import os + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + + +def _eng(): + reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + return reg + + +@pytest.mark.asyncio +async def test_session_persists_and_resumes_across_instances(db_engine): + reg = _eng() + eng = await reg.get_engine("ktcommerce") + + # 인스턴스 1: 협상 시작 + 몇 턴 진행 + svc1 = ChatService() + r = await svc1.chat(eng, Req_Chat(rq_type="재협상", target_price=10000, anchor_price=8000)) + sid = r.session_id + for ui in ["확인", "예", "확인", "11000"]: + r = await svc1.chat(eng, Req_Chat(session_id=sid, user_input=ui)) + step_before = r.step + assert step_before == "가격협상_확인" # 11000 제시 후 확인 단계 + + # DB 에 저장됐는지 확인 + saved = await ChatSessionRepository(eng.company_id).get(sid) + assert saved is not None and saved.step == step_before + assert saved.context["anchor_price"] == 8000 + + # 인스턴스 2 (재시작/다른 워커 모사): 같은 session_id 로 이어가기 + svc2 = ChatService() + r2 = await svc2.chat(eng, Req_Chat(session_id=sid, user_input="예")) + assert r2.step == "가격협상" # 상태가 복원되어 다음 step 으로 진행 + assert r2.card_id is not None # 가격협상 턴 → 카드 선택 이어짐 + + +@pytest.mark.asyncio +async def test_session_company_scoped(db_engine): + reg = _eng() + eng = await reg.get_engine("ktcommerce") + svc = ChatService() + r = await svc.chat(eng, Req_Chat(rq_type="재협상")) + sid = r.session_id + + # 자사(ktcommerce)로는 조회됨 + assert await ChatSessionRepository(eng.company_id).get(sid) is not None + # 타테넌트(imarketkorea) company_id 로는 조회 안 됨 (격리) + assert await ChatSessionRepository("imarketkorea").get(sid) is None + + +@pytest.mark.asyncio +async def test_get_none_for_missing(db_engine): + repo = ChatSessionRepository("ktcommerce") + assert await repo.get(None) is None + assert await repo.get("00000000-0000-0000-0000-000000000000") is None diff --git a/agent/tests/test_scripts_resources.py b/agent/tests/test_scripts_resources.py new file mode 100644 index 0000000..ef5b23c --- /dev/null +++ b/agent/tests/test_scripts_resources.py @@ -0,0 +1,89 @@ +"""대화 스크립트 리소스 검증 (Chat_server 구조 참고 적용 + KT 중립화). + +1. 재협상/재견적 스크립트 step 구조 보존 (핵심 step 키 존재, next_step/모드 형태). +2. 와일드카드(wild_card_1pct/budget) 존재 + 병합. +3. 클린룸 가드: 'kt'/'commerce'/'커머스'/'Nego-Wiz' 등 특정사 표현이 남아있지 않음. +4. 브랜드 치환: {company_name}/{service_name} 가 테넌트별 값으로 치환. +5. 변수 치환: {input_price} 등 협상 변수 치환, 누락 변수는 원형 유지. +""" + +import json +import os +import re + +import pytest + +from negotiation.chat.service.script_repository import ScriptRepository +from tenancy.config_loader import TenantConfigLoader + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") +_FORBIDDEN = re.compile(r"kt\s*commerce|케이티|커머스|nego-?wiz", re.IGNORECASE) + + +def _repo(tenant_id="ktcommerce"): + cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load(tenant_id) + return ScriptRepository(cfg, _TENANTS_DIR) + + +def test_renegotiation_structure_preserved(): + s = _repo().load_scripts("재협상") + for key in ["서비스안내", "담당자확인", "협상품목안내", "기존가격제시", "가격협상_확인", "협상완료", "협상실패", "협상종료"]: + assert key in s, f"missing step {key}" + # 조건 분기 보존 (가격협상_확인 예 → 조건 리스트) + yes = s["가격협상_확인"]["next_step"]["예"] + conds = {c["condition"] for c in yes} + assert {"check_wildcard_entry", "check_iteration_limit", "default"} <= conds + # 담당자확인 yes/no 분기 + assert s["담당자확인"]["next_step"] == {"예": "협상품목안내", "아니오": "담당자확인_아니오"} + + +def test_requote_structure_preserved(): + s = _repo().load_scripts("재견적") + for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]: + assert key in s + assert s["배송형태선택"]["next_input_mode"] == "delivery_type" + assert s["배송형태선택"]["input_options"] == ["협력사배송", "지정택배배송", "픽업배송"] + + +def test_wildcard_present_and_merged(): + repo = _repo() + wc = repo.wildcard_scripts() + assert "wild_card_1pct" in wc and "wild_card_budget" in wc + # 재협상 흐름에 병합됨 + merged = repo.load_scripts("재협상") + assert "wild_card_1pct" in merged + assert "{offer_1pct}" in wc["wild_card_1pct"]["script"] + + +def test_cleanroom_no_proprietary_brand_in_any_resource(): + res_dir = os.path.join(_TENANTS_DIR, "_base", "resources") + for fn in os.listdir(res_dir): + if not fn.endswith(".json"): + continue + raw = open(os.path.join(res_dir, fn), encoding="utf-8").read() + assert not _FORBIDDEN.search(raw), f"특정사 표현 잔존: {fn}" + + +def test_brand_substitution_per_tenant(): + kt = _repo("ktcommerce").get_step("서비스안내", "재협상") + im = _repo("imarketkorea").get_step("서비스안내", "재협상") + assert "데모상사 A" in kt["script"] and "Negosium" in kt["script"] + assert "데모상사 B" in im["script"] + assert "{company_name}" not in kt["script"] # 치환 완료 + + +def test_variable_substitution_and_missing_kept(): + repo = _repo() + node = repo.get_step("가격협상_확인", "재협상", variables={"input_price": 950}) + assert "950" in node["script"] + # 누락 변수는 원형 유지 (KeyError 안 남) + budget = repo.get_step("wild_card_budget", "재협상", variables={}) + assert "{target}" in budget["script"] + + +def test_client_step_and_variable_mapping_load(): + repo = _repo() + csm = repo.client_step_mapping() + assert csm["가격협상_확인"] == "가격협상" + vm = repo.variable_mapping() + assert vm["인터넷 최저가"] == "internet_min_price" diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/tools/console_demo.py b/agent/tools/console_demo.py new file mode 100644 index 0000000..bb90b96 --- /dev/null +++ b/agent/tools/console_demo.py @@ -0,0 +1,183 @@ +"""콘솔 데모 — 현재까지 구현된(P0~P4) 협상 의사결정 루프를 화면 없이 콘솔에서 돌린다. + +흐름: TenantConfig 로드 → 엔진 조립 → (협상 관측치) → build_state/state_index + → 카드 선택(※임시 placeholder 정책) → reward 계산 → learning.experience_logs 로깅. + +주의: 실제 Q-Table UCB 정책/대화 step 체계는 아직 미구현(H1/P5/P7). + 여기 카드선택은 '가용 액션 중 최소 인덱스' 임시 정책이며 학습하지 않는다. + 이 데모의 목적은 "테넌트별 config 주입·상태분류·보상·DB 격리"를 눈으로 확인하는 것. + +실행: + cd agent + APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 시나리오 + APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 로깅 없이 + APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive +""" + +import argparse +import asyncio +import os +import uuid + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType, ErrorType +from negotiation.policies.base import ActionDecision, EpisodeState +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 negotiation.qtable.infra.repository.learning_repository import LearningRepository +from tenancy.config_loader import TenantConfigLoader +from tenancy.registry import TenantEngineRegistry + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + + +def _placeholder_select(engine, ctx_state_index, episode: EpisodeState) -> ActionDecision: + """임시 정책: 가용(미사용) 액션 중 최소 인덱스. propensity 는 균등분포 가정. + (실제 UCB Q-Table 정책은 H1/P5 에서 대체된다.) + """ + mask = engine.mapper.available_mask(episode.used_action_ids) + available = [a for a in engine.mapper.action_ids() if mask[a]] + if not available: + available = engine.mapper.action_ids() # 다 썼으면 리셋 + action_id = available[0] + propensity = 1.0 / len(available) + return ActionDecision( + action_id=action_id, + propensity=propensity, + card_id=engine.mapper.get_card_id(action_id), + available_actions=available, + ) + + +def _print_turn(turn, snap, st, idx, decision, reward): + print(f"\n── turn {turn} " + "─" * 40) + print(f" 관측: 매출={snap.revenue_amount:,.0f} 유통={snap.distribution_code} 파트너={snap.partner_count} " + f"수용률={snap.acceptance_ratio:.2f} 입력가={snap.input_price:,.0f} (앵커 {snap.anchor_price:,.0f}~목표 {snap.target_price:,.0f})") + print(f" 상태: revenue={st.revenue_idx} dist={st.distribution_idx} partner={st.partner_idx} " + f"accept={st.acceptance_idx} pricezone={st.price_zone_idx} → state_index={idx}") + print(f" 선택: action={decision.action_id} card={decision.card_id} " + f"propensity={decision.propensity:.3f} (가용 {decision.available_actions})") + print(f" 보상: total={reward.total:+.4f} (price={reward.price_reward:.3f} end={reward.end_reward:+.2f} " + f"penalty={reward.penalty:.3f} weight={reward.weight:.2f}) outcome={snap.outcome.value}") + + +def _scenario(): + """기본 3턴 시나리오 (KT 구매자: 협력사 제시가가 11000→10200→9800 으로 내려와 앵커가(9900) 이하에서 타결).""" + return [ + dict(input_price=11000, acceptance_ratio=0.02, round_number=1, outcome=NegotiationOutcome.ONGOING), + dict(input_price=10200, acceptance_ratio=0.05, round_number=2, outcome=NegotiationOutcome.ONGOING), + dict(input_price=9800, acceptance_ratio=0.11, round_number=3, outcome=NegotiationOutcome.SUCCESS), + ] + + +async def run(tenant_id: str, use_db: bool, interactive: bool): + loader = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0) + if not loader.is_registered(tenant_id): + print(f"[!] 미등록 테넌트: {tenant_id}. 등록된 테넌트: ktcommerce, imarketkorea, _base") + return + registry = TenantEngineRegistry(loader=loader) + engine = await registry.get_engine(tenant_id) + reward_calc = RewardCalculator(engine.config.reward) + repo = LearningRepository(engine.company_id) + episode = EpisodeState() + session_id = uuid.uuid4() + + print("=" * 56) + print(f" 콘솔 데모 — tenant={tenant_id} company_id={engine.company_id}") + print(f" state_space={engine.state_space_size} action_space={engine.action_space_size}") + print(f" 카드셋 예: action0={engine.mapper.get_card_id(0)} ... action{engine.action_space_size-1}={engine.mapper.get_card_id(engine.action_space_size-1)}") + print(f" DB 로깅: {'ON (learning.experience_logs)' if use_db else 'OFF'}") + print(" ※ 카드선택은 임시 placeholder 정책 (실제 UCB Q-Table 은 H1/P5)") + print("=" * 56) + + turns = _interactive_turns() if interactive else _scenario() + logged = 0 + for i, params in enumerate(turns, start=1): + snap = NegotiationSnapshot( + revenue_amount=params.get("revenue_amount", 20_000_000), + distribution_code=params.get("distribution_code", "A"), + partner_count=params.get("partner_count", 1), + acceptance_ratio=params["acceptance_ratio"], + input_price=params["input_price"], + anchor_price=params.get("anchor_price", 9900), + target_price=params.get("target_price", 10000), + round_number=params["round_number"], + outcome=params["outcome"], + ) + try: + st = build_state(snap, engine.config.state) + idx = state_index(snap, engine.config.state) + except ValueError as ex: + print(f"[!] 상태 산출 실패: {ex}") + continue + decision = _placeholder_select(engine, idx, episode) + episode.mark_used(decision.action_id) + reward = reward_calc.calculate(snap) + _print_turn(i, snap, st, idx, decision, reward) + + if use_db: + 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, + "settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None, + } + err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)]) + logged += 1 if err == ErrorType.SUCCESS else 0 + + if use_db: + err, cnt = await DB_SESSION_MNG.execute_lambda( + DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: repo.count_experience(s) + ) + print(f"\n[DB] 이번 실행에서 {logged}건 로깅. company_id={engine.company_id} 누적 experience={cnt}건") + print(" (다른 테넌트로 실행해도 서로 섞이지 않음 — company_id 논리격리 확인용)") + + await DB_SESSION_MNG.dispose_all() + + +def _interactive_turns(): + print("\n[대화형] 빈 줄(엔터)이면 기본값. outcome: o(ongoing)/s(success)/f(failure). 'q' 입력 시 종료.\n") + turns = [] + rnd = 1 + while True: + raw = input(f"turn {rnd} - 입력가(예 930) [q종료]: ").strip() + if raw.lower() == "q": + break + try: + input_price = float(raw) if raw else 900 + except ValueError: + print(" 숫자를 입력하세요."); continue + acc = input(" 수용률(0~1, 예 0.05): ").strip() + oc = input(" 결과 o/s/f: ").strip().lower() + outcome = {"s": NegotiationOutcome.SUCCESS, "f": NegotiationOutcome.FAILURE}.get(oc, NegotiationOutcome.ONGOING) + turns.append(dict( + input_price=input_price, + acceptance_ratio=float(acc) if acc else 0.05, + round_number=rnd, + outcome=outcome, + )) + rnd += 1 + if outcome != NegotiationOutcome.ONGOING: + break + return turns + + +def main(): + ap = argparse.ArgumentParser(description="협상 의사결정 루프 콘솔 데모 (P0~P4)") + ap.add_argument("--tenant", default="ktcommerce", help="테넌트 id (ktcommerce|imarketkorea)") + ap.add_argument("--no-db", action="store_true", help="DB 로깅 비활성화") + ap.add_argument("--interactive", action="store_true", help="턴마다 직접 입력") + args = ap.parse_args() + asyncio.run(run(args.tenant, use_db=not args.no_db, interactive=args.interactive)) + + +if __name__ == "__main__": + main() diff --git a/agent/tools/init_base.py b/agent/tools/init_base.py new file mode 100644 index 0000000..b601ac9 --- /dev/null +++ b/agent/tools/init_base.py @@ -0,0 +1,74 @@ +"""init_base — 공유 베이스 정책(_base) 시드 (P5, 계획서 D). + +시뮬레이터로 UCB Q-Table 을 학습시켜 learning 스키마의 _base(scope=base) 버전에 저장한다. +신규 테넌트는 cold-start 시 이 베이스를 warm-start 복제해 어느 정도 학습된 상태로 시작한다. + +실행: cd agent && APP_ENV=local python -m tools.init_base [--action-space 9] [--episodes 500] +차원(state×action)은 신규 테넌트와 호환돼야 복제된다(데모 테넌트=162×9). +""" + +import argparse +import asyncio + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import BASE_COMPANY_ID +from common.logger import LOG +from eval_harness.buyer import HeuristicBuyer, Scenario, best_actions, make_card_effectiveness +from eval_harness.registry import build_policy +from eval_harness.simulator import run_episode +from negotiation.qtable.infra.repository.learning_repository import LearningRepository +from tenancy.config_loader import TenantConfigLoader + + +async def seed_base(action_space: int = 9, episodes: int = 500, seed: int = 123, + anchor: float = 8000, target: float = 10000) -> dict: + cfg = TenantConfigLoader().load(BASE_COMPANY_ID) # _base: state 162 + S = cfg.state.state_space_size + A = action_space + lr, gamma = cfg.policy.learning_rate, cfg.policy.gamma + + # 시뮬레이터로 베이스 학습 + policy = build_policy("qtable_ucb", cfg.state, A, cfg.policy, seed=seed) + eff = make_card_effectiveness(A, seed=seed) + buyer = HeuristicBuyer(eff, seed=seed) + scenario = Scenario(anchor_price=anchor, target_price=target) + for i in range(episodes): + buyer.reseed(seed * 100_000 + i) + run_episode(policy, buyer, scenario, cfg.state, cfg.reward, A, learn=True) + + # _base 버전(scope=base)에 저장 (재시드 시 기존 셀 비우고 갱신) + repo = LearningRepository(BASE_COMPANY_ID) + vid = await repo.get_or_create_active_version( + state_space_size=S, action_space_size=A, learning_rate=lr, discount_factor=gamma, + scope=1, version_name="base_v000") + await repo.reset_learning() # 이전 셀 정리(버전은 유지) + cells = policy.qtable.nonzero_cells() + for st, a, q, c in cells: + await repo.upsert_cell(vid, st, a, q, c) + + return {"version_id": str(vid), "state_space": S, "action_space": A, + "episodes": episodes, "cells": len(cells), "good_cards": best_actions(eff)} + + +async def _main(args): + info = await seed_base(action_space=args.action_space, episodes=args.episodes) + print("=" * 56) + print(" 베이스 정책 시드 완료 (_base, scope=base)") + print(f" 버전: {info['version_id']} 차원: {info['state_space']}x{info['action_space']}") + print(f" 학습 에피소드: {info['episodes']} 저장 셀: {info['cells']}") + print(f" (숨은) 좋은 카드: {info['good_cards']}") + print(" → 이제 신규 테넌트 첫 협상 시 warm-start 로 이 베이스를 복제합니다.") + print("=" * 56) + await DB_SESSION_MNG.dispose_all() + + +def main(): + ap = argparse.ArgumentParser(description="공유 베이스 정책 시드 (P5)") + ap.add_argument("--action-space", type=int, default=9) + ap.add_argument("--episodes", type=int, default=500) + args = ap.parse_args() + asyncio.run(_main(args)) + + +if __name__ == "__main__": + main() diff --git a/agent/tools/show_logs.py b/agent/tools/show_logs.py new file mode 100644 index 0000000..60a30e4 --- /dev/null +++ b/agent/tools/show_logs.py @@ -0,0 +1,36 @@ +"""learning.experience_logs 를 company_id 별로 집계 출력 (DB 격리 확인용). + +실행: cd agent && APP_ENV=local python -m tools.show_logs +""" + +import asyncio + +from sqlalchemy import func, select + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import ExperienceLog +from common.enums import DBType, DBWRType + + +async def main(): + def q(s): + stmt = ( + select(ExperienceLog.company_id, func.count().label("n"), func.max(ExperienceLog.reward).label("max_reward")) + .group_by(ExperienceLog.company_id) + .order_by(ExperienceLog.company_id) + ) + return DB_SESSION_MNG.execute(s, stmt) + + err, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, q) + print("company_id count max_reward") + print("-" * 44) + for company_id, n, max_reward in rows: + mr = f"{max_reward:+.4f}" if max_reward is not None else " n/a " + print(f"{company_id:<20} {n:>6} {mr}") + if not rows: + print("(비어있음 — 먼저 tools.console_demo 를 실행하세요)") + await DB_SESSION_MNG.dispose_all() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agent/web_main.py b/agent/web_main.py new file mode 100644 index 0000000..970add0 --- /dev/null +++ b/agent/web_main.py @@ -0,0 +1,41 @@ +# 실행 방법 +# pip install -r requirements.txt +# python web_main.py # 기본 local 환경 +# APP_ENV=dev python web_main.py # 환경 지정 +# +# 또는 uvicorn 직접 실행: +# uvicorn router.router:app --reload --host=0.0.0.0 --port=9500 + +import uvicorn + +from common.logger import LOG +from config.server_configs import web_server_config + +LOG.SetPrefix(web_server_config.server_name) + +# import 시점에 app 및 DB 세션 매니저(싱글톤)가 초기화된다. +import router.router + +if __name__ == "__main__": + LOG.i(f"Server Name : {web_server_config.server_name}") + LOG.i(f"Server Port : {web_server_config.port}") + LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}") + + if web_server_config.is_ssl: + uvicorn.run( + "router.router:app", + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + workers=web_server_config.process_count, + ssl_keyfile="./SSL/key.pem", + ssl_certfile="./SSL/cert.pem", + ) + else: + uvicorn.run( + "router.router:app", + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + workers=web_server_config.process_count, + ) diff --git a/backend/Dockerfile b/backend/Dockerfile index 918eb81..f83c077 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,8 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# docker-compose 에서 APP_ENV=docker 로 config.docker.toml 을 읽는다. -ENV APP_ENV=docker +# 항상 APP_ENV=local 로 실행 → config.local.toml 사용. +ENV APP_ENV=local EXPOSE 9300 diff --git a/backend/config/config.docker.toml b/backend/config/config.docker.toml deleted file mode 100644 index 2aa3f7a..0000000 --- a/backend/config/config.docker.toml +++ /dev/null @@ -1,33 +0,0 @@ -[WebServerConfig] -server_name = "NegosiumServer" -port = 9300 -process_count = 4 -is_ssl = false -is_test = true - -[LogConfig] -print_console = true -log_level = "debug" - -# docker-compose 네트워크에서는 DB 가 compose 밖에 있으므로 호스트의 DB 에 host.docker.internal 로 접근한다. -[MainDBConfig] -db_type = "postgresql" -name = "negosium_db" -write_host = "host.docker.internal" -write_port = 5432 -write_id = "postgres" -write_pw = "password" -read_host = "host.docker.internal" -read_port = 5432 -read_id = "postgres" -read_pw = "password" -show_log = false -# 워커 4개 기준: (pool_size + max_overflow) x 2(R/W) x 4 = 320 < max_connections(500) -pool_size = 20 -max_overflow = 20 - -[JwtToken] -access_key = "docker_access_secret_key" -refresh_key = "docker_refresh_secret_key" -access_expire_min = 30 -refresh_expire_day = 7 diff --git a/backend/config/config.local.toml b/backend/config/config.local.toml deleted file mode 100644 index 17a0384..0000000 --- a/backend/config/config.local.toml +++ /dev/null @@ -1,32 +0,0 @@ -[WebServerConfig] -server_name = "NegosiumServer" -port = 9300 -process_count = 1 -is_ssl = false -is_test = true - -[LogConfig] -print_console = true -log_level = "debug" - -# DB Read/Write 분리. 단일 DB 환경에서는 read/write 동일 호스트로 설정. -[MainDBConfig] -db_type = "postgresql" -name = "negosium_db" -write_host = "127.0.0.1" -write_port = 5432 -write_id = "postgres" -write_pw = "password" -read_host = "127.0.0.1" -read_port = 5432 -read_id = "postgres" -read_pw = "password" -show_log = false -pool_size = 100 -max_overflow = 200 - -[JwtToken] -access_key = "CHANGE_ME_ACCESS_SECRET_KEY" -refresh_key = "CHANGE_ME_REFRESH_SECRET_KEY" -access_expire_min = 30 -refresh_expire_day = 7 diff --git a/backend/config/config.test.toml b/backend/config/config.test.toml deleted file mode 100644 index 259c43b..0000000 --- a/backend/config/config.test.toml +++ /dev/null @@ -1,33 +0,0 @@ -[WebServerConfig] -server_name = "NegosiumServer-Test" -port = 9301 -process_count = 1 -is_ssl = false -is_test = true - -[LogConfig] -print_console = true -log_level = "debug" - -# 테스트는 단일 postgres 의 negosium_db 를 사용한다. -# pytest 가 tbl_account 를 TRUNCATE 로 비워 격리하므로, 운영 데이터가 있다면 주의. -[MainDBConfig] -db_type = "postgresql" -name = "negosium_db" -write_host = "127.0.0.1" -write_port = 5432 -write_id = "postgres" -write_pw = "password" -read_host = "127.0.0.1" -read_port = 5432 -read_id = "postgres" -read_pw = "password" -show_log = false -pool_size = 100 -max_overflow = 200 - -[JwtToken] -access_key = "test_access_secret_key" -refresh_key = "test_refresh_secret_key" -access_expire_min = 30 -refresh_expire_day = 7 diff --git a/backend/config/server_configs.py b/backend/config/server_configs.py index 6a6adbe..fed6d5c 100644 --- a/backend/config/server_configs.py +++ b/backend/config/server_configs.py @@ -9,8 +9,9 @@ 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}). config..toml 을 생성하세요.") + raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). APP_ENV=local 로 실행하세요.") configs = Configs(_config_file) diff --git a/backend/conftest.py b/backend/conftest.py index 58d40a7..d5a3fe8 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,8 +1,8 @@ -# pytest 진입 시점에 가장 먼저 APP_ENV=test 를 설정해야 한다. -# (config.server_configs 가 import 되는 순간 config..toml 을 읽기 때문) +# 테스트도 APP_ENV=local 로 실행한다 (config.local.toml 사용). +# config.server_configs 가 import 되는 순간 config..toml 을 읽으므로 가장 먼저 설정. import os -os.environ.setdefault("APP_ENV", "test") +os.environ.setdefault("APP_ENV", "local") import pytest_asyncio from httpx import ASGITransport, AsyncClient diff --git a/docker-compose.yml b/docker-compose.yml index 62edcd1..64f53db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,17 +6,18 @@ # docker compose up -d # negosium 서버: http://localhost:9300/docs # negodata 서버: http://localhost:9400/docs +# agent 서버: http://localhost:9500/docs # -# DB 준비(최초 1회): postgres-init/01-schema.sql 을 대상 DB 에 적용한다. -# psql -h -p -U -f postgres-init/01-schema.sql -# (negosium_db / negodata_db 와 tbl_account 생성) +# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다. +# psql -h -p -U -f postgres-init/01-schema.sql (negosium_db/negodata_db + tbl_account) +# psql -h -p -U -f postgres-init/02-learning-schema.sql (agent learning 스키마) services: negosium-backend: build: ./backend container_name: negosium-backend environment: - APP_ENV: docker + APP_ENV: local ports: - "9300:9300" # 컨테이너에서 호스트의 DB 로 접근 (config.docker.toml 의 host.docker.internal) @@ -28,9 +29,21 @@ services: build: ./negodata/backend container_name: negodata-backend environment: - APP_ENV: docker + APP_ENV: local ports: - "9400:9400" extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped + + # 협상 에이전트 (negosium_db 공유, learning 스키마 사용). + agent: + build: ./agent + container_name: negosium-agent + environment: + APP_ENV: local + ports: + - "9500:9500" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped diff --git a/negodata/backend/Dockerfile b/negodata/backend/Dockerfile index 918eb81..f83c077 100644 --- a/negodata/backend/Dockerfile +++ b/negodata/backend/Dockerfile @@ -8,8 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# docker-compose 에서 APP_ENV=docker 로 config.docker.toml 을 읽는다. -ENV APP_ENV=docker +# 항상 APP_ENV=local 로 실행 → config.local.toml 사용. +ENV APP_ENV=local EXPOSE 9300 diff --git a/negodata/backend/config/config.docker.toml b/negodata/backend/config/config.docker.toml deleted file mode 100644 index b00384d..0000000 --- a/negodata/backend/config/config.docker.toml +++ /dev/null @@ -1,33 +0,0 @@ -[WebServerConfig] -server_name = "NegodataServer" -port = 9400 -process_count = 4 -is_ssl = false -is_test = true - -[LogConfig] -print_console = true -log_level = "debug" - -# docker-compose 네트워크에서는 DB 가 compose 밖에 있으므로 호스트의 DB 에 host.docker.internal 로 접근한다. -[MainDBConfig] -db_type = "postgresql" -name = "negodata_db" -write_host = "host.docker.internal" -write_port = 5432 -write_id = "postgres" -write_pw = "password" -read_host = "host.docker.internal" -read_port = 5432 -read_id = "postgres" -read_pw = "password" -show_log = false -# 워커 4개 기준: (pool_size + max_overflow) x 2(R/W) x 4 = 320 < max_connections(500) -pool_size = 20 -max_overflow = 20 - -[JwtToken] -access_key = "docker_access_secret_key" -refresh_key = "docker_refresh_secret_key" -access_expire_min = 30 -refresh_expire_day = 7 diff --git a/negodata/backend/config/config.local.toml b/negodata/backend/config/config.local.toml deleted file mode 100644 index bd5fb04..0000000 --- a/negodata/backend/config/config.local.toml +++ /dev/null @@ -1,32 +0,0 @@ -[WebServerConfig] -server_name = "NegodataServer" -port = 9400 -process_count = 1 -is_ssl = false -is_test = true - -[LogConfig] -print_console = true -log_level = "debug" - -# DB Read/Write 분리. 단일 DB 환경에서는 read/write 동일 호스트로 설정. -[MainDBConfig] -db_type = "postgresql" -name = "negodata_db" -write_host = "127.0.0.1" -write_port = 5432 -write_id = "postgres" -write_pw = "password" -read_host = "127.0.0.1" -read_port = 5432 -read_id = "postgres" -read_pw = "password" -show_log = false -pool_size = 100 -max_overflow = 200 - -[JwtToken] -access_key = "CHANGE_ME_ACCESS_SECRET_KEY" -refresh_key = "CHANGE_ME_REFRESH_SECRET_KEY" -access_expire_min = 30 -refresh_expire_day = 7 diff --git a/negodata/backend/config/config.test.toml b/negodata/backend/config/config.test.toml deleted file mode 100644 index f0ff5a8..0000000 --- a/negodata/backend/config/config.test.toml +++ /dev/null @@ -1,33 +0,0 @@ -[WebServerConfig] -server_name = "NegodataServer-Test" -port = 9401 -process_count = 1 -is_ssl = false -is_test = true - -[LogConfig] -print_console = true -log_level = "debug" - -# 테스트는 단일 postgres 의 negodata_db 를 사용한다. -# pytest 가 tbl_account 를 TRUNCATE 로 비워 격리하므로, 운영 데이터가 있다면 주의. -[MainDBConfig] -db_type = "postgresql" -name = "negodata_db" -write_host = "127.0.0.1" -write_port = 5432 -write_id = "postgres" -write_pw = "password" -read_host = "127.0.0.1" -read_port = 5432 -read_id = "postgres" -read_pw = "password" -show_log = false -pool_size = 100 -max_overflow = 200 - -[JwtToken] -access_key = "test_access_secret_key" -refresh_key = "test_refresh_secret_key" -access_expire_min = 30 -refresh_expire_day = 7 diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py index 6a6adbe..fed6d5c 100644 --- a/negodata/backend/config/server_configs.py +++ b/negodata/backend/config/server_configs.py @@ -9,8 +9,9 @@ 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}). config..toml 을 생성하세요.") + raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). APP_ENV=local 로 실행하세요.") configs = Configs(_config_file) diff --git a/negodata/backend/conftest.py b/negodata/backend/conftest.py index 58d40a7..d5a3fe8 100644 --- a/negodata/backend/conftest.py +++ b/negodata/backend/conftest.py @@ -1,8 +1,8 @@ -# pytest 진입 시점에 가장 먼저 APP_ENV=test 를 설정해야 한다. -# (config.server_configs 가 import 되는 순간 config..toml 을 읽기 때문) +# 테스트도 APP_ENV=local 로 실행한다 (config.local.toml 사용). +# config.server_configs 가 import 되는 순간 config..toml 을 읽으므로 가장 먼저 설정. import os -os.environ.setdefault("APP_ENV", "test") +os.environ.setdefault("APP_ENV", "local") import pytest_asyncio from httpx import ASGITransport, AsyncClient diff --git a/postgres-init/02-learning-schema.sql b/postgres-init/02-learning-schema.sql new file mode 100644 index 0000000..9a1dd5d --- /dev/null +++ b/postgres-init/02-learning-schema.sql @@ -0,0 +1,141 @@ +-- ============================================================ +-- learning : 협상 에이전트(agent) RL 학습 자산 (Q-Table / 경험로그) +-- ============================================================ +-- negosium_db 안의 7번째 schema. agent 서비스가 소유한다(backend 는 미사용). +-- 01-schema.sql 과 동일 컨벤션: FK 미사용(앱 레이어 무결성), TIMESTAMPTZ(UTC), 코드값 SMALLINT(1부터). +-- +-- 멀티테넌트 논리 격리: 모든 테이블에 company_id 컬럼. company.companies.company_id(uuid)를 +-- 문자열로 보관하되, 공유 베이스 정책은 예약어 '_base' 를 쓴다(uuid/sentinel 혼용 → VARCHAR). +-- 모든 유니크/인덱스는 company_id 선두 복합으로 둔다(테넌트 간 충돌 방지 + 스코프 조회). + +\connect negosium_db + +CREATE SCHEMA IF NOT EXISTS learning; + +-- ------------------------------------------------------------ +-- Q-Table 버전 (학습 스냅샷의 헤더) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS learning.q_table_versions ( + version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id VARCHAR(64) NOT NULL, -- 테넌트 키(company uuid 문자열 또는 '_base') + version_name VARCHAR(50) NOT NULL, -- 버전명 (예: v000_warmstart_from_base) + scope SMALLINT NOT NULL DEFAULT 2, -- 1=base, 2=tenant + base_version_id uuid NULL, -- warm-start 출처 추적(베이스 버전) + state_space_size INTEGER NOT NULL, -- 차원 정합성 체크용 + action_space_size INTEGER NOT NULL, + learning_rate NUMERIC(6,4) NOT NULL DEFAULT 0.1000, + discount_factor NUMERIC(6,4) NOT NULL DEFAULT 0.9500, + epochs INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 활성 버전 포인터(테넌트당 1개) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); +-- version_name 은 테넌트 스코프에서만 유니크 (계획서 C: UniqueConstraint(tenant, version_name)) +CREATE UNIQUE INDEX IF NOT EXISTS uq_qtv_company_version + ON learning.q_table_versions (company_id, version_name); +-- 테넌트별 활성 버전은 최대 1개 (부분 유니크) +CREATE UNIQUE INDEX IF NOT EXISTS uq_qtv_company_active + ON learning.q_table_versions (company_id) WHERE is_active AND NOT deleted; + +-- ------------------------------------------------------------ +-- Q 값 (state_index, action_id) -> q_value +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS learning.q_values ( + id BIGSERIAL PRIMARY KEY, + company_id VARCHAR(64) NOT NULL, + version_id uuid NOT NULL, + state_index INTEGER NOT NULL, + action_id INTEGER NOT NULL, + q_value DOUBLE PRECISION NOT NULL DEFAULT 0.0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_qval_company_version_sa + ON learning.q_values (company_id, version_id, state_index, action_id); +CREATE INDEX IF NOT EXISTS idx_qval_company_version_state + ON learning.q_values (company_id, version_id, state_index); + +-- ------------------------------------------------------------ +-- 방문 횟수 (UCB 탐색용) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS learning.visit_counts ( + id BIGSERIAL PRIMARY KEY, + company_id VARCHAR(64) NOT NULL, + version_id uuid NOT NULL, + state_index INTEGER NOT NULL, + action_id INTEGER NOT NULL, + count BIGINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_visit_company_version_sa + ON learning.visit_counts (company_id, version_id, state_index, action_id); +CREATE INDEX IF NOT EXISTS idx_visit_company_version_state + ON learning.visit_counts (company_id, version_id, state_index); + +-- ------------------------------------------------------------ +-- 경험 로그 (transition). OPE/오프라인RL 의 데이터 소스. +-- propensity / turn / available_actions / settled_price 는 신규 로깅(소급 불가, 계획서 H0). +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS learning.experience_logs ( + id BIGSERIAL PRIMARY KEY, + company_id VARCHAR(64) NOT NULL, + transition_id uuid NOT NULL DEFAULT gen_random_uuid(), + session_id uuid NULL, -- negotiation.sessions.session_id 연결 + state_index INTEGER NOT NULL, + action_id INTEGER NOT NULL, + card_id VARCHAR(40) NULL, -- 사용된 카드(테넌트 카탈로그) + q_value_at_selection DOUBLE PRECISION NULL, + reward DOUBLE PRECISION NULL, -- 보상 산출 후 update + next_state_index INTEGER NULL, + done BOOLEAN NOT NULL DEFAULT FALSE, + snapshot JSONB NULL, -- NegotiationSnapshot 전체(연속 feature) + propensity DOUBLE PRECISION NULL, -- 행동정책 선택확률 (OPE 필수) + turn INTEGER NULL, -- 협상 라운드(iteration) + available_actions JSONB NULL, -- 선택 시점 가용 액션(마스킹) + settled_price BIGINT NULL, -- 타결가(원) + visit_count_at_selection BIGINT NULL, + total_visits_at_selection BIGINT NULL, + ucb_score_at_selection DOUBLE PRECISION NULL, + is_new_quote BOOLEAN NOT NULL DEFAULT FALSE, -- 학습 격리(신규견적은 UCB 비활성) + is_invalidated BOOLEAN NOT NULL DEFAULT FALSE, + invalidated_reason VARCHAR(255) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_exp_company_transition + ON learning.experience_logs (company_id, transition_id); +CREATE INDEX IF NOT EXISTS idx_exp_company_session + ON learning.experience_logs (company_id, session_id); +CREATE INDEX IF NOT EXISTS idx_exp_company_state_action + ON learning.experience_logs (company_id, state_index, action_id); + +-- ------------------------------------------------------------ +-- 테넌트별 action_id -> card 매핑 (계획서 C: tenant_action_cards) +-- PoC 는 카드 매핑 고정. P6 에서 동기화 소스로 사용. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS learning.tenant_action_cards ( + id BIGSERIAL PRIMARY KEY, + company_id VARCHAR(64) NOT NULL, + action_id INTEGER NOT NULL, + card_id VARCHAR(40) NOT NULL, -- card.nego_cards.number 등 테넌트 카탈로그 식별자 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_tac_company_action + ON learning.tenant_action_cards (company_id, action_id) WHERE NOT deleted; + +-- ------------------------------------------------------------ +-- 대화 세션 상태 (P8-A: /chat 진행 상태 영속화 — 재시작/멀티워커 안전) +-- 채팅 '로그'(메시지)가 아니라 진행 '상태'(현재 step·맥락·사용카드·라운드)다. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS learning.chat_sessions ( + session_id uuid PRIMARY KEY, + company_id VARCHAR(64) NOT NULL, + tenant_id VARCHAR(64) NOT NULL, + rq_type VARCHAR(10) NOT NULL DEFAULT '재협상', + step VARCHAR(40) NOT NULL DEFAULT '시작', -- 현재 대기 중인 step + context JSONB NOT NULL DEFAULT '{}'::jsonb, -- 앵커/목표가·라운드·last_state 등 + used_action_ids JSONB NOT NULL DEFAULT '[]'::jsonb, -- 사용한 카드(중복방지/소진 판정) + action_space_size INTEGER NOT NULL DEFAULT 0, + ended BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_chat_sessions_company ON learning.chat_sessions (company_id);