[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>
This commit is contained in:
hbyang 2026-07-08 15:19:55 +09:00
parent 7b7f606ace
commit b368a19f79
6 changed files with 154 additions and 25 deletions

View File

@ -55,6 +55,7 @@ class QTableVersion(_DBTypeMixin, MAIN_BASE):
discount_factor = Column(Numeric(6, 4), nullable=False, default=0.95) discount_factor = Column(Numeric(6, 4), nullable=False, default=0.95)
epochs = Column(Integer, nullable=False, default=0) epochs = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=False) 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()")) created_at = Column(DateTime(timezone=True), server_default=text("now()"))
deleted = Column(Boolean, nullable=False, default=False) deleted = Column(Boolean, nullable=False, default=False)

View File

@ -27,31 +27,42 @@ class QTablePolicyStore:
lr = pol_cfg.learning_rate lr = pol_cfg.learning_rate
gamma = pol_cfg.gamma gamma = pol_cfg.gamma
# 현재 카탈로그 스냅샷(action_id → 카드번호). 버전에 저장해 카드번호 기반 마이그레이션에 쓴다.
card_list = [engine.mapper.get_card_id(i) for i in range(A)]
# cold-start 3단 (계획서 D): # cold-start 3단 (계획서 D):
# ① 활성 버전 있으면 그대로 ② 없고 inherits_base 면 base warm-start 복제(차원 호환 시) # ① 활성 버전 있으면 그대로 ② 없고 inherits_base 면 base warm-start 복제(차원 호환 시)
# ③ 차원 불일치/base 없음 → 휴리스틱 빈 버전 # ③ 차원 불일치/base 없음 → 휴리스틱 빈 버전
err, active = await repo.read(lambda s: repo.get_active_version(s)) err, active = await repo.read(lambda s: repo.get_active_version(s))
if active is not None: if active is not None:
if active.action_space_size != A or active.state_space_size != S: dim_changed = active.action_space_size != A or active.state_space_size != S
# 카탈로그 카드 수(또는 상태 차원) 변경 → 학습 보존 마이그레이션. # 동일 차원이라도 카탈로그 내용(카드 구성)이 바뀌면 마이그레이션(카드번호 리맵).
# 겹치는 셀 복사(append/truncate 안전) + 새 카드 fresh. 실패 시 기존 버전 유지(load 가 reshape). # 레거시 버전(action_cards=None)은 옛 구성을 몰라 내용변경 감지 불가 → 차원만 본다.
cards_changed = active.action_cards is not None and list(active.action_cards) != card_list
if dim_changed or cards_changed:
# 카드번호 기반 학습 보존 마이그레이션: 같은 카드의 Q값을 새 action_id 로 이동.
migrated = await repo.migrate_active_version_dim( migrated = await repo.migrate_active_version_dim(
old_version=active, state_space_size=S, action_space_size=A, old_version=active, state_space_size=S, action_space_size=A,
learning_rate=lr, discount_factor=gamma, version_name=f"v_migrated_a{A}") learning_rate=lr, discount_factor=gamma, version_name=f"v_migrated_a{A}",
action_cards=card_list)
if migrated is not None: if migrated is not None:
LOG.i(f"[QTablePolicyStore] 차원 변경 마이그레이션 company={engine.company_id} " LOG.i(f"[QTablePolicyStore] 카탈로그 변경 마이그레이션 company={engine.company_id} "
f"{active.action_space_size}{A} action (학습 보존)") f"action {active.action_space_size}{A} (카드번호 리맵, 학습 보존)")
version_id = migrated or active.version_id version_id = migrated or active.version_id
else: else:
version_id = active.version_id version_id = active.version_id
if active.action_cards is None: # 레거시 버전 backfill → 향후 카탈로그 변경 감지 가능
await repo.set_version_action_cards(version_id, card_list)
else: else:
version_id = None version_id = None
if engine.config.inherits_base: if engine.config.inherits_base:
version_id = await repo.warm_start_from_base( version_id = await repo.warm_start_from_base(
state_space_size=S, action_space_size=A, learning_rate=lr, discount_factor=gamma) state_space_size=S, action_space_size=A, learning_rate=lr, discount_factor=gamma,
action_cards=card_list)
if version_id is None: if version_id is None:
version_id = await repo.get_or_create_active_version( version_id = await repo.get_or_create_active_version(
state_space_size=S, action_space_size=A, learning_rate=lr, discount_factor=gamma) state_space_size=S, action_space_size=A, learning_rate=lr, discount_factor=gamma,
action_cards=card_list)
qtable = QTable(S, A, learning_rate=lr, discount_factor=gamma) qtable = QTable(S, A, learning_rate=lr, discount_factor=gamma)
qrows, vrows = await repo.load_cells(version_id) qrows, vrows = await repo.load_cells(version_id)

