e529112a90
## 问题 比价记录进入中途退出后未触发 LLM 成本回填,周期补偿也未扫描 cancelled,导致实际已有 LLM 调用的记录长期显示成本、LLM、TOKEN 为空。 ## 修改 - finalize 落库后立即追加 LLM 成本回填 - 周期补偿范围加入 cancelled - 保持无有效调用和全调用失败记录不伪造成本 - 增加即时回填和周期补偿回归测试 ## 验证 - ruff 检查通过 - 相关测试 28 项通过 - 全仓 626 项通过;主干既有失败已在未修改的 origin/main 复现 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: #191 Co-authored-by: linkeyu <linkeyu@wonderable.ai> Co-committed-by: linkeyu <linkeyu@wonderable.ai>
157 lines
5.4 KiB
Python
157 lines
5.4 KiB
Python
"""Persist and repair comparison-record LLM token costs."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.core.config import settings
|
|
from app.core.rewards import CN_TZ
|
|
from app.db.session import SessionLocal
|
|
from app.models.app_config import AppConfig
|
|
from app.models.comparison import ComparisonRecord
|
|
from app.services.llm_cost import compute_llm_cost, get_llm_prices
|
|
from app.services.pricebot_llm_calls import fetch_llm_calls
|
|
|
|
logger = logging.getLogger("shagua.comparison_llm_backfill")
|
|
|
|
|
|
def _utc_to_beijing_naive(value: datetime) -> datetime:
|
|
"""Convert a DB UTC timestamp to comparison_record's Beijing wall-clock."""
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=UTC)
|
|
return value.astimezone(CN_TZ).replace(tzinfo=None)
|
|
|
|
|
|
def _store_calls(record_id: int, trace_id: str, calls: list[dict]) -> bool:
|
|
"""Store calls and all derived fields atomically."""
|
|
with SessionLocal() as db:
|
|
rec = db.get(ComparisonRecord, record_id)
|
|
if rec is None or rec.trace_id != trace_id:
|
|
logger.warning(
|
|
"LLM cost backfill record mismatch record_id=%s trace=%s",
|
|
record_id,
|
|
trace_id,
|
|
)
|
|
return False
|
|
|
|
# Never recalculate a frozen historical cost with a newer price config.
|
|
if rec.llm_cost_yuan is not None and rec.llm_calls:
|
|
return False
|
|
|
|
rec.llm_calls = calls
|
|
rec.llm_call_count = len(calls)
|
|
rec.retry_count = sum(1 for call in calls if call.get("error"))
|
|
rec.input_tokens = sum(
|
|
(call.get("usage") or {}).get("prompt_tokens") or 0 for call in calls
|
|
)
|
|
rec.output_tokens = sum(
|
|
(call.get("usage") or {}).get("completion_tokens") or 0 for call in calls
|
|
)
|
|
rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(
|
|
calls, get_llm_prices(db)
|
|
)
|
|
db.commit()
|
|
logger.info(
|
|
"LLM cost backfilled trace=%s calls=%d input_tokens=%d "
|
|
"output_tokens=%d cost=%s",
|
|
trace_id,
|
|
len(calls),
|
|
rec.input_tokens,
|
|
rec.output_tokens,
|
|
rec.llm_cost_yuan,
|
|
)
|
|
return True
|
|
|
|
|
|
def backfill_comparison_llm_cost(
|
|
record_id: int,
|
|
trace_id: str,
|
|
*,
|
|
attempts: int = 3,
|
|
retry_delays: tuple[float, ...] = (1.0, 3.0),
|
|
) -> bool:
|
|
"""Fetch and persist one record, retrying short-lived upstream races."""
|
|
if not settings.INTERNAL_API_SECRET or not trace_id:
|
|
logger.warning(
|
|
"LLM cost backfill skipped trace=%s: INTERNAL_API_SECRET is not configured",
|
|
trace_id,
|
|
)
|
|
return False
|
|
total_attempts = max(1, attempts)
|
|
for attempt in range(total_attempts):
|
|
calls = fetch_llm_calls(trace_id)
|
|
if calls:
|
|
try:
|
|
return _store_calls(record_id, trace_id, calls)
|
|
except Exception: # noqa: BLE001 - background repair must stay alive
|
|
logger.exception(
|
|
"LLM cost store failed trace=%s record_id=%s",
|
|
trace_id,
|
|
record_id,
|
|
)
|
|
return False
|
|
|
|
if attempt + 1 < total_attempts:
|
|
delay = retry_delays[min(attempt, len(retry_delays) - 1)] if retry_delays else 0
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
|
|
logger.warning(
|
|
"LLM cost backfill has no calls trace=%s record_id=%s attempts=%d",
|
|
trace_id,
|
|
record_id,
|
|
total_attempts,
|
|
)
|
|
return False
|
|
|
|
|
|
def repair_missing_comparison_llm_costs(
|
|
*,
|
|
limit: int = 100,
|
|
lookback_days: int = 30,
|
|
) -> dict[str, int]:
|
|
"""Repair a bounded batch of recent terminal records with missing cost."""
|
|
cutoff = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(
|
|
days=max(1, lookback_days)
|
|
)
|
|
with SessionLocal() as db:
|
|
# app_config has no price history. Repricing a record from before the
|
|
# current config became effective would fabricate a historical cost, so
|
|
# only repair records at/after that timestamp.
|
|
price_config_updated_at = db.execute(
|
|
select(AppConfig.updated_at).where(AppConfig.key == "llm_token_price")
|
|
).scalar_one_or_none()
|
|
date_conditions = [ComparisonRecord.created_at >= cutoff]
|
|
if price_config_updated_at is not None:
|
|
date_conditions.append(
|
|
ComparisonRecord.created_at
|
|
>= _utc_to_beijing_naive(price_config_updated_at)
|
|
)
|
|
candidates = list(
|
|
db.execute(
|
|
select(ComparisonRecord.id, ComparisonRecord.trace_id)
|
|
.where(
|
|
*date_conditions,
|
|
ComparisonRecord.status.in_(("success", "failed", "cancelled")),
|
|
ComparisonRecord.llm_cost_yuan.is_(None),
|
|
)
|
|
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
|
|
.limit(max(1, limit))
|
|
).all()
|
|
)
|
|
|
|
repaired = 0
|
|
for record_id, trace_id in candidates:
|
|
if backfill_comparison_llm_cost(
|
|
record_id, trace_id, attempts=1, retry_delays=()
|
|
):
|
|
repaired += 1
|
|
return {
|
|
"candidates": len(candidates),
|
|
"repaired": repaired,
|
|
"unresolved": len(candidates) - repaired,
|
|
}
|