Ver Fonte

Fix query pagination walk origin

Sam Lee há 3 semanas atrás
pai
commit
d6e856429b

+ 1 - 1
content_agent/flow_ledger_service.py

@@ -520,7 +520,7 @@ class FlowLedgerService:
         reason = _text(action.get("reason_code"))
         reason = _text(action.get("reason_code"))
         status = _text(action.get("walk_status"), "unknown")
         status = _text(action.get("walk_status"), "unknown")
         gate_reason = _text(action.get("walk_gate_reason_code") or action.get("allow_walk_reason") or reason)
         gate_reason = _text(action.get("walk_gate_reason_code") or action.get("allow_walk_reason") or reason)
-        source_content = _content_for_action(action, bundle)
+        source_content = None if edge == "query_next_page" else _content_for_action(action, bundle)
         source_video = self._video(source_content, bundle, debug=debug) if source_content else None
         source_video = self._video(source_content, bundle, debug=debug) if source_content else None
         target_query = _target_query_for_action(action, bundle)
         target_query = _target_query_for_action(action, bundle)
         target_query_summary = self._query_summary(target_query, bundle, debug=debug) if target_query else None
         target_query_summary = self._query_summary(target_query, bundle, debug=debug) if target_query else None

+ 54 - 4
tests/test_flow_ledger_api.py

@@ -67,6 +67,14 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "search_query_generation_method": "tag_query",
                 "search_query_generation_method": "tag_query",
                 "raw_payload": {"parent_search_query_id": "q_001", "hashtag": "肩颈放松"},
                 "raw_payload": {"parent_search_query_id": "q_001", "hashtag": "肩颈放松"},
             },
             },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "search_query_id": "q_001_page_002",
+                "search_query": "睡前拉伸",
+                "search_query_generation_method": "query_next_page",
+                "raw_payload": {"parent_search_query_id": "q_001"},
+            },
             {
             {
                 "run_id": run_id,
                 "run_id": run_id,
                 "policy_run_id": policy_run_id,
                 "policy_run_id": policy_run_id,
@@ -155,6 +163,19 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "statistics": {},
                 "statistics": {},
                 "raw_payload": {},
                 "raw_payload": {},
             },
             },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "content_discovery_id": "content_004",
+                "platform_content_id": "douyin_004",
+                "search_query_id": "q_001_page_002",
+                "previous_discovery_step": "query_next_page",
+                "platform": "douyin",
+                "description": "翻页搜到的视频",
+                "author_display_name": "拉伸助手",
+                "statistics": {},
+                "raw_payload": {},
+            },
         ],
         ],
     )
     )
     service.runtime.append_jsonl(
     service.runtime.append_jsonl(
@@ -261,7 +282,29 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                     "platform_performance_score": 74,
                     "platform_performance_score": 74,
                 },
                 },
                 "raw_payload": {},
                 "raw_payload": {},
