From b368a19f79b6c81323fb8270d16b4d21c6e313fb Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 8 Jul 2026 15:19:55 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20agent:=20=EC=B9=B4=EB=93=9C=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B8=B0=EB=B0=98=20Q-table=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20+=20=ED=95=99=EC=8A=B5?= =?UTF-8?q?=20=EB=A6=AC=EC=85=8B=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카탈로그 카드 추가/삭제/재정렬 시 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 --- agent/common/database/model/models.py | 1 + agent/negotiation/policy/model_store.py | 27 +++++--- .../infra/repository/learning_repository.py | 57 +++++++++++----- agent/tests/test_p5_warmstart.py | 26 ++++++++ agent/tools/reset_learning.py | 65 +++++++++++++++++++ postgres-init/init-data/init.sql | 3 + 6 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 agent/tools/reset_learning.py diff --git a/agent/common/database/model/models.py b/agent/common/database/model/models.py index a66e862..4347f89 100644 --- a/agent/common/database/model/models.py +++ b/agent/common/database/model/models.py @@ -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) diff --git a/agent/negotiation/policy/model_store.py b/agent/negotiation/policy/model_store.py index 0b76329..665b551 100644 --- a/agent/negotiation/policy/model_store.py +++ b/agent/negotiation/policy/model_store.py @@ -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) diff --git a/agent/negotiation/qtable/infra/repository/learning_repository.py b/agent/negotiation/qtable/infra/repository/learning_repository.py index 5749b9a..4732714 100644 --- a/agent/negotiation/qtable/infra/repository/learning_repository.py +++ b/agent/negotiation/qtable/infra/repository/learning_repository.py @@ -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): diff --git a/agent/tests/test_p5_warmstart.py b/agent/tests/test_p5_warmstart.py index ca57df4..c70407f 100644 --- a/agent/tests/test_p5_warmstart.py +++ b/agent/tests/test_p5_warmstart.py @@ -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) diff --git a/agent/tools/reset_learning.py b/agent/tools/reset_learning.py new file mode 100644 index 0000000..3198d99 --- /dev/null +++ b/agent/tools/reset_learning.py @@ -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 # 특정 회사만 + 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() diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 4eced3d..796c8c4 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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;