فهرست منبع

增加可视化页面

xueyiming 1 هفته پیش
والد
کامیت
d3019221d5

+ 33 - 0
alembic/versions/20260813_15_add_find_agent_v2_trigger_audit.py

@@ -0,0 +1,33 @@
+"""add find_agent_v2 trigger audit fields
+
+Revision ID: 20260813_15
+Revises: 20260813_14
+Create Date: 2026-08-13
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260813_15"
+down_revision: str | None = "20260813_14"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column("find_agent_v2_run", sa.Column("triggered_by_user_id", sa.BigInteger(), nullable=True))
+    op.add_column("find_agent_v2_run", sa.Column("triggered_by_username", sa.String(64), nullable=True))
+    op.add_column(
+        "find_agent_v2_run",
+        sa.Column("trigger_source", sa.String(32), server_default="cli", nullable=False),
+    )
+
+
+def downgrade() -> None:
+    op.drop_column("find_agent_v2_run", "trigger_source")
+    op.drop_column("find_agent_v2_run", "triggered_by_username")
+    op.drop_column("find_agent_v2_run", "triggered_by_user_id")

+ 2 - 0
api/app.py

@@ -16,6 +16,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
 from api.auth_middleware import AuthenticationMiddleware
 from api.routers.auth import router as auth_router
 from api.routers.llm_billing import router as llm_billing_router
+from api.routers.find_agent_v2 import router as find_agent_v2_router
 from api.routers.pipeline import router as pipeline_router
 from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.services.agent_catalog import (
@@ -88,6 +89,7 @@ app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
 app.include_router(auth_router)
 app.include_router(pipeline_router)
 app.include_router(llm_billing_router)
+app.include_router(find_agent_v2_router)
 
 app.add_middleware(
     CORSMiddleware,

+ 42 - 0
api/routers/find_agent_v2.py

@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Request, status
+
+from api.schemas.find_agent_v2 import CreateFindAgentV2TestBody
+from api.services.find_agent_v2 import execute_test_run, get_run_detail, list_runs, prepare_test_run
+
+router = APIRouter(prefix="/api/find-agent-v2", tags=["find-agent-v2"])
+
+
+@router.get("/runs")
+def runs(
+    status_filter: str | None = Query(default=None, alias="status"),
+    keyword: str | None = Query(default=None, max_length=256),
+    limit: int = Query(default=30, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    return list_runs(status=status_filter, keyword=keyword, limit=limit, offset=offset)
+
+
+@router.get("/runs/{run_id}")
+def run_detail(run_id: str) -> dict:
+    result = get_run_detail(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="find_agent_v2 run not found")
+    return result
+
+
+@router.post("/tests", status_code=status.HTTP_202_ACCEPTED)
+def create_test(
+    body: CreateFindAgentV2TestBody,
+    request: Request,
+    background_tasks: BackgroundTasks,
+) -> dict:
+    try:
+        prepared = prepare_test_run(body.demand_word, request.state.current_user)
+    except LookupError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    background_tasks.add_task(execute_test_run, prepared["run_id"])
+    return {"accepted": True, **prepared}

+ 7 - 0
api/schemas/find_agent_v2.py

@@ -0,0 +1,7 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class CreateFindAgentV2TestBody(BaseModel):
+    demand_word: str = Field(min_length=1, max_length=256)

+ 197 - 0
api/services/find_agent_v2.py

@@ -0,0 +1,197 @@
+"""Admin-facing read model and execution service for find_agent_v2."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from sqlalchemy import func, or_, select
+
+from find_agent_v2.demand_context import prepare_latest_v2_demand_run_by_name
+from find_agent_v2.models import (
+    FindAgentV2Candidate,
+    FindAgentV2Evidence,
+    FindAgentV2Round,
+    FindAgentV2Run,
+    FindAgentV2Search,
+)
+from find_agent_v2.runner import run_prepared_find_agent_v2
+from find_agent_v2.service import get_find_agent_v2_service
+from supply_infra.db.session import get_session
+
+
+def _json(raw: str | None, default: Any) -> Any:
+    try:
+        return json.loads(raw) if raw else default
+    except (TypeError, ValueError):
+        return default
+
+
+def _time(value: Any) -> str | None:
+    return value.isoformat() if value is not None else None
+
+
+def _number(value: Any) -> float | None:
+    return float(value) if value is not None else None
+
+
+def _run(row: FindAgentV2Run) -> dict[str, Any]:
+    return {
+        "id": int(row.id),
+        "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,
+        "input_tokens": row.input_tokens,
+        "output_tokens": row.output_tokens,
+        "total_tokens": row.total_tokens,
+        "cost_usd": _number(row.cost_usd) or 0,
+        "intent_summary": row.intent_summary,
+        "stop_reason": row.stop_reason,
+        "obagent_run_uid": row.obagent_run_uid,
+        "triggered_by_user_id": row.triggered_by_user_id,
+        "triggered_by_username": row.triggered_by_username,
+        "trigger_source": row.trigger_source,
+        "create_time": _time(row.create_time),
+        "update_time": _time(row.update_time),
+    }
+
+
+def list_runs(
+    *, status: str | None = None, keyword: str | None = None,
+    limit: int = 30, offset: int = 0,
+) -> dict[str, Any]:
+    with get_session() as session:
+        conditions = []
+        if status:
+            conditions.append(FindAgentV2Run.status == status)
+        normalized = str(keyword or "").strip().lower()
+        if normalized:
+            pattern = f"%{normalized}%"
+            conditions.append(or_(
+                func.lower(FindAgentV2Run.demand_word).like(pattern),
+                func.lower(FindAgentV2Run.run_id).like(pattern),
+                func.lower(func.coalesce(FindAgentV2Run.triggered_by_username, "")).like(pattern),
+            ))
+        total = int(session.scalar(
+            select(func.count()).select_from(FindAgentV2Run).where(*conditions)
+        ) or 0)
+        rows = list(session.scalars(
+            select(FindAgentV2Run).where(*conditions)
+            .order_by(FindAgentV2Run.create_time.desc(), FindAgentV2Run.id.desc())
+            .limit(limit).offset(offset)
+        ))
+        return {"items": [_run(row) for row in rows], "total": total, "limit": limit, "offset": offset}
+
+
+def get_run_detail(run_id: str) -> dict[str, Any] | None:
+    with get_session() as session:
+        run = session.scalar(select(FindAgentV2Run).where(FindAgentV2Run.run_id == run_id))
+        if run is None:
+            return None
+        rounds = list(session.scalars(
+            select(FindAgentV2Round).where(FindAgentV2Round.run_id == run_id)
+            .order_by(FindAgentV2Round.round_index, FindAgentV2Round.id)
+        ))
+        searches = list(session.scalars(
+            select(FindAgentV2Search).where(FindAgentV2Search.run_id == run_id)
+            .order_by(FindAgentV2Search.round_index, FindAgentV2Search.id)
+        ))
+        candidates = list(session.scalars(
+            select(FindAgentV2Candidate).where(FindAgentV2Candidate.run_id == run_id)
+            .order_by(FindAgentV2Candidate.id)
+        ))
+        evidence = list(session.scalars(
+            select(FindAgentV2Evidence).where(FindAgentV2Evidence.run_id == run_id)
+            .order_by(FindAgentV2Evidence.id)
+        ))
+        round_items = [{
+            "id": int(row.id), "round_index": row.round_index, "phase": row.phase,
+            "status": row.status, "plan": _json(row.plan_json, {}),
+            "start_snapshot": _json(row.start_snapshot_json, {}),
+            "end_snapshot": _json(row.end_snapshot_json, {}),
+            "error_message": row.error_message, "create_time": _time(row.create_time),
+            "update_time": _time(row.update_time),
+        } for row in rounds]
+        search_items = [{
+            "id": int(row.id), "round_index": row.round_index, "keyword": row.keyword,
+            "query_reason": row.query_reason, "source_type": row.source_type,
+            "provider": row.provider, "cursor": row.cursor, "page_no": row.page_no,
+            "has_more": bool(row.has_more), "next_cursor": row.next_cursor,
+            "provider_state": _json(row.provider_state_json, {}),
+            "result_count": row.result_count, "status": row.status,
+            "error_message": row.error_message,
+            "raw_response": _json(row.raw_response_json, {}),
+            "create_time": _time(row.create_time),
+        } for row in searches]
+        evidence_by_candidate: dict[int, list[dict[str, Any]]] = {}
+        evidence_items = []
+        for row in evidence:
+            item = {
+                "id": int(row.id), "candidate_id": row.candidate_id,
+                "evidence_type": row.evidence_type, "provider": row.provider,
+                "status": row.status, "raw": _json(row.raw_json, {}),
+                "normalized": _json(row.normalized_json, {}),
+                "error_message": row.error_message, "create_time": _time(row.create_time),
+            }
+            evidence_items.append(item)
+            if row.candidate_id is not None:
+                evidence_by_candidate.setdefault(int(row.candidate_id), []).append(item)
+        candidate_items = [{
+            "id": int(row.id), "first_search_id": row.first_search_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": _json(row.source_keywords_json, []), "tags": _json(row.tags_json, []),
+            "publish_at": _time(row.publish_at), "duration_seconds": _number(row.duration_seconds),
+            "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": _json(row.detail_json, {}),
+            "portrait": _json(row.portrait_json, {}), "detail_status": row.detail_status,
+            "portrait_status": row.portrait_status,
+            "content_50_plus_ratio": _number(row.content_50_plus_ratio),
+            "account_50_plus_ratio": _number(row.account_50_plus_ratio),
+            "relevance_score": _number(row.relevance_score), "elder_score": _number(row.elder_score),
+            "share_score": _number(row.share_score), "value_score": _number(row.value_score),
+            "gate_status": row.gate_status, "gate_result": _json(row.gate_result_json, {}),
+            "decision_bucket": row.decision_bucket, "decision_reason": row.decision_reason,
+            "reject_reason_code": row.reject_reason_code,
+            "evidence": evidence_by_candidate.get(int(row.id), []),
+            "create_time": _time(row.create_time), "update_time": _time(row.update_time),
+        } for row in candidates]
+        timeline = []
+        for row in round_items:
+            timeline.append({"type": "round", "time": row["create_time"], "data": row})
+        for row in search_items:
+            timeline.append({"type": "search", "time": row["create_time"], "data": row})
+        for row in evidence_items:
+            timeline.append({"type": "evidence", "time": row["create_time"], "data": row})
+        timeline.sort(key=lambda item: str(item.get("time") or ""))
+        return {
+            "run": {**_run(run), "input": _json(run.input_json, {}),
+                    "rule_config": _json(run.rule_config_json, {})},
+            "rounds": round_items, "searches": search_items, "candidates": candidate_items,
+            "evidence": evidence_items, "timeline": timeline,
+        }
+
+
+def prepare_test_run(demand_word: str, current_user: dict[str, Any]) -> dict[str, Any]:
+    prepared = prepare_latest_v2_demand_run_by_name(
+        demand_word,
+        triggered_by_user_id=int(current_user["id"]),
+        triggered_by_username=str(current_user["username"]),
+        trigger_source="web_admin",
+    )
+    return prepared.summary()
+
+
+def execute_test_run(run_id: str) -> None:
+    """Background task entry point. Always persist bootstrap failures."""
+    try:
+        run_prepared_find_agent_v2(run_id)
+    except Exception as exc:
+        get_find_agent_v2_service().fail_run(run_id, f"{type(exc).__name__}: {exc}")

