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

92 lines
6.5 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 변경 (견적 생성 측)
### 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 적용 (앵커 산출 교체 — 이 시점부터 조정된 rate 가 실제 기준가에 반영)
```
각 단계는 독립적으로 안전하다(어느 단계까지만 적용돼도 기존 동작이 깨지지 않음). agent 는 변경 대상이 아니다. 문의는 backend 담당(민헌)에게.