Kaynağa Gözat

增加寻找Agent v2版本

xueyiming 6 gün önce
ebeveyn
işleme
f1b2c0c667

+ 1 - 0
alembic/env.py

@@ -8,6 +8,7 @@ from sqlalchemy import engine_from_config, pool
 from supply_infra.config import get_infra_settings
 from supply_infra.db.base import Base
 import supply_infra.db.models  # noqa: F401
+import find_agent_v2.models  # noqa: F401
 
 config = context.config
 if config.config_file_name is not None:

+ 2 - 2
alembic/versions/20260731_08_add_find_agent_p0_gates.py

@@ -1,7 +1,7 @@
 """add find_agent P0 gate fields
 
 Revision ID: 20260731_08
-Revises: 20260730_07
+Revises: 20260731_08a
 Create Date: 2026-07-31
 """
 from __future__ import annotations
@@ -12,7 +12,7 @@ import sqlalchemy as sa
 from alembic import op
 
 revision: str = "20260731_08"
-down_revision: str | None = "20260730_07"
+down_revision: str | None = "20260731_08a"
 branch_labels: str | Sequence[str] | None = None
 depends_on: str | Sequence[str] | None = None
 

+ 155 - 0
alembic/versions/20260731_08a_create_video_discovery_tables.py

@@ -0,0 +1,155 @@
+"""adopt the legacy video discovery tables into Alembic
+
+Revision ID: 20260731_08a
+Revises: 20260730_07
+Create Date: 2026-07-31
+
+These tables originally came from ``sql/video_discovery_tables.sql``.  Creating
+their pre-gate shape here makes a fresh ``alembic upgrade head`` self-contained;
+``checkfirst`` behavior keeps existing installations safe.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+revision: str = "20260731_08a"
+down_revision: str | None = "20260730_07"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _tables() -> set[str]:
+    return set(sa.inspect(op.get_bind()).get_table_names())
+
+
+def _create_run() -> None:
+    op.create_table(
+        "video_discovery_run",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("biz_dt", sa.String(32), nullable=True),
+        sa.Column("demand_grade_id", sa.BigInteger(), nullable=True),
+        sa.Column("demand_word", sa.String(256), nullable=False),
+        sa.Column("seed_video_id", sa.String(64), nullable=True),
+        sa.Column("seed_video_title", sa.String(512), nullable=True),
+        sa.Column("relevant_points_json", sa.Text(), nullable=False),
+        sa.Column("intent_summary", sa.Text(), nullable=True),
+        sa.Column("status", sa.String(24), server_default="running", nullable=False),
+        sa.Column("search_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("primary_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("stop_reason", sa.Text(), nullable=True),
+        sa.Column("create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.Column("update_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("run_id", name="uk_video_discovery_run_id"),
+        sa.UniqueConstraint("biz_dt", "demand_grade_id", name="uk_video_discovery_run_biz_grade"),
+        mysql_charset="utf8mb4",
+        mysql_collate="utf8mb4_unicode_ci",
+    )
+    op.create_index("idx_video_discovery_run_demand", "video_discovery_run", ["demand_word"])
+    op.create_index("idx_video_discovery_run_grade", "video_discovery_run", ["demand_grade_id"])
+    op.create_index("idx_video_discovery_run_biz_dt", "video_discovery_run", ["biz_dt"])
+    op.create_index("idx_video_discovery_run_status", "video_discovery_run", ["status"])
+    op.execute("ALTER TABLE video_discovery_run MODIFY update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
+
+
+def _create_search() -> None:
+    op.create_table(
+        "video_discovery_search",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("search_key", sa.String(64), nullable=False),
+        sa.Column("keyword", sa.String(256), nullable=False),
+        sa.Column("query_reason", sa.Text(), nullable=False),
+        sa.Column("source_type", sa.String(32), nullable=False),
+        sa.Column("source_value", sa.Text(), nullable=True),
+        sa.Column("parent_search_id", sa.BigInteger(), nullable=True),
+        sa.Column("provider", sa.String(32), server_default="internal_keyword", nullable=False),
+        sa.Column("provider_state_json", sa.Text(), nullable=True),
+        sa.Column("content_type", sa.String(16), server_default="视频", nullable=False),
+        sa.Column("sort_type", sa.String(32), server_default="综合排序", nullable=False),
+        sa.Column("publish_time", sa.String(32), server_default="不限", nullable=False),
+        sa.Column("cursor", sa.String(128), server_default="0", nullable=False),
+        sa.Column("page_no", sa.Integer(), server_default="1", nullable=False),
+        sa.Column("results_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("new_candidate_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("has_more", mysql.TINYINT(), server_default="0", nullable=False),
+        sa.Column("next_cursor", sa.String(128), nullable=True),
+        sa.Column("result_ids_json", sa.Text(), nullable=True),
+        sa.Column("status", sa.String(16), server_default="success", nullable=False),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.Column("update_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        mysql_charset="utf8mb4",
+        mysql_collate="utf8mb4_unicode_ci",
+    )
+    op.create_index("idx_video_discovery_search_run", "video_discovery_search", ["run_id", "id"])
+    op.create_index("idx_video_discovery_search_parent", "video_discovery_search", ["run_id", "parent_search_id"])
+    op.create_index("idx_video_discovery_search_keyword", "video_discovery_search", ["keyword"])
+    op.execute("ALTER TABLE video_discovery_search MODIFY update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
+
+
+def _create_candidate() -> None:
+    op.create_table(
+        "video_discovery_candidate",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("search_id", sa.BigInteger(), nullable=True),
+        sa.Column("aweme_id", sa.String(64), nullable=False),
+        sa.Column("title", sa.String(512), nullable=True),
+        sa.Column("content_link", sa.String(1024), nullable=True),
+        sa.Column("author_name", sa.String(256), nullable=True),
+        sa.Column("author_sec_uid", sa.String(256), nullable=True),
+        sa.Column("source_keywords_json", sa.Text(), nullable=True),
+        sa.Column("source_search_ids_json", sa.Text(), nullable=True),
+        sa.Column("tags_json", sa.Text(), nullable=True),
+        sa.Column("play_count", sa.BigInteger(), nullable=True),
+        sa.Column("like_count", sa.BigInteger(), nullable=True),
+        sa.Column("comment_count", sa.BigInteger(), nullable=True),
+        sa.Column("collect_count", sa.BigInteger(), nullable=True),
+        sa.Column("share_count", sa.BigInteger(), nullable=True),
+        sa.Column("content_age_evidence_json", sa.Text(), nullable=True),
+        sa.Column("account_age_evidence_json", sa.Text(), nullable=True),
+        sa.Column("age_normalization_json", sa.Text(), nullable=True),
+        sa.Column("relevance_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("elder_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("share_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("value_score", sa.Numeric(8, 2), nullable=True),
+        sa.Column("decision_reason", sa.Text(), nullable=True),
+        sa.Column("decision_bucket", sa.String(24), server_default="unreviewed", nullable=False),
+        sa.Column("aigc_crawler_plan_id", sa.String(64), nullable=True),
+        sa.Column("aigc_produce_plan_id", sa.String(64), nullable=True),
+        sa.Column("aigc_publish_plan_id", sa.String(64), nullable=True),
+        sa.Column("aigc_plan_label", sa.String(64), nullable=True),
+        sa.Column("create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.Column("update_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.ForeignKeyConstraint(["search_id"], ["video_discovery_search.id"], name="fk_video_discovery_candidate_search", ondelete="RESTRICT"),
+        sa.PrimaryKeyConstraint("id"),
+        mysql_charset="utf8mb4",
+        mysql_collate="utf8mb4_unicode_ci",
+    )
+    op.create_index("idx_video_discovery_candidate_search", "video_discovery_candidate", ["search_id", "id"])
+    op.create_index("idx_video_discovery_candidate_bucket", "video_discovery_candidate", ["run_id", "decision_bucket"])
+    op.create_index("idx_video_discovery_candidate_author", "video_discovery_candidate", ["author_sec_uid"])
+    op.execute("ALTER TABLE video_discovery_candidate MODIFY update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
+
+
+def upgrade() -> None:
+    tables = _tables()
+    if "video_discovery_run" not in tables:
+        _create_run()
+    if "video_discovery_search" not in tables:
+        _create_search()
+    if "video_discovery_candidate" not in tables:
+        _create_candidate()
+
+
+def downgrade() -> None:
+    # Adoption is forward-only: existing installations may predate Alembic.
+    pass

+ 59 - 0
alembic/versions/20260803_11a_bootstrap_model_tables.py

@@ -0,0 +1,59 @@
+"""bootstrap model-owned and legacy application tables for empty databases
+
+Revision ID: 20260803_11a
+Revises: 20260803_11
+Create Date: 2026-08-11
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+from supply_infra.db.base import Base
+import supply_infra.db.models  # noqa: F401
+
+revision: str = "20260803_11a"
+down_revision: str | None = "20260803_11"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    """Create model tables that historical deployments supplied outside Alembic."""
+    model_tables = [
+        table
+        for name, table in Base.metadata.tables.items()
+        if not name.startswith("find_agent_v2_")
+    ]
+    Base.metadata.create_all(bind=op.get_bind(), tables=model_tables, checkfirst=True)
+
+    if "global_category" not in sa.inspect(op.get_bind()).get_table_names():
+        op.create_table(
+            "global_category",
+            sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+            sa.Column("name", sa.String(128), nullable=True, comment="名称"),
+            sa.Column("description", sa.Text(), nullable=True, comment="描述"),
+            sa.Column("level", sa.Integer(), nullable=True, comment="层级"),
+            sa.Column("parent_id", sa.BigInteger(), nullable=True, comment="父节点"),
+            sa.Column("source_id", sa.BigInteger(), nullable=False, comment="hive表stable_id"),
+            sa.Column("source_parent_id", sa.BigInteger(), nullable=False, comment="hive表父stable_id"),
+            sa.Column("is_delete", sa.Integer(), server_default="0", nullable=False, comment="是否删除0-正常 1-删除"),
+            sa.Column("create_time", sa.DateTime(), server_default=sa.func.now(), nullable=True, comment="创建时间"),
+            sa.Column("update_time", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"),
+            sa.PrimaryKeyConstraint("id"),
+            sa.UniqueConstraint("source_id", name="uk_source_id"),
+            mysql_charset="utf8mb4",
+            mysql_collate="utf8mb4_unicode_ci",
+            comment="全局分类 — 从 ODPS loghubods.global_category 同步",
+        )
+        op.create_index("idx_global_category_parent_id", "global_category", ["parent_id"])
+        op.create_index("idx_global_category_level", "global_category", ["level"])
+        op.execute("ALTER TABLE global_category MODIFY update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
+
+
+def downgrade() -> None:
+    # Adoption is forward-only because existing deployments can own these tables.
+    pass

+ 157 - 0
alembic/versions/20260811_12_add_find_agent_v2_tables.py

@@ -0,0 +1,157 @@
+"""add isolated find_agent_v2 tables
+
+Revision ID: 20260811_12
+Revises: 20260803_11a
+Create Date: 2026-08-11
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+revision: str = "20260811_12"
+down_revision: str | None = "20260803_11a"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _timestamps() -> list[sa.Column]:
+    return [
+        sa.Column("create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.Column("update_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+    ]
+
+
+def upgrade() -> None:
+    op.create_table(
+        "find_agent_v2_run",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("demand_grade_id", sa.BigInteger(), nullable=True),
+        sa.Column("demand_word", sa.String(256), nullable=False),
+        sa.Column("input_json", mysql.LONGTEXT(), nullable=False),
+        sa.Column("rule_config_json", sa.Text(), nullable=False),
+        sa.Column("status", sa.String(24), server_default="running", nullable=False),
+        sa.Column("outcome_status", sa.String(24), nullable=True),
+        sa.Column("current_round", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("search_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("candidate_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("valid_primary_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("intent_summary", sa.Text(), nullable=True),
+        sa.Column("stop_reason", sa.Text(), nullable=True),
+        sa.Column("obagent_run_uid", sa.String(64), nullable=True),
+        *_timestamps(),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("run_id", name="uk_find_agent_v2_run_id"),
+    )
+    op.create_index("idx_find_agent_v2_run_status", "find_agent_v2_run", ["status"])
+    op.create_index("idx_find_agent_v2_run_demand", "find_agent_v2_run", ["demand_grade_id"])
+
+    op.create_table(
+        "find_agent_v2_round",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("round_index", sa.Integer(), nullable=False),
+        sa.Column("phase", sa.String(24), server_default="planning", nullable=False),
+        sa.Column("status", sa.String(16), server_default="open", nullable=False),
+        sa.Column("plan_json", mysql.LONGTEXT(), nullable=True),
+        sa.Column("start_snapshot_json", sa.Text(), nullable=True),
+        sa.Column("end_snapshot_json", sa.Text(), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        *_timestamps(),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("run_id", "round_index", name="uk_find_agent_v2_round"),
+    )
+    op.create_index("idx_find_agent_v2_round_run", "find_agent_v2_round", ["run_id", "round_index"])
+
+    op.create_table(
+        "find_agent_v2_search",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("round_index", sa.Integer(), nullable=False),
+        sa.Column("keyword", sa.String(256), nullable=False),
+        sa.Column("query_reason", sa.Text(), nullable=False),
+        sa.Column("source_type", sa.String(32), nullable=False),
+        sa.Column("provider", sa.String(32), nullable=False),
+        sa.Column("cursor", sa.String(128), server_default="0", nullable=False),
+        sa.Column("page_no", sa.Integer(), server_default="1", nullable=False),
+        sa.Column("has_more", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("next_cursor", sa.String(128), nullable=True),
+        sa.Column("provider_state_json", sa.Text(), nullable=True),
+        sa.Column("result_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("status", sa.String(16), nullable=False),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("raw_response_json", mysql.LONGTEXT(), nullable=True),
+        sa.Column("create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("idx_find_agent_v2_search_run", "find_agent_v2_search", ["run_id", "id"])
+
+    op.create_table(
+        "find_agent_v2_candidate",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("first_search_id", sa.BigInteger(), nullable=True),
+        sa.Column("aweme_id", sa.String(64), nullable=False),
+        sa.Column("title", sa.String(512), nullable=True),
+        sa.Column("content_link", sa.String(1024), nullable=True),
+        sa.Column("author_name", sa.String(256), nullable=True),
+        sa.Column("author_sec_uid", sa.String(256), nullable=True),
+        sa.Column("source_keywords_json", sa.Text(), nullable=True),
+        sa.Column("tags_json", sa.Text(), nullable=True),
+        sa.Column("publish_at", sa.DateTime(), nullable=True),
+        sa.Column("duration_seconds", sa.Numeric(10, 3), nullable=True),
+        sa.Column("play_count", sa.BigInteger(), nullable=True),
+        sa.Column("like_count", sa.BigInteger(), nullable=True),
+        sa.Column("comment_count", sa.BigInteger(), nullable=True),
+        sa.Column("collect_count", sa.BigInteger(), nullable=True),
+        sa.Column("share_count", sa.BigInteger(), nullable=True),
+        sa.Column("detail_json", mysql.LONGTEXT(), nullable=True),
+        sa.Column("portrait_json", mysql.LONGTEXT(), nullable=True),
+        sa.Column("detail_status", sa.String(16), server_default="pending", nullable=False),
+        sa.Column("portrait_status", sa.String(16), server_default="pending", nullable=False),
+        sa.Column("content_50_plus_ratio", sa.Numeric(8, 6), nullable=True),
+        sa.Column("account_50_plus_ratio", sa.Numeric(8, 6), nullable=True),
+        sa.Column("relevance_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("elder_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("share_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("value_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("gate_status", sa.String(16), nullable=True),
+        sa.Column("gate_result_json", sa.Text(), nullable=True),
+        sa.Column("decision_bucket", sa.String(24), server_default="pending_evaluation", nullable=False),
+        sa.Column("decision_reason", sa.Text(), nullable=True),
+        sa.Column("reject_reason_code", sa.String(64), nullable=True),
+        *_timestamps(),
+        sa.ForeignKeyConstraint(["first_search_id"], ["find_agent_v2_search.id"], name="fk_find_agent_v2_candidate_search"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("run_id", "aweme_id", name="uk_find_agent_v2_candidate_aweme"),
+    )
+    op.create_index("idx_find_agent_v2_candidate_bucket", "find_agent_v2_candidate", ["run_id", "decision_bucket"])
+
+    op.create_table(
+        "find_agent_v2_evidence",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(64), nullable=False),
+        sa.Column("candidate_id", sa.BigInteger(), nullable=True),
+        sa.Column("evidence_type", sa.String(32), nullable=False),
+        sa.Column("provider", sa.String(32), nullable=False),
+        sa.Column("status", sa.String(16), nullable=False),
+        sa.Column("raw_json", mysql.LONGTEXT(), nullable=True),
+        sa.Column("normalized_json", mysql.LONGTEXT(), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("idx_find_agent_v2_evidence_subject", "find_agent_v2_evidence", ["run_id", "candidate_id", "id"])
+
+
+def downgrade() -> None:
+    op.drop_table("find_agent_v2_evidence")
+    op.drop_table("find_agent_v2_candidate")
+    op.drop_table("find_agent_v2_search")
+    op.drop_table("find_agent_v2_round")
+    op.drop_table("find_agent_v2_run")

+ 94 - 0
find_agent_v2/README.md

@@ -0,0 +1,94 @@
+# find_agent_v2
+
+`find_agent_v2` 是完全自包含的新实现。它不导入旧寻找 Agent 的提示词、工具、provider、画像
+归一化、门禁、数据服务或日志模块,也不共享运行、搜索、候选或证据表。
+
+## 结构
+
+```text
+Python 外循环(最多 N 个业务轮次)
+  └─ 单轮 DAG
+      planner → search → evidence → evaluator → END
+  └─ 宿主代码校验并写终态
+  └─ report(只读)
+```
+
+每个 ReAct 节点只注册本阶段工具;业务重数据在独立数据库表中,内存 state 只保存轮次、阶段、
+计划和小型快照。
+
+## 可视化
+
+v2 只使用 obagent,不写项目原有的 `logs/*.jsonl`、本地可视化产物或 OSS 日志:
+
+```text
+observe.run(project=find_agent_v2)
+  ├─ graph(第 1 轮)
+  │   ├─ planner
+  │   ├─ search
+  │   ├─ evidence
+  │   └─ evaluator
+  ├─ graph(第 2 轮)
+  │   └─ ...
+  └─ report
+```
+
+节点通过固定 `InputSlot` 声明输入,`ctx.declare()` 的返回值直接作为真实模型输入。自研 ReAct
+运行时的完整 LLM 输入/输出、usage、工具调用和消息链会汇总到各节点稳定的 `react` stage。
+`find_agent_v2_run.obagent_run_uid` 保存观测台深链 UID。
+
+配置环境变量:`OBAGENT_ENDPOINT`、`OBAGENT_API_KEY`、`OBAGENT_WAL_DIR`、
+`OBAGENT_ENABLED`、`OBAGENT_TIMEOUT`、`OBAGENT_TIMEOUT_PER_OP`。
+
+## 独立表
+
+- `find_agent_v2_run`
+- `find_agent_v2_round`
+- `find_agent_v2_search`
+- `find_agent_v2_candidate`
+- `find_agent_v2_evidence`
+
+迁移:
+
+```bash
+.venv/bin/alembic upgrade head
+```
+
+## 调用
+
+```python
+from find_agent_v2 import create_find_agent_v2_run, run_find_agent_v2
+
+user_input = "...完整需求和 reference_videos..."
+run_id = create_find_agent_v2_run(
+    user_input=user_input,
+    demand_word="手机防诈骗",
+    demand_grade_id=123,
+)
+result = run_find_agent_v2(user_input, run_id=run_id)
+```
+
+`run_id` 必须来自 `find_agent_v2_run`,不会隐式接受或写入旧 `video_discovery_run`。
+
+本地数据库准备好需求、视频和点位后,可直接创建一条测试运行:
+
+```bash
+.venv/bin/python -m find_agent_v2.test_entry
+```
+
+该命令默认只准备 `find_agent_v2_run`,不会调用模型。真正执行时显式传入:
+
+```bash
+.venv/bin/python -m find_agent_v2.test_entry --execute
+```
+
+已准备的任务可按 `run_id` 恢复执行:
+
+```bash
+.venv/bin/python -m find_agent_v2.test_entry \
+  --existing-run-id local-test-... --execute
+```
+
+## 复用边界
+
+新包拥有自己的外部接口客户端、响应解析、年龄画像标准化、门禁和提示词。需求测试上下文只读取
+通用数据模型;搜索结果、详情、画像和评估结果均由 `FindAgentV2Service` 写入独立表。

+ 38 - 0
find_agent_v2/__init__.py

@@ -0,0 +1,38 @@
+"""Deterministic, staged implementation of the video discovery agent.
+
+The package owns its business prompts, providers, gates, persistence and visualization.
+Callers can migrate one entry point at a time.
+"""
+
+from find_agent_v2.agent import FindAgentV2, create_find_agent_v2
+from find_agent_v2.demand_context import (
+    PreparedV2DemandRun,
+    V2DemandContext,
+    load_v2_demand_context,
+    pick_latest_v2_demand_context,
+    prepare_v2_demand_run,
+)
+from find_agent_v2.runner import (
+    arun_find_agent_v2,
+    create_find_agent_v2_run,
+    run_prepared_find_agent_v2,
+    run_find_agent_v2,
+)
+from find_agent_v2.state import FindAgentResult, FindAgentState, NodeRun
+
+__all__ = [
+    "FindAgentResult",
+    "FindAgentState",
+    "FindAgentV2",
+    "NodeRun",
+    "PreparedV2DemandRun",
+    "V2DemandContext",
+    "arun_find_agent_v2",
+    "create_find_agent_v2_run",
+    "create_find_agent_v2",
+    "load_v2_demand_context",
+    "pick_latest_v2_demand_context",
+    "prepare_v2_demand_run",
+    "run_prepared_find_agent_v2",
+    "run_find_agent_v2",
+]

+ 168 - 0
find_agent_v2/agent.py

@@ -0,0 +1,168 @@
+"""Outer round orchestrator for the isolated find agent."""
+
+from __future__ import annotations
+
+import json
+
+from find_agent_v2.context import build_node_slots
+from find_agent_v2.graph import FindAgentRoundGraph, NodeRunner
+from find_agent_v2.observability import ObagentObserver
+from find_agent_v2.prompts import REPORT_PROMPT
+from find_agent_v2.runtime import FindAgentNodeHost, normalize_models
+from find_agent_v2.service import FindAgentV2Service, get_find_agent_v2_service
+from find_agent_v2.state import FindAgentResult, FindAgentState
+from find_agent_v2.tools import REPORT_TOOLS
+from supply_agent.config import Settings
+
+
+class FindAgentV2:
+    """Python outer loop + one-round DAG + node-local ReAct."""
+
+    def __init__(
+        self,
+        *,
+        service: FindAgentV2Service | None = None,
+        node_runner: NodeRunner | None = None,
+        settings: Settings | None = None,
+        models_by_role: dict[str, str] | None = None,
+        max_rounds: int = 2,
+        target_primary_count: int = 5,
+        observer: ObagentObserver | None = None,
+    ) -> None:
+        self.service = service or get_find_agent_v2_service()
+        self.observer = observer or ObagentObserver()
+        self.node_runner = node_runner or FindAgentNodeHost(
+            settings=settings,
+            models_by_role=models_by_role,
+            observer=self.observer,
+        )
+        self.models_by_role = dict(models_by_role or {})
+        self.max_rounds = max(1, int(max_rounds))
+        self.target_primary_count = max(1, int(target_primary_count))
+
+    async def arun(self, *, run_id: str, user_input: str) -> FindAgentResult:
+        run = self.service.require_run(run_id)
+        if str(run.get("status") or "") != "running":
+            raise ValueError(f"run_id={run_id} 当前状态不可执行: {run.get('status')}")
+        state = FindAgentState(run_id=run_id, user_input=user_input)
+        graph = FindAgentRoundGraph(
+            service=self.service, runner=self.node_runner, observer=self.observer,
+        )
+        default_model = self.models_by_role.get("planner", "google/gemini-3-flash-preview")
+        with self.observer.run(
+            run_id=run_id,
+            demand_word=str(run.get("demand_word") or ""),
+            model=default_model,
+            models_by_role=self.models_by_role,
+        ) as observation_run:
+            self.service.set_obagent_run_uid(
+                run_id, getattr(observation_run, "run_uid", None),
+            )
+            result = await self._arun_inner(state=state, graph=graph)
+            observation_run.finish(final_output=result.final_output)
+            return result
+
+    async def _arun_inner(
+        self, *, state: FindAgentState, graph: FindAgentRoundGraph,
+    ) -> FindAgentResult:
+        run_id = state.run_id
+        end_reason = ""
+        failed = False
+        try:
+            for round_index in range(1, self.max_rounds + 1):
+                state.round_index = round_index
+                state.previous_snapshot = self.service.snapshot(run_id)
+                self.service.begin_round(run_id, round_index, state.previous_snapshot)
+                await graph.invoke(state)
+                current = state.snapshot or self.service.snapshot(run_id)
+                if current.valid_primary_count >= self.target_primary_count:
+                    end_reason = f"已获得 {current.valid_primary_count} 条有效 primary"
+                    break
+                if current.pending_count:
+                    failed = True
+                    end_reason = f"第 {round_index} 轮结束仍有 {current.pending_count} 条待评估候选"
+                    break
+                if current.candidate_count <= state.previous_snapshot.candidate_count:
+                    end_reason = "本轮未发现新增候选,搜索前沿已无信息增益"
+                    break
+                if round_index == self.max_rounds:
+                    end_reason = f"达到最大业务轮数 {self.max_rounds}"
+        except Exception as exc:
+            failed = True
+            end_reason = f"{type(exc).__name__}: {exc}"
+            state.failures.append({"round": state.round_index, "error": end_reason})
+            if state.round_index:
+                try:
+                    self.service.update_round(
+                        run_id, state.round_index, status="failed", error=end_reason,
+                    )
+                except Exception:
+                    pass
+
+        final_run = self.service.finalize(run_id, failed=failed, reason=end_reason)
+        final_output = (
+            f"find_agent_v2 {final_run['outcome_status']},"
+            f"有效 primary={final_run['valid_primary_count']}。"
+        )
+        if not failed:
+            try:
+                report = await self.node_runner.run_node(
+                    node="report",
+                    round_index=state.round_index,
+                    system_prompt=REPORT_PROMPT,
+                    user_content=json.dumps(
+                        self.service.get_full_state(run_id),
+                        ensure_ascii=False,
+                        default=str,
+                    ),
+                    tools=REPORT_TOOLS,
+                    max_iterations=4,
+                    slots=build_node_slots(
+                        user_input=state.user_input,
+                        full_state=self.service.get_full_state(run_id),
+                        round_index=state.round_index,
+                        plan=state.plan,
+                    ),
+                )
+                state.node_runs.append(report)
+                final_output = report.content or final_output
+            except Exception as exc:
+                state.failures.append({"round": state.round_index, "node": "report", "error": str(exc)})
+
+        outcome = str(final_run.get("outcome_status") or "failed")
+        return FindAgentResult(
+            run_id=run_id,
+            status=outcome if outcome in {"goal_met", "partial", "no_match", "failed"} else "failed",  # type: ignore[arg-type]
+            succeeded=not failed and final_run.get("status") == "finished",
+            business_outcome=outcome,
+            valid_primary_count=int(final_run.get("valid_primary_count") or 0),
+            rounds=state.round_index,
+            final_output=final_output,
+            node_runs=tuple(state.node_runs),
+            stop_reason=end_reason,
+        )
+
+
+def create_find_agent_v2(
+    settings: Settings | None = None,
+    *,
+    model: str | None = None,
+    planning_model: str | None = None,
+    search_model: str | None = None,
+    evidence_model: str | None = None,
+    evaluation_model: str | None = None,
+    report_model: str | None = None,
+    max_rounds: int = 2,
+) -> FindAgentV2:
+    return FindAgentV2(
+        settings=settings,
+        models_by_role=normalize_models(
+            model=model,
+            planning=planning_model,
+            search=search_model,
+            evidence=evidence_model,
+            evaluation=evaluation_model,
+            report=report_model,
+        ),
+        max_rounds=max_rounds,
+    )

+ 55 - 0
find_agent_v2/context.py

@@ -0,0 +1,55 @@
+"""Context builders backed only by ``find_agent_v2_*`` tables."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from find_agent_v2.observability import InputSlot
+from find_agent_v2.service import get_find_agent_v2_service
+from find_agent_v2.state import DiscoverySnapshot
+
+
+def load_full_state(run_id: str, *, limit: int = 100) -> dict[str, Any]:
+    return get_find_agent_v2_service().get_full_state(run_id, limit=limit)
+
+
+def snapshot_run(run_id: str) -> DiscoverySnapshot:
+    return get_find_agent_v2_service().snapshot(run_id)
+
+
+def require_running_run(run_id: str) -> dict[str, Any]:
+    run = get_find_agent_v2_service().require_run(run_id)
+    if str(run.get("status") or "") not in {"running", "finished"}:
+        raise ValueError(f"run_id={run_id} 当前状态不可执行: {run.get('status')}")
+    return run
+
+
+def render_node_context(
+    *, user_input: str, full_state: dict[str, Any], round_index: int, plan: str = "",
+) -> str:
+    return (
+        f"【当前轮次】\n{round_index}\n\n"
+        f"【原始任务】\n{user_input}\n\n"
+        f"【find_agent_v2 数据库状态快照】\n"
+        f"{json.dumps(full_state, ensure_ascii=False, default=str)}\n\n"
+        f"【本轮搜索计划】\n{plan}"
+    )
+
+
+def build_node_slots(
+    *, user_input: str, full_state: dict[str, Any], round_index: int, plan: str = "",
+) -> tuple[InputSlot, ...]:
+    """Stable input structure shared by all node instances and obagent declarations."""
+    return (
+        InputSlot("当前轮次", str(round_index), "round_index", "FindAgentV2.begin_round", False),
+        InputSlot("原始任务", user_input, "task", "find_agent_v2_run.input_json", False),
+        InputSlot(
+            "数据库状态快照",
+            json.dumps(full_state, ensure_ascii=False, default=str),
+            "state_snapshot",
+            "FindAgentV2Service.get_full_state",
+            False,
+        ),
+        InputSlot("本轮搜索计划", plan, "round_plan", "planner output"),
+    )

+ 256 - 0
find_agent_v2/demand_context.py

@@ -0,0 +1,256 @@
+"""Build runnable v2 test contexts directly from generic demand data tables."""
+
+from __future__ import annotations
+
+import json
+import uuid
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+from sqlalchemy import select
+
+from find_agent_v2.gates import build_rule_snapshot
+from find_agent_v2.service import get_find_agent_v2_service
+from supply_infra.db.models.demand_grade import DemandGrade
+from supply_infra.db.models.demand_video_expansion import DemandVideoExpansion
+from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
+from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
+from supply_infra.db.session import get_session
+
+_POINT_TYPES = {"inspiration", "purpose", "key"}
+
+
+@dataclass(frozen=True)
+class V2ReferencePoint:
+    point: str
+    point_type: str
+    point_desc: str | None = None
+
+
+@dataclass
+class V2ReferenceVideo:
+    video_id: str
+    title: str
+    points: list[V2ReferencePoint] = field(default_factory=list)
+
+
+@dataclass
+class V2DemandContext:
+    biz_dt: str
+    demand_grade_id: int
+    demand_name: str
+    grade: str
+    score: float | None
+    videos: list[V2ReferenceVideo] = field(default_factory=list)
+
+    @property
+    def point_count(self) -> int:
+        return sum(len(video.points) for video in self.videos)
+
+    def payload(self) -> dict[str, Any]:
+        return {
+            "biz_dt": self.biz_dt,
+            "demand_grade_id": self.demand_grade_id,
+            "demand_name": self.demand_name,
+            "grade": self.grade,
+            "score": self.score,
+            "reference_videos": [asdict(video) for video in self.videos],
+        }
+
+
+@dataclass(frozen=True)
+class PreparedV2DemandRun:
+    run_id: str
+    demand_grade_id: int
+    demand_name: str
+    biz_dt: str
+    user_input: str
+    reference_video_count: int
+    point_count: int
+
+    def summary(self) -> dict[str, Any]:
+        return {
+            "run_id": self.run_id,
+            "demand_grade_id": self.demand_grade_id,
+            "demand_name": self.demand_name,
+            "biz_dt": self.biz_dt,
+            "reference_video_count": self.reference_video_count,
+            "point_count": self.point_count,
+        }
+
+
+def _json_ids(raw: str | None) -> list[str]:
+    try:
+        value = json.loads(raw or "[]")
+    except (TypeError, ValueError):
+        return []
+    if not isinstance(value, list):
+        return []
+    return list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
+
+
+def _points_from_expansions(rows: list[Any]) -> tuple[list[str], dict[str, list[V2ReferencePoint]]]:
+    order: list[str] = []
+    result: dict[str, list[V2ReferencePoint]] = {}
+    seen: set[tuple[str, str, str]] = set()
+    for row in rows:
+        video_id = str(row.video_id or "").strip()
+        point_type = str(row.point_type or "").strip()
+        point = str(row.expanded_text or "").strip()
+        key = (video_id, point_type, point)
+        if not video_id or point_type not in _POINT_TYPES or not point or key in seen:
+            continue
+        seen.add(key)
+        if video_id not in result:
+            order.append(video_id)
+            result[video_id] = []
+        result[video_id].append(V2ReferencePoint(
+            point=point,
+            point_type=point_type,
+            point_desc=str(row.point_desc or "").strip() or None,
+        ))
+    return order, result
+
+
+def _points_from_source_rows(
+    video_ids: list[str], rows: list[Any],
+) -> tuple[list[str], dict[str, list[V2ReferencePoint]]]:
+    result: dict[str, list[V2ReferencePoint]] = {video_id: [] for video_id in video_ids}
+    for row in rows:
+        video_id = str(row.video_id or "").strip()
+        point_type = str(row.point_type or "").strip()
+        point = str(row.point_data or "").strip()
+        if video_id in result and point_type in _POINT_TYPES and point:
+            result[video_id].append(V2ReferencePoint(
+                point=point,
+                point_type=point_type,
+                point_desc=str(row.point_desc or "").strip() or None,
+            ))
+    return video_ids, {key: value for key, value in result.items() if value}
+
+
+def _load_context_in_session(session, grade: DemandGrade) -> V2DemandContext | None:
+    expansions = list(session.scalars(
+        select(DemandVideoExpansion).where(
+            DemandVideoExpansion.biz_dt == grade.biz_dt,
+            DemandVideoExpansion.source_demand_grade_id == grade.id,
+            DemandVideoExpansion.is_delete == 0,
+        ).order_by(DemandVideoExpansion.id)
+    ))
+    video_order, points_by_video = _points_from_expansions(expansions)
+    if not points_by_video:
+        source_ids = _json_ids(grade.video_list)
+        source_points = list(session.scalars(
+            select(MultiDemandVideoPoint).where(
+                MultiDemandVideoPoint.video_id.in_(source_ids)
+            ).order_by(MultiDemandVideoPoint.video_id, MultiDemandVideoPoint.id)
+        )) if source_ids else []
+        video_order, points_by_video = _points_from_source_rows(source_ids, source_points)
+    if not points_by_video:
+        return None
+
+    details = {
+        str(row.vid): row
+        for row in session.scalars(select(MultiDemandVideoDetail).where(
+            MultiDemandVideoDetail.vid.in_(list(points_by_video))
+        ))
+    }
+    videos = [
+        V2ReferenceVideo(
+            video_id=video_id,
+            title=str(details[video_id].title or "").strip() or f"(无标题|{video_id})",
+            points=points_by_video[video_id],
+        )
+        for video_id in video_order
+        if video_id in points_by_video and video_id in details
+    ]
+    if not videos:
+        return None
+    return V2DemandContext(
+        biz_dt=str(grade.biz_dt),
+        demand_grade_id=int(grade.id),
+        demand_name=str(grade.demand_name),
+        grade=str(grade.grade),
+        score=float(grade.score) if grade.score is not None else None,
+        videos=videos,
+    )
+
+
+def load_v2_demand_context(demand_grade_id: int) -> V2DemandContext:
+    with get_session() as session:
+        grade = session.get(DemandGrade, int(demand_grade_id))
+        if grade is None:
+            raise LookupError(f"demand_grade_id 不存在: {demand_grade_id}")
+        context = _load_context_in_session(session, grade)
+        if context is None:
+            raise ValueError(f"需求没有可用参考视频和点位: demand_grade_id={demand_grade_id}")
+        return context
+
+
+def pick_latest_v2_demand_context(*, index: int = 0) -> V2DemandContext:
+    with get_session() as session:
+        latest = session.scalar(select(DemandGrade.biz_dt).where(
+            DemandGrade.grade == "S"
+        ).order_by(DemandGrade.biz_dt.desc()).limit(1))
+        if not latest:
+            raise LookupError("本地没有 S 级需求")
+        grades = list(session.scalars(select(DemandGrade).where(
+            DemandGrade.biz_dt == latest,
+            DemandGrade.grade == "S",
+        ).order_by(DemandGrade.score.desc(), DemandGrade.id)))
+        contexts = [
+            context
+            for grade in grades
+            if (context := _load_context_in_session(session, grade)) is not None
+        ]
+        if not 0 <= int(index) < len(contexts):
+            raise IndexError(f"可用 S 级上下文共 {len(contexts)} 条,index={index} 越界")
+        return contexts[int(index)]
+
+
+def build_v2_user_input(
+    context: V2DemandContext, *, run_id: str, rules: dict[str, Any],
+) -> str:
+    payload = {
+        "task": "根据需求语义和参考视频点位,寻找新的高价值抖音候选视频。",
+        "run_id": run_id,
+        "demand": {
+            "biz_dt": context.biz_dt,
+            "demand_grade_id": context.demand_grade_id,
+            "demand_word": context.demand_name,
+            "grade": context.grade,
+            "score": context.score,
+        },
+        "quality_gate_rules": rules,
+        "reference_videos": [asdict(video) for video in context.videos],
+    }
+    return json.dumps(payload, ensure_ascii=False, default=str)
+
+
+def prepare_v2_demand_run(
+    *, demand_grade_id: int | None = None, index: int = 0, run_id: str | None = None,
+) -> PreparedV2DemandRun:
+    context = (
+        load_v2_demand_context(demand_grade_id)
+        if demand_grade_id is not None
+        else pick_latest_v2_demand_context(index=index)
+    )
+    run_key = str(run_id or f"local-test-{uuid.uuid4().hex}")[:64]
+    rules = build_rule_snapshot()
+    user_input = build_v2_user_input(context, run_id=run_key, rules=rules)
+    get_find_agent_v2_service().create_run(
+        run_id=run_key,
+        user_input=user_input,
+        demand_word=context.demand_name,
+        demand_grade_id=context.demand_grade_id,
+        rule_config=rules,
+    )
+    return PreparedV2DemandRun(
+        run_id=run_key,
+        demand_grade_id=context.demand_grade_id,
+        demand_name=context.demand_name,
+        biz_dt=context.biz_dt,
+        user_input=user_input,
+        reference_video_count=len(context.videos),
+        point_count=context.point_count,
+    )

+ 160 - 0
find_agent_v2/gates.py

@@ -0,0 +1,160 @@
+"""Deterministic candidate gates owned by find_agent_v2."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from datetime import date, datetime, time
+from decimal import Decimal
+from typing import Any, Mapping
+from zoneinfo import ZoneInfo
+
+
+def _env_number(name: str, default: float) -> float:
+    try:
+        return float(os.getenv(name, str(default)))
+    except ValueError:
+        return default
+
+
+def build_rule_snapshot(now: datetime | None = None) -> dict[str, Any]:
+    timezone_name = os.getenv("FIND_AGENT_V2_TIMEZONE", "Asia/Shanghai")
+    timezone = ZoneInfo(timezone_name)
+    current = now or datetime.now(timezone)
+    current = current.replace(tzinfo=timezone) if current.tzinfo is None else current.astimezone(timezone)
+    return {
+        "rule_version": os.getenv("FIND_AGENT_V2_RULE_VERSION", "find-agent-v2-gate-v1"),
+        "timezone": timezone_name,
+        "min_duration_seconds": int(_env_number("FIND_AGENT_V2_MIN_DURATION_SECONDS", 30)),
+        "min_share_count": int(_env_number("FIND_AGENT_V2_MIN_SHARE_COUNT", 1000)),
+        "min_content_50_plus_ratio": _env_number("FIND_AGENT_V2_MIN_CONTENT_50_PLUS_RATIO", 0.20),
+        "min_account_50_plus_ratio": _env_number("FIND_AGENT_V2_MIN_ACCOUNT_50_PLUS_RATIO", 0.20),
+        "event_max_age_days": int(_env_number("FIND_AGENT_V2_EVENT_MAX_AGE_DAYS", 7)),
+        "seasonal_max_age_days": int(_env_number("FIND_AGENT_V2_SEASONAL_MAX_AGE_DAYS", 180)),
+        "current_datetime": current.isoformat(timespec="seconds"),
+        "current_date": current.strftime("%Y-%m-%d"),
+    }
+
+
+def _rules(value: Mapping[str, Any] | str | None) -> dict[str, Any]:
+    if isinstance(value, str):
+        try:
+            loaded = json.loads(value)
+        except (TypeError, ValueError):
+            loaded = {}
+    else:
+        loaded = dict(value or {})
+    return {**build_rule_snapshot(), **loaded}
+
+
+def parse_datetime_value(value: Any, *, timezone_name: str = "Asia/Shanghai") -> datetime | None:
+    if value in (None, "") or isinstance(value, bool):
+        return None
+    timezone = ZoneInfo(timezone_name)
+    if isinstance(value, datetime):
+        parsed = value
+    elif isinstance(value, date):
+        parsed = datetime.combine(value, time.min)
+    elif isinstance(value, (int, float, Decimal)):
+        number = float(value)
+        if number > 10_000_000_000:
+            number /= 1000
+        try:
+            parsed = datetime.fromtimestamp(number, timezone)
+        except (OSError, OverflowError, ValueError):
+            return None
+    else:
+        text = str(value).strip()
+        if re.fullmatch(r"\d{10,13}", text):
+            return parse_datetime_value(int(text), timezone_name=timezone_name)
+        parsed = None
+        for candidate in (text.replace("Z", "+00:00"), text.replace("/", "-")):
+            try:
+                parsed = datetime.fromisoformat(candidate)
+                break
+            except ValueError:
+                continue
+        if parsed is None:
+            return None
+    return parsed.replace(tzinfo=timezone) if parsed.tzinfo is None else parsed.astimezone(timezone)
+
+
+def _number(value: Any) -> float | None:
+    if value in (None, "") or isinstance(value, bool):
+        return None
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _strong(candidate: Mapping[str, Any]) -> bool:
+    scores = [_number(candidate.get(key)) for key in ("relevance_score", "elder_score", "share_score")]
+    value = _number(candidate.get("value_score"))
+    return value is not None and value >= 0.65 or all(score is not None and score >= floor for score, floor in zip(scores, (0.70, 0.70, 0.65), strict=True))
+
+
+def _temporal(candidate: Mapping[str, Any], rules: Mapping[str, Any]) -> dict[str, Any]:
+    timezone_name = str(rules["timezone"])
+    current = parse_datetime_value(rules.get("current_datetime"), timezone_name=timezone_name) or datetime.now(ZoneInfo(timezone_name))
+    published = parse_datetime_value(candidate.get("publish_at"), timezone_name=timezone_name)
+    title = str(candidate.get("title") or "")
+    temporal_type = str(candidate.get("temporal_type") or "evergreen")
+    if published is None:
+        compensated = _strong(candidate) and not any(word in title for word in ("今天", "今日", "明天", "刚刚", "突发"))
+        return {"status": "pass" if compensated else "unknown", "reason_code": None if compensated else "TEMPORAL_UNKNOWN", "temporal_type": temporal_type, "evidence": {"publish_at": None, "compensated": compensated}}
+    age_days = max(0.0, (current - published).total_seconds() / 86400)
+    reason_code = None
+    if any(word in title for word in ("今天", "今日", "明天", "昨日", "昨天")) and published.date() != current.date():
+        reason_code = "RELATIVE_DATE_EXPIRED"
+    elif temporal_type == "event" and age_days > int(rules["event_max_age_days"]):
+        reason_code = "EVENT_EXPIRED"
+    elif temporal_type == "seasonal" and age_days > int(rules["seasonal_max_age_days"]):
+        reason_code = "SEASONAL_EXPIRED"
+    return {"status": "fail" if reason_code else "pass", "reason_code": reason_code, "temporal_type": temporal_type, "evidence": {"publish_at": published.isoformat(timespec="seconds"), "content_age_days": round(age_days, 3)}}
+
+
+def evaluate_candidate_gate(candidate: Mapping[str, Any], rule_snapshot: Mapping[str, Any] | str | None) -> dict[str, Any]:
+    rules = _rules(rule_snapshot)
+    temporal = _temporal(candidate, rules)
+    duration = _number(candidate.get("duration_seconds"))
+    shares = _number(candidate.get("share_count"))
+    content_ratio = _number(candidate.get("content_50_plus_ratio"))
+    account_ratio = _number(candidate.get("account_50_plus_ratio"))
+    checks: list[dict[str, Any]] = [{"name": "temporal", **temporal}]
+
+    def threshold(name: str, actual: float | None, minimum: float, missing: str, low: str, compensate: bool = False) -> None:
+        if actual is None:
+            status, reason = ("pass", None) if compensate else ("fail", missing)
+        else:
+            status, reason = ("pass", None) if actual >= minimum else ("fail", low)
+        checks.append({"name": name, "status": status, "reason_code": reason, "actual": actual, "threshold": minimum, "compensated": actual is None and compensate})
+
+    min_duration = float(rules["min_duration_seconds"])
+    min_shares = float(rules["min_share_count"])
+    threshold("duration_seconds", duration, min_duration, "DURATION_UNKNOWN", "DURATION_TOO_SHORT", compensate=_strong(candidate) or shares is not None and shares >= min_shares * 1.5)
+    likes, plays = _number(candidate.get("like_count")), _number(candidate.get("play_count"))
+    threshold("share_count", shares, min_shares, "SHARE_COUNT_UNKNOWN", "SHARE_COUNT_TOO_LOW", compensate=_strong(candidate) or bool(likes and likes >= 5000) or bool(plays and plays >= 50000))
+
+    min_content = float(rules["min_content_50_plus_ratio"])
+    min_account = float(rules["min_account_50_plus_ratio"])
+    portrait_pass = bool(content_ratio is not None and content_ratio >= min_content or account_ratio is not None and account_ratio >= min_account)
+    portrait_compensated = content_ratio is None and account_ratio is None and _strong(candidate)
+    checks.append({
+        "name": "elder_portrait", "status": "pass" if portrait_pass or portrait_compensated else "fail",
+        "reason_code": None if portrait_pass or portrait_compensated else "CONTENT_PORTRAIT_MISSING" if content_ratio is None and account_ratio is None else "PORTRAIT_50_PLUS_TOO_LOW",
+        "actual": {"content_50_plus_ratio": content_ratio, "account_50_plus_ratio": account_ratio},
+        "threshold": {"min_content_50_plus_ratio": min_content, "min_account_50_plus_ratio": min_account},
+        "compensated": portrait_compensated,
+    })
+    failed = [str(check["reason_code"]) for check in checks if check.get("status") != "pass" and check.get("reason_code")]
+    content_status = "missing" if content_ratio is None else "pass" if content_ratio >= min_content else "fail"
+    account_status = "missing" if account_ratio is None else "pass" if account_ratio >= min_account else "fail"
+    return {
+        "rule_version": str(rules["rule_version"]), "status": "pass" if not failed else "fail",
+        "primary_eligible": not failed, "failed_reason_codes": failed, "checks": checks,
+        "temporal": temporal, "content_portrait_status": content_status,
+        "account_portrait_status": account_status,
+        "portrait_conflict": content_status in {"pass", "fail"} and account_status in {"pass", "fail"} and content_status != account_status,
+    }

+ 164 - 0
find_agent_v2/graph.py

@@ -0,0 +1,164 @@
+"""One complete business round as a deterministic DAG.
+
+The project does not depend on LangGraph, so this module preserves the same boundary
+without adding a second Agent runtime: one invocation is one acyclic business round.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+from find_agent_v2.context import build_node_slots, render_node_context
+from find_agent_v2.observability import InputSlot, NullObserver
+from find_agent_v2.prompts import EVALUATOR_PROMPT, EVIDENCE_PROMPT, PLANNER_PROMPT, SEARCH_PROMPT
+from find_agent_v2.service import FindAgentV2Service
+from find_agent_v2.state import FindAgentState, NodeRun
+from find_agent_v2.tools import EVALUATION_TOOLS, EVIDENCE_TOOLS, SEARCH_TOOLS, ToolFn
+
+
+class NodeRunner(Protocol):
+    async def run_node(
+        self,
+        *,
+        node: str,
+        round_index: int,
+        system_prompt: str,
+        user_content: str,
+        tools: tuple[ToolFn, ...] = (),
+        max_iterations: int = 12,
+        slots: tuple[InputSlot, ...] = (),
+    ) -> NodeRun: ...
+
+
+class FindAgentRoundGraph:
+    """planner -> search -> evidence -> evaluator -> END."""
+
+    def __init__(
+        self, *, service: FindAgentV2Service, runner: NodeRunner,
+        observer=None,
+    ) -> None:
+        self.service = service
+        self.runner = runner
+        self.observer = observer or NullObserver()
+
+    def _full_state(self, state: FindAgentState, *, pending_only: bool = False):
+        return self.service.get_full_state(
+            state.run_id, pending_only=pending_only,
+        )
+
+    def _context(self, state: FindAgentState, *, pending_only: bool = False) -> str:
+        return render_node_context(
+            user_input=state.user_input,
+            full_state=self._full_state(state, pending_only=pending_only),
+            round_index=state.round_index,
+            plan=state.plan,
+        )
+
+    def _slots(
+        self, state: FindAgentState, *, pending_only: bool = False,
+    ) -> tuple[InputSlot, ...]:
+        return build_node_slots(
+            user_input=state.user_input,
+            full_state=self._full_state(state, pending_only=pending_only),
+            round_index=state.round_index,
+            plan=state.plan,
+        )
+
+    async def invoke(self, state: FindAgentState) -> FindAgentState:
+        with self.observer.round(round_index=state.round_index) as round_observation:
+            state = await self._invoke_nodes(state)
+            round_observation.set_output({
+                "状态快照": state.snapshot.__dict__ if state.snapshot else {},
+                "本轮计划": state.plan,
+            }, ok=True)
+        return state
+
+    async def _invoke_nodes(self, state: FindAgentState) -> FindAgentState:
+        state.phase = "planning"
+        plan = await self.runner.run_node(
+            node="planner",
+            round_index=state.round_index,
+            system_prompt=PLANNER_PROMPT,
+            user_content=self._context(state),
+            tools=(),
+            max_iterations=2,
+            slots=self._slots(state),
+        )
+        state.node_runs.append(plan)
+        state.plan = plan.content.strip()
+        self.service.update_round(
+            state.run_id, state.round_index, phase="searching", plan=state.plan,
+        )
+
+        state.phase = "searching"
+        search = await self.runner.run_node(
+            node="search",
+            round_index=state.round_index,
+            system_prompt=SEARCH_PROMPT,
+            user_content=self._context(state),
+            tools=SEARCH_TOOLS,
+            max_iterations=10,
+            slots=self._slots(state),
+        )
+        state.node_runs.append(search)
+        after_search = self.service.snapshot(state.run_id)
+
+        if after_search.pending_count:
+            state.phase = "evidence"
+            self.service.update_round(state.run_id, state.round_index, phase="evidence")
+            evidence = await self.runner.run_node(
+                node="evidence",
+                round_index=state.round_index,
+                system_prompt=EVIDENCE_PROMPT,
+                user_content=self._context(state, pending_only=True),
+                tools=EVIDENCE_TOOLS,
+                max_iterations=12,
+                slots=self._slots(state, pending_only=True),
+            )
+            state.node_runs.append(evidence)
+
+            state.phase = "evaluating"
+            self.service.update_round(state.run_id, state.round_index, phase="evaluating")
+            # A model may intentionally keep one tool payload small (for example,
+            # evaluate ten candidates at a time).  One evaluator invocation is
+            # therefore not proof that the queue has been drained.  Re-enter the
+            # node with a fresh DB snapshot until every candidate has a terminal
+            # bucket, while failing fast if an invocation makes no progress.
+            stagnant_attempts = 0
+            for _ in range(64):
+                before_evaluation = self.service.snapshot(state.run_id)
+                if not before_evaluation.pending_count:
+                    break
+                evaluation = await self.runner.run_node(
+                    node="evaluator",
+                    round_index=state.round_index,
+                    system_prompt=EVALUATOR_PROMPT,
+                    user_content=self._context(state, pending_only=True),
+                    tools=EVALUATION_TOOLS,
+                    max_iterations=12,
+                    slots=self._slots(state, pending_only=True),
+                )
+                state.node_runs.append(evaluation)
+                after_evaluation = self.service.snapshot(state.run_id)
+                if after_evaluation.pending_count >= before_evaluation.pending_count:
+                    stagnant_attempts += 1
+                    if stagnant_attempts >= 3:
+                        raise RuntimeError(
+                            "评估节点连续 3 次未消费 pending_evaluation 候选:"
+                            f"remaining={after_evaluation.pending_count}"
+                        )
+                else:
+                    stagnant_attempts = 0
+            else:
+                raise RuntimeError("评估批次超过安全上限 64")
+
+        state.snapshot = self.service.snapshot(state.run_id)
+        state.phase = "done"
+        self.service.update_round(
+            state.run_id,
+            state.round_index,
+            phase="done",
+            status="done",
+            snapshot=state.snapshot,
+        )
+        return state

+ 147 - 0
find_agent_v2/models.py

@@ -0,0 +1,147 @@
+"""Completely isolated persistence models for ``find_agent_v2``."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import BigInteger, ForeignKey, Index, Integer, Numeric, String, Text, UniqueConstraint, func
+from sqlalchemy.dialects.mysql import LONGTEXT
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+_LONG_TEXT = Text().with_variant(LONGTEXT(), "mysql")
+
+
+class FindAgentV2Run(Base):
+    __tablename__ = "find_agent_v2_run"
+    __table_args__ = (
+        UniqueConstraint("run_id", name="uk_find_agent_v2_run_id"),
+        Index("idx_find_agent_v2_run_status", "status"),
+        Index("idx_find_agent_v2_run_demand", "demand_grade_id"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    run_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    demand_grade_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    demand_word: Mapped[str] = mapped_column(String(256), nullable=False)
+    input_json: Mapped[str] = mapped_column(_LONG_TEXT, nullable=False)
+    rule_config_json: Mapped[str] = mapped_column(Text, nullable=False)
+    status: Mapped[str] = mapped_column(String(24), nullable=False, default="running")
+    outcome_status: Mapped[str | None] = mapped_column(String(24), nullable=True)
+    current_round: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    search_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    candidate_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    valid_primary_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    intent_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
+    stop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+    obagent_run_uid: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    create_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now())
+    update_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now(), onupdate=func.now())
+
+
+class FindAgentV2Round(Base):
+    __tablename__ = "find_agent_v2_round"
+    __table_args__ = (
+        UniqueConstraint("run_id", "round_index", name="uk_find_agent_v2_round"),
+        Index("idx_find_agent_v2_round_run", "run_id", "round_index"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    run_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    round_index: Mapped[int] = mapped_column(Integer, nullable=False)
+    phase: Mapped[str] = mapped_column(String(24), nullable=False, default="planning")
+    status: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
+    plan_json: Mapped[str | None] = mapped_column(_LONG_TEXT, nullable=True)
+    start_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+    end_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    create_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now())
+    update_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now(), onupdate=func.now())
+
+
+class FindAgentV2Search(Base):
+    __tablename__ = "find_agent_v2_search"
+    __table_args__ = (Index("idx_find_agent_v2_search_run", "run_id", "id"),)
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    run_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    round_index: Mapped[int] = mapped_column(Integer, nullable=False)
+    keyword: Mapped[str] = mapped_column(String(256), nullable=False)
+    query_reason: Mapped[str] = mapped_column(Text, nullable=False)
+    source_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    provider: Mapped[str] = mapped_column(String(32), nullable=False)
+    cursor: Mapped[str] = mapped_column(String(128), nullable=False, default="0")
+    page_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+    has_more: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    next_cursor: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    provider_state_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+    result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    status: Mapped[str] = mapped_column(String(16), nullable=False)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    raw_response_json: Mapped[str | None] = mapped_column(_LONG_TEXT, nullable=True)
+    create_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now())
+
+
+class FindAgentV2Candidate(Base):
+    __tablename__ = "find_agent_v2_candidate"
+    __table_args__ = (
+        UniqueConstraint("run_id", "aweme_id", name="uk_find_agent_v2_candidate_aweme"),
+        Index("idx_find_agent_v2_candidate_bucket", "run_id", "decision_bucket"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    run_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    first_search_id: Mapped[int | None] = mapped_column(
+        BigInteger,
+        ForeignKey("find_agent_v2_search.id", name="fk_find_agent_v2_candidate_search"),
+        nullable=True,
+    )
+    aweme_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    title: Mapped[str | None] = mapped_column(String(512), nullable=True)
+    content_link: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+    author_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
+    author_sec_uid: Mapped[str | None] = mapped_column(String(256), nullable=True)
+    source_keywords_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+    tags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+    publish_at: Mapped[datetime | None] = mapped_column(nullable=True)
+    duration_seconds: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
+    play_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    like_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    comment_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    collect_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    share_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    detail_json: Mapped[str | None] = mapped_column(_LONG_TEXT, nullable=True)
+    portrait_json: Mapped[str | None] = mapped_column(_LONG_TEXT, nullable=True)
+    detail_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
+    portrait_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
+    content_50_plus_ratio: Mapped[Decimal | None] = mapped_column(Numeric(8, 6), nullable=True)
+    account_50_plus_ratio: Mapped[Decimal | None] = mapped_column(Numeric(8, 6), nullable=True)
+    relevance_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 6), nullable=True)
+    elder_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 6), nullable=True)
+    share_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 6), nullable=True)
+    value_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 6), nullable=True)
+    gate_status: Mapped[str | None] = mapped_column(String(16), nullable=True)
+    gate_result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+    decision_bucket: Mapped[str] = mapped_column(String(24), nullable=False, default="pending_evaluation")
+    decision_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+    reject_reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    create_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now())
+    update_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now(), onupdate=func.now())
+
+
+class FindAgentV2Evidence(Base):
+    __tablename__ = "find_agent_v2_evidence"
+    __table_args__ = (Index("idx_find_agent_v2_evidence_subject", "run_id", "candidate_id", "id"),)
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    run_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    candidate_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    evidence_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    provider: Mapped[str] = mapped_column(String(32), nullable=False)
+    status: Mapped[str] = mapped_column(String(16), nullable=False)
+    raw_json: Mapped[str | None] = mapped_column(_LONG_TEXT, nullable=True)
+    normalized_json: Mapped[str | None] = mapped_column(_LONG_TEXT, nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    create_time: Mapped[datetime] = mapped_column(nullable=False, server_default=func.now())

+ 223 - 0
find_agent_v2/observability.py

@@ -0,0 +1,223 @@
+"""obagent is the only visualization backend for find_agent_v2."""
+
+from __future__ import annotations
+
+import os
+from contextlib import contextmanager
+from dataclasses import dataclass
+from typing import Any, Iterator
+
+OBAGENT_PROJECT = "find_agent_v2"
+OBAGENT_AGENT = "find_agent_v2"
+OBAGENT_ROUND_ANCHOR = {"in": "run", "on": ["graph"]}
+DEFAULT_ENDPOINT = "http://ob.aiddit.com"
+
+MODULE_TITLES = {
+    "planner": "搜索规划",
+    "search": "候选搜索",
+    "evidence": "证据补全",
+    "evaluator": "评估与分池",
+    "report": "结果报告",
+}
+
+GRAPH_SPEC = {
+    "nodes": [
+        {"key": key, "title": title,
+         "module_key": f"{OBAGENT_PROJECT}.{OBAGENT_AGENT}.{key}"}
+        for key, title in MODULE_TITLES.items()
+        if key != "report"
+    ]
+}
+
+_configured = False
+
+
+@dataclass(frozen=True)
+class InputSlot:
+    title: str
+    value: str
+    key: str
+    source: str
+    optional: bool = True
+
+
+class NullRunHandle:
+    run_uid: str | None = None
+
+    def finish(self, final_output: Any = None) -> None:
+        del final_output
+
+
+class NullModuleHandle:
+    def declare(self, *, fallback: str, **_kwargs) -> str:
+        return fallback
+
+    def record_react(self, **_kwargs) -> None:
+        return None
+
+    def set_output(self, _output: Any, *, ok: bool = True) -> None:
+        del ok
+
+
+class NullObserver:
+    """Test-only observer. Production construction uses :class:`ObagentObserver`."""
+
+    @contextmanager
+    def run(self, **_kwargs) -> Iterator[NullRunHandle]:
+        yield NullRunHandle()
+
+    @contextmanager
+    def round(self, **_kwargs) -> Iterator[NullModuleHandle]:
+        yield NullModuleHandle()
+
+    @contextmanager
+    def node(self, **_kwargs) -> Iterator[NullModuleHandle]:
+        yield NullModuleHandle()
+
+
+def console_endpoint() -> str:
+    return (os.getenv("OBAGENT_ENDPOINT") or DEFAULT_ENDPOINT).strip().rstrip("/")
+
+
+def run_url(run_uid: str) -> str:
+    return f"{console_endpoint()}/#client_uid={run_uid}"
+
+
+def ensure_configured() -> None:
+    global _configured
+    if _configured:
+        return
+    from obagent_sdk import configure
+
+    kwargs: dict[str, Any] = {
+        "endpoint": console_endpoint(),
+        "project": OBAGENT_PROJECT,
+        "timeout": float(os.getenv("OBAGENT_TIMEOUT", "120")),
+        "timeout_per_op": float(os.getenv("OBAGENT_TIMEOUT_PER_OP", "1.0")),
+    }
+    api_key = os.getenv("OBAGENT_API_KEY", "").strip()
+    if api_key:
+        kwargs["api_key"] = api_key
+    wal_dir = os.getenv("OBAGENT_WAL_DIR", "").strip()
+    if wal_dir:
+        kwargs["wal_dir"] = wal_dir
+    enabled = os.getenv("OBAGENT_ENABLED", "1").strip().lower()
+    if enabled in {"0", "false", "no", "off"}:
+        kwargs["enabled"] = False
+    configure(**kwargs)
+    _configured = True
+
+
+def _blocks(slots: tuple[InputSlot, ...]):
+    from obagent_sdk.observe import InputBlock
+
+    return [
+        InputBlock(
+            slot.title,
+            slot.value,
+            key=slot.key,
+            source=slot.source,
+            optional=slot.optional,
+        )
+        for slot in slots
+    ]
+
+
+class _ModuleHandle:
+    def __init__(self, ctx) -> None:
+        self.ctx = ctx
+
+    def declare(
+        self,
+        *,
+        fallback: str,
+        system_prompt: str,
+        slots: tuple[InputSlot, ...],
+        tools: tuple,
+        model: str,
+    ) -> str:
+        del fallback
+        return self.ctx.declare(
+            system_prompt=system_prompt,
+            blocks=_blocks(slots),
+            tools=list(tools),
+            model=model,
+        )
+
+    def record_react(self, *, output: dict[str, Any], ok: bool) -> None:
+        # One stable code stage per agent module; the payload holds the complete
+        # custom ReAct message chain because this project does not use LangChain hooks.
+        self.ctx.record_stage(
+            "react",
+            fn=_react_stage_identity,
+            title="ReAct 运行",
+            output=output,
+            ok=ok,
+        )
+
+    def set_output(self, output: Any, *, ok: bool = True) -> None:
+        self.ctx.set_output(output, ok=ok)
+
+
+def _react_stage_identity(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    """Stable source identity for the custom ReAct runtime stage."""
+    return messages
+
+
+class ObagentObserver:
+    @contextmanager
+    def run(
+        self,
+        *,
+        run_id: str,
+        demand_word: str,
+        model: str,
+        models_by_role: dict[str, str],
+    ):
+        ensure_configured()
+        from obagent_sdk import observe
+
+        with observe.run(
+            agent=OBAGENT_AGENT,
+            objective=f"寻找视频 · {demand_word} · run_id={run_id}",
+            project=OBAGENT_PROJECT,
+            model_name=model,
+            payload={"run_id": run_id, "demand_word": demand_word,
+                     "models_by_role": models_by_role},
+            tags={"engine": "staged-react", "version": "v2"},
+            meta={"run_name": f"寻找 Agent v2 · {demand_word}",
+                  "run_id": run_id, "models_by_role": models_by_role},
+            round_anchor=OBAGENT_ROUND_ANCHOR,
+        ) as handle:
+            yield handle
+
+    @contextmanager
+    def round(self, *, round_index: int):
+        from obagent_sdk import observe
+
+        with observe.module(
+            "graph",
+            kind="workflow",
+            title=f"寻找 Agent · 第 {round_index} 轮",
+            module_key="graph",
+            spec=GRAPH_SPEC,
+        ) as ctx:
+            from obagent_sdk.observe import InputBlock
+
+            ctx.declare(blocks=[InputBlock(
+                "当前轮次", str(round_index), key="round_index",
+                source="FindAgentV2.begin_round", optional=False,
+            )])
+            yield _ModuleHandle(ctx)
+
+    @contextmanager
+    def node(self, *, node: str):
+        from obagent_sdk import observe
+
+        with observe.module(
+            node,
+            kind="agent",
+            title=MODULE_TITLES[node],
+            module_key=node,
+        ) as ctx:
+            yield _ModuleHandle(ctx)

+ 72 - 0
find_agent_v2/prompts.py

@@ -0,0 +1,72 @@
+"""Self-contained prompts for the isolated v2 workflow."""
+
+from __future__ import annotations
+
+COMMON_RULES = """
+# 寻找 Agent v2:公共业务规则
+
+目标是从抖音视频中找到与用户需求高度相关、适合目标人群且具有传播价值的候选内容。
+所有事实只能来自本轮输入、工具响应以及 `find_agent_v2_*` 数据快照,禁止编造候选、互动数、
+发布时间、画像比例或工具执行结果。
+
+## 候选评分
+
+- R(relevance_score):内容与需求的直接相关性,范围 0~1。
+- E(elder_score):内容和作者受众对 50+ 人群的适配程度,范围 0~1。
+- S(share_score):转发意愿与传播价值,范围 0~1。
+- V(value_score):综合价值,范围 0~1。
+
+写入 primary 只是模型的申请,程序还会执行确定性门禁:时效、视频时长、分享量以及内容侧或
+账号侧的 50+ 画像。门禁失败时程序会强制写入 rejected,模型不得绕过。证据不足时应明确说明,
+不得把点赞画像描述成转发画像。
+
+## 数据和生命周期边界
+
+- 只允许操作当前 `run_id` 对应的 `find_agent_v2_run/round/search/candidate/evidence` 数据。
+- `run_id` 和 `round_index` 必须原样传给工具,不得自行生成或替换。
+- 运行创建、轮次推进、停止条件和最终状态由宿主代码控制,Agent 不得修改。
+- 每个模块只能调用当前真实提供的工具;未提供的能力视为物理不可用。
+- 优先批量调用工具,避免同一轮重复查询或重复补证。
+"""
+
+PLANNER_PROMPT = COMMON_RULES + """
+
+# 当前模块:搜索规划
+
+你只负责规划,不调用工具,也不评估候选。根据原始任务、已有搜索和候选快照,设计本轮最多
+3 个意图互补的搜索方向。避开已经没有信息增益的关键词。只输出 JSON:
+{"intent_summary":"...","searches":[{"keyword":"...","query_reason":"...","source_type":"demand|seed|point|mixed","provider":"internal_keyword|tikhub","max_pages":1}]}
+"""
+
+SEARCH_PROMPT = COMMON_RULES + """
+
+# 当前模块:候选搜索
+
+执行本轮搜索计划。只能使用 `search_videos_v2` 和 `query_find_agent_v2_state`;不得获取详情或
+画像,不得评分或分池。优先把互不依赖的关键词放入一次批量搜索,完成后查询状态确认写入结果。
+"""
+
+EVIDENCE_PROMPT = COMMON_RULES + """
+
+# 当前模块:证据补全
+
+使用 `query_pending_candidates_v2` 查询待评估候选,选择高潜候选批量调用 `fetch_candidate_details_v2` 和
+`fetch_candidate_portraits_v2`。不得继续搜索、评分或分池。上游失败也必须保留为明确证据状态。
+"""
+
+EVALUATOR_PROMPT = COMMON_RULES + """
+
+# 当前模块:评估与分池
+
+使用 `query_pending_candidates_v2` 查询所有 pending_evaluation 候选,根据已保存证据给出 R/E/S/V,并批量调用
+`evaluate_candidates_v2`。所有待评估候选必须进入 primary 或 rejected;更新后再次查询确认。
+不得搜索、补证或修改运行终态。
+"""
+
+REPORT_PROMPT = COMMON_RULES + """
+
+# 当前模块:结果报告
+
+最终状态已由宿主写入。查询数据库并报告业务结果、有效 primary 数量、每条 primary 的标题和
+关键证据,以及主要淘汰原因。不得再搜索、补证、评分或修改数据。
+"""

+ 393 - 0
find_agent_v2/providers.py

@@ -0,0 +1,393 @@
+"""Independent external-data clients for find_agent_v2.
+
+This module owns request validation, HTTP calls and response normalization;
+database persistence remains in :mod:`find_agent_v2.service`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import re
+import time
+from typing import Any
+
+import httpx
+from dotenv import load_dotenv
+
+from supply_agent.paths import find_project_root
+
+INTERNAL_SEARCH_ENDPOINT = os.getenv(
+    "FIND_AGENT_V2_INTERNAL_SEARCH_ENDPOINT",
+    "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword",
+)
+TIKHUB_SEARCH_ENDPOINT = os.getenv(
+    "FIND_AGENT_V2_TIKHUB_SEARCH_ENDPOINT",
+    "https://api.tikhub.io/api/v1/douyin/search/fetch_video_search_v2",
+)
+DETAIL_ENDPOINT = os.getenv(
+    "FIND_AGENT_V2_DETAIL_ENDPOINT",
+    "http://8.217.190.241:8888/crawler/dou_yin/detail",
+)
+CONTENT_PORTRAIT_ENDPOINT = os.getenv(
+    "FIND_AGENT_V2_CONTENT_PORTRAIT_ENDPOINT",
+    "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait",
+)
+ACCOUNT_PORTRAIT_ENDPOINT = os.getenv(
+    "FIND_AGENT_V2_ACCOUNT_PORTRAIT_ENDPOINT",
+    "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait",
+)
+
+DEFAULT_TIMEOUT = 60.0
+DEFAULT_ACCOUNT_ID = "771431222"
+MAX_BATCH_ITEMS = 8
+_env_loaded = False
+
+
+def _ensure_env_loaded() -> None:
+    global _env_loaded
+    if _env_loaded:
+        return
+    load_dotenv(find_project_root() / ".env")
+    _env_loaded = True
+
+
+class _RateLimiter:
+    def __init__(self, interval_seconds: float) -> None:
+        self.interval_seconds = interval_seconds
+        self._lock = asyncio.Lock()
+        self._last_request = 0.0
+
+    async def wait(self) -> None:
+        async with self._lock:
+            remaining = self.interval_seconds - (time.monotonic() - self._last_request)
+            if remaining > 0:
+                await asyncio.sleep(remaining)
+            self._last_request = time.monotonic()
+
+
+_internal_search_limiter = _RateLimiter(10.1)
+_tikhub_limiter = _RateLimiter(1.0)
+_detail_limiter = _RateLimiter(10.1)
+
+_CONTENT_TYPES = {"不限": "0", "视频": "1", "图片": "2", "图文": "2", "文章": "3"}
+_SORT_TYPES = {"综合排序": "0", "最多点赞": "1", "最新发布": "2"}
+_PUBLISH_TIMES = {"不限": "0", "一天内": "1", "最近一天": "1", "一周内": "7", "最近一周": "7", "半年内": "180", "最近半年": "180"}
+_DURATIONS = {"不限": "0", "一分钟内": "0-1", "1分钟以内": "0-1", "1-5分钟": "1-5", "五分钟以上": "5-10000", "5分钟以上": "5-10000"}
+
+
+def _safe_int(value: Any, default: int = 0) -> int:
+    if value is None or isinstance(value, bool):
+        return default
+    try:
+        return int(float(str(value).strip()))
+    except (TypeError, ValueError):
+        return default
+
+
+def _error(exc: Exception | str) -> dict[str, Any]:
+    return {"error": str(exc), "search_results": [], "has_more": False}
+
+
+def _request_error(exc: Exception) -> str:
+    if isinstance(exc, httpx.HTTPStatusError):
+        return f"HTTP {exc.response.status_code}: {exc.response.text[:1000]}"
+    if isinstance(exc, httpx.TimeoutException):
+        return "请求超时"
+    if isinstance(exc, httpx.RequestError):
+        return f"网络错误: {exc}"
+    return f"未知错误: {exc}"
+
+
+def _topics(aweme: dict[str, Any]) -> list[str]:
+    result: list[str] = []
+    for collection in (aweme.get("topic_list"), aweme.get("text_extra"), aweme.get("cha_list")):
+        for item in collection or []:
+            if isinstance(item, str):
+                value = item
+            elif isinstance(item, dict):
+                value = item.get("topic_name") or item.get("cha_name") or item.get("hashtag_name") or item.get("name")
+            else:
+                value = None
+            if value and str(value).strip() not in result:
+                result.append(str(value).strip())
+    return result
+
+
+def _normalize_search_item(item: dict[str, Any]) -> dict[str, Any] | None:
+    aweme_id = str(item.get("aweme_id") or "").strip()
+    if not aweme_id:
+        return None
+    author = item.get("author") if isinstance(item.get("author"), dict) else {}
+    stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
+    video = item.get("video") if isinstance(item.get("video"), dict) else {}
+    return {
+        "aweme_id": aweme_id,
+        "desc": str(item.get("desc") or item.get("item_title") or "无标题")[:200],
+        "url": f"https://www.douyin.com/video/{aweme_id}",
+        "author": {
+            "nickname": str(author.get("nickname") or "未知作者"),
+            "sec_uid": str(author.get("sec_uid") or ""),
+        },
+        "statistics": {
+            "digg_count": _safe_int(stats.get("digg_count")),
+            "comment_count": _safe_int(stats.get("comment_count")),
+            "share_count": _safe_int(stats.get("share_count")),
+            "collect_count": _safe_int(stats.get("collect_count")),
+            "play_count": _safe_int(stats.get("play_count")),
+        },
+        "duration_ms": _safe_int(item.get("duration_ms") or item.get("duration") or video.get("duration")),
+        "publish_at": item.get("publish_at") or item.get("create_time") or item.get("create_timestamp") or item.get("publish_timestamp"),
+        "topics": _topics(item),
+    }
+
+
+async def search_internal(
+    *, keyword: str, content_type: str = "视频", sort_type: str = "综合排序",
+    publish_time: str = "不限", cursor: str = "0", min_duration_seconds: int = 30,
+    timeout: float = DEFAULT_TIMEOUT,
+) -> dict[str, Any]:
+    """Search one page through the internal crawler and return normalized candidates."""
+    started = time.monotonic()
+    try:
+        await _internal_search_limiter.wait()
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            response = await client.post(INTERNAL_SEARCH_ENDPOINT, json={
+                "keyword": keyword,
+                "content_type": content_type,
+                "sort_type": sort_type,
+                "publish_time": publish_time,
+                "cursor": cursor,
+                "account_id": DEFAULT_ACCOUNT_ID,
+            }, headers={"Content-Type": "application/json"})
+            response.raise_for_status()
+            body = response.json()
+        data = body.get("data") if isinstance(body.get("data"), dict) else {}
+        items = data.get("data") if isinstance(data.get("data"), list) else []
+        minimum_ms = max(30, int(min_duration_seconds)) * 1000
+        normalized = [value for item in items if (value := _normalize_search_item(item))]
+        results = [item for item in normalized if not item["duration_ms"] or item["duration_ms"] >= minimum_ms]
+        return {
+            "provider": "internal_keyword", "keyword": keyword,
+            "search_results": results, "results_count": len(results),
+            "filtered_count": len(normalized) - len(results),
+            "has_more": bool(data.get("has_more")),
+            "next_cursor": str(data.get("next_cursor") or ""),
+            "duration_ms": int((time.monotonic() - started) * 1000),
+            "_raw_response": body,
+        }
+    except Exception as exc:
+        return _error(_request_error(exc))
+
+
+def _enum(value: str, mapping: dict[str, str], field: str) -> str:
+    text = str(value).strip()
+    if text in mapping.values():
+        return text
+    if text not in mapping:
+        raise ValueError(f"{field} 不支持: {value}")
+    return mapping[text]
+
+
+async def search_tikhub(
+    *, keyword: str, content_type: str = "视频", sort_type: str = "综合排序",
+    publish_time: str = "不限", cursor: int = 0, filter_duration: str = "不限",
+    search_id: str = "", backtrace: str = "", min_duration_seconds: int = 30,
+    timeout: float = DEFAULT_TIMEOUT,
+) -> dict[str, Any]:
+    """Search one TikHub page and retain its pagination state."""
+    _ensure_env_loaded()
+    api_key = os.getenv("TIKHUB_API_KEY", "").strip()
+    if not api_key:
+        return _error("未设置环境变量 TIKHUB_API_KEY")
+    try:
+        payload = {
+            "keyword": str(keyword).strip(), "cursor": max(0, int(cursor)),
+            "sort_type": _enum(sort_type, _SORT_TYPES, "sort_type"),
+            "publish_time": _enum(publish_time, _PUBLISH_TIMES, "publish_time"),
+            "filter_duration": _enum(filter_duration, _DURATIONS, "filter_duration"),
+            "content_type": _enum(content_type, _CONTENT_TYPES, "content_type"),
+            "search_id": str(search_id or ""), "backtrace": str(backtrace or ""),
+        }
+        await _tikhub_limiter.wait()
+        async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
+            response = await client.post(TIKHUB_SEARCH_ENDPOINT, json=payload, headers={
+                "Content-Type": "application/json", "Authorization": f"Bearer {api_key}",
+            })
+            response.raise_for_status()
+            body = response.json()
+        data = body.get("data") if isinstance(body.get("data"), dict) else {}
+        raw_items = data.get("business_data") if isinstance(data.get("business_data"), list) else []
+        config = data.get("business_config") if isinstance(data.get("business_config"), dict) else {}
+        next_page = config.get("next_page") if isinstance(config.get("next_page"), dict) else {}
+        minimum_ms = max(30, int(min_duration_seconds)) * 1000
+        results: list[dict[str, Any]] = []
+        seen: set[str] = set()
+        for raw in raw_items:
+            nested = raw.get("data") if isinstance(raw, dict) and isinstance(raw.get("data"), dict) else {}
+            aweme = nested.get("aweme_info") if isinstance(nested.get("aweme_info"), dict) else {}
+            item = _normalize_search_item(aweme)
+            if item and item["aweme_id"] not in seen and (not item["duration_ms"] or item["duration_ms"] >= minimum_ms):
+                seen.add(item["aweme_id"])
+                results.append(item)
+        return {
+            "provider": "tikhub", "keyword": keyword, "search_results": results,
+            "results_count": len(results), "has_more": config.get("has_more") in (1, True, "1"),
+            "next_cursor": _safe_int(next_page.get("cursor")),
+            "search_id": str(next_page.get("search_id") or search_id or ""),
+            "backtrace": str(next_page.get("backtrace") or config.get("backtrace") or backtrace or ""),
+            "_raw_response": body,
+        }
+    except Exception as exc:
+        return _error(_request_error(exc))
+
+
+def _detail_payload(raw: dict[str, Any], content_id: str) -> dict[str, Any]:
+    video_urls = raw.get("video_url_list") if isinstance(raw.get("video_url_list"), list) else []
+    first_video = video_urls[0] if video_urls and isinstance(video_urls[0], dict) else {}
+    topics = raw.get("topic_list") if isinstance(raw.get("topic_list"), list) else []
+    return {
+        "content_id": content_id,
+        "content_link": raw.get("content_link") or f"https://www.douyin.com/video/{content_id}",
+        "title": raw.get("title"), "body_text": raw.get("body_text"),
+        "channel_account_name": raw.get("channel_account_name"),
+        "channel_account_id": raw.get("channel_account_id"),
+        "topic_list": topics,
+        "duration_seconds": _safe_int(first_video.get("video_duration") or raw.get("duration_seconds")),
+        "publish_at": raw.get("publish_at") or raw.get("publish_time") or raw.get("publish_timestamp") or raw.get("create_time") or raw.get("create_timestamp"),
+        "play_count": raw.get("play_count") if raw.get("play_count") is not None else raw.get("view_count"),
+        "like_count": raw.get("like_count"), "comment_count": raw.get("comment_count"),
+        "collect_count": raw.get("collect_count"), "share_count": raw.get("share_count"),
+        "video_url": first_video.get("video_url"),
+    }
+
+
+async def fetch_details(content_ids: list[str], *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
+    """Fetch and normalize up to eight video detail records."""
+    ids = list(dict.fromkeys(str(value).strip() for value in content_ids if str(value).strip()))
+    if not ids or len(ids) > MAX_BATCH_ITEMS:
+        return {"error": f"content_ids 必须为 1~{MAX_BATCH_ITEMS} 条", "details": [], "errors": []}
+    details: list[dict[str, Any]] = []
+    errors: list[dict[str, str]] = []
+    async with httpx.AsyncClient(timeout=timeout, trust_env=False, headers={"Accept": "*/*"}) as client:
+        for content_id in ids:
+            try:
+                await _detail_limiter.wait()
+                response = await client.post(DETAIL_ENDPOINT, json={"content_id": content_id})
+                response.raise_for_status()
+                body = response.json()
+                if body.get("code") not in (0, None):
+                    raise RuntimeError(f"接口返回错误: {body.get('msg') or body.get('code')}")
+                outer = body.get("data") if isinstance(body.get("data"), dict) else {}
+                raw = outer.get("data") if isinstance(outer.get("data"), dict) else {}
+                if not raw:
+                    raise RuntimeError("未查到视频详情")
+                details.append(_detail_payload(raw, content_id))
+            except Exception as exc:
+                errors.append({"content_id": content_id, "error": _request_error(exc)})
+    return {"details": details, "errors": errors, "success_count": len(details), "failed_count": len(errors)}
+
+
+def _number(value: Any) -> float | None:
+    if value is None or isinstance(value, bool):
+        return None
+    try:
+        return float(str(value).strip().replace("%", ""))
+    except ValueError:
+        return None
+
+
+def _age_dimension(portrait: Any) -> dict[str, Any]:
+    if not isinstance(portrait, dict):
+        return {}
+    for key in ("portrait_data", "content", "account"):
+        nested = _age_dimension(portrait.get(key))
+        if nested:
+            return nested
+    for key in ("年龄", "age", "Age", "年龄分布"):
+        if isinstance(portrait.get(key), dict):
+            return portrait[key]
+    return portrait if portrait and all(isinstance(value, dict) for value in portrait.values()) else {}
+
+
+def _normalize_age(portrait: Any) -> dict[str, Any]:
+    buckets: list[dict[str, Any]] = []
+    older_ratio = 0.0
+    mature_ratio = 0.0
+    weighted: list[tuple[float, float]] = []
+    dimension = _age_dimension(portrait)
+    for label, raw in dimension.items():
+        metrics = raw if isinstance(raw, dict) else {}
+        ratio = _number(metrics.get("percentage", metrics.get("ratio")))
+        if ratio is not None and ratio > 1:
+            ratio /= 100
+        tgi = _number(metrics.get("preference", metrics.get("tgi")))
+        numbers = [int(value) for value in re.findall(r"\d+", str(label))]
+        lower, upper = (min(numbers), max(numbers)) if numbers else (0, 0)
+        kind = "older" if lower >= 50 else "mature" if lower >= 40 or upper >= 50 else "younger"
+        buckets.append({"label": str(label), "kind": kind, "ratio": ratio, "tgi": tgi})
+        if ratio is not None and kind == "older":
+            older_ratio += ratio
+            if tgi is not None:
+                weighted.append((tgi, ratio))
+        elif ratio is not None and kind == "mature":
+            mature_ratio += ratio
+    weight = sum(item[1] for item in weighted)
+    older_tgi = sum(tgi * ratio for tgi, ratio in weighted) / weight if weight else None
+    strength = "missing" if not dimension else "strong" if older_ratio >= 0.35 else "moderate" if older_ratio >= 0.20 or mature_ratio >= 0.30 else "weak"
+    return {"has_age_portrait": bool(dimension), "older_ratio": round(older_ratio, 6), "older_tgi": older_tgi, "mature_ratio": round(mature_ratio, 6), "strength": strength, "buckets": buckets}
+
+
+def normalize_age_pair(content: Any, account: Any) -> dict[str, Any]:
+    content_age, account_age = _normalize_age(content), _normalize_age(account)
+    if content_age["has_age_portrait"] and account_age["has_age_portrait"]:
+        consistency, cap = "aligned" if (content_age["strength"] in {"strong", "moderate"}) == (account_age["strength"] in {"strong", "moderate"}) else "conflict", 1.0
+    elif account_age["has_age_portrait"]:
+        consistency, cap = "account_only", 0.85 if account_age["strength"] == "strong" else 0.75
+    elif content_age["has_age_portrait"]:
+        consistency, cap = "content_only", 1.0
+    else:
+        consistency, cap = "missing", 0.5
+    return {"content": content_age, "account": account_age, "consistency": consistency, "elder_score_cap": cap}
+
+
+def _portrait_data(body: dict[str, Any]) -> dict[str, Any]:
+    outer = body.get("data") if isinstance(body.get("data"), dict) else {}
+    return outer.get("data") if isinstance(outer.get("data"), dict) else {}
+
+
+async def fetch_portraits(candidates: list[dict[str, Any]], *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
+    """Fetch content/account portraits and normalize age evidence independently."""
+    if not candidates or len(candidates) > MAX_BATCH_ITEMS:
+        return {"error": f"candidates 必须为 1~{MAX_BATCH_ITEMS} 条", "results": []}
+    flags = {"need_province": False, "need_city": False, "need_city_level": False, "need_gender": False, "need_age": True, "need_phone_brand": False, "need_phone_price": False}
+    results: list[dict[str, Any]] = []
+    async with httpx.AsyncClient(timeout=timeout) as client:
+        for candidate in candidates:
+            aweme_id = str(candidate.get("aweme_id") or "").strip()
+            author_id = str(candidate.get("author_sec_uid") or "").strip()
+            item: dict[str, Any] = {"aweme_id": aweme_id, "author_sec_uid": author_id or None, "content": {}, "account": {}, "error": None}
+            try:
+                if not aweme_id.isdigit():
+                    raise ValueError("aweme_id 必须为纯数字")
+                response = await client.post(CONTENT_PORTRAIT_ENDPOINT, json={"content_id": aweme_id, **flags})
+                response.raise_for_status()
+                portrait = _portrait_data(response.json())
+                item["content"] = {"ok": True, "has_portrait": bool(portrait), "portrait_data": portrait}
+            except Exception as exc:
+                item["content"] = {"ok": False, "has_portrait": False, "portrait_data": {}, "error": _request_error(exc)}
+            if author_id:
+                try:
+                    response = await client.post(ACCOUNT_PORTRAIT_ENDPOINT, json={"account_id": author_id, **flags})
+                    response.raise_for_status()
+                    portrait = _portrait_data(response.json())
+                    item["account"] = {"attempted": True, "has_portrait": bool(portrait), "portrait_data": portrait}
+                except Exception as exc:
+                    item["account"] = {"attempted": True, "has_portrait": False, "portrait_data": {}, "error": _request_error(exc)}
+            else:
+                item["account"] = {"attempted": False, "has_portrait": False, "portrait_data": {}, "skipped_reason": "缺少 author_sec_uid"}
+            item["age_normalization"] = normalize_age_pair(item["content"].get("portrait_data"), item["account"].get("portrait_data"))
+            if item["content"].get("error") and item["account"].get("error"):
+                item["error"] = "内容与账号画像均获取失败"
+            results.append(item)
+    return {"results": results, "count": len(results)}

+ 79 - 0
find_agent_v2/runner.py

@@ -0,0 +1,79 @@
+"""Public run creation and sync/async entry points."""
+
+from __future__ import annotations
+
+import asyncio
+
+from find_agent_v2.agent import FindAgentV2, create_find_agent_v2
+from find_agent_v2.service import get_find_agent_v2_service
+from find_agent_v2.state import FindAgentResult
+from supply_agent.config import Settings
+
+
+def create_find_agent_v2_run(
+    *,
+    user_input: str,
+    demand_word: str,
+    demand_grade_id: int | None = None,
+    run_id: str | None = None,
+    rule_config: dict | None = None,
+) -> str:
+    """Create a run in the new table namespace; old run IDs are never reused implicitly."""
+    return get_find_agent_v2_service().create_run(
+        user_input=user_input,
+        demand_word=demand_word,
+        demand_grade_id=demand_grade_id,
+        run_id=run_id,
+        rule_config=rule_config,
+    )
+
+
+async def arun_find_agent_v2(
+    user_input: str,
+    *,
+    run_id: str,
+    agent: FindAgentV2 | None = None,
+    settings: Settings | None = None,
+    model: str | None = None,
+) -> FindAgentResult:
+    runner = agent or create_find_agent_v2(settings=settings, model=model)
+    return await runner.arun(run_id=run_id, user_input=user_input)
+
+
+def run_find_agent_v2(
+    user_input: str,
+    *,
+    run_id: str,
+    agent: FindAgentV2 | None = None,
+    settings: Settings | None = None,
+    model: str | None = None,
+) -> FindAgentResult:
+    try:
+        asyncio.get_running_loop()
+    except RuntimeError:
+        return asyncio.run(arun_find_agent_v2(
+            user_input,
+            run_id=run_id,
+            agent=agent,
+            settings=settings,
+            model=model,
+        ))
+    raise RuntimeError("run_find_agent_v2 不能在已运行的事件循环内调用;请使用 arun_find_agent_v2")
+
+
+def run_prepared_find_agent_v2(
+    run_id: str,
+    *,
+    agent: FindAgentV2 | None = None,
+    settings: Settings | None = None,
+    model: str | None = None,
+) -> FindAgentResult:
+    """Execute a prepared run using its database-persisted immutable user input."""
+    user_input = get_find_agent_v2_service().get_run_user_input(run_id)
+    return run_find_agent_v2(
+        user_input,
+        run_id=run_id,
+        agent=agent,
+        settings=settings,
+        model=model,
+    )

+ 162 - 0
find_agent_v2/runtime.py

@@ -0,0 +1,162 @@
+"""Thin ReAct host used by each deterministic workflow node."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import Any
+
+from find_agent_v2.observability import InputSlot, ObagentObserver
+from find_agent_v2.state import NodeRun
+from find_agent_v2.tools import ToolFn, build_tool_registry
+from supply_agent import Agent
+from supply_agent.config import Settings
+
+
+class _ObagentEventCollector:
+    """Collect ReAct events in memory for obagent; never writes project log artifacts."""
+
+    def __init__(self) -> None:
+        self.events: list[dict[str, Any]] = []
+
+    def start_run(self, *_args, **_kwargs) -> str:
+        return ""
+
+    def log_llm_input(self, iteration, model, messages, tools, temperature) -> None:
+        self.events.append({
+            "type": "llm_input",
+            "iteration": iteration,
+            "model": model,
+            "temperature": temperature,
+            "messages": [message.model_dump(mode="json") for message in messages],
+            "tools": [item.model_dump(mode="json") for item in (tools or [])],
+        })
+
+    def log_llm_output(
+        self, iteration, response, raw_response=None, *, model=None, provider="openrouter",
+    ) -> None:
+        usage = getattr(raw_response, "usage", None)
+        if hasattr(usage, "model_dump"):
+            usage = usage.model_dump()
+        self.events.append({
+            "type": "llm_output",
+            "iteration": iteration,
+            "model": model,
+            "provider": provider,
+            "response": response.model_dump(mode="json"),
+            "usage": usage,
+        })
+
+    def log_tool_call(
+        self, iteration, name, arguments, result, is_error=False, *, tool_call_id=None,
+    ) -> None:
+        self.events.append({
+            "type": "tool_call",
+            "iteration": iteration,
+            "name": name,
+            "arguments": arguments,
+            "result": result,
+            "is_error": bool(is_error),
+            "tool_call_id": tool_call_id,
+        })
+
+    def log_skill_loaded(self, *_args, **_kwargs) -> None:
+        return None
+
+    def end_run(self, *_args, **_kwargs) -> None:
+        return None
+
+
+class FindAgentNodeHost:
+    """Build a fresh, physically capability-limited Agent for every node."""
+
+    def __init__(
+        self,
+        *,
+        settings: Settings | None = None,
+        models_by_role: dict[str, str] | None = None,
+        default_model: str = "google/gemini-3-flash-preview",
+        observer: ObagentObserver | None = None,
+    ) -> None:
+        self.settings = settings
+        self.models_by_role = dict(models_by_role or {})
+        self.default_model = default_model
+        self.observer = observer or ObagentObserver()
+
+    async def run_node(
+        self,
+        *,
+        node: str,
+        round_index: int,
+        system_prompt: str,
+        user_content: str,
+        tools: Iterable[ToolFn] = (),
+        max_iterations: int = 12,
+        slots: tuple[InputSlot, ...] = (),
+    ) -> NodeRun:
+        tool_functions = tuple(tools)
+        model = self.models_by_role.get(node, self.default_model)
+        event_collector = _ObagentEventCollector()
+        agent = Agent(
+            settings=self.settings,
+            name=f"find_agent_v2.{node}",
+            model=model,
+            system_prompt=system_prompt,
+            tools=build_tool_registry(tool_functions),
+            max_iterations=max_iterations,
+            temperature=0.2,
+            # v2 must not write the project's JSONL/log/OSS visualization artifacts.
+            logger=event_collector,
+        )
+        # Stage capabilities are exact. The generic load_skill tool is not part of this workflow.
+        agent.tools.unregister("load_skill")
+        try:
+            with self.observer.node(node=node) as observation:
+                try:
+                    actual_user_content = observation.declare(
+                        fallback=user_content,
+                        system_prompt=system_prompt,
+                        slots=slots,
+                        tools=tool_functions,
+                        model=model,
+                    )
+                    result = await agent.arun_core(actual_user_content)
+                    messages = [message.model_dump(mode="json") for message in result.messages]
+                    process = {
+                        "messages": messages,
+                        "events": event_collector.events,
+                        "iterations": result.iterations,
+                        "tool_calls_made": result.tool_calls_made,
+                    }
+                    observation.record_react(output=process, ok=True)
+                    observation.set_output(
+                        {"agent输出": result.content, **process}, ok=True,
+                    )
+                    return NodeRun.from_agent_result(node, round_index, result)
+                except Exception as exc:
+                    error = {"error": f"{type(exc).__name__}: {exc}"}
+                    observation.record_react(output=error, ok=False)
+                    observation.set_output(error, ok=False)
+                    raise
+        finally:
+            client = getattr(agent.llm, "_async_client", None)
+            if client is not None:
+                await client.close()
+
+
+def normalize_models(
+    *,
+    model: str | None = None,
+    planning: str | None = None,
+    search: str | None = None,
+    evidence: str | None = None,
+    evaluation: str | None = None,
+    report: str | None = None,
+) -> dict[str, str]:
+    base = model or "google/gemini-3-flash-preview"
+    return {
+        "planner": planning or base,
+        "search": search or base,
+        "evidence": evidence or base,
+        "evaluator": evaluation or base,
+        "report": report or base,
+    }

+ 473 - 0
find_agent_v2/service.py

@@ -0,0 +1,473 @@
+"""Transactional service for the isolated ``find_agent_v2_*`` tables."""
+
+from __future__ import annotations
+
+import json
+import uuid
+from dataclasses import asdict
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import func, select
+
+from find_agent_v2.models import (
+    FindAgentV2Candidate,
+    FindAgentV2Evidence,
+    FindAgentV2Round,
+    FindAgentV2Run,
+    FindAgentV2Search,
+)
+from find_agent_v2.state import DiscoverySnapshot
+from supply_infra.db.session import get_session
+from find_agent_v2.gates import (
+    build_rule_snapshot,
+    evaluate_candidate_gate,
+    parse_datetime_value,
+)
+
+
+class FindAgentV2RunNotFound(LookupError):
+    pass
+
+
+def _json(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, default=str)
+
+
+def _loads(value: str | None, default: Any) -> Any:
+    try:
+        return json.loads(value) if value else default
+    except (TypeError, ValueError):
+        return default
+
+
+def _ratio(value: Any) -> Decimal | None:
+    if value in (None, "") or isinstance(value, bool):
+        return None
+    number = Decimal(str(value))
+    if number > 1 and number <= 100:
+        number /= 100
+    if number < 0 or number > 1:
+        raise ValueError("ratio 必须在 0~1")
+    return number.quantize(Decimal("0.000001"))
+
+
+def _candidate_dict(row: FindAgentV2Candidate) -> dict[str, Any]:
+    return {
+        "candidate_id": int(row.id),
+        "aweme_id": row.aweme_id,
+        "title": row.title,
+        "content_link": row.content_link,
+        "author_name": row.author_name,
+        "author_sec_uid": row.author_sec_uid,
+        "source_keywords": _loads(row.source_keywords_json, []),
+        "tags": _loads(row.tags_json, []),
+        "publish_at": row.publish_at.isoformat() if row.publish_at else None,
+        "duration_seconds": float(row.duration_seconds) if row.duration_seconds is not None else None,
+        "play_count": row.play_count,
+        "like_count": row.like_count,
+        "comment_count": row.comment_count,
+        "collect_count": row.collect_count,
+        "share_count": row.share_count,
+        "detail_status": row.detail_status,
+        "portrait_status": row.portrait_status,
+        "content_50_plus_ratio": float(row.content_50_plus_ratio) if row.content_50_plus_ratio is not None else None,
+        "account_50_plus_ratio": float(row.account_50_plus_ratio) if row.account_50_plus_ratio is not None else None,
+        "relevance_score": float(row.relevance_score) if row.relevance_score is not None else None,
+        "elder_score": float(row.elder_score) if row.elder_score is not None else None,
+        "share_score": float(row.share_score) if row.share_score is not None else None,
+        "value_score": float(row.value_score) if row.value_score is not None else None,
+        "gate_status": row.gate_status,
+        "gate_result": _loads(row.gate_result_json, {}),
+        "decision_bucket": row.decision_bucket,
+        "decision_reason": row.decision_reason,
+        "reject_reason_code": row.reject_reason_code,
+    }
+
+
+class FindAgentV2Service:
+    def create_run(
+        self,
+        *,
+        user_input: str,
+        demand_word: str,
+        demand_grade_id: int | None = None,
+        run_id: str | None = None,
+        rule_config: dict[str, Any] | None = None,
+    ) -> str:
+        run_key = str(run_id or uuid.uuid4().hex)[:64]
+        with get_session() as session:
+            exists = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_key))
+            if exists is not None:
+                raise ValueError(f"find_agent_v2 run_id 已存在: {run_key}")
+            rules = rule_config or build_rule_snapshot()
+            session.add(FindAgentV2Run(
+                run_id=run_key,
+                demand_grade_id=demand_grade_id,
+                demand_word=str(demand_word)[:256],
+                input_json=_json({"user_input": user_input}),
+                rule_config_json=_json(rules),
+                status="running",
+            ))
+        return run_key
+
+    def lookup_run(self, run_id: str) -> dict[str, Any] | None:
+        with get_session() as session:
+            row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if row is None:
+                return None
+            return {
+                "run_id": row.run_id,
+                "demand_grade_id": row.demand_grade_id,
+                "demand_word": row.demand_word,
+                "status": row.status,
+                "outcome_status": row.outcome_status,
+                "current_round": row.current_round,
+                "search_count": row.search_count,
+                "candidate_count": row.candidate_count,
+                "valid_primary_count": row.valid_primary_count,
+                "intent_summary": row.intent_summary,
+                "stop_reason": row.stop_reason,
+                "obagent_run_uid": row.obagent_run_uid,
+                "rule_config": _loads(row.rule_config_json, {}),
+            }
+
+    def set_obagent_run_uid(self, run_id: str, run_uid: str | None) -> None:
+        if not run_uid:
+            return
+        with get_session() as session:
+            row = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if row is None:
+                raise FindAgentV2RunNotFound(run_id)
+            row.obagent_run_uid = str(run_uid)[:64]
+
+    def require_run(self, run_id: str) -> dict[str, Any]:
+        run = self.lookup_run(run_id)
+        if run is None:
+            raise FindAgentV2RunNotFound(f"find_agent_v2 run_id 不存在: {run_id}")
+        return run
+
+    def get_run_user_input(self, run_id: str) -> str:
+        """Return the immutable task input stored when a v2 run was prepared."""
+        with get_session() as session:
+            row = session.scalar(select(FindAgentV2Run).where(
+                FindAgentV2Run.run_id == run_id
+            ))
+            if row is None:
+                raise FindAgentV2RunNotFound(run_id)
+            user_input = _loads(row.input_json, {}).get("user_input")
+            if not isinstance(user_input, str) or not user_input.strip():
+                raise ValueError(f"run_id={run_id} 缺少有效 user_input")
+            return user_input
+
+    def begin_round(self, run_id: str, round_index: int, snapshot: DiscoverySnapshot) -> None:
+        with get_session() as session:
+            run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if run is None:
+                raise FindAgentV2RunNotFound(run_id)
+            run.current_round = int(round_index)
+            session.add(FindAgentV2Round(
+                run_id=run_id,
+                round_index=int(round_index),
+                phase="planning",
+                status="open",
+                start_snapshot_json=_json(asdict(snapshot)),
+            ))
+
+    def update_round(
+        self,
+        run_id: str,
+        round_index: int,
+        *,
+        phase: str | None = None,
+        plan: str | None = None,
+        status: str | None = None,
+        snapshot: DiscoverySnapshot | None = None,
+        error: str | None = None,
+    ) -> None:
+        with get_session() as session:
+            row = session.scalar(select(FindAgentV2Round).where(
+                FindAgentV2Round.run_id == run_id,
+                FindAgentV2Round.round_index == int(round_index),
+            ))
+            if row is None:
+                raise FindAgentV2RunNotFound(f"round 不存在: {run_id}/{round_index}")
+            if phase is not None:
+                row.phase = phase
+            if plan is not None:
+                row.plan_json = plan
+            if status is not None:
+                row.status = status
+            if snapshot is not None:
+                row.end_snapshot_json = _json(asdict(snapshot))
+            if error is not None:
+                row.error_message = error[:2000]
+
+    def save_search(
+        self,
+        *,
+        run_id: str,
+        round_index: int,
+        keyword: str,
+        query_reason: str,
+        source_type: str,
+        provider: str,
+        cursor: str,
+        page_no: int,
+        payload: dict[str, Any],
+    ) -> dict[str, Any]:
+        results = list(payload.get("search_results") or [])
+        with get_session() as session:
+            run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if run is None:
+                raise FindAgentV2RunNotFound(run_id)
+            search = FindAgentV2Search(
+                run_id=run_id,
+                round_index=round_index,
+                keyword=keyword[:256],
+                query_reason=query_reason,
+                source_type=source_type[:32],
+                provider=provider[:32],
+                cursor=str(cursor)[:128],
+                page_no=page_no,
+                has_more=int(bool(payload.get("has_more"))),
+                next_cursor=str(payload.get("next_cursor") or "")[:128] or None,
+                provider_state_json=_json({
+                    "search_id": payload.get("search_id"),
+                    "backtrace": payload.get("backtrace"),
+                }),
+                result_count=len(results),
+                status="failed" if payload.get("error") else "success",
+                error_message=str(payload.get("error") or "") or None,
+                raw_response_json=_json(payload),
+            )
+            session.add(search)
+            session.flush()
+            new_count = 0
+            for item in results:
+                aweme_id = str(item.get("aweme_id") or "").strip()
+                if not aweme_id:
+                    continue
+                candidate = session.scalar(select(FindAgentV2Candidate).where(
+                    FindAgentV2Candidate.run_id == run_id,
+                    FindAgentV2Candidate.aweme_id == aweme_id,
+                ))
+                author = item.get("author") if isinstance(item.get("author"), dict) else {}
+                stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
+                if candidate is None:
+                    candidate = FindAgentV2Candidate(
+                        run_id=run_id,
+                        first_search_id=search.id,
+                        aweme_id=aweme_id,
+                        decision_bucket="pending_evaluation",
+                    )
+                    session.add(candidate)
+                    new_count += 1
+                keywords = _loads(candidate.source_keywords_json, [])
+                if keyword not in keywords:
+                    keywords.append(keyword)
+                candidate.source_keywords_json = _json(keywords)
+                candidate.title = str(item.get("desc") or item.get("title") or candidate.title or "")[:512] or None
+                candidate.content_link = str(item.get("url") or candidate.content_link or "")[:1024] or None
+                candidate.author_name = str(author.get("nickname") or candidate.author_name or "")[:256] or None
+                candidate.author_sec_uid = str(author.get("sec_uid") or candidate.author_sec_uid or "")[:256] or None
+                candidate.tags_json = _json(item.get("topics") or item.get("tags") or [])
+                duration_ms = item.get("duration_ms")
+                if duration_ms:
+                    candidate.duration_seconds = Decimal(str(duration_ms)) / 1000
+                candidate.play_count = stats.get("play_count") or candidate.play_count
+                candidate.like_count = stats.get("digg_count") or stats.get("like_count") or candidate.like_count
+                candidate.comment_count = stats.get("comment_count") or candidate.comment_count
+                candidate.collect_count = stats.get("collect_count") or candidate.collect_count
+                candidate.share_count = stats.get("share_count") or candidate.share_count
+            run.search_count = int(session.scalar(select(func.count()).select_from(FindAgentV2Search).where(FindAgentV2Search.run_id == run_id)) or 0)
+            session.flush()
+            run.candidate_count = int(session.scalar(select(func.count()).select_from(FindAgentV2Candidate).where(FindAgentV2Candidate.run_id == run_id)) or 0)
+            return {"search_id": int(search.id), "new_candidate_count": new_count, "result_count": len(results)}
+
+    def candidate_inputs(self, run_id: str, candidate_ids: list[int]) -> list[dict[str, Any]]:
+        with get_session() as session:
+            rows = list(session.scalars(select(FindAgentV2Candidate).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.id.in_([int(v) for v in candidate_ids]),
+            )))
+            return [_candidate_dict(row) for row in rows]
+
+    def save_details(self, run_id: str, details: list[dict[str, Any]], errors: list[dict[str, Any]]) -> None:
+        by_id = {str(item.get("content_id") or ""): item for item in details}
+        error_by_id = {str(item.get("content_id") or ""): item for item in errors}
+        with get_session() as session:
+            rows = list(session.scalars(select(FindAgentV2Candidate).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.aweme_id.in_(list(by_id) + list(error_by_id)),
+            )))
+            for row in rows:
+                detail = by_id.get(row.aweme_id)
+                error = error_by_id.get(row.aweme_id)
+                if detail:
+                    row.detail_status = "success"
+                    row.detail_json = _json(detail)
+                    row.title = str(detail.get("title") or detail.get("body_text") or row.title or "")[:512] or None
+                    row.content_link = str(detail.get("content_link") or row.content_link or "")[:1024] or None
+                    row.author_name = str(detail.get("channel_account_name") or row.author_name or "")[:256] or None
+                    row.author_sec_uid = str(detail.get("channel_account_id") or row.author_sec_uid or "")[:256] or None
+                    row.tags_json = _json(detail.get("topic_list") or [])
+                    parsed = parse_datetime_value(detail.get("publish_at"))
+                    row.publish_at = parsed.replace(tzinfo=None) if parsed else None
+                    row.duration_seconds = detail.get("duration_seconds") or None
+                    for key in ("play_count", "like_count", "comment_count", "collect_count", "share_count"):
+                        value = detail.get(key)
+                        if value is not None:
+                            setattr(row, key, value)
+                    status, raw = "success", detail
+                else:
+                    row.detail_status = "failed"
+                    status, raw = "failed", error or {}
+                session.add(FindAgentV2Evidence(
+                    run_id=run_id,
+                    candidate_id=row.id,
+                    evidence_type="detail",
+                    provider="crawler",
+                    status=status,
+                    raw_json=_json(raw),
+                    error_message=str((error or {}).get("error") or "") or None,
+                ))
+
+    def save_portraits(self, run_id: str, results: list[dict[str, Any]]) -> None:
+        with get_session() as session:
+            for item in results:
+                aweme_id = str(item.get("aweme_id") or "")
+                row = session.scalar(select(FindAgentV2Candidate).where(
+                    FindAgentV2Candidate.run_id == run_id,
+                    FindAgentV2Candidate.aweme_id == aweme_id,
+                ))
+                if row is None:
+                    continue
+                normalization = item.get("age_normalization") or {}
+                content = normalization.get("content") or {}
+                account = normalization.get("account") or {}
+                row.portrait_json = _json(item)
+                row.portrait_status = "failed" if item.get("error") else "success"
+                row.content_50_plus_ratio = _ratio(
+                    content.get("older_ratio") if content.get("has_age_portrait") else None
+                )
+                row.account_50_plus_ratio = _ratio(
+                    account.get("older_ratio") if account.get("has_age_portrait") else None
+                )
+                session.add(FindAgentV2Evidence(
+                    run_id=run_id,
+                    candidate_id=row.id,
+                    evidence_type="portrait",
+                    provider="douhot",
+                    status=row.portrait_status,
+                    raw_json=_json(item),
+                    normalized_json=_json(normalization),
+                    error_message=str(item.get("error") or "") or None,
+                ))
+
+    def evaluate(self, run_id: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+        run_data = self.require_run(run_id)
+        output: list[dict[str, Any]] = []
+        with get_session() as session:
+            run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            assert run is not None
+            for item in items:
+                row = session.scalar(select(FindAgentV2Candidate).where(
+                    FindAgentV2Candidate.run_id == run_id,
+                    FindAgentV2Candidate.id == int(item.get("candidate_id") or 0),
+                ))
+                if row is None:
+                    raise ValueError(f"candidate_id 不属于 run: {item.get('candidate_id')}")
+                for key in ("relevance_score", "elder_score", "share_score", "value_score"):
+                    setattr(row, key, _ratio(item.get(key)))
+                requested = str(item.get("decision_bucket") or "rejected")
+                if requested not in {"primary", "rejected"}:
+                    raise ValueError("decision_bucket 只能是 primary/rejected")
+                gate_input = _candidate_dict(row)
+                gate = evaluate_candidate_gate(gate_input, run_data["rule_config"])
+                row.gate_status = gate["status"]
+                row.gate_result_json = _json(gate)
+                row.decision_bucket = "primary" if requested == "primary" and gate["status"] == "pass" else "rejected"
+                row.decision_reason = str(item.get("decision_reason") or "")
+                failed = list(gate.get("failed_reason_codes") or [])
+                row.reject_reason_code = (str(item.get("reject_reason_code") or "") or (failed[0] if failed else None))
+                output.append({"candidate_id": int(row.id), "decision_bucket": row.decision_bucket, "gate_status": row.gate_status})
+            session.flush()
+            primaries = list(session.scalars(select(FindAgentV2Candidate).where(
+                FindAgentV2Candidate.run_id == run_id,
+                FindAgentV2Candidate.decision_bucket == "primary",
+            )))
+            run.valid_primary_count = len({row.aweme_id for row in primaries if row.gate_status == "pass"})
+        return output
+
+    def get_full_state(
+        self, run_id: str, *, limit: int = 100, pending_only: bool = False,
+    ) -> dict[str, Any]:
+        run = self.require_run(run_id)
+        with get_session() as session:
+            searches = list(session.scalars(select(FindAgentV2Search).where(
+                FindAgentV2Search.run_id == run_id,
+            ).order_by(FindAgentV2Search.id)))
+            candidate_query = select(FindAgentV2Candidate).where(
+                FindAgentV2Candidate.run_id == run_id,
+            )
+            if pending_only:
+                candidate_query = candidate_query.where(
+                    FindAgentV2Candidate.decision_bucket == "pending_evaluation",
+                )
+            candidates = list(session.scalars(candidate_query.order_by(
+                FindAgentV2Candidate.value_score.desc(), FindAgentV2Candidate.id,
+            ).limit(max(1, min(limit, 500)))))
+            return {
+                "run": run,
+                "searches": [{
+                    "search_id": int(row.id), "round_index": row.round_index,
+                    "keyword": row.keyword, "query_reason": row.query_reason,
+                    "provider": row.provider, "page_no": row.page_no,
+                    "has_more": bool(row.has_more), "next_cursor": row.next_cursor,
+                    "status": row.status, "result_count": row.result_count,
+                } for row in searches],
+                "candidates": [_candidate_dict(row) for row in candidates],
+            }
+
+    def snapshot(self, run_id: str) -> DiscoverySnapshot:
+        state = self.get_full_state(run_id)
+        candidates = state["candidates"]
+        buckets = [item["decision_bucket"] for item in candidates]
+        run = state["run"]
+        return DiscoverySnapshot(
+            status=run["status"],
+            search_count=run["search_count"],
+            candidate_count=run["candidate_count"],
+            pending_count=sum(value == "pending_evaluation" for value in buckets),
+            primary_count=sum(value == "primary" for value in buckets),
+            valid_primary_count=run["valid_primary_count"],
+            rejected_count=sum(value == "rejected" for value in buckets),
+            outcome_status=run.get("outcome_status") or "",
+        )
+
+    def finalize(self, run_id: str, *, failed: bool = False, reason: str = "") -> dict[str, Any]:
+        snapshot = self.snapshot(run_id)
+        if failed:
+            outcome, status = "failed", "failed"
+        elif snapshot.valid_primary_count >= 5:
+            outcome, status = "goal_met", "finished"
+        elif snapshot.valid_primary_count > 0:
+            outcome, status = "partial", "finished"
+        else:
+            outcome, status = "no_match", "finished"
+        with get_session() as session:
+            run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+            if run is None:
+                raise FindAgentV2RunNotFound(run_id)
+            run.status = status
+            run.outcome_status = outcome
+            run.stop_reason = reason[:2000] or None
+        return self.require_run(run_id)
+
+
+_SERVICE = FindAgentV2Service()
+
+
+def get_find_agent_v2_service() -> FindAgentV2Service:
+    return _SERVICE

+ 95 - 0
find_agent_v2/state.py

@@ -0,0 +1,95 @@
+"""Thin workflow state.
+
+Search pages, candidates, evidence and final buckets stay in the existing database.
+This state only contains orchestration pointers and audit summaries.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+from supply_agent.types import AgentResult
+
+Phase = Literal["planning", "searching", "evidence", "evaluating", "done"]
+EndKind = Literal["goal_met", "partial", "no_match", "failed", "stopped"]
+
+
+@dataclass(frozen=True)
+class DiscoverySnapshot:
+    """Small deterministic projection of the database state."""
+
+    status: str
+    search_count: int
+    candidate_count: int
+    pending_count: int
+    primary_count: int
+    valid_primary_count: int
+    rejected_count: int
+    outcome_status: str = ""
+
+
+@dataclass(frozen=True)
+class NodeRun:
+    """One node's observable result."""
+
+    node: str
+    round_index: int
+    content: str
+    iterations: int
+    tool_calls_made: int
+
+    @classmethod
+    def from_agent_result(
+        cls,
+        node: str,
+        round_index: int,
+        result: AgentResult,
+    ) -> "NodeRun":
+        return cls(
+            node=node,
+            round_index=round_index,
+            content=result.content or "",
+            iterations=int(result.iterations or 0),
+            tool_calls_made=int(result.tool_calls_made or 0),
+        )
+
+
+@dataclass
+class FindAgentState:
+    """Control state passed through the single-round graph."""
+
+    run_id: str
+    user_input: str
+    round_index: int = 0
+    phase: Phase = "planning"
+    plan: str = ""
+    stop: bool = False
+    stop_reason: str = ""
+    previous_snapshot: DiscoverySnapshot | None = None
+    snapshot: DiscoverySnapshot | None = None
+    node_runs: list[NodeRun] = field(default_factory=list)
+    failures: list[dict[str, Any]] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class FindAgentResult:
+    """Workflow result with technical and business status kept separate."""
+
+    run_id: str
+    status: EndKind
+    succeeded: bool
+    business_outcome: str
+    valid_primary_count: int
+    rounds: int
+    final_output: str
+    node_runs: tuple[NodeRun, ...]
+    stop_reason: str = ""
+
+    @property
+    def iterations(self) -> int:
+        return sum(item.iterations for item in self.node_runs)
+
+    @property
+    def tool_calls_made(self) -> int:
+        return sum(item.tool_calls_made for item in self.node_runs)

+ 60 - 0
find_agent_v2/test_entry.py

@@ -0,0 +1,60 @@
+"""Local CLI for preparing or executing one database-backed v2 test run."""
+
+from __future__ import annotations
+
+import argparse
+import json
+
+from find_agent_v2.demand_context import prepare_v2_demand_run
+from find_agent_v2.runner import run_find_agent_v2, run_prepared_find_agent_v2
+from find_agent_v2.service import get_find_agent_v2_service
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="准备或执行一条寻找 Agent v2 本地测试任务")
+    parser.add_argument("--demand-grade-id", type=int, default=None)
+    parser.add_argument("--index", type=int, default=0, help="未指定需求 ID 时选择最新 S 级上下文序号")
+    parser.add_argument("--run-id", default=None)
+    parser.add_argument("--existing-run-id", default=None, help="读取并执行已准备的 v2 run")
+    parser.add_argument("--execute", action="store_true", help="准备后立即调用模型和外部搜索")
+    parser.add_argument("--model", default=None)
+    args = parser.parse_args()
+
+    if args.existing_run_id:
+        run = get_find_agent_v2_service().require_run(args.existing_run_id)
+        prepared = None
+        output: dict = {"prepared": {
+            "run_id": run["run_id"],
+            "demand_grade_id": run["demand_grade_id"],
+            "demand_name": run["demand_word"],
+            "status": run["status"],
+        }, "executed": False}
+    else:
+        prepared = prepare_v2_demand_run(
+            demand_grade_id=args.demand_grade_id,
+            index=args.index,
+            run_id=args.run_id,
+        )
+        output = {"prepared": prepared.summary(), "executed": False}
+    if args.execute:
+        result = (
+            run_prepared_find_agent_v2(args.existing_run_id, model=args.model)
+            if args.existing_run_id
+            else run_find_agent_v2(
+                prepared.user_input,
+                run_id=prepared.run_id,
+                model=args.model,
+            )
+        )
+        output.update({"executed": True, "result": {
+            "status": result.status,
+            "succeeded": result.succeeded,
+            "valid_primary_count": result.valid_primary_count,
+            "stop_reason": result.stop_reason,
+            "final_output": result.final_output,
+        }})
+    print(json.dumps(output, ensure_ascii=False, default=str, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 153 - 0
find_agent_v2/tools.py

@@ -0,0 +1,153 @@
+"""Stage tools that persist exclusively to ``find_agent_v2_*`` tables."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable, Iterable
+from typing import Any
+
+from find_agent_v2.providers import (
+    fetch_details,
+    fetch_portraits,
+    search_internal,
+    search_tikhub,
+)
+from find_agent_v2.service import get_find_agent_v2_service
+from supply_agent.tools import tool
+from supply_agent.tools.registry import ToolRegistry
+
+ToolFn = Callable[..., Any]
+
+
+@tool
+async def search_videos_v2(run_id: str, round_index: int, searches: list[dict[str, Any]]) -> str:
+    """批量搜索并仅写入 find_agent_v2_search/candidate;searches 最多 6 项。"""
+    if not searches or len(searches) > 6:
+        return json.dumps({"error": "searches 必须为 1~6 项"}, ensure_ascii=False)
+    service = get_find_agent_v2_service()
+    outputs: list[dict[str, Any]] = []
+    for raw_task in searches:
+        keyword = str(raw_task.get("keyword") or "").strip()
+        reason = str(raw_task.get("query_reason") or "").strip()
+        provider = str(raw_task.get("provider") or "internal_keyword")
+        if not keyword or not reason:
+            outputs.append({"error": "keyword/query_reason 不能为空"})
+            continue
+        max_pages = max(1, min(int(raw_task.get("max_pages") or 1), 2))
+        cursor: str | int = raw_task.get("cursor") or 0
+        provider_search_id = str(raw_task.get("search_id") or "")
+        backtrace = str(raw_task.get("backtrace") or "")
+        for page_no in range(1, max_pages + 1):
+            common = {
+                "keyword": keyword,
+                "content_type": str(raw_task.get("content_type") or "视频"),
+                "sort_type": str(raw_task.get("sort_type") or "综合排序"),
+                "publish_time": str(raw_task.get("publish_time") or "不限"),
+                "min_duration_seconds": int(raw_task.get("min_duration_seconds") or 30),
+            }
+            if provider == "tikhub":
+                payload = await search_tikhub(
+                    **common,
+                    cursor=int(cursor or 0),
+                    filter_duration=str(raw_task.get("filter_duration") or "不限"),
+                    search_id=provider_search_id,
+                    backtrace=backtrace,
+                )
+            else:
+                provider = "internal_keyword"
+                payload = await search_internal(**common, cursor=str(cursor or "0"))
+            saved = service.save_search(
+                run_id=run_id,
+                round_index=int(round_index),
+                keyword=keyword,
+                query_reason=reason,
+                source_type=str(raw_task.get("source_type") or "mixed"),
+                provider=provider,
+                cursor=str(cursor),
+                page_no=page_no,
+                payload=payload,
+            )
+            outputs.append({
+                "keyword": keyword,
+                "provider": provider,
+                "page_no": page_no,
+                "error": payload.get("error"),
+                "has_more": bool(payload.get("has_more")),
+                "next_cursor": payload.get("next_cursor"),
+                **saved,
+            })
+            if payload.get("error") or not payload.get("has_more"):
+                break
+            cursor = payload.get("next_cursor") or cursor
+            provider_search_id = str(payload.get("search_id") or provider_search_id)
+            backtrace = str(payload.get("backtrace") or backtrace)
+    return json.dumps({"run_id": run_id, "searches": outputs}, ensure_ascii=False)
+
+
+@tool
+async def fetch_candidate_details_v2(run_id: str, candidate_ids: list[int]) -> str:
+    """批量获取候选详情并仅写入 find_agent_v2_candidate/evidence;最多 8 项。"""
+    service = get_find_agent_v2_service()
+    candidates = service.candidate_inputs(run_id, candidate_ids[:8])
+    payload = await fetch_details([item["aweme_id"] for item in candidates])
+    service.save_details(run_id, list(payload.get("details") or []), list(payload.get("errors") or []))
+    return json.dumps({
+        "run_id": run_id,
+        "success_count": int(payload.get("success_count") or len(payload.get("details") or [])),
+        "failed_count": int(payload.get("failed_count") or len(payload.get("errors") or [])),
+        "errors": payload.get("errors") or [],
+    }, ensure_ascii=False)
+
+
+@tool
+async def fetch_candidate_portraits_v2(run_id: str, candidate_ids: list[int]) -> str:
+    """批量获取候选双侧年龄画像并仅写入 find_agent_v2_candidate/evidence。"""
+    service = get_find_agent_v2_service()
+    candidates = service.candidate_inputs(run_id, candidate_ids[:8])
+    payload = await fetch_portraits([{
+            "aweme_id": item["aweme_id"],
+            "author_sec_uid": item.get("author_sec_uid"),
+        } for item in candidates])
+    results = list(payload.get("results") or [])
+    service.save_portraits(run_id, results)
+    return json.dumps({"run_id": run_id, "count": len(results), "results": results}, ensure_ascii=False)
+
+
+@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)
+    return json.dumps({"run_id": run_id, "updated": updated}, ensure_ascii=False)
+
+
+@tool
+def query_find_agent_v2_state(run_id: str, limit: int = 100) -> str:
+    """查询完全隔离的 find_agent_v2 运行、搜索与候选状态。"""
+    state = get_find_agent_v2_service().get_full_state(run_id, limit=limit)
+    return json.dumps(state, ensure_ascii=False, default=str)
+
+
+@tool
+def query_pending_candidates_v2(run_id: str, limit: int = 100) -> str:
+    """仅查询当前 run 尚未分池的 pending_evaluation 候选。"""
+    state = get_find_agent_v2_service().get_full_state(
+        run_id, limit=limit, pending_only=True,
+    )
+    return json.dumps(state, ensure_ascii=False, default=str)
+
+
+SEARCH_TOOLS: tuple[ToolFn, ...] = (search_videos_v2, query_find_agent_v2_state)
+EVIDENCE_TOOLS: tuple[ToolFn, ...] = (
+    fetch_candidate_details_v2,
+    fetch_candidate_portraits_v2,
+    query_pending_candidates_v2,
+)
+EVALUATION_TOOLS: tuple[ToolFn, ...] = (
+    evaluate_candidates_v2,
+    query_pending_candidates_v2,
+)
+REPORT_TOOLS: tuple[ToolFn, ...] = (query_find_agent_v2_state,)
+
+
+def build_tool_registry(functions: Iterable[ToolFn]) -> ToolRegistry:
+    return ToolRegistry().from_decorated(*tuple(functions))

+ 3 - 1
pyproject.toml

@@ -24,6 +24,7 @@ dependencies = [
     "fastapi>=0.115.0",
     "uvicorn[standard]>=0.32.0",
     "markdown>=3.6",
+    "obagent-sdk>=0.5.6",
 ]
 
 [project.optional-dependencies]
@@ -36,13 +37,14 @@ dev = [
 ]
 
 [tool.hatch.build.targets.wheel]
-packages = ["supply_agent", "supply_infra", "agents", "api"]
+packages = ["supply_agent", "supply_infra", "agents", "api", "find_agent_v2"]
 
 [project.scripts]
 supply-visualize = "supply_agent.logging.cli:main"
 supply-api = "api.run:main"
 supply-scheduler = "supply_infra.scheduler.__main__:main"
 supply-pipeline = "supply_infra.pipeline.cli:main"
+find-agent-v2-test = "find_agent_v2.test_entry:main"
 
 [tool.ruff]
 line-length = 100

+ 1 - 0
requirements.txt

@@ -9,6 +9,7 @@ imageio-ffmpeg>=0.5.1
 rich>=13.0
 python-dotenv>=1.0.0
 markdown>=3.6
+obagent-sdk>=0.5.6
 
 # Database
 sqlalchemy>=2.0

+ 1 - 0
supply_infra/db/session.py

@@ -84,6 +84,7 @@ def ensure_mysql_pool_capacity(min_connections: int) -> None:
 def init_db() -> dict[str, list[str]]:
     """Create all tables (dev / first-run). Import models before calling."""
     import supply_infra.db.models  # noqa: F401 — register all models
+    import find_agent_v2.models  # noqa: F401 — isolated find_agent_v2 tables
 
     engine = get_engine()
     inspector = inspect(engine)

+ 334 - 0
tests/supply_agent/test_find_agent_v2.py

@@ -0,0 +1,334 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from find_agent_v2.graph import FindAgentRoundGraph
+from find_agent_v2.demand_context import (
+    V2DemandContext,
+    V2ReferencePoint,
+    V2ReferenceVideo,
+    _points_from_expansions,
+    build_v2_user_input,
+)
+from find_agent_v2.gates import build_rule_snapshot, evaluate_candidate_gate
+from find_agent_v2.models import (
+    FindAgentV2Candidate,
+    FindAgentV2Evidence,
+    FindAgentV2Round,
+    FindAgentV2Run,
+    FindAgentV2Search,
+)
+from find_agent_v2.observability import (
+    GRAPH_SPEC,
+    MODULE_TITLES,
+    OBAGENT_AGENT,
+    OBAGENT_PROJECT,
+    OBAGENT_ROUND_ANCHOR,
+)
+from find_agent_v2.prompts import COMMON_RULES
+from find_agent_v2.providers import normalize_age_pair
+from find_agent_v2.state import DiscoverySnapshot, FindAgentState, NodeRun
+from find_agent_v2.tools import (
+    EVALUATION_TOOLS,
+    EVIDENCE_TOOLS,
+    REPORT_TOOLS,
+    SEARCH_TOOLS,
+)
+
+
+def _names(functions) -> set[str]:
+    return {getattr(fn, "_tool_name", fn.__name__) for fn in functions}
+
+
+def test_v2_orm_uses_only_new_table_namespace() -> None:
+    assert {
+        FindAgentV2Run.__tablename__,
+        FindAgentV2Round.__tablename__,
+        FindAgentV2Search.__tablename__,
+        FindAgentV2Candidate.__tablename__,
+        FindAgentV2Evidence.__tablename__,
+    } == {
+        "find_agent_v2_run",
+        "find_agent_v2_round",
+        "find_agent_v2_search",
+        "find_agent_v2_candidate",
+        "find_agent_v2_evidence",
+    }
+    assert "obagent_run_uid" in FindAgentV2Run.__table__.columns
+
+
+def test_v2_package_has_no_legacy_business_imports() -> None:
+    package = Path(__file__).parents[2] / "find_agent_v2"
+    source = "\n".join(path.read_text(encoding="utf-8") for path in package.glob("*.py"))
+    assert "agents.find_agent" not in source
+    assert "video_discovery_gates" not in source
+    assert "services.video_discovery" not in source
+    assert "system_prompt.md" not in source
+
+
+def test_v2_owns_age_portrait_normalization() -> None:
+    normalized = normalize_age_pair(
+        {"年龄": {"50-": {"percentage": "35%", "preference": 120}}},
+        {"年龄": {"50岁以上": {"percentage": 0.25, "preference": 110}}},
+    )
+    assert normalized["content"]["older_ratio"] == 0.35
+    assert normalized["account"]["older_ratio"] == 0.25
+    assert normalized["consistency"] == "aligned"
+
+
+def test_v2_owns_primary_candidate_gate() -> None:
+    rules = build_rule_snapshot()
+    candidate = {
+        "title": "适合父母的实用生活建议",
+        "publish_at": rules["current_datetime"],
+        "duration_seconds": 60,
+        "share_count": 2000,
+        "content_50_plus_ratio": 0.35,
+        "account_50_plus_ratio": 0.25,
+        "relevance_score": 0.8,
+        "elder_score": 0.8,
+        "share_score": 0.8,
+        "value_score": 0.8,
+    }
+    result = evaluate_candidate_gate(candidate, rules)
+    assert result["status"] == "pass"
+    assert result["primary_eligible"] is True
+
+
+def test_v2_gate_rejects_explicitly_low_evidence() -> None:
+    rules = build_rule_snapshot()
+    candidate = {
+        "title": "普通视频",
+        "publish_at": rules["current_datetime"],
+        "duration_seconds": 10,
+        "share_count": 3,
+        "content_50_plus_ratio": 0.01,
+        "account_50_plus_ratio": 0.02,
+    }
+    result = evaluate_candidate_gate(candidate, rules)
+    assert result["status"] == "fail"
+    assert {"DURATION_TOO_SHORT", "SHARE_COUNT_TOO_LOW", "PORTRAIT_50_PLUS_TOO_LOW"} <= set(
+        result["failed_reason_codes"]
+    )
+
+
+def test_v2_context_deduplicates_expansion_points() -> None:
+    rows = [
+        SimpleNamespace(
+            video_id="v1", point_type="purpose", expanded_text="照顾父母",
+            point_desc="描述一",
+        ),
+        SimpleNamespace(
+            video_id="v1", point_type="purpose", expanded_text="照顾父母",
+            point_desc="重复描述",
+        ),
+        SimpleNamespace(
+            video_id="v1", point_type="invalid", expanded_text="忽略",
+            point_desc=None,
+        ),
+    ]
+    order, points = _points_from_expansions(rows)
+    assert order == ["v1"]
+    assert len(points["v1"]) == 1
+    assert points["v1"][0].point == "照顾父母"
+
+
+def test_v2_context_builds_self_contained_user_input() -> None:
+    context = V2DemandContext(
+        biz_dt="20260811",
+        demand_grade_id=123,
+        demand_name="测试需求",
+        grade="S",
+        score=95.0,
+        videos=[V2ReferenceVideo(
+            video_id="video-1",
+            title="参考视频",
+            points=[V2ReferencePoint("关键内容", "key", "关键描述")],
+        )],
+    )
+    raw = build_v2_user_input(
+        context,
+        run_id="v2-test-run",
+        rules={"rule_version": "v2-test"},
+    )
+    assert '"run_id": "v2-test-run"' in raw
+    assert '"demand_grade_id": 123' in raw
+    assert '"reference_videos"' in raw
+    assert '"关键内容"' in raw
+
+
+def test_obagent_identity_and_round_structure_are_stable() -> None:
+    assert OBAGENT_PROJECT == "find_agent_v2"
+    assert OBAGENT_AGENT == "find_agent_v2"
+    assert OBAGENT_ROUND_ANCHOR == {"in": "run", "on": ["graph"]}
+    assert [node["key"] for node in GRAPH_SPEC["nodes"]] == [
+        "planner", "search", "evidence", "evaluator",
+    ]
+    assert set(MODULE_TITLES) == {"planner", "search", "evidence", "evaluator", "report"}
+
+
+def test_stage_tool_allowlists_are_physical_and_isolated() -> None:
+    assert _names(SEARCH_TOOLS) == {"search_videos_v2", "query_find_agent_v2_state"}
+    assert _names(EVIDENCE_TOOLS) == {
+        "fetch_candidate_details_v2",
+        "fetch_candidate_portraits_v2",
+        "query_pending_candidates_v2",
+    }
+    assert _names(EVALUATION_TOOLS) == {
+        "evaluate_candidates_v2",
+        "query_pending_candidates_v2",
+    }
+    assert _names(REPORT_TOOLS) == {"query_find_agent_v2_state"}
+    all_names = _names((*SEARCH_TOOLS, *EVIDENCE_TOOLS, *EVALUATION_TOOLS, *REPORT_TOOLS))
+    assert not any(name.startswith("batch_update_video_discovery") for name in all_names)
+    assert "query_video_discovery_state" not in all_names
+
+
+def test_common_prompt_points_to_v2_tables_and_tools() -> None:
+    assert "find_agent_v2_run" in COMMON_RULES
+    assert "video_discovery_run" not in COMMON_RULES
+    assert "batch_search_and_record" not in COMMON_RULES
+    assert "batch_update_video_discovery_candidates" not in COMMON_RULES
+
+
+class _FakeService:
+    def __init__(self, *, pending_after_search: int) -> None:
+        self.pending_after_search = pending_after_search
+        self.stage = "start"
+        self.updates: list[dict] = []
+
+    def get_full_state(self, run_id: str, **_kwargs):
+        return {"run": {"run_id": run_id}, "searches": [], "candidates": []}
+
+    def snapshot(self, _run_id: str) -> DiscoverySnapshot:
+        base = DiscoverySnapshot("running", 0, 0, 0, 0, 0, 0)
+        if self.stage == "searched":
+            return replace(base, search_count=1, candidate_count=self.pending_after_search,
+                           pending_count=self.pending_after_search)
+        if self.stage == "evaluated":
+            return replace(base, search_count=1, candidate_count=self.pending_after_search,
+                           rejected_count=self.pending_after_search)
+        return base
+
+    def update_round(self, _run_id: str, _round_index: int, **kwargs) -> None:
+        self.updates.append(kwargs)
+
+
+class _FakeRunner:
+    def __init__(self, service: _FakeService) -> None:
+        self.service = service
+        self.calls: list[tuple[str, set[str]]] = []
+
+    async def run_node(self, *, node, round_index, tools=(), **_kwargs) -> NodeRun:
+        self.calls.append((node, _names(tools)))
+        if node == "search":
+            self.service.stage = "searched"
+        elif node == "evaluator":
+            self.service.stage = "evaluated"
+        return NodeRun(node, round_index, '{"searches": []}', 1, 0)
+
+
+@pytest.mark.asyncio
+async def test_round_graph_runs_fixed_stage_order_and_allowlists() -> None:
+    service = _FakeService(pending_after_search=2)
+    runner = _FakeRunner(service)
+    graph = FindAgentRoundGraph(service=service, runner=runner)
+    state = FindAgentState(run_id="new-run", user_input="task", round_index=1)
+
+    result = await graph.invoke(state)
+
+    assert [name for name, _ in runner.calls] == ["planner", "search", "evidence", "evaluator"]
+    assert runner.calls[0][1] == set()
+    assert runner.calls[1][1] == _names(SEARCH_TOOLS)
+    assert runner.calls[2][1] == _names(EVIDENCE_TOOLS)
+    assert runner.calls[3][1] == _names(EVALUATION_TOOLS)
+    assert result.phase == "done"
+    assert result.snapshot is not None and result.snapshot.pending_count == 0
+
+
+@pytest.mark.asyncio
+async def test_round_graph_skips_evidence_and_evaluation_without_candidates() -> None:
+    service = _FakeService(pending_after_search=0)
+    runner = _FakeRunner(service)
+    graph = FindAgentRoundGraph(service=service, runner=runner)
+
+    await graph.invoke(FindAgentState(run_id="new-run", user_input="task", round_index=1))
+
+    assert [name for name, _ in runner.calls] == ["planner", "search"]
+
+
+@pytest.mark.asyncio
+async def test_round_graph_reenters_evaluator_until_pending_queue_is_empty() -> None:
+    service = _FakeService(pending_after_search=3)
+
+    class BatchedRunner(_FakeRunner):
+        def __init__(self, fake_service: _FakeService) -> None:
+            super().__init__(fake_service)
+            self.remaining = 3
+
+        async def run_node(self, *, node, round_index, tools=(), **kwargs) -> NodeRun:
+            result = await super().run_node(
+                node=node, round_index=round_index, tools=tools, **kwargs,
+            )
+            if node == "evaluator":
+                self.remaining -= 1
+                self.service.stage = "evaluated" if self.remaining == 0 else "batched"
+            return result
+
+    runner = BatchedRunner(service)
+    original_snapshot = service.snapshot
+
+    def batched_snapshot(run_id: str) -> DiscoverySnapshot:
+        if service.stage == "batched":
+            base = original_snapshot(run_id)
+            return replace(
+                base,
+                search_count=1,
+                candidate_count=3,
+                pending_count=runner.remaining,
+                rejected_count=3 - runner.remaining,
+            )
+        return original_snapshot(run_id)
+
+    service.snapshot = batched_snapshot  # type: ignore[method-assign]
+    graph = FindAgentRoundGraph(service=service, runner=runner)
+
+    result = await graph.invoke(
+        FindAgentState(run_id="batched-run", user_input="task", round_index=1),
+    )
+
+    assert [name for name, _ in runner.calls].count("evaluator") == 3
+    assert result.snapshot is not None and result.snapshot.pending_count == 0
+
+
+@pytest.mark.asyncio
+async def test_round_graph_retries_one_stagnant_evaluator_response() -> None:
+    service = _FakeService(pending_after_search=2)
+
+    class RetryRunner(_FakeRunner):
+        evaluator_calls = 0
+
+        async def run_node(self, *, node, round_index, tools=(), **kwargs) -> NodeRun:
+            if node == "evaluator":
+                self.calls.append((node, _names(tools)))
+                self.evaluator_calls += 1
+                if self.evaluator_calls == 2:
+                    self.service.stage = "evaluated"
+                return NodeRun(node, round_index, "", 1, 0)
+            return await super().run_node(
+                node=node, round_index=round_index, tools=tools, **kwargs,
+            )
+
+    runner = RetryRunner(service)
+    graph = FindAgentRoundGraph(service=service, runner=runner)
+
+    result = await graph.invoke(
+        FindAgentState(run_id="retry-run", user_input="task", round_index=1),
+    )
+
+    assert runner.evaluator_calls == 2
+    assert result.snapshot is not None and result.snapshot.pending_count == 0