View File

@ -155,8 +155,10 @@ class LearningRepository:
# ---- Q-Table 영속화 (H1) ------------------------------------------- # ---- Q-Table 영속화 (H1) -------------------------------------------
async def get_or_create_active_version(self, *, state_space_size: int, action_space_size: int, async def get_or_create_active_version(self, *, state_space_size: int, action_space_size: int,
learning_rate: float, discount_factor: float, learning_rate: float, discount_factor: float,
scope: int = 2, version_name: str = "v000") -> Optional[uuid.UUID]: scope: int = 2, version_name: str = "v000",
"""활성 버전 version_id 반환. 없으면 v000 을 활성으로 생성. (company_id 스코프)""" action_cards: Optional[List[str]] = None) -> Optional[uuid.UUID]:
"""활성 버전 version_id 반환. 없으면 v000 을 활성으로 생성. (company_id 스코프)
action_cards: 카탈로그 스냅샷(카드번호 목록, index=action_id) 후일 카드번호 기반 마이그레이션용."""
err, existing = await DB_SESSION_MNG.execute_lambda( err, existing = await DB_SESSION_MNG.execute_lambda(
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: self.get_active_version(s) DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: self.get_active_version(s)
) )
@ -170,6 +172,7 @@ class LearningRepository:
version_id=vid, company_id=self.company_id, version_name=version_name, scope=scope, version_id=vid, company_id=self.company_id, version_name=version_name, scope=scope,
state_space_size=state_space_size, action_space_size=action_space_size, state_space_size=state_space_size, action_space_size=action_space_size,
learning_rate=learning_rate, discount_factor=discount_factor, is_active=True, learning_rate=learning_rate, discount_factor=discount_factor, is_active=True,
action_cards=action_cards,
) )
return await DB_SESSION_MNG.insert(s, obj) return await DB_SESSION_MNG.insert(s, obj)
@ -184,7 +187,8 @@ class LearningRepository:
async def warm_start_from_base(self, *, state_space_size: int, action_space_size: int, async def warm_start_from_base(self, *, state_space_size: int, action_space_size: int,
learning_rate: float, discount_factor: float, visit_decay: float = 0.5, learning_rate: float, discount_factor: float, visit_decay: float = 0.5,
version_name: str = "v000_warmstart_from_base") -> Optional[uuid.UUID]: version_name: str = "v000_warmstart_from_base",
action_cards: Optional[List[str]] = None) -> Optional[uuid.UUID]:
"""공유 베이스(_base)의 Q값/방문수를 자사로 복제해 활성 버전 생성 (cold-start ①, 계획서 D). """공유 베이스(_base)의 Q값/방문수를 자사로 복제해 활성 버전 생성 (cold-start ①, 계획서 D).
차원 불일치/베이스 없음 None (호출자가 휴리스틱 init 폴백). visit 감쇠 복제(탐색 여지). 차원 불일치/베이스 없음 None (호출자가 휴리스틱 init 폴백). visit 감쇠 복제(탐색 여지).
@ -205,7 +209,7 @@ class LearningRepository:
version_id=vid, company_id=self.company_id, version_name=version_name, scope=2, version_id=vid, company_id=self.company_id, version_name=version_name, scope=2,
base_version_id=base_ver.version_id, state_space_size=state_space_size, base_version_id=base_ver.version_id, state_space_size=state_space_size,
action_space_size=action_space_size, learning_rate=learning_rate, action_space_size=action_space_size, learning_rate=learning_rate,
discount_factor=discount_factor, is_active=True, discount_factor=discount_factor, is_active=True, action_cards=action_cards,
) )
e = await DB_SESSION_MNG.insert(s, ver) e = await DB_SESSION_MNG.insert(s, ver)
if e != ErrorType.SUCCESS: if e != ErrorType.SUCCESS:
@ -229,20 +233,30 @@ class LearningRepository:
async def migrate_active_version_dim(self, *, old_version, state_space_size: int, action_space_size: int, async def migrate_active_version_dim(self, *, old_version, state_space_size: int, action_space_size: int,
learning_rate: float, discount_factor: float, learning_rate: float, discount_factor: float,
version_name: str = "v_migrated") -> Optional[uuid.UUID]: version_name: str = "v_migrated",
"""활성 Q-table 을 새 차원으로 마이그레이션 (카탈로그 카드 수 변경 = action 차원 변경 시). action_cards: Optional[List[str]] = None) -> Optional[uuid.UUID]:
"""활성 Q-table 을 새 카탈로그로 마이그레이션 (카드 추가/삭제/재정렬 시).
겹치는 (state < S, action < A) 셀만 복사한다 **카탈로그 끝에 카드 추가(append)/삭제(truncate) **카드번호 기반 리맵**: 버전의 action_cards(카드번호 스냅샷) 카탈로그(action_cards)
안전**(action_id카드 위치 불변). 늘어난 action( 카드) fresh(0, UCB 우선 탐험). 비교해, 같은 **카드번호** 학습값을 action_id 옮긴다 중간 삽입/삭제로 action_id
기존 활성 버전은 비활성화하고 버전을 활성화한다. 실패 None(호출자가 기존 버전 유지). 밀려도 학습이 카드에 정확히 따라간다. 카드는 fresh, 사라진 카드는 버려진다.
버전에 action_cards 없으면(레거시) 위치 기반 폴백( 추가/삭제만 안전).
한계: 카탈로그 **중간 삽입·삭제** action_id 밀려 학습이 어긋날 있다 카드번호 기반 기존 활성 버전은 비활성화하고 버전을 활성화한다. 실패 None.
매핑(버전별 카탈로그 스냅샷 저장, tenant_action_cards 활용) 후속 과제다.
""" """
qcells, vcells = await self.load_cells(old_version.version_id) qcells, vcells = await self.load_cells(old_version.version_id)
vid = uuid.uuid4() vid = uuid.uuid4()
unique_name = f"{version_name}_{vid.hex[:8]}" # (company_id, version_name) 유니크 충돌 방지
S, A = state_space_size, action_space_size S, A = state_space_size, action_space_size
# 옛 action_id → 새 action_id 리맵 테이블. 카드번호로 매칭(스냅샷 있을 때).
old_cards = list(getattr(old_version, "action_cards", None) or [])
new_cards = list(action_cards or [])
if old_cards and new_cards:
new_index = {num: i for i, num in enumerate(new_cards)}
remap = {old_a: new_index[num] for old_a, num in enumerate(old_cards) if num in new_index}
else:
remap = {a: a for a in range(min(old_version.action_space_size, A))} # 위치 기반 폴백
async def _create(s: AsyncSession) -> ErrorType: async def _create(s: AsyncSession) -> ErrorType:
# 기존 활성 비활성화 → 새 버전 활성 삽입 (동시 2개 활성 방지). # 기존 활성 비활성화 → 새 버전 활성 삽입 (동시 2개 활성 방지).
e = await DB_SESSION_MNG.add(s, update(QTableVersion).where( e = await DB_SESSION_MNG.add(s, update(QTableVersion).where(
@ -251,17 +265,18 @@ class LearningRepository:
if e != ErrorType.SUCCESS: if e != ErrorType.SUCCESS:
return e return e
ver = QTableVersion( ver = QTableVersion(
version_id=vid, company_id=self.company_id, version_name=version_name, scope=2, version_id=vid, company_id=self.company_id, version_name=unique_name, scope=2,
base_version_id=old_version.version_id, state_space_size=S, action_space_size=A, base_version_id=old_version.version_id, state_space_size=S, action_space_size=A,
learning_rate=learning_rate, discount_factor=discount_factor, is_active=True, learning_rate=learning_rate, discount_factor=discount_factor, is_active=True,
action_cards=new_cards or None,
) )
e = await DB_SESSION_MNG.insert(s, ver) e = await DB_SESSION_MNG.insert(s, ver)
if e != ErrorType.SUCCESS: if e != ErrorType.SUCCESS:
return e return e
qobjs = [QValue(company_id=self.company_id, version_id=vid, state_index=st, action_id=a, q_value=q) qobjs = [QValue(company_id=self.company_id, version_id=vid, state_index=st, action_id=remap[a], q_value=q)
for st, a, q in qcells if st < S and a < A] for st, a, q in qcells if st < S and a in remap]
vobjs = [VisitCount(company_id=self.company_id, version_id=vid, state_index=st, action_id=a, count=c) vobjs = [VisitCount(company_id=self.company_id, version_id=vid, state_index=st, action_id=remap[a], count=c)
for st, a, c in vcells if st < S and a < A] for st, a, c in vcells if st < S and a in remap]
if qobjs: if qobjs:
e = await DB_SESSION_MNG.insert(s, qobjs) e = await DB_SESSION_MNG.insert(s, qobjs)
if e != ErrorType.SUCCESS: if e != ErrorType.SUCCESS:
@ -275,6 +290,14 @@ class LearningRepository:
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_create]) err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_create])
return vid if err == ErrorType.SUCCESS else None return vid if err == ErrorType.SUCCESS else None
async def set_version_action_cards(self, version_id, action_cards: List[str]) -> ErrorType:
"""버전의 카탈로그 스냅샷(action_cards) 백필 — 레거시 버전이 향후 카탈로그 변경을 감지하게 한다."""
async def _do(s: AsyncSession) -> ErrorType:
return await DB_SESSION_MNG.add(s, update(QTableVersion).where(
QTableVersion.company_id == self.company_id,
QTableVersion.version_id == version_id).values(action_cards=action_cards))
return await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_do])
async def load_cells(self, version_id) -> Tuple[List[tuple], List[tuple]]: async def load_cells(self, version_id) -> Tuple[List[tuple], List[tuple]]:
"""(q_cells, visit_cells) — q_cells: (state,action,q), visit_cells: (state,action,count). 자사 스코프.""" """(q_cells, visit_cells) — q_cells: (state,action,q), visit_cells: (state,action,count). 자사 스코프."""
def _q(s): def _q(s):

