cors 문제 해결 .

This commit is contained in:
hbyang 2026-06-18 17:02:12 +09:00
parent a52321e68d
commit 1b647ba2d5
7 changed files with 144 additions and 27 deletions

View File

@ -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`

View File

@ -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,

View File

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

View File

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

View 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

View File

@ -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():

View File

@ -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"