- 00-init.sql: 구 01(도메인)+02(learning)+05(anchoring) 통합, 구 04(누적 ALTER)는 01에 기반영되어 폐기 - temp-data.sql: 구 03 시드 + 협상 카드 시드(일반 11장·와일드 5장, 멱등 가드, available=TRUE) - card 테이블: script TEXT 전환 + tone·strategy_type 컬럼 추가, 카드 변수 9종 체계 문서화 - 참조 갱신: 루트 README·docker-compose 주석, schedules/anchoring conftest(00-init 적용)·README·문서 - anchoring 테스트 20건 통과, 신규 DB 초기화·멱등성 검증 완료 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
105 lines
8.1 KiB
Markdown
105 lines
8.1 KiB
Markdown
# 앵커링 v1.2 인수인계 명세 (negodata · agent 담당자용)
|
||
|
||
> **문서 성격**: 앵커링 시스템 v1.2 도입에 따라 `negodata`·`agent` 폴더에서 적용해야 할 변경 명세.
|
||
> 앵커링 모듈은 **`schedules/anchoring` 자립 서비스**(독립 컨테이너, 자체 스케줄러)로 개발 완료 후 전달되며, 이 문서는 그 모듈을 각 폴더에 적용하는 방법을 기술한다.
|
||
> 정책 배경: `기획용.md` / 기술 규범: `개발용.md` (§9.1, §13 참조).
|
||
|
||
## 배경 한 줄
|
||
|
||
앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.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행 자릿수 사다리, 불변) |
|
||
| `postgres-init/00-init.sql` (anchoring 섹션) | `anchoring.adjustments` 테이블·뷰·인덱스 — 모듈 소유 DDL, psql 수동 적용 (sessions 컬럼은 같은 파일 negotiation 섹션) |
|
||
| 루트 `docker-compose.yml` | anchoring 서비스 + redis 동봉(모듈 전용 캐시) — **negodata 는 Redis 를 쓰지 않는다**(`current_values` 뷰 직조회) |
|
||
| 이 문서 | 적용 위치·변경 전후 명세 |
|
||
|
||
---
|
||
|
||
## 1. negodata 변경 (견적 생성 측)
|
||
|
||
> ✅ **적용 완료 (2026-07-04)** — negodata 담당자 승인 하에 backend 담당(민헌)이 이 절을 직접 적용했다.
|
||
>
|
||
> - 이식 위치: `negodata/backend/common/anchoring/` (constants·base_table·service 는 읽기 경로 발췌, reader 는 이식판) + `_build_quotation` 앵커 산출 교체 + 재생성 앵커 상속 폐지 + `sessions.anchoring_value` 모델 매핑
|
||
> - **이식판 reader 는 Redis 캐시를 쓰지 않는다**: `anchoring.current_values` 뷰 단일 쿼리 → 실패·무이력 시 정적 테이블 폴백. (2026-07-03 단순화 결정 — 조회가 견적 생성 시 1회뿐이라 캐시 불필요. 모듈 쪽 Redis 제거는 후속 백로그로 진행)
|
||
> - 검증: negodata 테스트 스위트 50종 통과 — 앵커링 신설 5종(스키마 부재 폴백·칸별 조정 반영·유형 미지정 폴백·재생성 앵커 재계산·`calc_price_range_index` 경계 골든 벡터) 포함
|
||
> - 전환기 점프 확인(§3-③): 적용 시점 `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)
|
||
# price_range = calc_price_range_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식
|
||
# ② rate 조회 — 전달받은 reader 모듈 사용
|
||
value = await get_current_anchoring_value(db, company_id, supplier_type, price_range) # db = AsyncSession
|
||
# 내부 동작: Redis GET → miss 시 anchoring.adjustments 최신 행 → 없으면 정적 테이블(10‰)
|
||
# supplier_type ∉ {1,2,3} 이면 get_base_anchoring_value(price_range) 사용 (정적 테이블 시작값)
|
||
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
|
||
ap = tp * (1000 - rate) // 1000
|
||
# ④ 세션 INSERT 에 두 컬럼 모두 박제
|
||
sessions(..., anchoring_price=ap, anchoring_value=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.anchoring_price` / `anchoring_value` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다.
|
||
6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다.
|
||
|
||
### 1.5 적용 전(전환기) 동작
|
||
|
||
이 변경이 적용되기 전까지는 지금처럼 구 방식 값이 박제되어도 시스템은 안전하게 동작한다 — 협상 결과 판정은 "박제된 앵커가" 기준이므로 학습 데이터는 유효하게 쌓이고, 이 변경이 적용되는 시점부터 조정된 rate 가 실제 제안가에 반영되기 시작한다. 별도 데이터 마이그레이션은 필요 없다.
|
||
|
||
---
|
||
|
||
## 2. agent — **변경 없음**
|
||
|
||
앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offer_price`).
|
||
|
||
---
|
||
|
||
## 3. 적용 순서 (권장)
|
||
|
||
```
|
||
① DB 스키마 적용 (postgres-init/00-init.sql — adjustments·뷰·인덱스, sessions 컬럼 포함 전체 통합)
|
||
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
|
||
+ backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
|
||
③ 전환기 점프 확인 (negodata 적용 직전):
|
||
SELECT max(anchoring_value) FROM anchoring.current_values;
|
||
— ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다
|
||
("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부
|
||
또는 이력 리셋을 정책 결정 후 진행.
|
||
④ negodata 적용 (앵커 산출 교체 — 이 시점부터 조정된 rate 가 실제 기준가에 반영)
|
||
적용 후 첫 배치 로그에서 "박제 정합 불일치" WARN 이 없는지 확인 — 이식 오류 자동 감지.
|
||
```
|
||
|
||
각 단계는 독립적으로 안전하다(어느 단계까지만 적용돼도 기존 동작이 깨지지 않음). agent 는 변경 대상이 아니다. 문의는 backend 담당(민헌)에게.
|