+ 10 - 1
find_agent_v2/demand_context.py

@@ -294,7 +294,13 @@ def prepare_v2_demand_run(
     )
 
 
-def prepare_latest_v2_demand_run_by_name(demand_word: str) -> PreparedV2DemandRun:
+def prepare_latest_v2_demand_run_by_name(
+    demand_word: str,
+    *,
+    triggered_by_user_id: int | None = None,
+    triggered_by_username: str | None = None,
+    trigger_source: str = "cli",
+) -> PreparedV2DemandRun:
     """Resolve the newest demand record, build its full context, and create a V2 run."""
     context = load_latest_v2_demand_context_by_name(demand_word)
     run_key = f"demand-test-{uuid.uuid4().hex}"[:64]
@@ -306,6 +312,9 @@ def prepare_latest_v2_demand_run_by_name(demand_word: str) -> PreparedV2DemandRu
         demand_word=context.demand_name,
         demand_grade_id=context.demand_grade_id,
         rule_config=rules,
+        triggered_by_user_id=triggered_by_user_id,
+        triggered_by_username=triggered_by_username,
+        trigger_source=trigger_source,
     )
     return PreparedV2DemandRun(
         run_id=run_key,

+ 3 - 0
find_agent_v2/models.py

@@ -26,6 +26,9 @@ class FindAgentV2Run(Base):
     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)
