[feat] agent: per-company 카탈로그 — 회사 전용 카드도 action space 포함

카탈로그 스코프를 공용(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 <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-07-08 15:03:30 +09:00
parent 18020a9e07
commit f4e59a2184
4 changed files with 88 additions and 11 deletions

View File

@ -5,9 +5,9 @@
스키마 소유권: card 스키마는 backend/negodata 소유 read-only. 경량 table()/column() 구성. 스키마 소유권: 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 sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
@ -20,15 +20,28 @@ _NEGO_CARDS = table(
column("number"), column("user_id"), column("deleted"), column("number"), column("user_id"), column("deleted"),
schema="card", schema="card",
) )
_USERS = table("users", column("user_id"), column("company_id"), column("deleted"), schema="company")
class CardCatalogDbRepository(ICardCatalogRepository): 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: 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 = ( query = (
select(_NEGO_CARDS.c.number) select(_NEGO_CARDS.c.number)
.where(_NEGO_CARDS.c.user_id.is_(None), _NEGO_CARDS.c.deleted == False) # noqa: E712 .where(scope, _NEGO_CARDS.c.deleted == False) # noqa: E712
.order_by(asc(_NEGO_CARDS.c.number)) .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) 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: if err_type != ErrorType.SUCCESS or not rows:

View File

@ -9,7 +9,7 @@ action_to_card 하드코딩을 대체한다(config 결합 제거 — 카탈로
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Tuple from typing import List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -18,7 +18,11 @@ from common.enums import ErrorType
class ICardCatalogRepository(ABC): class ICardCatalogRepository(ABC):
@abstractmethod @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 space 를 정의하는 일반 협상카드 번호 목록(정렬됨). action_id = 리스트 인덱스.
비었으면([]) 호출부가 파일 action_to_card 폴백한다."""
company_id(UUID) 주면 공용 카드(user_id NULL) + 회사 카드(user_id 회사 유저) 함께,
**공용 먼저 회사 카드 ** 순으로 반환(공용 카드 action_id 안정성 보장). None 이면 공용만.
비었으면([]) 호출부가 파일 action_to_card 폴백한다.
"""
... ...

View File

@ -8,9 +8,18 @@ Chat_server 는 `chat_engine = ChatEngine()` 전역 무인자 싱글톤이라
""" """
import asyncio import asyncio
import uuid
from collections import defaultdict from collections import defaultdict
from typing import Dict, Optional 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.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType, ErrorType from common.enums import DBType, DBWRType, ErrorType
from common.logger import LOG from common.logger import LOG
@ -120,9 +129,12 @@ class TenantEngineRegistry:
async def _apply_db_catalog(self, config: TenantConfig) -> None: async def _apply_db_catalog(self, config: TenantConfig) -> None:
"""DB 카탈로그로 config.action_mapping.action_to_card 를 덮어쓴다(성공 시). """DB 카탈로그로 config.action_mapping.action_to_card 를 덮어쓴다(성공 시).
공용 카드 + 회사 전용 카드(company_id UUID 경우) action space 구성한다.
비었거나 DB 불가면 config 파일 action_to_card 그대로 폴백 사용한다.""" 비었거나 DB 불가면 config 파일 action_to_card 그대로 폴백 사용한다."""
cid = _as_uuid(config.company_id or config.tenant_id) # UUID(실 고객사)면 회사 카드 포함
async def _q(s): 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 return numbers
try: try:

View File

@ -105,7 +105,7 @@ async def test_action_space_from_db_catalog(db_engine):
from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository
class _FakeCatalog(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장) return ErrorType.SUCCESS, ["NGC-001", "NGC-002", "NGC-003"] # 3장짜리 카탈로그(파일은 11장)
reg = TenantEngineRegistry( 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 from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository
class _EmptyCatalog(ICardCatalogRepository): class _EmptyCatalog(ICardCatalogRepository):
async def get_nego_catalog(self, cdb): async def get_nego_catalog(self, cdb, company_id=None):
return ErrorType.SUCCESS, [] return ErrorType.SUCCESS, []
reg = TenantEngineRegistry( reg = TenantEngineRegistry(
@ -175,6 +175,54 @@ async def test_demo_tenant_keeps_file_brand(db_engine):
assert eng.config.resources.company_name == "데모상사 A" 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 @pytest.mark.asyncio
async def test_middleware_header_missing_unregistered_registered(client): async def test_middleware_header_missing_unregistered_registered(client):
# 헤더 누락 → 400 # 헤더 누락 → 400