[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() 구성.
"""
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:

View File

@ -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 폴백한다.
"""
...

View File

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

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