+    triggered_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    triggered_by_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    trigger_source: Mapped[str] = mapped_column(String(32), nullable=False, default="cli")
     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")

+ 21 - 0
find_agent_v2/service.py

@@ -94,6 +94,9 @@ class FindAgentV2Service:
         demand_grade_id: int | None = None,
         run_id: str | None = None,
         rule_config: dict[str, Any] | None = None,
+        triggered_by_user_id: int | None = None,
+        triggered_by_username: str | None = None,
+        trigger_source: str = "cli",
     ) -> str:
         run_key = str(run_id or uuid.uuid4().hex)[:64]
         with get_session() as session:
@@ -105,6 +108,9 @@ class FindAgentV2Service:
                 run_id=run_key,
                 demand_grade_id=demand_grade_id,
                 demand_word=str(demand_word)[:256],
+                triggered_by_user_id=triggered_by_user_id,
+                triggered_by_username=(str(triggered_by_username)[:64] if triggered_by_username else None),
+                trigger_source=str(trigger_source or "cli")[:32],
                 input_json=_json({"user_input": user_input}),
                 rule_config_json=_json(rules),
                 status="running",
@@ -120,6 +126,9 @@ class FindAgentV2Service:
                 "run_id": row.run_id,
                 "demand_grade_id": row.demand_grade_id,
                 "demand_word": row.demand_word,