-            }
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "walk_action_id": "walk_page_001",
+                "edge_id": "query_next_page",
+                "depth": 1,
+                "from_node_type": "SearchQuery",
+                "from_node_id": "q_001",
+                "to_node_type": "SearchQuery",
+                "to_node_id": "q_001_page_002",
+                "decision_id": "decision_001",
+                "walk_status": "success",
+                "walk_action": "fetch_next_page",
+                "page_cursor": "10",
+                "reason_code": "v4_query_and_platform_pass",
+                "walk_gate_snapshot": {
+                    "score": 82,
+                    "query_relevance_score": 90,
+                    "platform_performance_score": 74,
+                },
+                "raw_payload": {},
+            },
         ],
         ],
     )
     )
     service.runtime.write_json(
     service.runtime.write_json(
@@ -283,7 +326,7 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert ledger["data_origin_label"] == "本地调试缓存"
     assert ledger["data_origin_label"] == "本地调试缓存"
     assert len(ledger["rows"]) == 3
     assert len(ledger["rows"]) == 3
     assert ledger["summary"]["first_round_query_count"] == 3
     assert ledger["summary"]["first_round_query_count"] == 3
-    assert ledger["summary"]["extension_query_count"] == 1
+    assert ledger["summary"]["extension_query_count"] == 2
     row = next(item for item in ledger["rows"] if item["id"] == "q_001")
     row = next(item for item in ledger["rows"] if item["id"] == "q_001")
     assert row["query"]["method_label"] == "Pattern 种子词"
     assert row["query"]["method_label"] == "Pattern 种子词"
     assert row["source"]["details"] == ["需求单号:demand_123", "种子词:睡前拉伸", "来源帖子:seed_001"]
     assert row["source"]["details"] == ["需求单号:demand_123", "种子词:睡前拉伸", "来源帖子:seed_001"]
@@ -316,8 +359,8 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert videos["videos"][0]["oss_url"] == "https://oss.example.com/video.mp4"
     assert videos["videos"][0]["oss_url"] == "https://oss.example.com/video.mp4"
 
 
     walk = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/walk").json()
     walk = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/walk").json()
-    assert walk["summary"]["total_actions"] == 1
-    assert walk["summary"]["extension_query_count"] == 1
+    assert walk["summary"]["total_actions"] == 2
+    assert walk["summary"]["extension_query_count"] == 2
     assert walk["lanes"]["tag_query"][0]["edge_label"] == "从视频标签继续游走"
     assert walk["lanes"]["tag_query"][0]["edge_label"] == "从视频标签继续游走"
     assert walk["lanes"]["tag_query"][0]["source_video"]["title"] == "睡前 5 分钟肩颈拉伸"
     assert walk["lanes"]["tag_query"][0]["source_video"]["title"] == "睡前 5 分钟肩颈拉伸"
     assert walk["lanes"]["tag_query"][0]["trigger_label"] == "标签:#拉伸"
     assert walk["lanes"]["tag_query"][0]["trigger_label"] == "标签:#拉伸"
@@ -326,6 +369,13 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert "起点:从视频《睡前 5 分钟肩颈拉伸》触发" in walk["lanes"]["tag_query"][0]["detail_lines"]
     assert "起点:从视频《睡前 5 分钟肩颈拉伸》触发" in walk["lanes"]["tag_query"][0]["detail_lines"]
     assert "结果:已生成标签搜索词《肩颈放松》,带回 1 条视频。" in walk["lanes"]["tag_query"][0]["detail_lines"]
     assert "结果:已生成标签搜索词《肩颈放松》,带回 1 条视频。" in walk["lanes"]["tag_query"][0]["detail_lines"]
     assert walk["lanes"]["tag_query"][0]["score_items"][0]["score_label"] == "82 分"
     assert walk["lanes"]["tag_query"][0]["score_items"][0]["score_label"] == "82 分"
+    page_action = walk["lanes"]["query_next_page"][0]
+    assert page_action["edge_label"] == "翻页继续找"
+    assert page_action["source_video"] is None
+    assert page_action["from_label"] == "从搜索词《睡前拉伸》继续"
+    assert page_action["target_query"]["id"] == "q_001_page_002"
+    assert page_action["target_videos"][0]["title"] == "翻页搜到的视频"
+    assert "起点:从搜索词《睡前拉伸》继续" in page_action["detail_lines"]
 
 
     detail = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001").json()
     detail = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001").json()
     assert detail["video"]["playable_url"] == "https://oss.example.com/video.mp4"
     assert detail["video"]["playable_url"] == "https://oss.example.com/video.mp4"

+ 24 - 8
web2/features/QueryWalkPage.tsx

@@ -12,6 +12,7 @@ import { asRecord, text } from "@/lib/flow-ledger/format";
 
 
 type WalkGroup = {
 type WalkGroup = {
   id: string;
   id: string;
+  kind: "video" | "query";
   video?: RawRecord;
   video?: RawRecord;
   title: string;
   title: string;
   author: string;
   author: string;
@@ -121,7 +122,7 @@ function WalkGroupCard({
   return (
   return (
     <article className={`walk-story-block level-${Math.min(level, 3)}`} id={`source-${encodeURIComponent(group.id)}`}>
     <article className={`walk-story-block level-${Math.min(level, 3)}`} id={`source-${encodeURIComponent(group.id)}`}>
       <header className="walk-story-head">
       <header className="walk-story-head">
-        <span className="walk-source-kicker">{level ? `第 ${level + 1} 层起点` : `起点视频 ${index + 1}`}</span>
+        <span className="walk-source-kicker">{sourceKicker(group, index, level)}</span>
         <div className="walk-story-title">
         <div className="walk-story-title">
           <h2>{titleParts.title}</h2>
           <h2>{titleParts.title}</h2>
           {titleParts.tags.length ? (
           {titleParts.tags.length ? (
@@ -134,14 +135,14 @@ function WalkGroupCard({
       </header>
       </header>
 
 
       <div className="walk-source-facts">
       <div className="walk-source-facts">
-        <Fact label="作者" value={group.author} />
-        <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} />
+        {group.kind === "video" ? <Fact label="作者" value={group.author} /> : <Fact label="类型" value="搜索词级扩展" tone="info" />}
+        {group.kind === "video" ? <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} /> : <Fact label="来源" value="从当前搜索词继续找" />}
         {score ? <Fact label="评分" value={score} /> : null}
         {score ? <Fact label="评分" value={score} /> : null}
         {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
         {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
       </div>
       </div>
 
 
       <section className="walk-story-routes" aria-label={`${group.title} 的游走路线`}>
       <section className="walk-story-routes" aria-label={`${group.title} 的游走路线`}>
-        <div className="walk-story-routes-label">从这条视频继续往外找</div>
+        <div className="walk-story-routes-label">{group.kind === "video" ? "从这条视频继续往外找" : "从这个搜索词继续往外找"}</div>
         {routeActions.length ? routeActions.map((action, actionIndex) => (
         {routeActions.length ? routeActions.map((action, actionIndex) => (
           <WalkRouteCard
           <WalkRouteCard
             action={action}
             action={action}
@@ -152,7 +153,7 @@ function WalkGroupCard({
             seen={nextSeen}
             seen={nextSeen}
             key={`${text(action.id, "walk")}-${actionIndex}`}
             key={`${text(action.id, "walk")}-${actionIndex}`}
           />
           />
-        )) : <div className="walk-no-route">这条视频没有继续产生标签、作者或翻页路线。</div>}
+        )) : <div className="walk-no-route">{group.kind === "video" ? "这条视频没有继续产生标签、作者或翻页路线。" : "这个搜索词没有继续翻页或产生新路线。"}</div>}
       </section>
       </section>
     </article>
     </article>
   );
   );
@@ -207,7 +208,7 @@ function WalkRouteCard({
         </div>
         </div>
 
 
         <div className="walk-flow" aria-label={`${routeName} 的链路`}>
         <div className="walk-flow" aria-label={`${routeName} 的链路`}>
-          <FlowNode label="从" value="这条视频" href={`#source-${encodeURIComponent(sourceId)}`} />
+          <FlowNode label="从" value={text(action.from_label, "").includes("搜索词") ? "这个搜索词" : "这条视频"} href={`#source-${encodeURIComponent(sourceId)}`} />
           <FlowArrow />
           <FlowArrow />
           <FlowNode label={firstNode.label} value={firstNode.value} />
           <FlowNode label={firstNode.label} value={firstNode.value} />
           <FlowArrow />
           <FlowArrow />
@@ -321,8 +322,9 @@ function buildWalkGroups(actions: RawRecord[]): WalkGroup[] {
       const decision = asRecord(video.decision);
       const decision = asRecord(video.decision);
       groups.set(id, {
       groups.set(id, {
         id,
         id,
+        kind: video.id ? "video" : "query",
         video: video.id ? video : undefined,
         video: video.id ? video : undefined,
-        title: video.id ? text(video.title, "无标题视频") : cleanBusinessText(text(action.from_label, "从当前搜索词触发")),
+        title: video.id ? text(video.title, "无标题视频") : queryGroupTitle(action),
         author: video.id ? text(video.author, "未知作者") : "搜索词级扩展",
         author: video.id ? text(video.author, "未知作者") : "搜索词级扩展",
         decisionLabel: text(decision.label, "未判断"),
         decisionLabel: text(decision.label, "未判断"),
         terminalLabel: terminal ? terminalResultText(action) : "",
         terminalLabel: terminal ? terminalResultText(action) : "",
@@ -340,6 +342,17 @@ function actionKey(action: RawRecord): string {
   return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.from_label, "")}-${text(action.result, "")}`);
   return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.from_label, "")}-${text(action.result, "")}`);
 }
 }
 
 
+function queryGroupTitle(action: RawRecord): string {
+  const from = cleanBusinessText(text(action.from_label, ""));
+  if (from) return from.replace(/^从搜索词/, "搜索词").replace(/继续$/, "");
+  return "当前搜索词";
+}
+
+function sourceKicker(group: WalkGroup, index: number, level: number): string {
+  if (level) return `第 ${level + 1} 层起点`;
+  return group.kind === "video" ? `起点视频 ${index + 1}` : "起点搜索词";
+}
+
 function VideoLinks({ video, runId, compact = false }: { video?: RawRecord; runId: string; compact?: boolean }) {
 function VideoLinks({ video, runId, compact = false }: { video?: RawRecord; runId: string; compact?: boolean }) {
   if (!video || !video.id) return null;
   if (!video || !video.id) return null;
   const platformUrl = text(video.platform_url, "");
   const platformUrl = text(video.platform_url, "");
@@ -447,7 +460,10 @@ function routeResultNode(
 
 
 function successSentence(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[]): string {
 function successSentence(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[]): string {
   const edgeId = text(action.edge_id, "");
   const edgeId = text(action.edge_id, "");
-  if (edgeId === "query_next_page") return `翻到下一页,带回 ${targetVideos.length} 条视频。`;
+  if (edgeId === "query_next_page") {
+    const from = cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/继续$/, "");
+    return `${from}翻到下一页,带回 ${targetVideos.length} 条视频。`;
+  }
   if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
   if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
     const queryText = text(targetQuery.text, "");
     const queryText = text(targetQuery.text, "");
     return queryText ? `用标签生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `从标签继续搜,带回 ${targetVideos.length} 条视频。`;
     return queryText ? `用标签生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `从标签继续搜,带回 ${targetVideos.length} 条视频。`;