View File

@ -92,6 +92,32 @@ async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11 assert str(active.version_id) == str(new_vid) and active.action_space_size == 11
@pytest.mark.asyncio
async def test_migration_remaps_by_card_number(db_engine):
"""카드번호 기반 마이그레이션 — 중간 카드 삭제로 action_id 가 밀려도 학습이 카드를 따라간다."""
import uuid as _uuid
cid = str(_uuid.uuid4())
repo = LearningRepository(cid)
# A=3, action_cards=[NGC-001, NGC-002, NGC-003]. (5,2)=0.9 는 NGC-003 의 학습.
vid = await repo.get_or_create_active_version(
state_space_size=162, action_space_size=3, learning_rate=0.1, discount_factor=0.95,
scope=2, version_name="v_cards3", action_cards=["NGC-001", "NGC-002", "NGC-003"])
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=4) # NGC-003
await repo.upsert_cell(vid, state_index=5, action_id=0, q_value=0.3, count=2) # NGC-001
_, active = await repo.read(lambda s: repo.get_active_version(s))
# 새 카탈로그: 중간 NGC-002 제거 → [NGC-001, NGC-003] (A=2). NGC-003: old action 2 → new action 1.
new_vid = await repo.migrate_active_version_dim(
old_version=active, state_space_size=162, action_space_size=2,
learning_rate=0.1, discount_factor=0.95, action_cards=["NGC-001", "NGC-003"])
assert new_vid is not None
qcells, _ = await repo.load_cells(new_vid)
qmap = {(st, a): q for st, a, q in qcells}
assert qmap.get((5, 1)) == 0.9 # NGC-003 학습이 새 action_id 1 로 따라감(밀림 보정)
assert qmap.get((5, 0)) == 0.3 # NGC-001 은 그대로 action_id 0
assert (5, 2) not in qmap # 삭제된 NGC-002 자리 없음
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dimension_mismatch_falls_back_to_heuristic(db_engine): async def test_dimension_mismatch_falls_back_to_heuristic(db_engine):
await _seed_base(S=162, A=9) await _seed_base(S=162, A=9)