+                "triggered_by_user_id": row.triggered_by_user_id,
+                "triggered_by_username": row.triggered_by_username,
+                "trigger_source": row.trigger_source,
                 "status": row.status,
                 "outcome_status": row.outcome_status,
                 "current_round": row.current_round,
@@ -134,8 +143,20 @@ class FindAgentV2Service:
                 "stop_reason": row.stop_reason,
                 "obagent_run_uid": row.obagent_run_uid,
                 "rule_config": _loads(row.rule_config_json, {}),
+                "create_time": row.create_time.isoformat() if row.create_time else None,
+                "update_time": row.update_time.isoformat() if row.update_time else None,
             }
 
+    def fail_run(self, run_id: str, reason: str) -> None:
+        """Persist failures that happen outside the normal Agent finalization path."""
+        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.status = "failed"
+            row.outcome_status = "failed"
+            row.stop_reason = str(reason)[:2000]
+
     def set_obagent_run_uid(self, run_id: str, run_uid: str | None) -> None:
         if not run_uid:
             return

+ 1 - 0
web/src/App.vue

@@ -19,6 +19,7 @@ const adminNavItems = [
   { to: '/demand-process', label: 'Agent 审计', icon: '◎', admin: true },
   { to: '/find-agent-framework', label: '寻找 Agent 框架', icon: '⌬', admin: false },
   { to: '/find-agent-records', label: '找视频记录', icon: '⌕', admin: false },
+  { to: '/find-agent-v2', label: '寻找 Agent V2', icon: 'V2', admin: true },
   { to: '/admin/users', label: '用户管理', icon: '◇', admin: true },
 ]
 const navItems = computed(() =>

+ 31 - 0
web/src/api/findAgentV2.ts

@@ -0,0 +1,31 @@
+import type { FindAgentV2Detail, PagedFindAgentV2Runs } from '../types/findAgentV2'
+
+async function readJson<T>(response: Response): Promise<T> {
+  const body = await response.json().catch(() => null) as { detail?: string } | null
+  if (!response.ok) throw new Error(body?.detail || `请求失败(${response.status})`)
+  return body as T
+}
+
+export function fetchFindAgentV2Runs(params: {
+  status?: string; keyword?: string; limit?: number; offset?: number
+} = {}): Promise<PagedFindAgentV2Runs> {
+  const query = new URLSearchParams()
+  if (params.status) query.set('status', params.status)
+  if (params.keyword) query.set('keyword', params.keyword)
+  query.set('limit', String(params.limit || 30))
+  query.set('offset', String(params.offset || 0))
+  return fetch(`/api/find-agent-v2/runs?${query}`).then(readJson<PagedFindAgentV2Runs>)
+}
+
+export function fetchFindAgentV2Detail(runId: string): Promise<FindAgentV2Detail> {
+  return fetch(`/api/find-agent-v2/runs/${encodeURIComponent(runId)}`)
+    .then(readJson<FindAgentV2Detail>)
+}
+
+export function createFindAgentV2Test(demandWord: string): Promise<{ accepted: boolean; run_id: string }> {
+  return fetch('/api/find-agent-v2/tests', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ demand_word: demandWord }),
+  }).then(readJson<{ accepted: boolean; run_id: string }>)
+}

