- config.py: APP_ENV(기본 local) 기반 config.{APP_ENV}.toml 로드 — backend/negodata 와 동일 관례
- dev/prod 는 파일 부재 시 기동 즉시 중단(FileNotFoundError) — 오타·미배치 상태로
코드 기본값(로컬 DB)에 붙는 무증상 사고 방지. local 만 파일 없이 기본값 허용(개발 편의)
- main 기동 로그에 APP_ENV·설정 파일 명시
- 루트 compose: anchoring 서비스에 APP_ENV=local + config.local.toml 마운트
- config.toml.example 을 3환경 공용 템플릿으로 갱신, .gitignore 에 config.*.toml
(env별 실파일은 gitignore — dev/prod 는 배포 시 CHANGE_ME 채움)
- TODO.md 삭제(주요 과제 전부 종결) + README·개발용.md 참조 정리
- 검증: 모듈 20 테스트 통과, APP_ENV=staging fail-fast(exit 1)·local dry-run 기동 확인
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
51 KiB
앵커링 시스템 구현 스펙 (개발용)
문서 성격: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본). 규범 언어:
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참조.
목차
- 확정 결정 요약
- 정적 기본 테이블
- 상수 정의
- 도메인 규칙
- 아키텍처
- DB 스키마
- Redis 캐시 규약
- 배치 잡 명세
- 견적 생성·협상 플로우
- 참조 구현
- 검증 벡터 (Golden Tests)
- 금지·봉인 사항
- 선행·연계 작업
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 |
[
{ "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%.
# 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):
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/05-anchoring-schema.sql 한 파일(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관). sessions 앵커링 컬럼은 01-schema*.sql·04-alter*.sql 소관. 소유 서비스는 여전히 이 모듈이다.
네이밍 결정 — 기존 코드베이스 용어와 통일:
| 개념 | 명칭 | 이유 |
|---|---|---|
| 조정 이력 테이블 | 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 조정 이력 (신설 — 유일한 새 테이블)
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)
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 그대로):
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/01~05(IF NOT EXISTS 라 재적용 안전). sessions 3컬럼은 backend ORM 이 참조하므로postgres-init/01-schema*.sql·04-alter*.sql에도 반영돼 있다(backend 가 모듈 DDL 없이도 기동) — 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/PASSWORDenv 오버라이드. - 보안: 무인증 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)·제외 예정 건수만 로그로 남긴다(종료 statusdry_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. 참조 구현
# 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))
-- 배치의 미처리 세션 스캔 (§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)
-- 현재 값 조회 (캐시 미스 시)
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/05-anchoring-schema.sql, 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 읽기 제거(컬럼은 유지). 구현 완료 |