|
|
@@ -6,6 +6,8 @@ import json
|
|
|
from collections.abc import Callable, Iterable
|
|
|
from typing import Any
|
|
|
|
|
|
+from pydantic import BaseModel, Field, model_validator
|
|
|
+
|
|
|
from find_agent_v2.providers import (
|
|
|
fetch_details,
|
|
|
fetch_portraits,
|
|
|
@@ -19,6 +21,79 @@ from supply_agent.tools.registry import ToolRegistry
|
|
|
ToolFn = Callable[..., Any]
|
|
|
|
|
|
|
|
|
+class CandidateEvaluation(BaseModel):
|
|
|
+ """One candidate decision. Prefer candidate_id; aweme_id is a safe fallback."""
|
|
|
+
|
|
|
+ candidate_id: int | None = Field(
|
|
|
+ default=None,
|
|
|
+ description="数据库候选编号,即输入中的 candidate_id;不要填写 aweme_id。",
|
|
|
+ )
|
|
|
+ aweme_id: str | None = Field(
|
|
|
+ default=None,
|
|
|
+ description="抖音视频号;仅在无法填写 candidate_id 时作为兼容定位字段。",
|
|
|
+ )
|
|
|
+ relevance_score: float = Field(ge=0, le=1, description="R 相关性评分,0~1。")
|
|
|
+ elder_score: float = Field(ge=0, le=1, description="E 中老年适配评分,0~1。")
|
|
|
+ share_score: float = Field(ge=0, le=1, description="S 传播力评分,0~1。")
|
|
|
+ value_score: float = Field(ge=0, le=1, description="V 综合价值评分,0~1。")
|
|
|
+ decision_bucket: str = Field(description="只能是 primary 或 rejected。")
|
|
|
+ decision_reason: str = Field(min_length=1, description="基于真实证据的中文判断理由。")
|
|
|
+ reject_reason_code: str | None = None
|
|
|
+
|
|
|
+ @model_validator(mode="after")
|
|
|
+ def validate_identity_and_bucket(self):
|
|
|
+ if self.candidate_id is None and not str(self.aweme_id or "").strip():
|
|
|
+ raise ValueError("candidate_id 和 aweme_id 至少提供一个")
|
|
|
+ if self.decision_bucket not in {"primary", "rejected"}:
|
|
|
+ raise ValueError("decision_bucket 只能是 primary/rejected")
|
|
|
+ return self
|
|
|
+
|
|
|
+
|
|
|
+class EvaluationBatch(BaseModel):
|
|
|
+ """Structured evaluator output consumed and persisted by the host."""
|
|
|
+
|
|
|
+ items: list[CandidateEvaluation] = Field(
|
|
|
+ min_length=1,
|
|
|
+ description="当前 Worker 分片内全部候选的评估结果,每个候选恰好一条。",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def normalize_evaluation_items(
|
|
|
+ items: list[dict[str, Any] | CandidateEvaluation],
|
|
|
+ *,
|
|
|
+ allowed_candidates: list[dict[str, Any]],
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
+ """Validate a complete worker shard and resolve aweme_id without widening access."""
|
|
|
+ allowed_ids = {int(item["candidate_id"]) for item in allowed_candidates}
|
|
|
+ aweme_to_id = {
|
|
|
+ str(item.get("aweme_id") or ""): int(item["candidate_id"])
|
|
|
+ for item in allowed_candidates
|
|
|
+ }
|
|
|
+ normalized: list[dict[str, Any]] = []
|
|
|
+ for raw in items:
|
|
|
+ value = raw if isinstance(raw, CandidateEvaluation) else CandidateEvaluation.model_validate(raw)
|
|
|
+ candidate_id = value.candidate_id
|
|
|
+ if candidate_id is None:
|
|
|
+ candidate_id = aweme_to_id.get(str(value.aweme_id or ""))
|
|
|
+ if candidate_id not in allowed_ids:
|
|
|
+ raise ValueError(
|
|
|
+ "评估项不属于当前 Worker 分片;"
|
|
|
+ f"candidate_id={candidate_id}, aweme_id={value.aweme_id}, "
|
|
|
+ f"allowed_candidate_ids={sorted(allowed_ids)}"
|
|
|
+ )
|
|
|
+ payload = value.model_dump(exclude_none=True)
|
|
|
+ payload["candidate_id"] = candidate_id
|
|
|
+ payload.pop("aweme_id", None)
|
|
|
+ normalized.append(payload)
|
|
|
+ requested = [int(item["candidate_id"]) for item in normalized]
|
|
|
+ if len(requested) != len(set(requested)):
|
|
|
+ raise ValueError("同一 candidate_id 不能重复评估")
|
|
|
+ missing = allowed_ids - set(requested)
|
|
|
+ if missing:
|
|
|
+ raise ValueError(f"必须一次评估完整分片,缺少 candidate_ids={sorted(missing)}")
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
def bound_candidate_tools(
|
|
|
functions: tuple[ToolFn, ...], *, run_id: str, candidate_ids: list[int],
|
|
|
) -> tuple[ToolFn, ...]:
|
|
|
@@ -64,16 +139,20 @@ def bound_candidate_tools(
|
|
|
|
|
|
def make_sync(fn: ToolFn, fn_name: str, worker_run_id: str, worker_allowed: set[int]):
|
|
|
@tool(name=fn_name, description=getattr(fn, "_tool_description", ""))
|
|
|
- def bound_sync(run_id: str, items: list[dict[str, Any]]) -> str:
|
|
|
+ def bound_sync(run_id: str, items: list[CandidateEvaluation]) -> str:
|
|
|
if run_id != worker_run_id:
|
|
|
- return json.dumps({"error": "run_id 不属于当前 Worker"}, ensure_ascii=False)
|
|
|
- requested = {int(item.get("candidate_id") or 0) for item in items}
|
|
|
- if not requested or not requested <= worker_allowed:
|
|
|
- return json.dumps({
|
|
|
- "error": "items 超出当前 Worker 分片",
|
|
|
- "allowed_candidate_ids": sorted(worker_allowed),
|
|
|
- }, ensure_ascii=False)
|
|
|
- return fn(run_id=run_id, items=items)
|
|
|
+ raise ValueError("run_id 不属于当前 Worker")
|
|
|
+ state = get_find_agent_v2_service().get_full_state(
|
|
|
+ run_id, pending_only=True,
|
|
|
+ )
|
|
|
+ allowed_candidates = [
|
|
|
+ item for item in state.get("candidates", [])
|
|
|
+ if int(item["candidate_id"]) in worker_allowed
|
|
|
+ ]
|
|
|
+ normalized = normalize_evaluation_items(
|
|
|
+ items, allowed_candidates=allowed_candidates,
|
|
|
+ )
|
|
|
+ return fn(run_id=run_id, items=normalized)
|
|
|
|
|
|
return bound_sync
|
|
|
|
|
|
@@ -183,9 +262,10 @@ async def fetch_candidate_portraits_v2(run_id: str, candidate_ids: list[int]) ->
|
|
|
|
|
|
|
|
|
@tool
|
|
|
-def evaluate_candidates_v2(run_id: str, items: list[dict[str, Any]]) -> str:
|
|
|
- """写入 R/E/S/V 与 primary/rejected;程序会对 primary 强制执行 P0 门禁。"""
|
|
|
- updated = get_find_agent_v2_service().evaluate(run_id, items)
|
|
|
+def evaluate_candidates_v2(run_id: str, items: list[CandidateEvaluation]) -> str:
|
|
|
+ """按 candidate_id 写入完整分片的 R/E/S/V 与分池;不要用 aweme_id 代替。"""
|
|
|
+ payload = [item.model_dump(exclude_none=True) if isinstance(item, CandidateEvaluation) else item for item in items]
|
|
|
+ updated = get_find_agent_v2_service().evaluate(run_id, payload)
|
|
|
return json.dumps({"run_id": run_id, "updated": updated}, ensure_ascii=False)
|
|
|
|
|
|
|