+ 7 - 0
web/src/router.ts

@@ -5,6 +5,7 @@ import GlobalDemandMapView from './views/GlobalDemandMapView.vue'
 import ForbiddenView from './views/ForbiddenView.vue'
 import FindAgentRecordsView from './views/FindAgentRecordsView.vue'
 import FindAgentFrameworkView from './views/FindAgentFrameworkView.vue'
+import FindAgentV2View from './views/FindAgentV2View.vue'
 import LoginView from './views/LoginView.vue'
 import OverviewView from './views/OverviewView.vue'
 import PipelineRunsView from './views/PipelineRunsView.vue'
@@ -52,6 +53,12 @@ export const router = createRouter({
       component: FindAgentRecordsView,
       meta: { title: '找视频记录', userAllowed: true },
     },
+    {
+      path: '/find-agent-v2',
+      name: 'find-agent-v2',
+      component: FindAgentV2View,
+      meta: { title: '寻找 Agent V2', admin: true },
+    },
     {
       path: '/find-agent-framework',
       name: 'find-agent-framework',

+ 37 - 0
web/src/types/findAgentV2.ts

@@ -0,0 +1,37 @@
+export interface FindAgentV2Run {
+  id: number
+  run_id: string
+  demand_grade_id: number | null
+  demand_word: string
+  status: string
+  outcome_status: string | null
+  current_round: number
+  search_count: number
+  candidate_count: number
+  valid_primary_count: number
+  total_tokens: number
+  cost_usd: number
+  intent_summary: string | null
+  stop_reason: string | null
+  obagent_run_uid: string | null
+  triggered_by_username: string | null
+  trigger_source: string
+  create_time: string | null
+  update_time: string | null
+}
+
+export interface FindAgentV2Detail {
+  run: FindAgentV2Run & { input: unknown; rule_config: unknown }
+  rounds: Array<Record<string, any>>
+  searches: Array<Record<string, any>>
+  candidates: Array<Record<string, any>>
+  evidence: Array<Record<string, any>>
+  timeline: Array<{ type: string; time: string | null; data: Record<string, any> }>
+}
+
+export interface PagedFindAgentV2Runs {
+  items: FindAgentV2Run[]
+  total: number
+  limit: number
+  offset: number
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 171 - 0
web/src/views/FindAgentV2View.vue


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است