o2o-negosium-original/agent/common/database/model/models.py
hbyang b368a19f79 [feat] agent: 카드번호 기반 Q-table 마이그레이션 + 학습 리셋 스크립트
카탈로그 카드 추가/삭제/재정렬 시 action_id 가 밀려도 학습이 카드를 따라가도록,
Q-table 버전에 카탈로그 스냅샷(카드번호)을 저장하고 카드번호로 리맵한다.

- q_table_versions.action_cards JSONB 신설(카드번호 목록, index=action_id).
  models.py + init.sql(DDL·ALTER) + 로컬 DB ALTER.
- 버전 생성(get_or_create/warm_start/migrate)이 action_cards 저장.
- migrate_active_version_dim: 옛 action_cards ↔ 새 카탈로그를 카드번호로 리맵
  (중간 삽입/삭제 보정, 사라진 카드 버림, 새 카드 fresh). 레거시(스냅샷 없음)는 위치 폴백.
  version_name 은 vid 접미로 유니크.
- model_store.load: card_list 계산 → 차원변경 OR 동일차원 내용변경 시 마이그레이션,
  레거시 버전 action_cards backfill(set_version_action_cards).
- tools/reset_learning.py: learning 스키마만 비우는 리셋(카드·협상 데이터 보존),
  --company/--yes 옵션. 카탈로그 바꾸고 학습 처음부터 할 때 사용.
- 테스트: 중간 카드 삭제 시 카드번호 리맵으로 학습 보존 검증. agent 100/100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:19:55 +09:00

146 lines
6.3 KiB
Python

from sqlalchemy.orm import declarative_base
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
Integer,
Numeric,
SmallInteger,
String,
Float,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.sql import text
from common.enums import DBType
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. (backend 와 동일 패턴)
#
# agent 는 negosium_db 를 backend 와 공유한다.
# - 기존 스키마(company/card/negotiation 등)는 postgres-init/01-schema*.sql 이 소유 → 읽기/쓰기만.
# - RL 학습 자산은 신설 `learning` 스키마(postgres-init/02-learning-schema.sql)에 둔다.
MAIN_BASE = declarative_base()
LEARNING_SCHEMA = "learning"
# 공유 베이스 정책의 예약 테넌트 키 (company_id 컬럼에 저장). company uuid 와 혼용되므로 VARCHAR.
BASE_COMPANY_ID = "_base"
class _DBTypeMixin:
"""모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택)."""
@staticmethod
def DBType():
return DBType.MAIN.value
# ============================================================
# learning 스키마 ORM (02-learning-schema.sql 과 1:1)
# 모든 테이블에 company_id(테넌트 키) — 논리 격리.
# ============================================================
class QTableVersion(_DBTypeMixin, MAIN_BASE):
__tablename__ = "q_table_versions"
__table_args__ = {"schema": LEARNING_SCHEMA}
version_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
company_id = Column(String(64), nullable=False)
version_name = Column(String(50), nullable=False)
scope = Column(SmallInteger, nullable=False, default=2) # 1=base, 2=tenant
base_version_id = Column(UUID(as_uuid=True), nullable=True)
state_space_size = Column(Integer, nullable=False)
action_space_size = Column(Integer, nullable=False)
learning_rate = Column(Numeric(6, 4), nullable=False, default=0.1)
discount_factor = Column(Numeric(6, 4), nullable=False, default=0.95)
epochs = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=False)
action_cards = Column(JSONB, nullable=True) # 카탈로그 스냅샷: 카드번호 목록(index=action_id). 카드번호 기반 마이그레이션용.
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
deleted = Column(Boolean, nullable=False, default=False)
class QValue(_DBTypeMixin, MAIN_BASE):
__tablename__ = "q_values"
__table_args__ = {"schema": LEARNING_SCHEMA}
id = Column(BigInteger, primary_key=True, autoincrement=True)
company_id = Column(String(64), nullable=False)
version_id = Column(UUID(as_uuid=True), nullable=False)
state_index = Column(Integer, nullable=False)
action_id = Column(Integer, nullable=False)
q_value = Column(Float, nullable=False, default=0.0)
class VisitCount(_DBTypeMixin, MAIN_BASE):
__tablename__ = "visit_counts"
__table_args__ = {"schema": LEARNING_SCHEMA}
id = Column(BigInteger, primary_key=True, autoincrement=True)
company_id = Column(String(64), nullable=False)
version_id = Column(UUID(as_uuid=True), nullable=False)
state_index = Column(Integer, nullable=False)
action_id = Column(Integer, nullable=False)
count = Column(BigInteger, nullable=False, default=0)
class ExperienceLog(_DBTypeMixin, MAIN_BASE):
__tablename__ = "experience_logs"
__table_args__ = {"schema": LEARNING_SCHEMA}
id = Column(BigInteger, primary_key=True, autoincrement=True)
company_id = Column(String(64), nullable=False)
transition_id = Column(UUID(as_uuid=True), nullable=False, server_default=text("gen_random_uuid()"))
session_id = Column(UUID(as_uuid=True), nullable=True)
state_index = Column(Integer, nullable=False)
action_id = Column(Integer, nullable=False)
card_id = Column(String(40), nullable=True)
q_value_at_selection = Column(Float, nullable=True)
reward = Column(Float, nullable=True)
next_state_index = Column(Integer, nullable=True)
done = Column(Boolean, nullable=False, default=False)
snapshot = Column(JSONB, nullable=True)
propensity = Column(Float, nullable=True) # OPE 필수
turn = Column(Integer, nullable=True)
available_actions = Column(JSONB, nullable=True)
settled_price = Column(BigInteger, nullable=True)
visit_count_at_selection = Column(BigInteger, nullable=True)
total_visits_at_selection = Column(BigInteger, nullable=True)
ucb_score_at_selection = Column(Float, nullable=True)
is_new_quote = Column(Boolean, nullable=False, default=False)
is_invalidated = Column(Boolean, nullable=False, default=False)
invalidated_reason = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
class TenantActionCard(_DBTypeMixin, MAIN_BASE):
__tablename__ = "tenant_action_cards"
__table_args__ = {"schema": LEARNING_SCHEMA}
id = Column(BigInteger, primary_key=True, autoincrement=True)
company_id = Column(String(64), nullable=False)
action_id = Column(Integer, nullable=False)
card_id = Column(String(40), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
updated_at = Column(DateTime(timezone=True), server_default=text("now()"))
deleted = Column(Boolean, nullable=False, default=False)
class ChatSessionRow(_DBTypeMixin, MAIN_BASE):
"""/chat 진행 상태 영속화 (P8-A). 채팅 메시지 로그가 아니라 step 머신 상태."""
__tablename__ = "chat_sessions"
__table_args__ = {"schema": LEARNING_SCHEMA}
session_id = Column(UUID(as_uuid=True), primary_key=True)
company_id = Column(String(64), nullable=False)
tenant_id = Column(String(64), nullable=False)
rq_type = Column(String(10), nullable=False, default="재협상")
step = Column(String(40), nullable=False, default="시작")
context = Column(JSONB, nullable=False, default=dict)
used_action_ids = Column(JSONB, nullable=False, default=list)
action_space_size = Column(Integer, nullable=False, default=0)
ended = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=text("now()"))
updated_at = Column(DateTime(timezone=True), server_default=text("now()"))