[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:
parent
18020a9e07
commit
f4e59a2184
@ -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:
|
||||||
|
|||||||
@ -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 로 폴백한다.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user