From 1b647ba2d53ba668730d35b7666dffd3e311927e Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 18 Jun 2026 17:02:12 +0900 Subject: [PATCH] =?UTF-8?q?cors=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT_INTEGRATION.md | 15 ++++- agent/services/chat_service.py | 4 +- agent/tenancy/config_loader.py | 38 +++++++++--- agent/tenants/_base/tenant.yaml | 13 +++- agent/tests/test_agent_integration.py | 71 ++++++++++++++++++++++ agent/tests/test_p1_tenant_config.py | 7 ++- agent/tests/test_p4_registry_middleware.py | 23 ++++--- 7 files changed, 144 insertions(+), 27 deletions(-) create mode 100644 agent/tests/test_agent_integration.py diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md index 8317b9f..62de5fb 100644 --- a/AGENT_INTEGRATION.md +++ b/AGENT_INTEGRATION.md @@ -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//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` diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 5f3bd80..6b2584d 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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, diff --git a/agent/tenancy/config_loader.py b/agent/tenancy/config_loader.py index c2d803a..8c7088f 100644 --- a/agent/tenancy/config_loader.py +++ b/agent/tenancy/config_loader.py @@ -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) diff --git a/agent/tenants/_base/tenant.yaml b/agent/tenants/_base/tenant.yaml index 2156b66..615dec4 100644 --- a/agent/tenants/_base/tenant.yaml +++ b/agent/tenants/_base/tenant.yaml @@ -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 diff --git a/agent/tests/test_agent_integration.py b/agent/tests/test_agent_integration.py new file mode 100644 index 0000000..21afeb8 --- /dev/null +++ b/agent/tests/test_agent_integration.py @@ -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 diff --git a/agent/tests/test_p1_tenant_config.py b/agent/tests/test_p1_tenant_config.py index 4f5e908..54d9d88 100644 --- a/agent/tests/test_p1_tenant_config.py +++ b/agent/tests/test_p1_tenant_config.py @@ -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(): diff --git a/agent/tests/test_p4_registry_middleware.py b/agent/tests/test_p4_registry_middleware.py index 51dd027..b8d826d 100644 --- a/agent/tests/test_p4_registry_middleware.py +++ b/agent/tests/test_p4_registry_middleware.py @@ -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"