From f4e59a21841d82385e627855d8091a305d06a61b Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 8 Jul 2026 15:03:30 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20agent:=20per-company=20=EC=B9=B4?= =?UTF-8?q?=ED=83=88=EB=A1=9C=EA=B7=B8=20=E2=80=94=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=B9=B4=EB=93=9C=EB=8F=84=20action=20spa?= =?UTF-8?q?ce=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카탈로그 스코프를 공용(user_id NULL) 전용에서 "공용 + 그 회사 유저가 만든 카드"로 확장. 공용 먼저(action_id 0..N-1 안정) → 회사 카드 뒤(append) 정렬로 공용 카드 학습을 보호. - get_nego_catalog(cdb, company_id): company_id 주면 공용 + company.users 소속 카드 함께 조회. - registry._apply_db_catalog: company_id(UUID)면 회사 카드 포함(데모/비-UUID는 공용만). - 효과: 견적이 회사 전용 카드를 선택해도 카탈로그에 있어 _selection_mask 가 매핑됨 (기존 스코프 불일치 갭 — 회사 카드 선택이 무시되던 문제 해소). 한계: 공용 카드 추가 시 회사 카드 action_id 밀림(공용은 안정) — 카드번호 기반 매핑 후속. agent 99/99. Co-Authored-By: Claude Fable 5 --- .../cards/adapters/card_catalog_db.py | 23 ++++++-- .../cards/ports/card_catalog_port.py | 10 ++-- agent/tenancy/registry.py | 14 ++++- agent/tests/test_p4_registry_middleware.py | 52 ++++++++++++++++++- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/agent/negotiation/cards/adapters/card_catalog_db.py b/agent/negotiation/cards/adapters/card_catalog_db.py index ca1592d..2d716bd 100644 --- a/agent/negotiation/cards/adapters/card_catalog_db.py +++ b/agent/negotiation/cards/adapters/card_catalog_db.py @@ -5,9 +5,9 @@ 스키마 소유권: card 스키마는 backend/negodata 소유 — read-only. 경량 table()/column() 구성. """ -from typing import List, Tuple +from typing import List, Optional, Tuple -from sqlalchemy import asc, column, select, table +from sqlalchemy import asc, case, column, or_, select, table from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG @@ -20,15 +20,28 @@ _NEGO_CARDS = table( column("number"), column("user_id"), column("deleted"), schema="card", ) +_USERS = table("users", column("user_id"), column("company_id"), column("deleted"), schema="company") class CardCatalogDbRepository(ICardCatalogRepository): - async def get_nego_catalog(self, cdb: AsyncSession) -> Tuple[ErrorType, List[str]]: + async def get_nego_catalog(self, cdb: AsyncSession, company_id: Optional[object] = None) -> Tuple[ErrorType, List[str]]: try: + # 스코프: 공용(user_id NULL) + (company_id 주면) 그 회사 유저가 만든 카드. + scope = _NEGO_CARDS.c.user_id.is_(None) + if company_id is not None: + company_users = ( + select(_USERS.c.user_id) + .where(_USERS.c.company_id == company_id, _USERS.c.deleted == False) # noqa: E712 + .scalar_subquery() + ) + scope = or_(scope, _NEGO_CARDS.c.user_id.in_(company_users)) + # 정렬: 공용(0) 먼저 → 회사(1) 뒤, 각 그룹 내 number 오름차순. + # 공용 카드의 action_id(0..N-1) 안정성 보장 — 회사 카드 추가는 뒤에 append. + shared_first = case((_NEGO_CARDS.c.user_id.is_(None), 0), else_=1) query = ( select(_NEGO_CARDS.c.number) - .where(_NEGO_CARDS.c.user_id.is_(None), _NEGO_CARDS.c.deleted == False) # noqa: E712 - .order_by(asc(_NEGO_CARDS.c.number)) + .where(scope, _NEGO_CARDS.c.deleted == False) # noqa: E712 + .order_by(shared_first, asc(_NEGO_CARDS.c.number)) ) err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_nego_catalog failed.", raise_error=False) if err_type != ErrorType.SUCCESS or not rows: diff --git a/agent/negotiation/cards/ports/card_catalog_port.py b/agent/negotiation/cards/ports/card_catalog_port.py index 6d327af..670c9f1 100644 --- a/agent/negotiation/cards/ports/card_catalog_port.py +++ b/agent/negotiation/cards/ports/card_catalog_port.py @@ -9,7 +9,7 @@ action_to_card 하드코딩을 대체한다(config 결합 제거 — 카탈로 """ from abc import ABC, abstractmethod -from typing import List, Tuple +from typing import List, Optional, Tuple from sqlalchemy.ext.asyncio import AsyncSession @@ -18,7 +18,11 @@ from common.enums import ErrorType class ICardCatalogRepository(ABC): @abstractmethod - async def get_nego_catalog(self, cdb: AsyncSession) -> Tuple[ErrorType, List[str]]: + async def get_nego_catalog(self, cdb: AsyncSession, company_id: Optional[object] = None) -> Tuple[ErrorType, List[str]]: """action space 를 정의하는 일반 협상카드 번호 목록(정렬됨). action_id = 리스트 인덱스. - 비었으면([]) 호출부가 파일 action_to_card 로 폴백한다.""" + + company_id(UUID) 주면 공용 카드(user_id NULL) + 그 회사 카드(user_id ∈ 회사 유저)를 함께, + **공용 먼저 → 회사 카드 뒤** 순으로 반환(공용 카드 action_id 안정성 보장). None 이면 공용만. + 비었으면([]) 호출부가 파일 action_to_card 로 폴백한다. + """ ... diff --git a/agent/tenancy/registry.py b/agent/tenancy/registry.py index e4945d2..d2196e6 100644 --- a/agent/tenancy/registry.py +++ b/agent/tenancy/registry.py @@ -8,9 +8,18 @@ Chat_server 는 `chat_engine = ChatEngine()` 전역 무인자 싱글톤이라 """ import asyncio +import uuid from collections import defaultdict from typing import Dict, Optional + +def _as_uuid(key: Optional[str]): + """key 가 UUID(실 고객사 company_id)면 uuid.UUID 반환, 데모 테넌트명 등이면 None.""" + try: + return uuid.UUID(key) + except (ValueError, TypeError): + return None + from common.database.db_session_manager import DB_SESSION_MNG from common.enums import DBType, DBWRType, ErrorType from common.logger import LOG @@ -120,9 +129,12 @@ class TenantEngineRegistry: async def _apply_db_catalog(self, config: TenantConfig) -> None: """DB 카탈로그로 config.action_mapping.action_to_card 를 덮어쓴다(성공 시). + 공용 카드 + 회사 전용 카드(company_id UUID 인 경우)로 action space 를 구성한다. 비었거나 DB 불가면 config 의 파일 action_to_card 를 그대로 폴백 사용한다.""" + cid = _as_uuid(config.company_id or config.tenant_id) # UUID(실 고객사)면 회사 카드 포함 + async def _q(s): - _, numbers = await self._catalog_repo.get_nego_catalog(s) + _, numbers = await self._catalog_repo.get_nego_catalog(s, cid) return numbers try: diff --git a/agent/tests/test_p4_registry_middleware.py b/agent/tests/test_p4_registry_middleware.py index 64f9b78..1c399cd 100644 --- a/agent/tests/test_p4_registry_middleware.py +++ b/agent/tests/test_p4_registry_middleware.py @@ -105,7 +105,7 @@ async def test_action_space_from_db_catalog(db_engine): from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository class _FakeCatalog(ICardCatalogRepository): - async def get_nego_catalog(self, cdb): + async def get_nego_catalog(self, cdb, company_id=None): return ErrorType.SUCCESS, ["NGC-001", "NGC-002", "NGC-003"] # 3장짜리 카탈로그(파일은 11장) reg = TenantEngineRegistry( @@ -126,7 +126,7 @@ async def test_action_space_falls_back_to_file_when_catalog_empty(db_engine): from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository class _EmptyCatalog(ICardCatalogRepository): - async def get_nego_catalog(self, cdb): + async def get_nego_catalog(self, cdb, company_id=None): return ErrorType.SUCCESS, [] reg = TenantEngineRegistry( @@ -175,6 +175,54 @@ async def test_demo_tenant_keeps_file_brand(db_engine): assert eng.config.resources.company_name == "데모상사 A" +@pytest.mark.asyncio +async def test_catalog_includes_company_cards(db_engine): + """per-company 카탈로그: 공용 카드(NGC-*) + 그 회사 유저가 만든 카드 — 공용 먼저 → 회사 뒤.""" + import uuid as _uuid + from datetime import datetime, timezone + + from sqlalchemy import column, delete, insert, table + + from common.database.db_session_manager import DB_SESSION_MNG + from common.enums import DBType, ErrorType + from negotiation.cards.adapters.card_catalog_db import CardCatalogDbRepository + + cid, uid, card_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4() + _USERS = table("users", column("user_id"), column("company_id"), column("id"), column("password"), + column("last_accessed_at"), column("status"), column("role"), schema="company") + _NEGO = table("nego_cards", column("nego_card_id"), column("user_id"), column("name"), + column("number"), column("usage_type"), schema="card") + + async def _seed(s): + await DB_SESSION_MNG.add(s, insert(_USERS).values( + user_id=uid, company_id=cid, id="pytest_catalog_user", password="x", + last_accessed_at=datetime.now(timezone.utc), status=1, role=1)) + return await DB_SESSION_MNG.add(s, insert(_NEGO).values( + nego_card_id=card_id, user_id=uid, name="회사전용카드", number="COMP-01", usage_type=1)) + + err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_seed]) + assert err == ErrorType.SUCCESS + try: + repo = CardCatalogDbRepository() + _, nums = await DB_SESSION_MNG.execute_lambda( + DBType.MAIN.value, 1, lambda s: repo.get_nego_catalog(s, cid)) + assert "COMP-01" in nums # 회사 카드 포함 + assert nums[0] == "NGC-001" # 공용이 먼저(action_id 0 안정) + assert nums[-1] == "COMP-01" # 회사 카드는 뒤에 append + assert nums.index("NGC-011") < nums.index("COMP-01") # 공용 전부 → 회사 + + # company_id 없으면 공용만 (회사 카드 제외) + _, shared_only = await DB_SESSION_MNG.execute_lambda( + DBType.MAIN.value, 1, lambda s: repo.get_nego_catalog(s, None)) + assert "COMP-01" not in shared_only + finally: + await DB_SESSION_MNG.execute_lambda_run( + [DBType.MAIN.value], + [lambda s: DB_SESSION_MNG.add(s, delete(_NEGO).where(_NEGO.c.nego_card_id == card_id)), + lambda s: DB_SESSION_MNG.add(s, delete(_USERS).where(_USERS.c.user_id == uid))], + ) + + @pytest.mark.asyncio async def test_middleware_header_missing_unregistered_registered(client): # 헤더 누락 → 400