feat(anchoring): 운영 로그 개선 — run_id·칸별 조정 상세·회사별 요약

장기 운영 관점 자체 점검에서 나온 6개 구멍 반영:

- 회차 추적: 모든 배치 라인에 [batch {run_id}] 태그 + 시작 로그(ISO 주차·force)
  + 상주 기동 시 다음 실행 예정 시각 출력
- 회사별 구분: 칸별 조정 상세(company= type= bracket= n= 성공= before‰→after‰
  adj_id=)와 회사요약(테넌트당 1줄: 평가/상승/유지/하락/이월/실패/제외) —
  grep company=<uuid> 로 테넌트 단위 추적, adj_id 로 DB 행 교차 확인
- 경보 연결: failed_cells>0 이면 종료 요약 WARNING 승격, Redis 실패는 연산별
  처음 5건만 WARN 후 누계 요약(폭주 억제)
- 시간대: 로그 타임스탬프를 컨테이너 TZ 무관 KST(+0900) 고정, slim 컨테이너
  zoneinfo 보장용 tzdata 의존 추가
- 스케줄 가시성: apscheduler 로거를 동일 핸들러에 연결(misfire 등 유실 방지)
- 로테이션: compose 에 json-file 10MB×5 설정(디스크 보호)

docs/개발용.md §8 로그 규약, README 로그 확인법 추가. 테스트에 로그 규약
검증(run_id 태그·조정 라인·회사요약) 포함 — 15 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-02 16:46:16 +09:00
parent fd8a2425c5
commit e33754961b
9 changed files with 153 additions and 22 deletions

View File

@ -50,6 +50,19 @@ docker compose up -d --build
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q PYTHONPATH=src .venv/bin/python -m pytest tests/ -q
``` ```
## 로그 확인
```bash
docker logs anchoring | grep "batch 20260705" # 특정 회차 전체
docker logs anchoring | grep "company=<uuid>" # 특정 회사만 (조정·회사요약 라인)
docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
```
- 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과
`회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id` 로 `anchoring.rate_adjustments` 행과 교차 확인한다.
- 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다.
- 로그 로테이션은 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당.
## 운영 런북 ## 운영 런북
- **미스파이어**: 토 00:00 에 서비스가 내려가 있었고 1시간(misfire_grace) 초과로 그 회차가 스킵됐다면, - **미스파이어**: 토 00:00 에 서비스가 내려가 있었고 1시간(misfire_grace) 초과로 그 회차가 스킵됐다면,

View File

@ -1,6 +1,13 @@
# anchoring 자립 서비스 — 루트 compose 와 독립(다른 서버를 건드리지 않음). # anchoring 자립 서비스 — 루트 compose 와 독립(다른 서버를 건드리지 않음).
# DB 는 기존 외부 PostgreSQL(host.docker.internal), Redis 는 여기 동봉. # DB 는 기존 외부 PostgreSQL(host.docker.internal), Redis 는 여기 동봉.
# negodata(견적 생성 측)는 이 redis 인스턴스를 REDIS_HOST 로 바라본다(docs/인수인계.md). # negodata(견적 생성 측)는 이 redis 인스턴스를 REDIS_HOST 로 바라본다(docs/인수인계.md).
# 로그: stdout(json-file) — 로테이션 필수(장기 운영 디스크 보호). 로그 시각은 코드가 KST 로 고정.
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services: services:
anchoring-redis: anchoring-redis:
image: redis:7-alpine image: redis:7-alpine
@ -8,6 +15,7 @@ services:
ports: ports:
- "6379:6379" - "6379:6379"
restart: unless-stopped restart: unless-stopped
logging: *default-logging
anchoring: anchoring:
build: . build: .
@ -15,6 +23,8 @@ services:
environment: environment:
DB_HOST: host.docker.internal DB_HOST: host.docker.internal
REDIS_HOST: anchoring-redis REDIS_HOST: anchoring-redis
TZ: Asia/Seoul
depends_on: depends_on:
- anchoring-redis - anchoring-redis
restart: unless-stopped restart: unless-stopped
logging: *default-logging

View File

