# 앵커링 시스템 구현 스펙 (개발용) > **문서 성격**: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본). > **규범 언어**: `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=`). - 라인 구성: 시작(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` 읽기 제거(컬럼은 유지). 구현 완료 |