View File

@ -0,0 +1,65 @@
"""로컬 학습 리셋 — learning 스키마를 비운다 (카드 카탈로그·협상 데이터는 보존).
카탈로그(카드) 구성을 바꾼 Q-table 처음부터 다시 학습시키고 싶을 사용한다.
learning.* (버전/Q값/방문수/경험로그/세션/카드매핑) 삭제 다음 협상부터 fresh 재학습.
card.*·negotiation.*·quotation.* 실제 데이터는 건드리지 않는다.
사용 (agent 디렉토리에서):
APP_ENV=local python -m tools.reset_learning # 전체 회사 학습 리셋
APP_ENV=local python -m tools.reset_learning --company <id> # 특정 회사만
APP_ENV=local python -m tools.reset_learning --yes # 확인 프롬프트 생략
"""
import argparse
import asyncio
import asyncpg
from config.server_configs import main_db_config
# 삭제 대상(learning 스키마). 전부 company_id 스코프라 --company 로 회사별 리셋 가능.
_LEARNING_TABLES = [
"q_values", "visit_counts", "experience_logs", "chat_sessions",
"tenant_action_cards", "q_table_versions",
]
async def _reset(company_id: str | None) -> None:
cfg = main_db_config
conn = await asyncpg.connect(
host=cfg.write_host, port=cfg.write_port,
user=cfg.write_id, password=cfg.write_pw, database=cfg.name,
)
try:
total = 0
for t in _LEARNING_TABLES:
if company_id:
res = await conn.execute(f"DELETE FROM learning.{t} WHERE company_id = $1", company_id)
else:
res = await conn.execute(f"DELETE FROM learning.{t}")
n = int(res.split()[-1]) if res else 0
total += n
print(f" learning.{t}: {n} 행 삭제")
scope = f"회사 {company_id}" if company_id else "전체 회사"
print(f"== 학습 리셋 완료: 총 {total} 행 삭제 (scope={scope}) ==")
finally:
await conn.close()
def main() -> None:
p = argparse.ArgumentParser(description="learning 스키마 리셋 (카드/협상 데이터는 보존)")
p.add_argument("--company", help="특정 company_id 만 리셋. 생략 시 전체")
p.add_argument("--yes", action="store_true", help="확인 프롬프트 생략")
args = p.parse_args()
scope = f"회사 {args.company}" if args.company else "전체 회사"
if not args.yes:
ans = input(f"[{main_db_config.name}] learning 스키마({scope})를 비웁니다. 계속? [y/N] ")
if ans.strip().lower() != "y":
print("취소됨.")
return
asyncio.run(_reset(args.company))
if __name__ == "__main__":
main()

