feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲
- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
This commit is contained in:
@@ -183,7 +183,7 @@ async def run_agent(
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
max_tokens=600,
|
||||
max_tokens=4096, # 推理模型需要更大余量
|
||||
)
|
||||
if resp.error:
|
||||
logger.warning("agent %s LLM failed: %s", spec.name, resp.error)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.core.config import settings
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Match, Prediction
|
||||
from src.db.unit_of_work import get_uow
|
||||
from src.llm.predict import _upsert_prediction
|
||||
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
|
||||
from src.llm.context_builder import (
|
||||
MatchHeader,
|
||||
@@ -24,6 +25,7 @@ from src.llm.context_builder import (
|
||||
load_match_header,
|
||||
stats_slice,
|
||||
)
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
from src.llm.provider import LLMProvider, get_default_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,7 +60,20 @@ SPECIALIST_SPECS: list[AgentSpec] = [
|
||||
),
|
||||
]
|
||||
|
||||
AGGREGATOR_SYSTEM = "你是足球预测终裁专家。综合各领域报告输出最终预测。只输出 JSON。"
|
||||
AGGREGATOR_SYSTEM = (
|
||||
"你是足球预测终裁专家。综合各领域专家报告输出最终预测。"
|
||||
"引用专家时必须使用报告中的专家全名(如「攻防数据分析专家」),禁止使用英文代码。"
|
||||
"只输出 JSON。"
|
||||
)
|
||||
|
||||
# 专家代码 → 终裁/展示统一称呼
|
||||
AGENT_LABELS_ZH: dict[str, str] = {
|
||||
"form": "近期状态分析专家",
|
||||
"stats": "攻防数据分析专家",
|
||||
"home_away": "主客因素分析专家",
|
||||
"injuries": "阵容完整性分析专家",
|
||||
"h2h": "历史交锋分析专家",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,6 +85,8 @@ class MultiPredictResult:
|
||||
mode: str
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None
|
||||
alt_pred_away_goals: int | None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -80,31 +97,38 @@ class MultiPredictResult:
|
||||
raw: dict | None
|
||||
|
||||
|
||||
def _get_specialist_provider() -> LLMProvider:
|
||||
"""专家模型: LLM_SPECIALIST_MODEL 回落 LLM_MODEL。"""
|
||||
p = get_default_provider()
|
||||
if settings.LLM_SPECIALIST_MODEL:
|
||||
p.model = settings.LLM_SPECIALIST_MODEL
|
||||
return p
|
||||
async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
|
||||
"""构造某 agent 专属 provider。
|
||||
|
||||
|
||||
def _get_aggregator_provider() -> LLMProvider:
|
||||
"""终裁模型: LLM_AGGREGATOR_MODEL 回落 LLM_MODEL。"""
|
||||
p = get_default_provider()
|
||||
if settings.LLM_AGGREGATOR_MODEL:
|
||||
p.model = settings.LLM_AGGREGATOR_MODEL
|
||||
覆盖优先级:
|
||||
模型: AGENT_MODEL_{ID}(运行时) → 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) → 全局 LLM_MODEL
|
||||
地址/密钥: AGENT_BASE_URL_{ID} / AGENT_API_KEY_{ID}(运行时) → 全局 LLM_BASE_URL / LLM_API_KEY
|
||||
"""
|
||||
pfx = f"AGENT_{agent_id.upper()}_"
|
||||
p = await get_default_provider()
|
||||
tier_model = settings.LLM_SPECIALIST_MODEL if tier == "specialist" else settings.LLM_AGGREGATOR_MODEL
|
||||
if tier_model:
|
||||
p.model = tier_model
|
||||
model = await get_runtime_value(f"{pfx}MODEL")
|
||||
if model:
|
||||
p.model = model
|
||||
base = await get_runtime_value(f"{pfx}BASE_URL")
|
||||
if base:
|
||||
p.base_url = base
|
||||
key = await get_runtime_value(f"{pfx}API_KEY")
|
||||
if key:
|
||||
p.api_key = key
|
||||
return p
|
||||
|
||||
|
||||
async def run_specialists(
|
||||
header: MatchHeader,
|
||||
*,
|
||||
provider: LLMProvider,
|
||||
version: str = "v1",
|
||||
) -> list[AgentReport]:
|
||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
|
||||
tasks = [
|
||||
_run_one(spec, header, provider, version=version)
|
||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version)
|
||||
for spec in SPECIALIST_SPECS
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -125,7 +149,13 @@ async def _run_one(spec, header, provider, *, version) -> AgentReport:
|
||||
|
||||
|
||||
def _reports_to_json(reports: list[AgentReport]) -> str:
|
||||
return json.dumps([r.to_dict() for r in reports], ensure_ascii=False, indent=1)
|
||||
"""报告序列化: agent 字段直接用中文专家全名,引导终裁用统一称呼引用。"""
|
||||
out = []
|
||||
for r in reports:
|
||||
d = r.to_dict()
|
||||
d["agent"] = AGENT_LABELS_ZH.get(d.get("agent", ""), d.get("agent"))
|
||||
out.append(d)
|
||||
return json.dumps(out, ensure_ascii=False, indent=1)
|
||||
|
||||
|
||||
async def run_aggregator(
|
||||
@@ -147,7 +177,7 @@ async def run_aggregator(
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
max_tokens=1000,
|
||||
max_tokens=4096, # 推理模型需要更大余量
|
||||
)
|
||||
if resp.error:
|
||||
raise RuntimeError(f"aggregator LLM error: {resp.error}")
|
||||
@@ -171,12 +201,11 @@ async def predict_match_multi(
|
||||
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 2. 并行专家
|
||||
specialist_provider = _get_specialist_provider()
|
||||
reports = await run_specialists(header, provider=specialist_provider, version=version)
|
||||
# 2. 并行专家(各自独立配置)
|
||||
reports = await run_specialists(header, version=version)
|
||||
|
||||
# 3. 终裁
|
||||
aggregator_provider = _get_aggregator_provider()
|
||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
|
||||
header, reports, provider=aggregator_provider, version=version
|
||||
)
|
||||
@@ -203,30 +232,33 @@ async def predict_match_multi(
|
||||
|
||||
# agent_weights 同样必须过校验(旧实现直接取 raw 值落库,未做任何检查)
|
||||
agent_weights = validate_agent_weights(final.get("agent_weights"))
|
||||
pred = Prediction(
|
||||
pred = await _upsert_prediction(
|
||||
session,
|
||||
match_id=match_id,
|
||||
provider=settings.LLM_PROVIDER,
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=aggregator_provider.model,
|
||||
prompt_version=f"multi_{version}",
|
||||
mode="multi",
|
||||
prompt_tokens=sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
completion_tokens=sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
pred_home_goals=validated.pred_home_goals,
|
||||
pred_away_goals=validated.pred_away_goals,
|
||||
pred_1x2=validated.pred_1x2,
|
||||
subjective_confidence=validated.subjective_confidence,
|
||||
reasoning=validated.reasoning,
|
||||
raw_response=final,
|
||||
agent_outputs=[r.to_dict() for r in reports],
|
||||
status="success",
|
||||
match_kickoff_at=match_kickoff_at,
|
||||
prediction_cutoff_at=prediction_cutoff_at,
|
||||
prediction_created_at=now,
|
||||
input_hash=input_hash,
|
||||
values={
|
||||
"prompt_version": f"multi_{version}",
|
||||
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
||||
"latency_ms": latency_ms,
|
||||
"pred_home_goals": validated.pred_home_goals,
|
||||
"pred_away_goals": validated.pred_away_goals,
|
||||
"alt_pred_home_goals": validated.alt_pred_home_goals,
|
||||
"alt_pred_away_goals": validated.alt_pred_away_goals,
|
||||
"pred_1x2": validated.pred_1x2,
|
||||
"subjective_confidence": validated.subjective_confidence,
|
||||
"reasoning": validated.reasoning,
|
||||
"raw_response": final,
|
||||
"agent_outputs": [r.to_dict() for r in reports],
|
||||
"status": "success",
|
||||
"match_kickoff_at": match_kickoff_at,
|
||||
"prediction_cutoff_at": prediction_cutoff_at,
|
||||
"prediction_created_at": now,
|
||||
"input_hash": input_hash,
|
||||
},
|
||||
)
|
||||
session.add(pred)
|
||||
await session.refresh(pred)
|
||||
|
||||
return MultiPredictResult(
|
||||
prediction_id=pred.id,
|
||||
@@ -236,6 +268,8 @@ async def predict_match_multi(
|
||||
mode="multi",
|
||||
pred_home_goals=pred.pred_home_goals,
|
||||
pred_away_goals=pred.pred_away_goals,
|
||||
alt_pred_home_goals=pred.alt_pred_home_goals,
|
||||
alt_pred_away_goals=pred.alt_pred_away_goals,
|
||||
pred_1x2=pred.pred_1x2,
|
||||
subjective_confidence=pred.subjective_confidence,
|
||||
reasoning=pred.reasoning,
|
||||
|
||||
@@ -20,6 +20,7 @@ from src.db.unit_of_work import get_uow
|
||||
from src.llm.eval import settle_prediction
|
||||
from src.llm.predict import predict_match
|
||||
from src.llm.utils import actual_1x2
|
||||
from src.data.team_names_zh import zh_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +32,8 @@ class BacktestMatchResult:
|
||||
league_code: str | None
|
||||
home_team: str
|
||||
away_team: str
|
||||
home_team_zh: str | None
|
||||
away_team_zh: str | None
|
||||
match_date: str
|
||||
actual_home: int
|
||||
actual_away: int
|
||||
@@ -54,6 +57,8 @@ class BacktestCandidate:
|
||||
league_code: str | None
|
||||
home_team: str
|
||||
away_team: str
|
||||
home_team_zh: str | None
|
||||
away_team_zh: str | None
|
||||
match_date: datetime
|
||||
home_goals: int
|
||||
away_goals: int
|
||||
@@ -113,6 +118,8 @@ async def _get_historical_matches(
|
||||
league_code=m.league.code if m.league else None,
|
||||
home_team=m.home_team.name if m.home_team else "?",
|
||||
away_team=m.away_team.name if m.away_team else "?",
|
||||
home_team_zh=zh_name(m.home_team.name) if m.home_team else None,
|
||||
away_team_zh=zh_name(m.away_team.name) if m.away_team else None,
|
||||
match_date=m.match_date,
|
||||
home_goals=m.home_goals,
|
||||
away_goals=m.away_goals,
|
||||
@@ -164,6 +171,8 @@ async def run_backtest(
|
||||
league_code=c.league_code,
|
||||
home_team=c.home_team,
|
||||
away_team=c.away_team,
|
||||
home_team_zh=c.home_team_zh,
|
||||
away_team_zh=c.away_team_zh,
|
||||
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
|
||||
actual_home=c.home_goals,
|
||||
actual_away=c.away_goals,
|
||||
|
||||
+68
-21
@@ -11,6 +11,8 @@ from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from src.core.config import settings
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Match, Prediction
|
||||
from src.db.unit_of_work import get_uow
|
||||
@@ -89,6 +91,8 @@ class PredictResult:
|
||||
prompt_version: str
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None
|
||||
alt_pred_away_goals: int | None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -97,6 +101,43 @@ class PredictResult:
|
||||
raw: dict | None
|
||||
|
||||
|
||||
async def _upsert_prediction(
|
||||
session,
|
||||
*,
|
||||
match_id: int,
|
||||
provider_name: str,
|
||||
model: str,
|
||||
mode: str,
|
||||
values: dict,
|
||||
) -> Prediction:
|
||||
"""按 (match, provider, model) 唯一约束写入预测。
|
||||
|
||||
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
|
||||
"""
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Prediction).where(
|
||||
Prediction.match_id == match_id,
|
||||
Prediction.provider == provider_name,
|
||||
Prediction.model == model,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None and existing.settled:
|
||||
raise ValueError("该比赛已有已结算的预测,不能重新预测")
|
||||
|
||||
pred = existing if existing is not None else Prediction(
|
||||
match_id=match_id, provider=provider_name, model=model,
|
||||
)
|
||||
pred.mode = mode
|
||||
for k, v in values.items():
|
||||
setattr(pred, k, v)
|
||||
if existing is None:
|
||||
session.add(pred)
|
||||
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
|
||||
return pred
|
||||
|
||||
|
||||
async def predict_match(
|
||||
match_id: int,
|
||||
*,
|
||||
@@ -140,7 +181,7 @@ async def _predict_single(
|
||||
) -> PredictResult:
|
||||
"""单次调用路径(原有实现)。"""
|
||||
if provider is None:
|
||||
provider = get_default_provider()
|
||||
provider = await get_default_provider()
|
||||
if model:
|
||||
provider.model = model
|
||||
version = prompt_version or "v1"
|
||||
@@ -172,7 +213,7 @@ async def _predict_single(
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
max_tokens=800,
|
||||
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
|
||||
)
|
||||
|
||||
if resp.error:
|
||||
@@ -194,28 +235,32 @@ async def _predict_single(
|
||||
if m is None:
|
||||
raise ValueError(f"match {match_id} not found")
|
||||
|
||||
pred = Prediction(
|
||||
pred = await _upsert_prediction(
|
||||
session,
|
||||
match_id=match_id,
|
||||
provider=settings.LLM_PROVIDER,
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=provider.model,
|
||||
prompt_version=version,
|
||||
prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens,
|
||||
latency_ms=resp.latency_ms,
|
||||
pred_home_goals=validated.pred_home_goals,
|
||||
pred_away_goals=validated.pred_away_goals,
|
||||
pred_1x2=validated.pred_1x2,
|
||||
subjective_confidence=validated.subjective_confidence,
|
||||
reasoning=validated.reasoning,
|
||||
raw_response=resp.raw,
|
||||
status="success",
|
||||
match_kickoff_at=match_kickoff_at,
|
||||
prediction_cutoff_at=prediction_cutoff_at,
|
||||
prediction_created_at=now,
|
||||
input_hash=input_hash,
|
||||
mode="single",
|
||||
values={
|
||||
"prompt_version": version,
|
||||
"prompt_tokens": resp.prompt_tokens,
|
||||
"completion_tokens": resp.completion_tokens,
|
||||
"latency_ms": resp.latency_ms,
|
||||
"pred_home_goals": validated.pred_home_goals,
|
||||
"pred_away_goals": validated.pred_away_goals,
|
||||
"alt_pred_home_goals": validated.alt_pred_home_goals,
|
||||
"alt_pred_away_goals": validated.alt_pred_away_goals,
|
||||
"pred_1x2": validated.pred_1x2,
|
||||
"subjective_confidence": validated.subjective_confidence,
|
||||
"reasoning": validated.reasoning,
|
||||
"raw_response": resp.raw,
|
||||
"status": "success",
|
||||
"match_kickoff_at": match_kickoff_at,
|
||||
"prediction_cutoff_at": prediction_cutoff_at,
|
||||
"prediction_created_at": now,
|
||||
"input_hash": input_hash,
|
||||
},
|
||||
)
|
||||
session.add(pred)
|
||||
await session.refresh(pred)
|
||||
|
||||
result = PredictResult(
|
||||
prediction_id=pred.id,
|
||||
@@ -224,6 +269,8 @@ async def _predict_single(
|
||||
prompt_version=version,
|
||||
pred_home_goals=pred.pred_home_goals,
|
||||
pred_away_goals=pred.pred_away_goals,
|
||||
alt_pred_home_goals=pred.alt_pred_home_goals,
|
||||
alt_pred_away_goals=pred.alt_pred_away_goals,
|
||||
pred_1x2=pred.pred_1x2,
|
||||
subjective_confidence=pred.subjective_confidence,
|
||||
reasoning=pred.reasoning,
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
裁决规则:
|
||||
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
|
||||
- 5 个专家维度: form(近期状态) / stats(攻防数据) / home_away(主客因素) / injuries(阵容完整性) / h2h(历史交锋)
|
||||
- 5 位专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 阵容完整性分析专家 / 历史交锋分析专家
|
||||
- 引用专家意见时使用上述全称,不要使用英文代码(form/stats/h2h 等)
|
||||
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
|
||||
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
|
||||
- reasoning 需引用具体报告的证据
|
||||
@@ -17,8 +18,10 @@
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": <float, 预测主队进球>,
|
||||
"pred_away_goals": <float, 预测客队进球>,
|
||||
"pred_home_goals": <int 0-10, 预测主队进球,必须是整数>,
|
||||
"pred_away_goals": <int 0-10, 预测客队进球,必须是整数>,
|
||||
"alt_pred_home_goals": <int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>,
|
||||
"alt_pred_away_goals": <int 0-10, 备选比分客队进球>,
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": <0.0-1.0>,
|
||||
"reasoning": "<250 字内推理,引用各报告证据>",
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
严格按此 JSON 输出:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": "<float, 预测主队进球>",
|
||||
"pred_away_goals": "<float, 预测客队进球>",
|
||||
"pred_home_goals": "<int 0-10, 预测主队进球,必须是整数>",
|
||||
"pred_away_goals": "<int 0-10, 预测客队进球,必须是整数>",
|
||||
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
||||
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": "<0.0-1.0>",
|
||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": "<float, 预测主队进球>",
|
||||
"pred_away_goals": "<float, 预测客队进球>",
|
||||
"pred_home_goals": "<int 0-10, 预测主队进球,必须是整数>",
|
||||
"pred_away_goals": "<int 0-10, 预测客队进球,必须是整数>",
|
||||
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
||||
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": "<0.0-1.0>",
|
||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||
|
||||
+19
-6
@@ -10,8 +10,11 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.http_client import get_client
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,17 +70,26 @@ class LLMProvider:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
client = get_client()
|
||||
# 连接与读取分离:端点不可达时 10s 内快速失败,
|
||||
# 避免每个 agent 各挂满 LLM_TIMEOUT 导致整次预测长时间无响应
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
timeout=httpx.Timeout(connect=10.0, read=float(self.timeout), write=float(self.timeout), pool=10.0),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
latency = int((time.perf_counter() - start) * 1000)
|
||||
usage = data.get("usage", {})
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
message = data["choices"][0]["message"]
|
||||
content = message.get("content") or ""
|
||||
if not content:
|
||||
# 推理模型可能把 token 全花在 reasoning_content 上
|
||||
raise RuntimeError(
|
||||
"模型未返回文本内容"
|
||||
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
|
||||
)
|
||||
parsed = None
|
||||
if json_mode:
|
||||
try:
|
||||
@@ -105,10 +117,11 @@ class LLMProvider:
|
||||
return LLMResponse(content="", error=str(e), latency_ms=latency)
|
||||
|
||||
|
||||
def get_default_provider() -> LLMProvider:
|
||||
async def get_default_provider() -> LLMProvider:
|
||||
"""构造默认 provider:运行时配置(DB)优先,回落 .env。"""
|
||||
return LLMProvider(
|
||||
api_key=settings.LLM_API_KEY,
|
||||
base_url=settings.LLM_BASE_URL,
|
||||
model=settings.LLM_MODEL,
|
||||
api_key=await get_runtime_value("LLM_API_KEY"),
|
||||
base_url=await get_runtime_value("LLM_BASE_URL"),
|
||||
model=await get_runtime_value("LLM_MODEL"),
|
||||
timeout=settings.LLM_TIMEOUT,
|
||||
)
|
||||
|
||||
+32
-4
@@ -5,6 +5,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
@@ -54,12 +55,28 @@ class AgentReportSchema(BaseModel):
|
||||
class PredictionOutputSchema(BaseModel):
|
||||
"""最终预测输出的校验 schema。"""
|
||||
|
||||
pred_home_goals: float = Field(ge=0.0, le=10.0)
|
||||
pred_away_goals: float = Field(ge=0.0, le=10.0)
|
||||
pred_home_goals: int = Field(ge=0, le=10)
|
||||
pred_away_goals: int = Field(ge=0, le=10)
|
||||
# 备选比分(次可能比分);缺失/无效/与主选相同 → None
|
||||
alt_pred_home_goals: int | None = Field(default=None, ge=0, le=10)
|
||||
alt_pred_away_goals: int | None = Field(default=None, ge=0, le=10)
|
||||
pred_1x2: str
|
||||
subjective_confidence: float = Field(ge=0.0, le=1.0)
|
||||
reasoning: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_alt_score(self) -> "PredictionOutputSchema":
|
||||
"""备选比分与主选相同则丢弃(备选必须是不同比分)。"""
|
||||
if (
|
||||
self.alt_pred_home_goals is not None
|
||||
and self.alt_pred_away_goals is not None
|
||||
and self.alt_pred_home_goals == self.pred_home_goals
|
||||
and self.alt_pred_away_goals == self.pred_away_goals
|
||||
):
|
||||
self.alt_pred_home_goals = None
|
||||
self.alt_pred_away_goals = None
|
||||
return self""
|
||||
|
||||
@field_validator("pred_1x2")
|
||||
@classmethod
|
||||
def validate_1x2(cls, v: str) -> str:
|
||||
@@ -172,9 +189,20 @@ def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
||||
logger.warning("Deprecated field 'confidence' used, prefer 'subjective_confidence'")
|
||||
conf = raw["confidence"]
|
||||
|
||||
def _alt(side: str):
|
||||
v = raw.get(f"alt_pred_{side}_goals")
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return int(Decimal(str(v)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return PredictionOutputSchema(
|
||||
pred_home_goals=float(raw.get("pred_home_goals", 0)),
|
||||
pred_away_goals=float(raw.get("pred_away_goals", 0)),
|
||||
pred_home_goals=int(Decimal(str(raw.get("pred_home_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
pred_away_goals=int(Decimal(str(raw.get("pred_away_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
alt_pred_home_goals=_alt("home"),
|
||||
alt_pred_away_goals=_alt("away"),
|
||||
pred_1x2=raw.get("1x2") or raw.get("pred_1x2", "X"),
|
||||
subjective_confidence=float(conf if conf is not None else 0.5),
|
||||
reasoning=str(raw.get("reasoning", ""))[:1000],
|
||||
|
||||
Reference in New Issue
Block a user