o2o-negosium-original/schedules/anchoring/docs/인수인계.md
민헌 6b7526bcc3 docs(anchoring): negodata 적용 완료 반영 — 인수인계·개발용·README 동기화
- 인수인계.md §1 에 적용 완료 블록(이식 위치·무Redis 계약·검증 결과·전환기 점프
  확인 결과) 추가, 적용 순서 ③ 쿼리 컬럼명 오류 수정(anchor_rate_after →
  뷰 실제 컬럼 anchor_rate_permille)
- 개발용.md §7(Redis)·§9.1 과 README 경계표의 "negodata 가 Redis 참조" 서술을
  실제 적용 상태(current_rates 뷰 직조회)로 보정 — Redis 사용 주체는 배치만 남음

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:37:39 +09:00

105 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 앵커링 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 변경 (견적 생성 측)
> ✅ **적용 완료 (2026-07-04)** — negodata 담당자 승인 하에 backend 담당(민헌)이 이 절을 직접 적용했다.
>
> - 이식 위치: `negodata/backend/common/anchoring/` (constants·base_table·service 는 읽기 경로 발췌, reader 는 이식판) + `_build_quotation` 앵커 산출 교체 + 재생성 앵커 상속 폐지 + `sessions.anchor_rate_permille` 모델 매핑
> - **이식판 reader 는 Redis 캐시를 쓰지 않는다**: `anchoring.current_rates` 뷰 단일 쿼리 → 실패·무이력 시 정적 테이블 폴백. (2026-07-03 단순화 결정 — 조회가 견적 생성 시 1회뿐이라 캐시 불필요. 모듈 쪽 Redis 제거는 후속 백로그로 진행)
> - 검증: negodata 테스트 스위트 50종 통과 — 앵커링 신설 5종(스키마 부재 폴백·칸별 조정 반영·유형 미지정 폴백·재생성 앵커 재계산·`calc_bracket_index` 경계 골든 벡터) 포함
> - 전환기 점프 확인(§3-③): 적용 시점 `rate_adjustments` 0건 → 점프 없음(시작값 10‰ = 구 기본 `anchoring_value` 0.01 과 동일)
### 1.1 변경 대상
`negodata/backend/services/quotation_service.py` — `_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근).
### 1.2 현재 동작 (변경 전)
```python
# 재생성: 직전 라운드 값 그대로 상속 (재계산 안 함, 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 산정은 그대로 두고, 앵커링가 계산만 교체한다.**
```python
# 세션(상품 × 공급사)마다:
# ① 칸 해석
# 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 적용 직전):
SELECT max(anchor_rate_permille) FROM anchoring.current_rates;
— ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다
("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부
또는 이력 리셋을 정책 결정 후 진행.
④ negodata 적용 (앵커 산출 교체 — 이 시점부터 조정된 rate 가 실제 기준가에 반영)
적용 후 첫 배치 로그에서 "박제 정합 불일치" WARN 이 없는지 확인 — 이식 오류 자동 감지.
```
각 단계는 독립적으로 안전하다(어느 단계까지만 적용돼도 기존 동작이 깨지지 않음). agent 는 변경 대상이 아니다. 문의는 backend 담당(민헌)에게.