90 lines
3.6 KiB
Python
90 lines
3.6 KiB
Python
"""任务级成本/Token/耗时预估(交付报告 P2-1)。
|
|||
|
|
|
||
|
|
思路:用本组织近期已结算的真实用量外推(历史均值 × 本次段数),
|
||
|
|
没有历史数据时用保守常量估计。不依赖任何外部服务。
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from sqlmodel import Session, select
|
||
|
|
|
||
|
|
from .models import Campaign, LLMCall, Segment, UsageEvent
|
||
|
|
from .billing import POINTS_PER_TOKEN
|
||
|
|
|
||
|
|
|
||
|
|
# 无历史数据时的保守单段估计(deepseek 级别的小上下文生产段)
|
||
|
|
FALLBACK_INPUT_TOKENS = 6000
|
||
|
|
FALLBACK_OUTPUT_TOKENS = 2500
|
||
|
|
FALLBACK_SECONDS = 90
|
||
|
|
|
||
|
|
|
||
|
|
def estimate_campaign_cost(session: Session, campaign: Campaign, single_segment_id: int | None = None) -> dict:
|
||
|
|
"""single_segment_id:逐段生成模式——只按指定段估算。"""
|
||
|
|
if single_segment_id:
|
||
|
|
segments = [s for s in session.exec(select(Segment).where(
|
||
|
|
Segment.campaign_id == campaign.id)).all() if s.id == int(single_segment_id)]
|
||
|
|
else:
|
||
|
|
segments = session.exec(select(Segment).where(
|
||
|
|
Segment.campaign_id == campaign.id)).all()
|
||
|
|
seg_count = len(segments)
|
||
|
|
if not seg_count:
|
||
|
|
return {"ready": False, "reason": "任务还没有内容段,先完成导图拆解",
|
||
|
|
"segments": 0}
|
||
|
|
|
||
|
|
org_id = int(campaign.organization_id or 1)
|
||
|
|
|
||
|
|
# 历史均值:本组织最近 200 条已测量 LLM 用量事件
|
||
|
|
events = session.exec(select(UsageEvent).where(
|
||
|
|
UsageEvent.organization_id == org_id,
|
||
|
|
UsageEvent.service_type == "llm",
|
||
|
|
).order_by(UsageEvent.id.desc()).limit(200)).all()
|
||
|
|
measured = [e for e in events
|
||
|
|
if e.status == "measured" and (e.input_quantity or 0) + (e.output_quantity or 0) > 0]
|
||
|
|
|
||
|
|
if measured:
|
||
|
|
avg_in = sum(e.input_quantity or 0 for e in measured) / len(measured)
|
||
|
|
avg_out = sum(e.output_quantity or 0 for e in measured) / len(measured)
|
||
|
|
source = "history"
|
||
|
|
else:
|
||
|
|
avg_in, avg_out = float(FALLBACK_INPUT_TOKENS), float(FALLBACK_OUTPUT_TOKENS)
|
||
|
|
source = "fallback"
|
||
|
|
|
||
|
|
# 耗时:近 200 次 LLM 调用的平均时长 × 每段约 3 次调用(生产+质检)+ 组合余量
|
||
|
|
durations = [float(row.duration_ms or 0) for row in session.exec(
|
||
|
|
select(LLMCall).order_by(LLMCall.id.desc()).limit(200)).all()
|
||
|
|
if (row.duration_ms or 0) > 0]
|
||
|
|
avg_seconds = FALLBACK_SECONDS
|
||
|
|
if durations:
|
||
|
|
avg_seconds = (sum(durations) / len(durations)) / 1000 * 3 + 15
|
||
|
|
|
||
|
|
total_in = avg_in * seg_count
|
||
|
|
total_out = avg_out * seg_count
|
||
|
|
total_tokens = int((total_in + total_out) * 1.6) # 1.6 倍:每段附带质检与组合分摊
|
||
|
|
est_points = total_tokens * POINTS_PER_TOKEN
|
||
|
|
est_seconds = int(avg_seconds * seg_count + 20)
|
||
|
|
|
||
|
|
# 费用:历史事件费率均值
|
||
|
|
if measured:
|
||
|
|
avg_in_rate = sum(e.input_rate or 0 for e in measured) / len(measured)
|
||
|
|
avg_out_rate = sum(e.output_rate or 0 for e in measured) / len(measured)
|
||
|
|
else:
|
||
|
|
avg_in_rate = avg_out_rate = 0.0
|
||
|
|
amount_minor = int(round((total_in / 1e6 * avg_in_rate + total_out / 1e6 * avg_out_rate) * 100))
|
||
|
|
|
||
|
|
return {
|
||
|
|
"ready": True,
|
||
|
|
"source": source,
|
||
|
|
"segments": seg_count,
|
||
|
|
"estimated_input_tokens": int(total_in),
|
||
|
|
"estimated_output_tokens": int(total_out),
|
||
|
|
"estimated_tokens": total_tokens,
|
||
|
|
"estimated_points": est_points,
|
||
|
|
"estimated_amount_minor": amount_minor,
|
||
|
|
"estimated_seconds": est_seconds,
|
||
|
|
"estimated_duration_text": (f"{est_seconds // 60} 分 {est_seconds % 60} 秒"
|
||
|
|
if est_seconds >= 60 else f"{est_seconds} 秒"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
__all__ = ["estimate_campaign_cost"]
|
||
|
|
|