View File

@ -472,6 +472,7 @@ CREATE TABLE IF NOT EXISTS learning.q_table_versions (
discount_factor NUMERIC(6,4) NOT NULL DEFAULT 0.9500, discount_factor NUMERIC(6,4) NOT NULL DEFAULT 0.9500,
epochs INTEGER NOT NULL DEFAULT 0, epochs INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 활성 버전 포인터(테넌트당 1개) is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 활성 버전 포인터(테넌트당 1개)
action_cards JSONB NULL, -- 카탈로그 스냅샷: 카드번호 목록(index=action_id). 카드번호 기반 마이그레이션용
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE
); );
@ -657,3 +658,5 @@ ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC;
-- 기준선(2026-07-07 main 스키마)까지의 보정은 이 섹션에 있고, 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용. -- 기준선(2026-07-07 main 스키마)까지의 보정은 이 섹션에 있고, 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
-- 기준선 이후의 새 스키마 변경은 위 테이블 정의를 갱신하고, 보정 ALTER 는 alters/ 아래 별도 파일로 만들어 적용한다. -- 기준선 이후의 새 스키마 변경은 위 테이블 정의를 갱신하고, 보정 ALTER 는 alters/ 아래 별도 파일로 만들어 적용한다.
ALTER TABLE quotation.quotations DROP COLUMN IF EXISTS supplier_type; ALTER TABLE quotation.quotations DROP COLUMN IF EXISTS supplier_type;
-- [2026-07-08] Q-table 버전에 카탈로그 스냅샷(카드번호 기반 마이그레이션용).
ALTER TABLE learning.q_table_versions ADD COLUMN IF NOT EXISTS action_cards JSONB NULL;