o2o-negosium-original/schedules/anchoring/docs/개발용.md
민헌 26f518ed6e refactor(db): postgres-init 2파일 체계로 통합 — 00-init.sql(스키마 전체) + temp-data.sql(시드)
- 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>
2026-07-07 10:20:44 +09:00

720 lines
51 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# 앵커링 시스템 구현 스펙 (개발용)
> **문서 성격**: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본).
> **규범 언어**: `MUST` = 반드시 준수, `MUST NOT` = 금지, `SHOULD` = 권장, `MAY` = 선택.
> **스택**: Python(asyncio) + PostgreSQL(SQLAlchemy async / SQL 수동 적용, Alembic 없음) + Redis + APScheduler — **`schedules/anchoring` 자립 컨테이너**(backend 내장 아님, FastAPI 미사용).
> **버전**: v1.2 (2026-07-02 확정) — 정책 배경은 `기획용.md`, 흐름 해설은 `워크플로우.md`, 타 팀(negodata) 적용 명세는 `인수인계.md` 참조.
---
## 목차
1. [확정 결정 요약](#1-확정-결정-요약)
2. [정적 기본 테이블](#2-정적-기본-테이블)
3. [상수 정의](#3-상수-정의)
4. [도메인 규칙](#4-도메인-규칙)
5. [아키텍처](#5-아키텍처)
6. [DB 스키마](#6-db-스키마)
7. [Redis 캐시 규약](#7-redis-캐시-규약)
8. [배치 잡 명세](#8-배치-잡-명세)
9. [견적 생성·협상 플로우](#9-견적-생성협상-플로우)
10. [참조 구현](#10-참조-구현)
11. [검증 벡터 (Golden Tests)](#11-검증-벡터-golden-tests)
12. [금지·봉인 사항](#12-금지봉인-사항)
13. [선행·연계 작업](#13-선행연계-작업)
---
## 1. 확정 결정 요약
| 항목 | 결정 |
|---|---|
| 역할 분담 | **`schedules/anchoring` 자립 모듈** = 정적 테이블·rate 조회(reader)·조정 배치·Redis 규약·DDL 소유, 독립 컨테이너로 자체 스케줄 실행 / **negodata** = 세션 생성 시 reader 로 rate 조회 → 앵커가·rate 박제 (인수인계, §9.1) / **backend** = 협상 채팅(박제값 소비 + 마지막 제시가 기록, anchoring 모듈 **무의존**) (§9.2) / **agent** = **변경 없음**(앵커 비노출 — 정보 비대칭 전략) |
| 소유·수정 범위 | 직접 수정 가능 = `backend`·`frontend`·`schedules`(우리 모듈). `negodata`·`agent`는 인수인계 문서로 전달 → 담당 개발자가 적용 |
| 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`src/anchoring/resources/anchoring_base.json`, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 |
| 가격구간 | **자릿수 계단식 사다리(46칸)** — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = **정확히 1억**, `target_price > 1억`은 전부 **마지막 인덱스(45)** 로 클램프 |
| 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 |
| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`anchoring_price`, `anchoring_value`, `last_offer_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 |
| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offer_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 |
| 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 |
| 조정 이력 저장 | **append-only 조정 이력** `anchoring.adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 |
| 소비 경계 | `sessions.used_by_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** |
| 평가 트리거 | **격주 토요일 00:00 (KST)**, 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 |
| 평가 방식 | **누적 전량 평가**: 미처리 유효 표본 전부(n건)로 `r = 성공/n` 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월 |
| 앵커링가 반올림 | **1원 단위 내림(floor)** — 정수 연산만 사용 |
| 값 표현 | 앵커링 값은 **정수 천분율(‰)** 로 저장·계산 (부동소수점 산술 금지) |
| 코드값 | 프로젝트 컨벤션: **SMALLINT 1-based 코드 + 앱 enum 매핑, DB CHECK/FK/ENUM 없음** |
---
## 2. 정적 기본 테이블
서비스 시작 시 메모리에 로드되는 불변 리스트. **DB에 저장하지 않으며, 런타임에 절대 수정하지 않는다** (MUST NOT).
파일: `src/anchoring/resources/anchoring_base.json` (리포에 커밋, **46행**). 키는 프로젝트 컨벤션대로 snake_case. 구간은 **자릿수 계단식 사다리**:
| 구간 | 폭 | 칸 수 |
|---|---|---|
| 0 ~ 1,000 | (한 칸으로 통일) | 1 |
| 1,000 ~ 1만 | 1,000원 | 9 |
| 1만 ~ 10만 | 1만 | 9 |
| 10만 ~ 100만 | 10만 | 9 |
| 100만 ~ 1,000만 | 100만 | 9 |
| 1,000만 ~ 1억 | 1,000만 | 9 |
| **합계** | 폭 = 자릿수 시작값(구간 상한의 10%) | **46** |
```json
[
{ "idx": 1, "upper_bound": 1000, "anchoring_value": 0.01 },
{ "idx": 2, "upper_bound": 2000, "anchoring_value": 0.01 },
...
{ "idx": 10, "upper_bound": 10000, "anchoring_value": 0.01 },
{ "idx": 11, "upper_bound": 20000, "anchoring_value": 0.01 },
...
{ "idx": 46, "upper_bound": 100000000, "anchoring_value": 0.01 }
]
```
> 각 칸은 사람이 부르는 가격대와 일치한다 — idx 13 = "3만 원대"([30,000, 40,000)). 1억 초과 가격은 전부 마지막 인덱스로 클램프된다(§2.1). 사다리의 단일 소스는 `constants.UPPER_BOUNDS`(생성식)이며, json 은 기동 시 이와 대조 검증된다.
### 2.1 매핑 규약 (MUST)
| 항목 | 규약 |
|---|---|
| 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) |
| 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) |
| 내부 인덱스 변환 | `price_range_index = idx − 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `price_range_index` 사용 |
| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `price_range_index` 45). **1억 초과도 예외 없이 마지막 인덱스** |
| 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 `anchoring_value` 천분율 변환 정수: `int(round(anchoring_value * 1000))`. 현재 전 구간 10‰ |
| 기동 검증 | 로드 시 46행·idx 연속(1..46)·`upper_bound == constants.UPPER_BOUNDS[i]`(사다리 대조)·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) |
- 시작값은 **정적 테이블에서만** 읽는다. 코드에 `0.01`/`10` 하드코딩 **MUST NOT** (테이블이 유일한 소스).
- `anchoring_value`는 회사 무관 공통. 회사별 차이는 **조정 이력의 누적**에서만 발생한다.
---
## 3. 상수 정의
모든 비율은 정수 천분율(permille). `10‰ = 1%`.
```python
# src/anchoring/constants.py
ANCHORING_VALUE_MIN = 10 # 하한 1%
ANCHORING_VALUE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
ADJUSTMENT_STEP = {
1: 20, # 유통(DISTRIBUTION) ±2%
2: 10, # 제조(MANUFACTURE) ±1%
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
}
SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면 스킵·이월)
# 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드)
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개
PRICE_RANGE_COUNT = 46
PRICE_RANGE_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
MARK_EXCLUDED = 0 # sessions.used_by_adjustment_id 제외 확정 마킹값
CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
```
앱 enum — backend 는 anchoring 판정을 하지 않으므로(무의존) enum 은 **모듈 내부**(`constants.py`)에 둔다 (프로젝트 컨벤션 — plain `Enum`, 1-based, 대상 컬럼 docstring):
```python
class SupplierType(Enum):
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type
(negodata SupplierType 과 동일 코드)"""
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
DISTRIBUTION = 1 # 유통
MANUFACTURE = 2 # 제조
SOLE_AGENCY = 3 # 총판
class AnchoringSampleType(Enum):
"""앵커링 표본 판정 결과(파생값 — DB 에 저장하지 않음, 평가 로직·로그용).
기준 = "가격 흔적": 가격을 써낸 협상만 표본."""
BID_SUCCESS = 1 # 정상종료 + bid ≤ 박제 앵커
BID_FAIL = 2 # 가격 흔적 있으나 성공 아님 (앵커 초과 합의 / 결렬 / 가격 쓰고 이탈·만료)
EXCLUDED = 3 # 가격 흔적 없음 / 앵커 박제 없음 / 유형 미지정
```
- 상수 변경은 정책 재확정 사안이다. 코드에서 임의 조정 **MUST NOT**.
- 앵커링 값을 float으로 저장·연산 **MUST NOT**. 모든 산술은 정수로 수행한다 (성공률 비교도 §10처럼 정수 비교).
---
## 4. 도메인 규칙
### 4.1 칸(cell) 식별
칸 = **`(company_id, supplier_type, price_range_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.
```
price_range_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
```
- `price_range_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45.
- 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨).
- 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**.
- 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
- `supplier_type ∉ {1,2,3}` 이거나 `company_id` 미해석 세션은 칸을 구성할 수 없다 → 가격 산출은 정적 테이블 시작값으로 동작(§9), 집계에서는 제외(§4.3).
### 4.2 앵커링가 계산
```
anchor_price = target_price × (1000 − anchoring_value) // 1000
```
- `target_price`가 정수(원)이므로 위 식은 **정수 연산만으로 정확한 내림**을 보장한다.
- 부동소수점 곱셈 경유 **MUST NOT** (`int(price * 0.99)`, `round(price * 0.99)` 형태 금지).
- 결과는 항상 1원 단위 정수.
### 4.3 표본 판정 (배치 시점 파생 — "가격 흔적" 기준)
표본 = 종료된 재협상 세션 중 **협력사가 가격을 한 번이라도 써낸 것**. 전용 테이블 없이, 배치가 아래 종료 후 불변 입력에서 판정을 파생한다.
> 한 줄 요약: **"가격을 써낸 협상만 세고 — 앵커 이하로 합의됐으면 성공, 나머지는 전부 실패."**
판정 입력:
| 컬럼 | 의미 | 기록 시점 |
|---|---|---|
| `sessions.anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) |
| `sessions.anchoring_value` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 |
| `sessions.last_offer_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 |
| `sessions.status` / `bid_price` | 종료 상태 / 확정 투찰가 | 세션 종료 시 확정 |
판정 대상: `qt_type = 1(재협상)` AND `status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED}` AND `deleted = false`.
| 판정 | 조건 | 유효 표본 | 성공 |
|---|---|---|---|
| `BID_SUCCESS` | `status=DONE` AND `bid_price ≤ anchoring_price` | O | O |
| `BID_FAIL` | 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / **가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)** | O | X |
| `EXCLUDED` | `last_offer_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — |
- "유효 표본" = `EXCLUDED`가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다.
- **왜 실패에 결렬·이탈이 반드시 포함돼야 하나**: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(`check_price_match`) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다.
- 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offer_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.
### 4.4 평가 산식 (누적 전량 평가)
배치 시점에 칸별로 수행한다.
```
pending = 해당 칸의 미처리(used_by_adjustment_id IS NULL) 유효 표본 전부
n = |pending|
n < 10 → 평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨)
n ≥ 10 → r = (pending 중 BID_SUCCESS 건수) / n
┌ +δ(p) if r ≥ 0.60
delta = ┤ 0 if 0.30 ≤ r < 0.60
└ −δ(p) if r < 0.30
anchoring_value_after = clamp(anchoring_value_before + delta, 10, 200)
→ 한 트랜잭션으로:
① anchoring.adjustments INSERT (n, success, before/after, used_session_ids 박제)
② 소비 세션 UPDATE sessions SET used_by_adjustment_id = <조정 id>
WHERE session_id IN (...) AND used_by_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST)
```
- **분모는 항상 실제 누적 건수 n** (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
- delta = 0이어도, clamp에 막혀 값이 안 변해도 **조정 레코드는 반드시 INSERT**하고 표본을 소비(마킹)한다 (MUST).
- "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `used_by_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다.
- 한 칸은 한 배치에서 **최대 1회** 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).
### 4.5 현재 앵커링 값 조회
값은 저장된 단일 상태가 아니라 **조정 이력의 최신 행**이다.
```
rate = (칸의 최신 anchoring.adjustments 행).anchoring_value_after
없으면 → 정적 테이블 시작값 (§2.1)
```
- 재현성: 조정 행에 박제된 `used_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST).
- 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `used_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능).
- 알려진 완화: `sessions` 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.
---
## 5. 아키텍처
```
[anchoring 서비스 기동 — schedules/anchoring 독립 컨테이너]
정적 기본 테이블 메모리 로드·검증 (불변, §2) ── Redis 클라이언트 init ── APScheduler 기동
[견적/세션 생성 — negodata, 인수인계 §9.1]
│ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수)
│ → 세션 INSERT 에 anchoring_price + anchoring_value 박제 (재생성 상속 폐지)
▼
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
│ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
│ 가격 입력 턴마다 last_offer_price 갱신 (가격 흔적)
▼
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
│
│ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
│ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
▼
anchoring.adjustments ────────── 진실 원천 (INSERT only, used_session_ids·값 변화 박제)
│
│ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
▼
Redis anchor:{company_id}:{supplier_type}:{price_range_index} → rate(‰), TTL 7일
│
│ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
▼
[다음 견적/세션 생성] 조정된 rate 로 앵커가 산출
```
- 조정 이력 테이블에 UPDATE / DELETE **MUST NOT**.
- 배치가 `sessions`에 쓰는 것은 `used_by_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT.
- 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
- 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
- Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).
---
## 6. DB 스키마
프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 `postgres-init/00-init.sql` 의 anchoring 섹션(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관, 2026-07-07 01~05 통합). sessions 앵커링 컬럼은 같은 파일 negotiation 섹션 소관. 소유 서비스는 여전히 이 모듈이다.
**네이밍 결정** — 기존 코드베이스 용어와 통일:
| 개념 | 명칭 | 이유 |
|---|---|---|
| 조정 이력 테이블 | **`anchoring.adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 |
| 협력사 유형 | **`supplier_type`** | 기존 `quotations.supplier_type`과 용어 통일 |
| 가격구간 | **`price_range_index`** | 가격구간임을 명시 (코드 내부 변수는 `price_range_index`) |
| 표본 수 | **`sample_count`** | "협상 결과 n건" — 정책 문서 용어 |
| 값 변화 | **`anchoring_value_before` / `anchoring_value_after`** | `sessions.anchoring_value`와 계열 통일 (‰) |
| 소비 창 | **`used_session_ids`** | "이 조정이 소비한 세션"임을 명시 |
| 생성 시각 | **`created_at`** | 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) |
| 소비 마킹 | **`sessions.used_by_adjustment_id`** | 조정 테이블명과 정합 |
### 6.1 조정 이력 (신설 — 유일한 새 테이블)
```sql
CREATE SCHEMA IF NOT EXISTS anchoring;
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
CREATE TABLE IF NOT EXISTS anchoring.adjustments (
id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
price_range_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
sample_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
anchoring_value_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
anchoring_value_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
used_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 현재 값 조회 최적화: 칸별 최신 조정
CREATE INDEX IF NOT EXISTS idx_adjustments_cell
ON anchoring.adjustments (company_id, supplier_type, price_range_index, id DESC);
```
### 6.2 sessions 확장 (기존 테이블 ALTER)
```sql
ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 rate(‰) 박제
ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변
ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST —
-- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장)
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
ON negotiation.sessions (status)
WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
```
### 6.3 조회용 뷰 (파생 — 상태 없음)
회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 adjustments 그대로):
```sql
anchoring.value_history -- 값 변경 이력 리스트업: 이전 값(anchoring_value_before)→새 값 + value_change·success_rate·created_at
anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
```
- 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다.
- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다.
주의사항:
- `sessions.anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).
- 신규 DB 구축: `postgres-init/00-init.sql` 하나로 적용 (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 같은 파일 negotiation 섹션에 반영돼 있다**(backend 가 anchoring 섹션 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 이 모듈이 소유.
- backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).
---
## 7. Redis 캐시 규약
| 항목 | 규약 |
|---|---|
| 키 | `anchor:{company_id}:{supplier_type}:{price_range_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` |
| 값 | 정수 천분율 문자열. 예: `"30"` |
| TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) |
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 |
| 갱신 | 배치가 평가한 칸 SET + **매주 토 잡 실행 시(격주 게이트 무관) 조정 이력 보유 칸 전체 re-SET** (§8 절차 0.5) |
| 장애 내성 | Redis 에러 시 GET→None 취급(DB 폴백), SET 은 로그만 남기고 무시 (MUST — 견적 생성·배치를 Redis 가 막으면 안 됨). socket timeout **0.2~0.5초** 설정 MUST(행 방지) |
| 값 검증 | GET 값이 정책 범위 **[10, 200] 밖이면 오염**(외부 SET 등)으로 간주 — WARN 후 미스 취급(DB 폴백 + 재적재로 자가 교정). 캐시 값을 검증 없이 제안가에 쓰지 않는다 (MUST) |
- 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
- ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
- 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
- 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)뿐이다. backend 는 Redis 를 쓰지 않고, **negodata 도 쓰지 않는다**(2026-07-04 적용된 이식판 reader 는 `current_values` 뷰 직조회 — 인수인계 §1. Redis 캐시 전체 제거가 후속 백로그로 확정됨). 설정은 모듈 `config.{APP_ENV}.toml`(local/dev/prod, 기본 local) + `REDIS_HOST/PORT/PASSWORD` env 오버라이드.
- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. compose(루트 docker-compose.yml)는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다.
---
## 8. 배치 잡 명세
- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·config.{APP_ENV}.toml 보유(APP_ENV 로 local/dev/prod 선택, compose 는 루트 docker-compose.yml 에 통합), backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). 플래그는 argparse 로 검증한다 — `--dry-run` 단독(상주에 dry-run 은 없음)·미지의 플래그(오타)는 **기동 전 즉시 에러(종료코드 2)**: 예행인 줄 알고 실제 변경 상주 스케줄러가 뜨는 사고를 차단. `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지). config.{env}.toml 은 이미지에 넣지 않는다(.dockerignore 포함) — compose 가 읽기 전용 마운트하거나 env 로 주입. dev/prod 는 파일 부재 시 기동 즉시 중단(local 만 기본값 허용).
- **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST).
- **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND used_by_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다.
- **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
```
절차 (run_evaluation_batch(force=False)):
0. force 아니고 격주 게이트 미충족 → 절차 0.5 만 수행 후 종료
0.5. 캐시 정합(매주, 게이트 무관): Redis ping 확인 후 조정 이력 보유 칸 전체의 최신 rate 를 일괄 re-SET
(Redis 미가용이면 WARN 후 즉시 건너뜀 — 셀마다 timeout 을 태우며 지연되지 않게)
(SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조):
sessions s LEFT JOIN quotations q (deleted=false) LEFT JOIN items i (deleted=false)
WHERE s.used_by_adjustment_id IS NULL AND s.deleted = false
AND s.qt_type = 1 AND s.status IN (3, 4, 5)
2. 세션별 파생 판정(§4.3):
- EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
→ used_by_adjustment_id = 0 일괄 마킹 (재스캔 방지)
- 유효 표본 → 칸별 그룹 적재
- 박제 정합 감시: rate 가 박제된 세션에 대해 calc_anchoring_price(tp, anchoring_value)(§4.2 정수식)와
박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch
— negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는
검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다)
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
anchoring_value_before = 최신 조정 anchoring_value_after (없으면 정적 테이블 시작값)
anchoring_value_after = evaluate_samples(...) # §4.4 / §10
① anchoring.adjustments INSERT (used_session_ids 박제)
② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST)
4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchoring_value_after (best effort, TTL 7일)
5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / clamp 포화(조정치가 상·하한 밖으로 나가 잘린 칸 —
경계값에서의 단순 '유지'는 세지 않음) / 이월 칸 수 / 제외 마킹 건수
+ 가격 제시율(종료 재협상 세션 중 last_offer_price 보유 비율) — 0% 면 WARN
(backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
```
**로그 규약** (운영 추적):
- 출력 = stdout(컨테이너 json-file 드라이버, 루트 compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**.
- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= price_range=` key=value 형식 → **회사별 grep**(`grep company=<uuid>`).
- 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약.
- 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN.
- 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
- **dry-run 모드** (`--once --dry-run` / `run_evaluation_batch(dry_run=True)`): 절차 0.5 re-SET·제외 마킹·조정 INSERT·캐시 SET 을 전부 건너뛰고, 판정 결과·예상 조정(`조정예정` 라인, adj_id=None)·제외 예정 건수만 로그로 남긴다(종료 status `dry_run`). 상태를 소비하지 않으므로 직후 실제 실행 결과와 동일하다 — 첫 운영 실행(레거시 세션 전량 판정) 전에 규모를 확인하는 예행 용도.
- 수동·테스트 실행은 `run_evaluation_batch(company_ids=[...])` 로 대상 회사를 한정할 수 있다 — 공유 DB 에서 다른 회사의 미처리 세션을 소비하지 않는다(테스트 스위트가 사용).
- INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다.
- n < 10 칸의 유효 표본은 **마킹하지 않는다** — 그것이 이월이다.
- 배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다.
- 별도 batch_runs 테이블 없음 — 조정 이력이 곧 실행 기록이며, 회차 요약은 LOG로 남긴다.
- **운영 런북**: 토 00:00 에 서비스가 내려가 있었다면(misfire_grace 1h 초과) 그 회차는 스킵되고 패리티 게이트 때문에 2주 뒤 실행된다. 누적 평가라 데이터 손실은 없으나, 재기동 후 `--once` 수동 1회 실행으로 즉시 따라잡을 수 있다.
---
## 9. 견적 생성·협상 플로우 (역할 분담)
앵커가의 **산출·박제 주체는 negodata(견적 생성 측)** 이고, backend(협상 채팅)는 박제값의 소비자다. negodata 변경은 직접 수정하지 않고 `인수인계.md`로 전달한다. **agent 는 변경하지 않는다.**
### 9.1 견적/세션 생성 — negodata (인수인계 대상)
> ✅ 2026-07-04 적용 완료(인수인계.md §1 참조). 적용된 이식판은 아래 2번의 Redis GET/SET 없이
> `current_values` 뷰 직조회 → 정적 테이블 폴백으로 동작한다(단순화 결정).
구(舊) negodata `_build_quotation`은 세션 생성 시 `anchoring_price`를 구 방식으로 채웠다
(신규: `int(tp * (1 - quotation_settings.anchoring_value))` float 계산 / 재생성: 직전 라운드 값 상속).
새 앵커링 모듈 전달 후 아래로 교체된다:
```
세션(상품 × 공급사) 생성 시마다:
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
price_range_index = calc_price_range_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식
2. rate 조회 (모듈의 reader 이식):
supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12)
3. anchor_price = target_price × (1000 − rate) // 1000 ← 정수 연산 MUST (기존 float 식 폐기)
4. 세션 INSERT 에 anchoring_price = anchor_price, anchoring_value = rate 포함 (박제)
```
- **재생성 상속 폐지 (MUST)**: 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
- `quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다(컬럼·화면 표기는 유지 가능).
- **전환기 동작**: negodata 적용 전까지는 구 방식 값이 계속 박제된다 — 판정(§4.3)은 박제된 anchor 기준이므로 표본·조정은 그동안에도 유효하게 쌓이고, negodata 적용 시점부터 조정된 rate 가 실제 기준가에 반영되기 시작한다(자연 부트스트랩, 별도 마이그레이션 불필요).
### 9.2 협상 채팅 — backend (직접 구현, anchoring 모듈 무의존)
`backend/services/chat_service.py::_resolve_anchor_price` — `quotation_settings.anchoring_value` 읽기 **삭제**. `_agent_context`가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다.
```
1. 박제값 사용 (MUST): sessions.anchoring_price 를 그대로 사용.
→ negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
→ 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
박제하지 않는다 → 이 세션은 anchor 박제가 없어 배치 판정에서 자동 EXCLUDED (학습 무오염).
agent 에는 양수 anchor 가 보장되어 기존 검증(ValueError) 안전.
3. agent 컨텍스트로 anchor_price 전달 (기존 AgentChatContext.anchor_price 그대로)
```
- 퇴화 케이스: `target_price` 가 0/NULL 인 세션은 anchor 0 을 반환한다(기존 동작 보존) — 정상 데이터에서는 발생하지 않는다.
**가격 흔적 기록** (MUST):
- agent 는 **변경하지 않는다**. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
- backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offer_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3).
- 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).
---
## 10. 참조 구현
```python
# src/anchoring/service.py (순수 함수만 — DB/Redis 접근 없음)
from bisect import bisect_right
from anchoring.constants import (
ANCHORING_VALUE_MIN, ANCHORING_VALUE_MAX, ADJUSTMENT_STEP,
SAMPLE_THRESHOLD, UPPER_BOUNDS, PRICE_RANGE_INDEX_MAX,
AnchoringSampleType,
)
from anchoring.base_table import get_base_anchoring_value # 정적 테이블 조회 (§2)
def calc_price_range_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프.
정적 테이블 idx = 반환값 + 1"""
return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)
def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
return target_price * (1000 - anchoring_value) // 1000
def judge_sample_type(
is_done: bool, # sessions.status == DONE(3)
bid_price: int | None, # 확정 투찰가(DONE 시)
last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
anchor_price: int | None, # sessions.anchoring_price (박제 앵커)
) -> int:
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
if anchor_price is None or last_offer_price is None:
return AnchoringSampleType.EXCLUDED.value
if is_done and bid_price is not None and bid_price <= anchor_price:
return AnchoringSampleType.BID_SUCCESS.value
return AnchoringSampleType.BID_FAIL.value
def evaluate_samples(
value_before: int,
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
supplier_type: int, # SMALLINT 코드 1/2/3
) -> tuple[int, bool] | None:
"""누적 전량 평가. §4.4
반환: (anchoring_value_after, clamped) (평가 수행 시) / None (n < 10, 스킵·이월)
clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 — 경계값에서의 '유지'와
구분되는 실제 포화 신호(운영 지표용).
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
"""
n = len(sample_types)
if n < SAMPLE_THRESHOLD:
return None
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
delta = ADJUSTMENT_STEP[supplier_type]
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
if success * 10 >= n * 6:
adjusted = value_before + delta
elif success * 10 < n * 3: # r < 0.30
adjusted = value_before - delta
else: # 0.30 ≤ r < 0.60
adjusted = value_before
value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted))
return value_after, value_after != adjusted
def get_current_value(latest_adjusted_rate: int | None, price_range_index: int) -> int:
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
if latest_adjusted_rate is not None:
return latest_adjusted_rate
return get_base_anchoring_value(price_range_index) # int(round(anchoring_value * 1000))
```
```sql
-- 배치의 미처리 세션 스캔 (§8 절차 1)
-- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어
-- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지.
SELECT s.session_id, s.status, s.bid_price, s.target_price,
s.anchoring_price, s.last_offer_price,
q.supplier_type, i.company_id
FROM negotiation.sessions s
LEFT JOIN quotation.quotations q ON q.qt_id = s.quotation_id AND q.deleted = false
LEFT JOIN partner.items i ON i.item_id = s.item_id AND i.deleted = false
WHERE s.used_by_adjustment_id IS NULL
AND s.deleted = false
AND s.qt_type = 1
AND s.status IN (3, 4, 5)
```
```sql
-- 현재 값 조회 (캐시 미스 시)
SELECT anchoring_value_after
FROM anchoring.adjustments
WHERE company_id = :c AND supplier_type = :p AND price_range_index = :b
ORDER BY id DESC
LIMIT 1
```
---
## 11. 검증 벡터 (Golden Tests)
아래 케이스가 전부 성립해야 한다. 위치: 골든 벡터·배치 통합 = **`schedules/anchoring/tests/`**, 가격 흔적 기록·NULL 폴백 = `backend/tests/`. 단 Redis 의존 케이스(re-SET 회복)와 다중 프로세스 동시 실행·타이밍 케이스는 자동 스위트(무Redis·단일 프로세스)가 아닌 **수동/후속 검증** 대상이다 — 자동화된 것은 pytest 로 고정돼 있다.
### 11.1 앵커링가 계산 (내림 검증)
| target_price | rate(‰) | 계산 | anchor_price |
|---|---|---|---|
| 30,000 | 200 | 30,000 × 800 // 1000 | **24,000** |
| 26,706 | 10 | 26,706 × 990 // 1000 = 26,438.94 → 내림 | **26,438** |
| 29,999 | 15 | 29,999 × 985 // 1000 = 29,549.015 → 내림 | **29,549** |
| 0 | 10 | 0 | **0** |
### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)
| target_price | price_range_index | 정적 테이블 idx | 칸 |
|---|---|---|---|
| 0 | 0 | 1 | [0, 1,000) 통일 칸 |
| 999 | 0 | 1 | [0, 1,000) |
| 1,000 | 1 (경계는 상위 구간) | 2 | 1천 원대 |
| 9,999 | 9 | 10 | 9천 원대 |
| 10,000 | 10 | 11 | 1만 원대 |
| 30,000 | 12 | 13 | 3만 원대 |
| 150,000 | 19 | 20 | 10만 원대 |
| 99,999,999 | 45 (마지막 구간) | 46 | 9천만 원대 |
| 100,000,000 | 45 | 46 | 마지막 칸 |
| 150,000,000 | 45 (**1억 초과 → 마지막 인덱스 클램프**) | 46 | 마지막 칸 |
정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000.
### 11.3 누적 전량 평가 (유통 코드1, δ=20, value_before=10)
| pending 구성 | n | r | 판정 | anchoring_value_after |
|---|---|---|---|---|
| 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | **30** |
| 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | **10** |
| 성공 3 / 실패 10 | 13 | ≈ 0.231 | < 0.30 → −20 | **10** (하한 clamp) |
| 성공 6 / 실패 4 | 10 | 0.60 정확히 | 경계 포함 → +20 | **30** |
| 성공 3 / 실패 7 | 10 | 0.30 정확히 | 유지 | **10** |
| 성공 9 / 실패 0 | 9 | — | **평가 안 함 (이월)** | None |
**δ 스왑 가드 (MUST)**: `evaluate_samples(10, [성공10/10], supplier_type=2) == (20, False)` (제조 +10), `supplier_type=3 → (25, False)` (총판 +15).
clamp·격리 케이스:
| 시나리오 | 기대 |
|---|---|
| value_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 |
| A사 칸 평가 | B사의 같은 (p, b) 칸 값에 영향 없음 |
| 조정 이력 없는 칸 | 정적 테이블 시작값(10) 반환 |
| EXCLUDED 15건 + 유효 5건 | 평가 안 함 (유효 5 < 10), EXCLUDED 는 마킹 0 처리 |
### 11.4 파생 판정
| status | bid_price | last_offer_price | anchor_price | 기대 |
|---|---|---|---|---|
| DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) |
| DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) |
| REJECTED | NULL | 25,000 | 24,000 | BID_FAIL (가격 쓰고 결렬) |
| NOT_PARTICIPATED (일괄마감) | NULL | 25,000 | 24,000 | BID_FAIL (가격 쓰고 중간 이탈) |
| 임의 종료 상태 | NULL | NULL | 24,000 | EXCLUDED (가격 흔적 없음) |
| DONE | 24,000 | 24,000 | NULL | EXCLUDED (앵커 박제 없음) |
### 11.5 배치 멱등성·이월·소비 (DB 통합 — 세션 시드 기반)
| 시나리오 | 기대 |
|---|---|
| 유효 13건 시드 → 배치 | 조정 1행(n=13, used_session_ids 13개 박제, 10→30) + 13건 모두 `used_by_adjustment_id`=조정 id |
| 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 — 마킹 멱등) |
| 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 |
| 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 |
| supplier_type NULL 세션 | 집계 제외 + 마킹 0, 이후 배치에서 재스캔 안 됨 |
| 세션 종료가 배치 스캔 직후 커밋 | 마킹 안 됐으므로 다음 배치에서 정상 소비 (영구 누락 없음) |
| 마킹 rowcount ≠ n (경합 시뮬레이션: pending 일부를 미리 마킹) | 조정 INSERT 포함 **전체 롤백** — 조정 0건, 이중 조정 없음 |
| 배치 2개 프로세스 동시 실행(오설정 시뮬레이션) | 한쪽만 조정 성공, 다른 쪽은 rowcount 불일치 롤백 → 칸당 조정 정확히 1건 |
| Redis 에 옛 rate 를 심고 주간 잡 실행(격주 게이트 OFF 주) | 절차 0.5 re-SET 으로 최신 rate 로 회복 |
| 가격 제시율 0% 상태에서 배치 실행 | 요약 로그에 WARN 출력 (backend 기록 배선 유실 감지) |
| dry-run 실행 (유효 10건 + 제외 1건 시드) | status=`dry_run`·`조정예정` 로그만 — 조정 0행·마킹 없음(제외 포함). 직후 실제 실행 시 그대로 반영(상태 미소비 증명) |
| rate=10‰ 박제인데 anchor 가 정수식과 다른 세션 | "박제 정합 불일치 1건" WARN (판정은 박제 anchor 기준 그대로) |
### 11.6 읽기 경로·가격 흔적 (E2E 스모크)
| 시나리오 | 기대 |
|---|---|
| 재협상 채팅 → 가격 입력 턴 | `last_offer_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) |
| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offer_price` 보존 → 배치에서 BID_FAIL 표본 |
| 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) |
| 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 |
| Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) |
---
## 12. 금지·봉인 사항
구현자가 임의로 추가·가정하면 안 되는 항목:
- **극희소 칸 fallback** (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
- **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
- **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
- **sessions 판정 입력 컬럼(`anchoring_price`, `anchoring_value`)의 사후 수정, `last_offer_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `used_by_adjustment_id` 단 하나.
- **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
- **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
- **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립.
- **float 산술** — 앵커링가·rate 계산에 부동소수점 사용 금지 (`round(target*0.99)` 패턴 금지).
- **앵커가 노출** — 앵커가를 협력사 화면에 표시하는 변경은 판정 의미론(§4.3의 무편향 전제)까지 바꾸는 정책 재확정 사안.
---
## 13. 선행·연계 작업
담당 구분: **[우리]** = backend/schedules 직접 구현(완료), **[인수인계]** = 모듈·명세를 전달 → 담당 개발자가 적용.
| # | 항목 | 담당 | 상태 |
|---|---|---|---|
| 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `postgres-init/00-init.sql` anchoring 섹션, psql 적용 시점 협의 | 구현 완료 (§6) |
| 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체** — `_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 |
| 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 |
| 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) |
| 5 | company_id 식별 | — | `partner.items.company_id` (세션→item 조인, 기존 `_agent_context` 해석 방식과 동일) |
| 6 | `quotations.supplier_type` 기록 | [인수인계 — negodata] | 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0) |
| 7 | 정적 테이블 로드 검증 | [우리 — 모듈] | 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료 |
| 8 | Redis 인프라 | [우리 — 모듈] | 루트 docker-compose 에 redis 동봉(모듈 전용 캐시). negodata 는 무Redis(뷰 직조회)·backend 도 무의존 |
| 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 |
| 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offer_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 |