@ -385,6 +385,14 @@ CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
(backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지) (backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
``` ```
**로그 규약** (운영 추적):
- 출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**.
- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= bracket=` 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.
- 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
- INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다. - INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다.
- n < 10 칸의 유효 표본은 **마킹하지 않는다** — 그것이 이월이다. - n < 10 칸의 유효 표본은 **마킹하지 않는다** — 그것이 이월이다.
- 배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다. - 배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다.

View File

@ -1,4 +1,5 @@
# anchoring 자립 모듈 (async — negodata 이식 호환) # anchoring 자립 모듈 (async — negodata 이식 호환)
tzdata>=2024.1 # slim 컨테이너에 IANA 시간대 데이터 보장(zoneinfo Asia/Seoul)
SQLAlchemy>=2.0 SQLAlchemy>=2.0
greenlet>=3.0 greenlet>=3.0
asyncpg>=0.29 asyncpg>=0.29

View File

@ -28,7 +28,7 @@ from anchoring.db import session_scope
from anchoring.log import LOG from anchoring.log import LOG
from anchoring.models import Item, Quotation, RateAdjustment, Session from anchoring.models import Item, Quotation, RateAdjustment, Session
from anchoring.reader import get_latest_adjusted_rate from anchoring.reader import get_latest_adjusted_rate
from anchoring.redis_client import set_rate from anchoring.redis_client import consume_failure_counts, set_rate
from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type
KST = ZoneInfo("Asia/Seoul") KST = ZoneInfo("Asia/Seoul")
@ -150,25 +150,43 @@ async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples:
if marked != len(session_ids): if marked != len(session_ids):
# 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백 # 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백
raise MarkingConflictError( raise MarkingConflictError(
f"cell=({company_id},{supplier_type},{bracket}) 마킹 {marked}/{len(session_ids)}" f"company={company_id} type={supplier_type} bracket={bracket} 마킹 {marked}/{len(session_ids)}"
) )
# 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복) # 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복)
await set_rate(company_id, supplier_type, bracket, rate_after) await set_rate(company_id, supplier_type, bracket, rate_after)
return {"before": rate_before, "after": rate_after} return {
"adjustment_id": adjustment.id,
"before": rate_before,
"after": rate_after,
"success": adjustment.success_count,
"n": len(samples),
}
def _new_company_agg() -> dict:
return {"evaluated": 0, "up": 0, "hold": 0, "down": 0, "carryover": 0, "failed": 0, "excluded": 0}
async def run_evaluation_batch(force: bool = False) -> dict: async def run_evaluation_batch(force: bool = False) -> dict:
"""배치 1회. force=True 면 격주 게이트만 무시(정책 파라미터는 불변).""" """배치 1회. force=True 면 격주 게이트만 무시(정책 파라미터는 불변).
로그 규약: 모든 라인에 `[batch {run_id}]` 태그(회차 grep), 칸/회사 단위 라인은
`company=` `type=` `bracket=` key=value 형식(회사별 grep — `grep company=<uuid>`).
"""
now = datetime.now(KST) now = datetime.now(KST)
run_id = now.strftime("%Y%m%d-%H%M%S")
tag = f"[batch {run_id}]"
LOG.info(f"{tag} 시작 — ISO 주차 {now.isocalendar().week}, force={force}")
# 절차 0.5 — 캐시 정합(매주, 게이트 무관) # 절차 0.5 — 캐시 정합(매주, 게이트 무관)
reconciled = await _reconcile_cache() reconciled = await _reconcile_cache()
LOG.info(f"[batch] 캐시 re-SET {reconciled}칸") LOG.info(f"{tag} 캐시 re-SET {reconciled}칸")
if not force and not is_evaluation_week(now): if not force and not is_evaluation_week(now):
LOG.info(f"[batch] 격주 게이트 미충족(ISO 주차 {now.isocalendar().week}) — 평가 스킵") _log_redis_failures(tag)
return {"status": "skipped", "reason": "week_parity", "cache_reconciled": reconciled} LOG.info(f"{tag} 격주 게이트 미충족 — 평가 스킵")
return {"run_id": run_id, "status": "skipped", "reason": "week_parity", "cache_reconciled": reconciled}
# 절차 1~2 — 스캔 + 파생 판정 # 절차 1~2 — 스캔 + 파생 판정
async with session_scope() as db: async with session_scope() as db:
@ -176,12 +194,15 @@ async def run_evaluation_batch(force: bool = False) -> dict:
excluded_ids: list = [] excluded_ids: list = []
cells: dict[tuple, list] = defaultdict(list) cells: dict[tuple, list] = defaultdict(list)
per_company: dict[str, dict] = defaultdict(_new_company_agg)
priced = 0 priced = 0
for r in rows: for r in rows:
if r.last_offered_price is not None: if r.last_offered_price is not None:
priced += 1 priced += 1
if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None: if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None:
excluded_ids.append(r.session_id) # 칸 구성 불가 excluded_ids.append(r.session_id) # 칸 구성 불가
if r.company_id is not None:
per_company[str(r.company_id)]["excluded"] += 1
continue continue
sample_type = judge_sample_type( sample_type = judge_sample_type(
is_done=r.status == SESSION_STATUS_DONE, is_done=r.status == SESSION_STATUS_DONE,
@ -191,47 +212,70 @@ async def run_evaluation_batch(force: bool = False) -> dict:
) )
if sample_type == AnchoringSampleType.EXCLUDED.value: if sample_type == AnchoringSampleType.EXCLUDED.value:
excluded_ids.append(r.session_id) excluded_ids.append(r.session_id)
per_company[str(r.company_id)]["excluded"] += 1
continue continue
bracket = calc_bracket_index(r.target_price) bracket = calc_bracket_index(r.target_price)
cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type)) cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type))
# 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5) # 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
if rows and priced == 0: if rows and priced == 0:
LOG.warning(f"[batch] 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offered_price 전무) " LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offered_price 전무) "
f"— backend 가격 입력 기록 배선 점검 필요") f"— backend 가격 입력 기록 배선 점검 필요")
# 절차 2 — 제외 확정 마킹(재스캔 방지) # 절차 2 — 제외 확정 마킹(재스캔 방지)
if excluded_ids: if excluded_ids:
async with session_scope() as db: async with session_scope() as db:
await _mark_sessions(db, excluded_ids, MARK_EXCLUDED) await _mark_sessions(db, excluded_ids, MARK_EXCLUDED)
LOG.info(f"{tag} 제외 확정 마킹 {len(excluded_ids)}건")
# 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음) # 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음)
evaluated = up = hold = down = clamped = failed = 0 evaluated = up = hold = down = clamped = failed = 0
carryover = 0 carryover = 0
for (company_id, stype, bracket), samples in cells.items(): for (company_id, stype, bracket), samples in cells.items():
agg = per_company[str(company_id)]
if len(samples) < SAMPLE_THRESHOLD: if len(samples) < SAMPLE_THRESHOLD:
carryover += 1 # 마킹하지 않음 = 이월(§4.4) carryover += 1 # 마킹하지 않음 = 이월(§4.4)
agg["carryover"] += 1
continue continue
try: try:
result = await _evaluate_cell(company_id, stype, bracket, samples) result = await _evaluate_cell(company_id, stype, bracket, samples)
except Exception as ex: except Exception as ex:
failed += 1 failed += 1
LOG.error(f"[batch] 칸 평가 실패 cell=({company_id},{stype},{bracket}): {ex}", exc_info=True) agg["failed"] += 1
LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} bracket={bracket}: {ex}", exc_info=True)
continue continue
if result is None: if result is None:
carryover += 1 carryover += 1
agg["carryover"] += 1
continue continue
evaluated += 1 evaluated += 1
agg["evaluated"] += 1
# 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인
LOG.info(f"{tag} 조정 company={company_id} type={stype} bracket={bracket} "
f"n={result['n']} 성공={result['success']} {result['before']}‰→{result['after']}‰ "
f"adj_id={result['adjustment_id']}")
if result["after"] > result["before"]: if result["after"] > result["before"]:
up += 1 up += 1
agg["up"] += 1
elif result["after"] < result["before"]: elif result["after"] < result["before"]:
down += 1 down += 1
agg["down"] += 1
else: else:
hold += 1 hold += 1
agg["hold"] += 1
if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX): if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX):
clamped += 1 clamped += 1
# 회사별 요약 — 멀티테넌트 운영에서 테넌트 단위 상태를 한 줄로
for company, agg in sorted(per_company.items()):
LOG.info(f"{tag} 회사요약 company={company} 평가={agg['evaluated']} 상승={agg['up']} "
f"유지={agg['hold']} 하락={agg['down']} 이월={agg['carryover']} "
f"실패={agg['failed']} 제외={agg['excluded']}")
_log_redis_failures(tag)
summary = { summary = {
"run_id": run_id,
"status": "done" if failed == 0 else "partial", "status": "done" if failed == 0 else "partial",
"scanned": len(rows), "scanned": len(rows),
"priced_rate": (priced / len(rows)) if rows else None, "priced_rate": (priced / len(rows)) if rows else None,
@ -240,7 +284,17 @@ async def run_evaluation_batch(force: bool = False) -> dict:
"up": up, "hold": hold, "down": down, "clamped": clamped, "up": up, "hold": hold, "down": down, "clamped": clamped,
"carryover_cells": carryover, "carryover_cells": carryover,
"failed_cells": failed, "failed_cells": failed,
"companies": len(per_company),
"cache_reconciled": reconciled, "cache_reconciled": reconciled,
} }
LOG.info(f"[batch] 종료 {summary}") # 칸 실패가 있으면 요약을 WARNING 으로 승격 — "WARN 이상 알람" 정책에 걸리도록
log_fn = LOG.warning if failed else LOG.info
log_fn(f"{tag} 종료 {summary}")
return summary return summary
def _log_redis_failures(tag: str) -> None:
counts = consume_failure_counts()
if counts["get"] or counts["set"]:
LOG.warning(f"{tag} redis 실패 누계 get={counts['get']} set={counts['set']} "
f"— DB 폴백으로 동작함, Redis 상태 점검 필요")

View File

@ -1,14 +1,31 @@
"""모듈 로거 — 표준 logging 얇은 래퍼(자립: backend logger 미사용).""" """모듈 로거 — 표준 logging 얇은 래퍼(자립: backend logger 미사용).
- 타임스탬프는 컨테이너 TZ 와 무관하게 항상 KST(+0900) — 배치 기준 시각과 로그 대조 편의.
- apscheduler 로거에도 같은 핸들러를 연결한다(미연결 시 misfire 등 스케줄 이상 로그가
포맷 없는 stderr 로 새거나 유실됨).
"""
import logging import logging
import sys import sys
from datetime import datetime
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
LOG = logging.getLogger("anchoring") LOG = logging.getLogger("anchoring")
class _KSTFormatter(logging.Formatter):
def formatTime(self, record, datefmt=None): # noqa: N802 (logging 시그니처)
dt = datetime.fromtimestamp(record.created, KST)
return dt.strftime(datefmt or "%Y-%m-%d %H:%M:%S%z")
def configure(level: str = "info") -> None: def configure(level: str = "info") -> None:
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) handler.setFormatter(_KSTFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
LOG.handlers.clear() for name in ("anchoring", "apscheduler"):
LOG.addHandler(handler) logger = logging.getLogger(name)
LOG.setLevel(getattr(logging, level.upper(), logging.INFO)) logger.handlers.clear()
LOG.propagate = False logger.addHandler(handler)
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
logger.propagate = False

View File

@ -37,7 +37,8 @@ async def _run(once: bool) -> None:
scheduler = build_scheduler() scheduler = build_scheduler()
scheduler.start() scheduler.start()
LOG.info("[main] 스케줄러 상주 시작") job = scheduler.get_job("anchoring_biweekly_evaluation")
LOG.info(f"[main] 스케줄러 상주 시작 — 다음 실행 예정: {job.next_run_time}")
await asyncio.Event().wait() # 컨테이너 메인 — SIGTERM 까지 대기 await asyncio.Event().wait() # 컨테이너 메인 — SIGTERM 까지 대기
finally: finally:
await close_redis() await close_redis()

View File

@ -12,6 +12,25 @@ from anchoring.log import LOG
_client: aioredis.Redis | None = None _client: aioredis.Redis | None = None
# 실패 WARN 폭주 억제: 연산별 처음 N 건만 WARN, 이후 무음. 누계는 배치가
# consume_failure_counts() 로 회수해 회차 요약에 한 줄로 남긴다.
_WARN_LIMIT = 5
_fail_counts = {"get": 0, "set": 0}
def _note_failure(op: str, key: str, ex: Exception) -> None:
_fail_counts[op] += 1
if _fail_counts[op] <= _WARN_LIMIT:
suffix = " — 이후 동일 실패는 억제(누계는 배치 요약)" if _fail_counts[op] == _WARN_LIMIT else ""
LOG.warning(f"[redis] {op.upper()} 실패({_fail_counts[op]}번째, DB 폴백) key={key}: {ex}{suffix}")
def consume_failure_counts() -> dict:
"""실패 누계 회수 + 리셋 — 배치 회차 요약용."""
global _fail_counts
counts, _fail_counts = _fail_counts, {"get": 0, "set": 0}
return counts
def init_redis(cfg: RedisConfig) -> None: def init_redis(cfg: RedisConfig) -> None:
global _client global _client
@ -45,7 +64,7 @@ async def get_rate(company_id, supplier_type: int, bracket_index: int) -> int |
raw = await _client.get(anchor_key(company_id, supplier_type, bracket_index)) raw = await _client.get(anchor_key(company_id, supplier_type, bracket_index))
return int(raw) if raw is not None else None return int(raw) if raw is not None else None
except Exception as ex: except Exception as ex:
LOG.warning(f"[redis] GET 실패(DB 폴백) key={anchor_key(company_id, supplier_type, bracket_index)}: {ex}") _note_failure("get", anchor_key(company_id, supplier_type, bracket_index), ex)
return None return None
@ -57,5 +76,5 @@ async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int
await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS) await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS)
return True return True
except Exception as ex: except Exception as ex:
LOG.warning(f"[redis] SET 실패(주간 re-SET 이 회복) key={anchor_key(company_id, supplier_type, bracket_index)}: {ex}") _note_failure("set", anchor_key(company_id, supplier_type, bracket_index), ex)
return False return False

View File

@ -2,6 +2,7 @@
실행: cd schedules/anchoring && PYTHONPATH=src .venv/bin/python -m pytest tests/test_batch.py -q 실행: cd schedules/anchoring && PYTHONPATH=src .venv/bin/python -m pytest tests/test_batch.py -q
""" """
import logging
import uuid import uuid
from sqlalchemy import select, text from sqlalchemy import select, text
@ -43,8 +44,8 @@ async def _seed_mixed(db, seeder, success: int, fail: int, **kw):
return ids return ids
# ── §11.5: 13건 전량 평가 + 멱등 (실패 3종 혼합) ────────── # ── §11.5: 13건 전량 평가 + 멱등 (실패 3종 혼합) + 로그 규약 ──
async def test_full_cycle_and_idempotency(seeder): async def test_full_cycle_and_idempotency(seeder, caplog):
async with adb.session_scope() as db: async with adb.session_scope() as db:
ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등) ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등)
for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패 for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패
@ -53,7 +54,14 @@ async def test_full_cycle_and_idempotency(seeder):
ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offered_price=FAIL_BID)) ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offered_price=FAIL_BID))
# 합계 13건, 성공 8 → r≈0.615 → +20 # 합계 13건, 성공 8 → r≈0.615 → +20
await run_evaluation_batch(force=True) with caplog.at_level(logging.INFO, logger="anchoring"):
result = await run_evaluation_batch(force=True)
# 로그 규약: run_id 태그 + 회사별 grep 가능한 칸별 조정 라인 + 회사요약 라인
assert result["run_id"]
tagged = [m for m in caplog.messages if f"[batch {result['run_id']}]" in m]
assert any(f"조정 company={seeder.company_id}" in m and "10‰→30‰" in m for m in tagged)
assert any(f"회사요약 company={seeder.company_id}" in m and "평가=1" in m for m in tagged)
async with adb.session_scope() as db: async with adb.session_scope() as db:
adjustments = await _adjustments(db, seeder) adjustments = await _adjustments(db, seeder)