cors 문제 해결 .
This commit is contained in:
parent
a52321e68d
commit
1b647ba2d5
@ -54,13 +54,22 @@
|
||||
5. **(범위 외) indicator / summary / reject** — 이번 범위 미포함. agent 응답에 협상지표·최종요약·거부폼이 생기면
|
||||
backend ChatMessage 의 예약 필드(`indicator_value`/`bot_chat_type`/summary)로 확장 협의.
|
||||
|
||||
## 4-바. agent 측 확정 회신 (4-4 tenant 키 형식) ✅
|
||||
|
||||
- **tenant 키 = 견적 작성자(갑) 회사 `company_id`(uuid 문자열)**. backend 가 `X-Tenant-ID` 에 그대로 넣으면 된다.
|
||||
- **`_base`** 는 공유 베이스 정책 예약어(직접 보내지 말 것).
|
||||
- **미등록 company_id 자동 온보딩**: 전용 `tenants/<id>/tenant.yaml` 이 없어도 agent 가 `_base` 설정으로
|
||||
엔진을 만들고(기본 9카드·162 state), 첫 협상에서 **base warm-start(cold-start)** 로 학습 시작한다.
|
||||
→ backend 는 회사를 agent 에 사전 등록할 필요 없이 company_id 만 보내면 된다.
|
||||
- 헤더 부재 시 400, 빈 값도 미등록(400). (그 외 present company_id 는 모두 수용)
|
||||
|
||||
## 5. mock → 실제 전환 체크리스트
|
||||
|
||||
- [x] backend `config.local.toml` → `[AgentConfig] use_mock=false` (전환 완료 — agent 미기동 시 1402 로 graceful degrade 확인)
|
||||
- [x] backend `httpx` 의존 설치(`requirements.txt` 반영됨)
|
||||
- [ ] 위 4-1 ~ 4-3 반영 (agent 측)
|
||||
- [ ] tenant_id 해석(4-4) 합의 후 backend `chat_service._agent_context` 의 `tenant_id` 채우기
|
||||
- [ ] agent(9500) 기동 후 양 서버 라이브 E2E
|
||||
- [x] 위 4-1 ~ 4-3 반영 (agent 측) — session_id honoring(req.session_id 그대로 사용)·tenant 헤더·신규 컨텍스트(target/anchor/rq_type) 완료
|
||||
- [x] tenant_id 형식 확정(4-4) — **company_id(uuid)**, backend 는 `chat_service._agent_context` 의 `tenant_id` 를 `quotation.user_id → company.users.company_id` 로 채우면 됨
|
||||
- [ ] agent(9500) 기동 후 양 서버 라이브 E2E (agent 단독 /chat 은 검증 완료)
|
||||
|
||||
> 로컬에서 agent 없이 mock 으로 개발하려면 환경변수로 덮는다: `AGENT_USE_MOCK=true`
|
||||
|
||||
|
||||
@ -35,8 +35,10 @@ class ChatService:
|
||||
chat_engine = ChatEngine(repo, rq_type=(session.rq_type if session else req.rq_type))
|
||||
|
||||
if session is None:
|
||||
# session_id honoring: backend 가 보낸 session_id(= negotiation.sessions.session_id)를
|
||||
# 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성.
|
||||
session = ChatSession(
|
||||
session_id=str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id,
|
||||
session_id=req.session_id or 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,
|
||||
|
||||
@ -44,6 +44,9 @@ class TenantConfigLoader:
|
||||
def _yaml_path(self, tenant_id: str) -> str:
|
||||
return os.path.join(self._tenants_dir, tenant_id, "tenant.yaml")
|
||||
|
||||
def _yaml_exists(self, tenant_id: str) -> bool:
|
||||
return bool(tenant_id) and os.path.exists(self._yaml_path(tenant_id))
|
||||
|
||||
def _read_yaml(self, tenant_id: str) -> Dict[str, Any]:
|
||||
path = self._yaml_path(tenant_id)
|
||||
if not os.path.exists(path):
|
||||
@ -52,7 +55,13 @@ class TenantConfigLoader:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
def is_registered(self, tenant_id: str) -> bool:
|
||||
return os.path.exists(self._yaml_path(tenant_id))
|
||||
"""tenant 키 = company_id(uuid) 또는 데모 테넌트명. 전용 설정이 없어도 _base 가 있으면
|
||||
자동 온보딩(cold-start warm-start) 대상이므로 '등록됨'으로 본다 (빈 키만 미등록)."""
|
||||
if not tenant_id:
|
||||
return False
|
||||
if self._yaml_exists(tenant_id):
|
||||
return True
|
||||
return tenant_id != _BASE_TENANT_ID and self._yaml_exists(_BASE_TENANT_ID)
|
||||
|
||||
def load(self, tenant_id: str, use_cache: bool = True) -> TenantConfig:
|
||||
if use_cache and self._ttl > 0:
|
||||
@ -60,15 +69,24 @@ class TenantConfigLoader:
|
||||
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)
|
||||
if self._yaml_exists(tenant_id):
|
||||
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._yaml_exists(_BASE_TENANT_ID):
|
||||
base_raw = self._read_yaml(_BASE_TENANT_ID)
|
||||
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)
|
||||
elif tenant_id != _BASE_TENANT_ID and self._yaml_exists(_BASE_TENANT_ID):
|
||||
# 미등록 company_id(backend 가 보낸 갑 회사 uuid) → _base 설정으로 자동 온보딩.
|
||||
# company_id 를 tenant 키로 박고 inherits_base=true → 첫 협상에서 base warm-start 복제.
|
||||
raw = self._read_yaml(_BASE_TENANT_ID)
|
||||
raw = {k: v for k, v in raw.items() if k not in ("tenant_id", "company_id", "name")}
|
||||
raw["inherits_base"] = True
|
||||
raw["company_id"] = tenant_id
|
||||
LOG.i(f"[TenantConfigLoader] 미등록 tenant_id={tenant_id} → _base 자동 온보딩")
|
||||
else:
|
||||
raw = self._read_yaml(tenant_id) # _base 도 없으면 FileNotFoundError
|
||||
|
||||
raw.setdefault("tenant_id", tenant_id)
|
||||
config = TenantConfig.model_validate(raw)
|
||||
|
||||
@ -50,7 +50,18 @@ policy:
|
||||
|
||||
action_mapping:
|
||||
type: file
|
||||
action_to_card: {} # 베이스는 카드 매핑 없음 — 테넌트가 자사 카탈로그로 supply
|
||||
# 기본 9카드 — 자동 온보딩(신규 company_id) 테넌트가 물려받는 카드 공간(base 정책 162×9와 정합).
|
||||
# 테넌트는 자사 카탈로그(card.nego_cards/tenant_action_cards)로 override 한다.
|
||||
action_to_card:
|
||||
"0": "NGC-001"
|
||||
"1": "NGC-002"
|
||||
"2": "NGC-003"
|
||||
"3": "NGC-004"
|
||||
"4": "NGC-005"
|
||||
"5": "NGC-006"
|
||||
"6": "NGC-007"
|
||||
"7": "NGC-008"
|
||||
"8": "NGC-009"
|
||||
|
||||
cards:
|
||||
source_type: file
|
||||
|
||||
71
agent/tests/test_agent_integration.py
Normal file
71
agent/tests/test_agent_integration.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""AGENT_INTEGRATION.md 규약 검증 (backend ↔ agent).
|
||||
|
||||
4-1 session_id honoring: backend 가 보낸 session_id(=negotiation.sessions.session_id)를
|
||||
새 uuid 발급 없이 그대로 세션 키로 사용.
|
||||
4-4 tenant 키 = company_id(uuid): 미등록 company_id 는 _base 자동 온보딩(cold-start) 으로 수용.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from router.v1.chat.protocol import Req_Chat
|
||||
from services.chat_service import ChatService, reset_sessions
|
||||
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
|
||||
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")
|
||||
|
||||
# backend 가 보내는 값 형식: session_id = negotiation.sessions.session_id(uuid), X-Tenant-ID = company_id(uuid)
|
||||
BACKEND_SESSION_ID = "11111111-1111-1111-1111-111111111111"
|
||||
COMPANY_ID = "00000000-0000-0000-0000-000000000099"
|
||||
|
||||
|
||||
def _reg():
|
||||
return TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_4_1_honors_backend_session_id(db_engine):
|
||||
reset_sessions()
|
||||
eng = await _reg().get_engine("ktcommerce")
|
||||
svc = ChatService()
|
||||
|
||||
# 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X)
|
||||
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상",
|
||||
target_price=10000, anchor_price=9900))
|
||||
assert r.session_id == BACKEND_SESSION_ID
|
||||
assert r.step == "서비스안내"
|
||||
|
||||
# 같은 session_id 로 이어가면 그 세션이 DB 에서 복원됨
|
||||
r2 = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, user_input="확인"))
|
||||
assert r2.session_id == BACKEND_SESSION_ID and r2.step == "담당자확인"
|
||||
|
||||
# learning.chat_sessions 에 backend 의 session_id 그대로 저장됨
|
||||
saved = await ChatSessionRepository(eng.company_id).get(BACKEND_SESSION_ID)
|
||||
assert saved is not None and saved.session_id == BACKEND_SESSION_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_4_4_company_id_auto_onboard():
|
||||
# 미등록 company_id → _base 자동 온보딩 (전용 tenant.yaml 없이도 엔진 생성)
|
||||
eng = await _reg().get_engine(COMPANY_ID)
|
||||
assert eng.tenant_id == COMPANY_ID
|
||||
assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리
|
||||
assert eng.action_space_size == 9 # base 기본 카드(162×9 정합)
|
||||
assert eng.state_space_size == 162
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_4_4_company_id_chat_end_to_end(db_engine):
|
||||
reset_sessions()
|
||||
eng = await _reg().get_engine(COMPANY_ID) # 자동 온보딩 테넌트
|
||||
svc = ChatService()
|
||||
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상",
|
||||
target_price=10000, anchor_price=9900))
|
||||
assert r.session_id == BACKEND_SESSION_ID and r.step == "서비스안내"
|
||||
# 카드선택 턴까지 진행 → company_id 스코프로 학습 기록
|
||||
for ui in ["확인", "예", "확인", "11000", "예"]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, user_input=ui))
|
||||
assert r.step == "가격협상" and r.card_id is not None
|
||||
@ -84,14 +84,17 @@ def test_second_tenant_overrides_merged_on_base():
|
||||
def test_base_self_does_not_inherit():
|
||||
cfg = _loader().load("_base")
|
||||
assert cfg.tenant_id == "_base"
|
||||
assert cfg.action_mapping.action_space_size == 0
|
||||
# _base 는 기본 9카드(자동 온보딩 테넌트가 물려받음, base 정책 162×9 정합)
|
||||
assert cfg.action_mapping.action_space_size == 9
|
||||
|
||||
|
||||
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
|
||||
# 미등록 company_id(uuid 등)는 _base 자동 온보딩 대상이라 '등록됨'으로 본다. 빈 키만 미등록.
|
||||
assert loader.is_registered("00000000-0000-0000-0000-000000000001") is True
|
||||
assert loader.is_registered("") is False
|
||||
|
||||
|
||||
def test_no_proprietary_card_codes_or_labels_in_repo():
|
||||
|
||||
@ -65,12 +65,16 @@ async def test_concurrent_first_build_once():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregistered_raises():
|
||||
async def test_unregistered_company_id_auto_onboards():
|
||||
reg = _registry()
|
||||
with pytest.raises(KeyError):
|
||||
await reg.get_engine("nonexistent_tenant")
|
||||
# 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state).
|
||||
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
|
||||
assert eng.action_space_size == 9 and eng.state_space_size == 162
|
||||
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
||||
assert reg.is_registered("ktcommerce") is True
|
||||
assert reg.is_registered("nonexistent_tenant") is False
|
||||
# 빈 키만 미등록 → KeyError
|
||||
with pytest.raises(KeyError):
|
||||
await reg.get_engine("")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -100,13 +104,12 @@ async def test_middleware_header_missing_unregistered_registered(client):
|
||||
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"})
|
||||
# 미등록 company_id(uuid) → 자동 온보딩이라 미들웨어 통과 (라우트 미존재라 404지만 TENANT_NOT_REGISTERED 아님)
|
||||
r = await client.get("/v1/protected", headers={"X-Tenant-ID": "00000000-0000-0000-0000-000000000001"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["result"]["desc"] == "TENANT_NOT_REGISTERED"
|
||||
assert r.json().get("result", {}).get("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"
|
||||
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user