"""카드 매핑 API (card-update / card-search, tenant 스코프). PoC: action_id ↔ card_id 매핑을 learning.tenant_action_cards 에 둔다(config 기본 + DB override). 카드 스크립트 본문은 ScriptRepository(tenants//resources)가 담당 — 여기선 매핑만. """ from typing import Optional from fastapi import APIRouter, Depends, Request from pydantic import BaseModel from common.enums import EXCEPTION_TENANT_HEADER_MISSING, ErrorType from negotiation.qtable.infra.repository.learning_repository import LearningRepository from router.deps import get_tenant_engine from tenancy.registry import TenantEngine, tenant_registry router = APIRouter(prefix="/v1", tags=["Card"], responses={404: {"description": "Not found"}}) async def _merged_mapping(engine: TenantEngine) -> dict: """config 기본 매핑 위에 DB override 를 얹은 action→card 최종 매핑.""" mapping = {int(a): c for a, c in engine.config.action_mapping.action_to_card.items()} overrides = await LearningRepository(engine.company_id).get_action_cards() for o in overrides: mapping[o["action_id"]] = o["card_id"] return mapping class CardUpdateReq(BaseModel): action_id: int card_id: str @router.post("/card-update", summary="카드 매핑 갱신(action→card)") async def card_update(req: CardUpdateReq, engine: TenantEngine = Depends(get_tenant_engine)): if not (0 <= req.action_id < engine.action_space_size): return {"success": False, "desc": "action_id out of range", "action_space_size": engine.action_space_size} err = await LearningRepository(engine.company_id).upsert_action_card(req.action_id, req.card_id) return {"success": err == ErrorType.SUCCESS, "company_id": engine.company_id, "action_id": req.action_id, "card_id": req.card_id, "desc": err.name} @router.post("/catalog-refresh-all", summary="공용 카탈로그 변경 전역 반영(모든 엔진 캐시 클리어)") async def catalog_refresh_all(): """공용 카드(card.nego_cards user_id NULL)는 모든 테넌트의 action space 를 정의하므로, 변경 시 전역 반영이 필요하다. 캐시된 엔진을 전부 비워 다음 요청에서 최신 카탈로그로 재조립한다. o2o 운영/negodata 공용카드 발행 훅에서 호출. 테넌트 헤더 불필요(미들웨어 화이트리스트) — 내부망 전용. """ cleared = tenant_registry.clear_all() return {"success": True, "cleared_engines": cleared} @router.post("/catalog-refresh", summary="카드 카탈로그 변경 반영(엔진 재조립)") async def catalog_refresh(request: Request): """negodata 가 카드 카탈로그(card.nego_cards)를 추가/삭제(발행)한 뒤 호출한다. 해당 테넌트의 캐시된 엔진을 재조립해 → ① DB 카탈로그로 action space 재구성(type:db) → 다음 협상 로드 시 ② 차원 변경이면 Q-table 학습 보존 마이그레이션이 걸린다. 엔진은 캐시되므로 이 호출 없이는 카탈로그 변경이 반영되지 않는다. """ tenant_id = getattr(request.state, "tenant_id", None) if not tenant_id: raise EXCEPTION_TENANT_HEADER_MISSING engine = await tenant_registry.reload(tenant_id) if engine is None: return {"success": False, "desc": "unregistered tenant or reload failed", "tenant_id": tenant_id} return {"success": True, "company_id": engine.company_id, "action_space_size": engine.action_space_size} @router.get("/card-search", summary="카드 검색(전체 매핑 또는 card_id 조회)") async def card_search(card_id: Optional[str] = None, engine: TenantEngine = Depends(get_tenant_engine)): mapping = await _merged_mapping(engine) if card_id: hits = [a for a, c in mapping.items() if c == card_id] return {"company_id": engine.company_id, "card_id": card_id, "action_ids": hits, "found": bool(hits)} return {"company_id": engine.company_id, "mapping": [{"action_id": a, "card_id": c} for a, c in sorted(mapping.items())]}