[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:
parent
7b7f606ace
commit
b368a19f79
@ -55,6 +55,7 @@ class QTableVersion(_DBTypeMixin, MAIN_BASE):
|
||||
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)
|
||||
|
||||
|
||||
@ -27,31 +27,42 @@ class QTablePolicyStore:
|
||||
lr = pol_cfg.learning_rate
|
||||
gamma = pol_cfg.gamma
|
||||
|
||||
# 현재 카탈로그 스냅샷(action_id → 카드번호). 버전에 저장해 카드번호 기반 마이그레이션에 쓴다.
|
||||
card_list = [engine.mapper.get_card_id(i) for i in range(A)]
|
||||
|
||||
# cold-start 3단 (계획서 D):
|
||||
# ① 활성 버전 있으면 그대로 ② 없고 inherits_base 면 base warm-start 복제(차원 호환 시)
|
||||
# ③ 차원 불일치/base 없음 → 휴리스틱 빈 버전
|
||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||
if active is not None:
|
||||
if active.action_space_size != A or active.state_space_size != S:
|
||||
# 카탈로그 카드 수(또는 상태 차원) 변경 → 학습 보존 마이그레이션.
|
||||
# 겹치는 셀 복사(append/truncate 안전) + 새 카드 fresh. 실패 시 기존 버전 유지(load 가 reshape).
|
||||
dim_changed = active.action_space_size != A or active.state_space_size != S
|
||||
# 동일 차원이라도 카탈로그 내용(카드 구성)이 바뀌면 마이그레이션(카드번호 리맵).
|
||||
# 레거시 버전(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(
|
||||
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:
|
||||
LOG.i(f"[QTablePolicyStore] 차원 변경 마이그레이션 company={engine.company_id} "
|
||||
f"{active.action_space_size}→{A} action (학습 보존)")
|
||||
LOG.i(f"[QTablePolicyStore] 카탈로그 변경 마이그레이션 company={engine.company_id} "
|
||||
f"action {active.action_space_size}→{A} (카드번호 리맵, 학습 보존)")
|
||||
version_id = migrated or active.version_id
|
||||
else:
|
||||
version_id = active.version_id
|
||||
if active.action_cards is None: # 레거시 버전 backfill → 향후 카탈로그 변경 감지 가능
|
||||
await repo.set_version_action_cards(version_id, card_list)
|
||||
else:
|
||||
version_id = None
|
||||
if engine.config.inherits_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:
|
||||
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)
|
||||
qrows, vrows = await repo.load_cells(version_id)
|
||||
|
||||
@ -155,8 +155,10 @@ class LearningRepository:
|
||||
# ---- Q-Table 영속화 (H1) -------------------------------------------
|
||||
async def get_or_create_active_version(self, *, state_space_size: int, action_space_size: int,
|
||||
learning_rate: float, discount_factor: float,
|
||||
scope: int = 2, version_name: str = "v000") -> Optional[uuid.UUID]:
|
||||
"""활성 버전 version_id 반환. 없으면 v000 을 활성으로 생성. (company_id 스코프)"""
|
||||
scope: int = 2, version_name: str = "v000",
|
||||
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(
|
||||
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,
|
||||
state_space_size=state_space_size, action_space_size=action_space_size,
|
||||
learning_rate=learning_rate, discount_factor=discount_factor, is_active=True,
|
||||
action_cards=action_cards,
|
||||
)
|
||||
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,
|
||||
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).
|
||||
|
||||
차원 불일치/베이스 없음 → None (호출자가 휴리스틱 init 폴백). visit 은 감쇠 복제(탐색 여지).
|
||||
@ -205,7 +209,7 @@ class LearningRepository:
|
||||
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,
|
||||
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)
|
||||
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,
|
||||
learning_rate: float, discount_factor: float,
|
||||
version_name: str = "v_migrated") -> Optional[uuid.UUID]:
|
||||
"""활성 Q-table 을 새 차원으로 마이그레이션 (카탈로그 카드 수 변경 = action 차원 변경 시).
|
||||
version_name: str = "v_migrated",
|
||||
action_cards: Optional[List[str]] = None) -> Optional[uuid.UUID]:
|
||||
"""활성 Q-table 을 새 카탈로그로 마이그레이션 (카드 추가/삭제/재정렬 시).
|
||||
|
||||
겹치는 (state < S, action < A) 셀만 복사한다 → **카탈로그 끝에 카드 추가(append)/삭제(truncate)에
|
||||
안전**(action_id↔카드 위치 불변). 늘어난 새 action(새 카드)은 fresh(0, UCB 가 우선 탐험).
|
||||
기존 활성 버전은 비활성화하고 새 버전을 활성화한다. 실패 시 None(호출자가 기존 버전 유지).
|
||||
|
||||
⚠️ 한계: 카탈로그 **중간 삽입·삭제**는 action_id 가 밀려 학습이 어긋날 수 있다 — 카드번호 기반
|
||||
매핑(버전별 카탈로그 스냅샷 저장, tenant_action_cards 활용)이 후속 과제다.
|
||||
**카드번호 기반 리맵**: 옛 버전의 action_cards(카드번호 스냅샷)와 새 카탈로그(action_cards)를
|
||||
비교해, 같은 **카드번호**의 학습값을 새 action_id 로 옮긴다 → 중간 삽입/삭제로 action_id 가
|
||||
밀려도 학습이 카드에 정확히 따라간다. 새 카드는 fresh, 사라진 카드는 버려진다.
|
||||
옛 버전에 action_cards 가 없으면(레거시) 위치 기반 폴백(끝 추가/삭제만 안전).
|
||||
기존 활성 버전은 비활성화하고 새 버전을 활성화한다. 실패 시 None.
|
||||
"""
|
||||
qcells, vcells = await self.load_cells(old_version.version_id)
|
||||
vid = uuid.uuid4()
|
||||
unique_name = f"{version_name}_{vid.hex[:8]}" # (company_id, version_name) 유니크 충돌 방지
|
||||
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:
|
||||
# 기존 활성 비활성화 → 새 버전 활성 삽입 (동시 2개 활성 방지).
|
||||
e = await DB_SESSION_MNG.add(s, update(QTableVersion).where(
|
||||
@ -251,17 +265,18 @@ class LearningRepository:
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
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,
|
||||
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)
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
qobjs = [QValue(company_id=self.company_id, version_id=vid, state_index=st, action_id=a, q_value=q)
|
||||
for st, a, q in qcells if st < S and a < A]
|
||||
vobjs = [VisitCount(company_id=self.company_id, version_id=vid, state_index=st, action_id=a, count=c)
|
||||
for st, a, c in vcells if st < S and a < A]
|
||||
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 in remap]
|
||||
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 in remap]
|
||||
if qobjs:
|
||||
e = await DB_SESSION_MNG.insert(s, qobjs)
|
||||
if e != ErrorType.SUCCESS:
|
||||
@ -275,6 +290,14 @@ class LearningRepository:
|
||||
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_create])
|
||||
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]]:
|
||||
"""(q_cells, visit_cells) — q_cells: (state,action,q), visit_cells: (state,action,count). 자사 스코프."""
|
||||
def _q(s):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@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
|
||||
async def test_dimension_mismatch_falls_back_to_heuristic(db_engine):
|
||||
await _seed_base(S=162, A=9)
|
||||
|
||||
65
agent/tools/reset_learning.py
Normal file
65
agent/tools/reset_learning.py
Normal 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()
|
||||
@ -472,6 +472,7 @@ CREATE TABLE IF NOT EXISTS learning.q_table_versions (
|
||||
discount_factor NUMERIC(6,4) NOT NULL DEFAULT 0.9500,
|
||||
epochs INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 활성 버전 포인터(테넌트당 1개)
|
||||
action_cards JSONB NULL, -- 카탈로그 스냅샷: 카드번호 목록(index=action_id). 카드번호 기반 마이그레이션용
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
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 을 먼저 적용.
|
||||
-- 기준선 이후의 새 스키마 변경은 위 테이블 정의를 갱신하고, 보정 ALTER 는 alters/ 아래 별도 파일로 만들어 적용한다.
|
||||
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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user