o2o-negosium-original/schedules/anchoring/docs/인수인계.md
민헌 ba996f47c1 fix(anchoring): 3방향 적대 리뷰 반영 — 테스트 격리·Redis 방어·운영 견고화
모듈·backend·문서 3개 관점의 적대 리뷰에서 확인된 결함 일괄 수정:

[모듈]
- 배치에 company_ids 스코프 옵션 추가 — 통합 테스트가 공유 dev DB 의 실세션을
  소비/마킹하던 문제 해소(테스트는 시드 회사로 한정), 표적 수동 실행 옵션 겸용
- Redis 방어: compose 포트를 127.0.0.1 바인딩(무인증 공개 차단), get_rate 에
  범위([10,200]) 검증 — 오염 캐시값은 미스 취급 후 자가 교정, 미스 백필은 SET NX
  (배치가 방금 쓴 새 값을 구값으로 덮는 write-after-read 경합 방지)
- 배치: Redis ping 후 re-SET(다운 시 셀×timeout 지연 없이 즉시 스킵), 스캔 조인
  ON 절에 quotations/items deleted 필터(철회 거래를 학습에서 배제), 제외 마킹을
  청크별 커밋(레거시 대량 첫 실행의 장시간 단일 트랜잭션 방지)
- main: SIGTERM/SIGINT 핸들러(docker stop 시 정리 로직 보장), --once 부분 실패 시
  종료코드 1(런북/cron 감지 가능)

[backend]
- finalize_session·update_last_offered_price 에 status=IN_PROGRESS 가드 —
  negodata 일괄마감/중복 전송 경합이 종료된 세션을 되살리거나 가격 흔적을
  사후 변경하는 것 차단(파생 판정 결정성 보호)
- 신규 DB 부트스트랩: sessions 3컬럼을 postgres-init/01-schema·04-alter 에도
  반영(backend 가 모듈 DDL 없이 기동) — anchoring 스키마 자체는 모듈 소유 유지
- 낡은 주석 정리(agent_client·quotation_settings 의 구 앵커 산출 서술)

[테스트·문서]
- 신규 테스트: 격주 게이트 골든(ISO 주차), supplier_type NULL, 가격 제시율 0%
  WARN — 모듈 18개·backend 57개 통과
- 문서 정합 감사 20건 반영: 잔존 33,334/노출 문구 제거, §10 SQL 을 실제 코드
  (LEFT JOIN+deleted)와 일치, §11 자동/수동 검증 구분, FastAPI 오기 제거,
  인수인계 reader 시그니처(db 인자), TODO 백로그 5건 기록

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:55:46 +09:00

6.5 KiB
Raw Blame History

앵커링 v1.2 인수인계 명세 (negodata · agent 담당자용)

문서 성격: 앵커링 시스템 v1.2 도입에 따라 negodata·agent 폴더에서 적용해야 할 변경 명세. 앵커링 모듈은 schedules/anchoring 자립 서비스(독립 컨테이너, 자체 스케줄러)로 개발 완료 후 전달되며, 이 문서는 그 모듈을 각 폴더에 적용하는 방법을 기술한다. 정책 배경: 기획용.md / 기술 규범: 개발용.md (§9.1, §13 참조).

배경 한 줄

앵커링 값(목표가에서 깎는 비율)이 고정 설정(quotation_settings.anchoring_value)에서 칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값으로 바뀐다. 값의 원천은 anchoring.rate_adjustments(조정 이력) + Redis 캐시이며, schedules/anchoring 자립 서비스(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다.

전달물 (→ 각 담당자)

전달물 내용
schedules/anchoring/src/anchoring/ 모듈 constants.py(상수·enum) · base_table.py(정적 테이블 로더) · service.py(순수 계산 함수) · redis_client.py · reader.py(rate 조회) — 전부 async(SQLAlchemy async + redis.asyncio) 자립형이라 negodata 에 그대로 복사/이식 가능
schedules/anchoring/src/anchoring/resources/anchoring_base.json 정적 기본 테이블 (46행 자릿수 사다리, 불변)
schedules/anchoring/schema.sql anchoring.rate_adjustments 테이블 + negotiation.sessions 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의)
schedules/anchoring/docker-compose.yml anchoring 서비스 + redis 동봉 — negodata 는 이 redis 인스턴스를 바라본다 (REDIS_HOST 환경변수)
이 문서 적용 위치·변경 전후 명세

1. negodata 변경 (견적 생성 측)

1.1 변경 대상

negodata/backend/services/quotation_service.py_build_quotation() 의 세션 생성 루프(현재 448~470행 부근)와 regenerate 경로의 상속 로직(현재 319행 부근).

1.2 현재 동작 (변경 전)

# 재생성: 직전 라운드 값 그대로 상속 (재계산 안 함, KTC 방식)
if inherited and iid in inherited:
    tp, ap = inherited[iid]
else:
    tp = self._calc_target_price(...)
    # 구 방식: 견적설정 고정 비율 + float 연산
    ap = int(tp * (1 - anchoring))     # anchoring = quotation_settings.anchoring_value

1.3 변경 후 동작 (MUST)

target_price 산정은 그대로 두고, 앵커링가 계산만 교체한다.

# 세션(상품 × 공급사)마다:
# ① 칸 해석
#    company_id    = items.company_id (해당 상품의 소유 회사)
#    supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3)
#    bracket       = calc_bracket_index(tp)   # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식
# ② rate 조회 — 전달받은 reader 모듈 사용
rate = await get_anchor_rate(db, company_id, supplier_type, bracket)   # db = AsyncSession
#    내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰)
#    supplier_type ∉ {1,2,3} 이면 get_base_rate_permille(bracket) 사용 (정적 테이블 시작값)
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
ap = tp * (1000 - rate) // 1000
# ④ 세션 INSERT 에 두 컬럼 모두 박제
sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)

1.4 필수 규칙

  1. 재생성(다음 라운드) 상속 폐지: inherited 로 앵커링가를 물려주지 않는다. 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다. (target_price 상속은 기존 정책대로 유지해도 무방 — 앵커만 재계산)
  2. 정수 연산 MUST: tp * (1000 - rate) // 1000. 부동소수점 곱셈(int(tp * (1 - x)), round(...)) 금지 — 1원 단위 내림의 정확성 보장.
  3. quotation_settings.anchoring_value 는 앵커가 계산에 더 이상 사용하지 않는다. 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택).
  4. quotations.supplier_type 기록 유지: 재협상 견적 생성 시 이 값이 채워져야 앵커링 집계가 유형별로 분류된다(NULL 이면 해당 세션은 학습에서 자동 제외).
  5. 박제 후 수정 금지: sessions.target_anchoring_price / anchor_rate_permille 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다.
  6. Redis 장애 내성: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다.

1.5 적용 전(전환기) 동작

이 변경이 적용되기 전까지는 지금처럼 구 방식 값이 박제되어도 시스템은 안전하게 동작한다 — 협상 결과 판정은 "박제된 앵커가" 기준이므로 학습 데이터는 유효하게 쌓이고, 이 변경이 적용되는 시점부터 조정된 rate 가 실제 제안가에 반영되기 시작한다. 별도 데이터 마이그레이션은 필요 없다.


2. agent — 변경 없음

앵커링가는 협력사에게 표시하지 않는 비노출 전략으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(check_price_match 등)로만 동작하며, 스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다. 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(sessions.last_offered_price).


3. 적용 순서 (권장)

① DB 스키마 적용 (schedules/anchoring/schema.sql — rate_adjustments + sessions 컬럼 3개)
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
   + backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
③ negodata 적용 (앵커 산출 교체 — 이 시점부터 조정된 rate 가 실제 기준가에 반영)

각 단계는 독립적으로 안전하다(어느 단계까지만 적용돼도 기존 동작이 깨지지 않음). agent 는 변경 대상이 아니다. 문의는 backend 담당(민헌)에게.