test_flow_ledger_api.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. from fastapi.testclient import TestClient
  2. from content_agent import api
  3. from content_agent.flow_ledger_service import _FLOW_LEDGER_CACHE
  4. from content_agent.run_service import RunService
  5. def test_first_round_detection_robust_to_old_labeling():
  6. from content_agent.flow_ledger_service import _is_first_round_content
  7. # 旧 run:标签带回视频被误标 search_query_direct;只看 previous_discovery_step 会误判成首轮。
  8. old = {"search_query_id": "tag_q", "previous_discovery_step": "search_query_direct"}
  9. assert _is_first_round_content(old) is True
  10. # 传入扩展 query 集合后(query→内容关联在旧 run 里仍正确),正确识别为游走带回。
  11. assert _is_first_round_content(old, {"tag_q"}) is False
  12. # 真首轮内容不受影响。
  13. assert _is_first_round_content({"search_query_id": "q1", "previous_discovery_step": "seed_term"}, {"tag_q"}) is True
  14. def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
  15. _FLOW_LEDGER_CACHE.clear()
  16. service = RunService(runtime_root=tmp_path / "runtime" / "v1")
  17. run_id = "run_flow_ledger"
  18. policy_run_id = "policy_flow_ledger"
  19. service.runtime.prepare_run(run_id)
  20. service.runtime.write_json(
  21. run_id,
  22. "source_context.json",
  23. {
  24. "run_id": run_id,
  25. "demand_content_id": "demand_123",
  26. "name": "睡前拉伸需求",
  27. "suggestion": "用户希望看到睡前放松内容",
  28. "reason": "来自 itemset 123",
  29. "merge_leve2": "PG Pattern",
  30. "score": 0.8,
  31. "dt": "20260615",
  32. "ext_data": {
  33. "desc": "用户希望看到睡前放松内容",
  34. "reason": "来自 itemset 123",
  35. "type": "pattern",
  36. "evidence_pack": {
  37. "source_post_id": "post_001",
  38. "pattern_execution_id": 581,
  39. "itemset_ids": [1608101],
  40. "seed_terms": ["睡前拉伸"],
  41. "absolute_support": 33,
  42. "category_bindings": [
  43. {
  44. "category_id": 76006,
  45. "category_path": "/理念/观念/个人观念/人生观",
  46. "category_level": 4,
  47. "category_name": "人生观",
  48. "itemset_item_id": 6055,
  49. }
  50. ],
  51. "element_bindings": [
  52. {
  53. "itemset_id": 1608101,
  54. "category_id": 76006,
  55. "sample_elements": [
  56. {
  57. "post_id": "post_001",
  58. "point_text": "睡前肩颈放松",
  59. "point_type": "目的点",
  60. }
  61. ],
  62. }
  63. ],
  64. }
  65. },
  66. "raw_demand_content": {
  67. "id": "demand_123",
  68. "name": "睡前拉伸需求",
  69. "suggestion": "用户希望看到睡前放松内容",
  70. "reason": "来自 itemset 123",
  71. "merge_leve2": "PG Pattern",
  72. "score": 0.8,
  73. "dt": "20260615",
  74. "ext_data": {
  75. "desc": "用户希望看到睡前放松内容",
  76. "reason": "来自 itemset 123",
  77. "type": "pattern",
  78. },
  79. },
  80. },
  81. )
  82. service.runtime.write_json(
  83. run_id,
  84. "pattern_seed_pack.json",
  85. {
  86. "run_id": run_id,
  87. "policy_run_id": policy_run_id,
  88. "pattern_execution_id": 581,
  89. "source_post_id": "post_001",
  90. "element_bindings": [],
  91. },
  92. )
  93. service.runtime.append_jsonl(
  94. run_id,
  95. "search_queries.jsonl",
  96. [
  97. {
  98. "run_id": run_id,
  99. "policy_run_id": policy_run_id,
  100. "search_query_id": "q_001",
  101. "search_query": "睡前拉伸",
  102. "search_query_generation_method": "seed_term",
  103. "pattern_seed_ref": {"query_source_text": "睡前拉伸", "source_post_id": "seed_001"},
  104. "query_source_terms": ["睡前拉伸"],
  105. "raw_payload": {"query_source_refs": [{"query_source_type": "seed_term"}]},
  106. },
  107. {
  108. "run_id": run_id,
  109. "policy_run_id": policy_run_id,
  110. "search_query_id": "q_002",
  111. "search_query": "肩颈放松",
  112. "search_query_generation_method": "tag_query",
  113. "raw_payload": {"parent_search_query_id": "q_001", "hashtag": "肩颈放松"},
  114. },
  115. {
  116. "run_id": run_id,
  117. "policy_run_id": policy_run_id,
  118. "search_query_id": "q_003",
  119. "search_query": "情绪放松练习",
  120. "search_query_generation_method": "query_seed_point",
  121. "pattern_seed_ref": {"query_source_text": "情绪放松练习", "source_post_id": "post_001"},
  122. "raw_payload": {
  123. "query_source_refs": [
  124. {
  125. "query_source_type": "piaoquan_point_text",
  126. "source_ref": {
  127. "post_id": "post_888",
  128. "point_id": "point_456",
  129. "point_type": "灵感点",
  130. "point_text": "情绪放松练习",
  131. },
  132. }
  133. ]
  134. },
  135. },
  136. {
  137. "run_id": run_id,
  138. "policy_run_id": policy_run_id,
  139. "search_query_id": "q_004",
  140. "search_query": "人生观",
  141. "search_query_generation_method": "category_leaf_element",
  142. "pattern_seed_ref": {
  143. "query_source_text": "人生观",
  144. "source_post_id": "post_001",
  145. "pattern_execution_id": 581,
  146. },
  147. "raw_payload": {
  148. "query_source_refs": [
  149. {
  150. "query_source_type": "category_terminal_element",
  151. "source_ref": {
  152. "category_id": 76006,
  153. "element_name": "人生观",
  154. "itemset_item_id": 6055,
  155. },
  156. }
  157. ]
  158. },
  159. },
  160. ],
  161. )
  162. service.runtime.append_jsonl(
  163. run_id,
  164. "discovered_content_items.jsonl",
  165. [
  166. {
  167. "run_id": run_id,
  168. "policy_run_id": policy_run_id,
  169. "content_discovery_id": "content_001",
  170. "platform_content_id": "douyin_001",
  171. "search_query_id": "q_001",
  172. "platform": "douyin",
  173. "description": "睡前 5 分钟肩颈拉伸",
  174. "author_display_name": "健康教练",
  175. "tags": ["肩颈", "拉伸"],
  176. "statistics": {"digg_count": 100, "comment_count": 8, "share_count": 3},
  177. "raw_payload": {},
  178. },
  179. {
  180. "run_id": run_id,
  181. "policy_run_id": policy_run_id,
  182. "content_discovery_id": "content_002",
  183. "platform_content_id": "douyin_002",
  184. "search_query_id": "q_002",
  185. "previous_discovery_step": "hashtag_to_query",
  186. "platform": "douyin",
  187. "description": "扩展搜到的视频",
  188. "statistics": {},
  189. "raw_payload": {},
  190. },
  191. {
  192. "run_id": run_id,
  193. "policy_run_id": policy_run_id,
  194. "content_discovery_id": "content_003",
  195. "platform_content_id": "douyin_003",
  196. "search_query_id": "q_003",
  197. "platform": "douyin",
  198. "description": "情绪放松练习视频",
  199. "author_display_name": "心理教练",
  200. "statistics": {},
  201. "raw_payload": {},
  202. },
  203. {
  204. "run_id": run_id,
  205. "policy_run_id": policy_run_id,
  206. "content_discovery_id": "content_tech",
  207. "platform_content_id": "douyin_tech",
  208. "search_query_id": "q_003",
  209. "platform": "douyin",
  210. "description": "技术重试视频",
  211. "author_display_name": "技术作者",
  212. "statistics": {},
  213. "raw_payload": {},
  214. },
  215. {
  216. "run_id": run_id,
  217. "policy_run_id": policy_run_id,
  218. "content_discovery_id": "content_no_valid",
  219. "platform_content_id": "shipinhao_no_valid",
  220. "search_query_id": "q_003",
  221. "platform": "shipinhao",
  222. "description": "无可用正片 URL",
  223. "author_display_name": "技术作者",
  224. "statistics": {},
  225. "raw_payload": {},
  226. },
  227. ],
  228. )
  229. service.runtime.append_jsonl(
  230. run_id,
  231. "content_media_records.jsonl",
  232. [
  233. {
  234. "run_id": run_id,
  235. "policy_run_id": policy_run_id,
  236. "platform": "douyin",
  237. "platform_content_id": "douyin_001",
  238. "play_url": "https://example.com/raw.mp4",
  239. "oss_url": "https://oss.example.com/video.mp4",
  240. "local_path": None,
  241. "content_media_status": "oss_uploaded",
  242. "raw_payload": {"oss_object_key": "video.mp4"},
  243. },
  244. {
  245. "run_id": run_id,
  246. "policy_run_id": policy_run_id,
  247. "platform": "douyin",
  248. "platform_content_id": "douyin_tech",
  249. "play_url": "https://example.com/raw-tech.mp4",
  250. "oss_url": "https://oss.example.com/tech.mp4",
  251. "local_path": None,
  252. "content_media_status": "oss_uploaded",
  253. "raw_payload": {
  254. "oss_archive_attempt_count": 1,
  255. "oss_timing_metrics": {"oss_upload_duration_ms": 1234},
  256. "oss_payload_mode": "no_referer",
  257. "oss_response_summary": {
  258. "status": 10000,
  259. "msg": "bad",
  260. "oss_object_present": False,
  261. "oss_object_has_cdn_url": False,
  262. },
  263. "selected_video_url_path": "$.search.video_url_list[0].video_url",
  264. "selected_video_url_host": "findermp.video.qq.com",
  265. "selected_video_url_probe_status": "verified",
  266. "selected_video_url_content_type": "video/mp4",
  267. "selected_video_url_content_range": "bytes 0-99/100",
  268. "video_url_candidate_counts": {"video": 1, "image": 1},
  269. "video_url_reject_reasons": {"image_path": 1},
  270. },
  271. },
  272. {
  273. "run_id": run_id,
  274. "policy_run_id": policy_run_id,
  275. "platform": "shipinhao",
  276. "platform_content_id": "shipinhao_no_valid",
  277. "play_url": None,
  278. "oss_url": None,
  279. "local_path": None,
  280. "content_media_status": "unavailable",
  281. "failure_reason": "no_valid_play_url",
  282. "raw_payload": {
  283. "failure_reason": "no_valid_play_url",
  284. "no_valid_play_url": True,
  285. "selected_video_url_path": "$.search.video_url_list[0].video_url",
  286. "selected_video_url_host": "findermp.video.qq.com",
  287. "selected_video_url_probe_status": "failed_fallback",
  288. "video_url_candidate_counts": {"video": 1, "image": 1},
  289. "video_url_reject_reasons": {"image_path": 1, "probe_failed": 1},
  290. },
  291. },
  292. ],
  293. )
  294. service.runtime.append_jsonl(
  295. run_id,
  296. "pattern_recall_evidence.jsonl",
  297. [
  298. {
  299. "run_id": run_id,
  300. "policy_run_id": policy_run_id,
  301. "recall_evidence_id": "evidence_001",
  302. "platform_content_id": "douyin_001",
  303. "decode_status": "ok",
  304. "raw_payload": {},
  305. },
  306. {
  307. "run_id": run_id,
  308. "policy_run_id": policy_run_id,
  309. "recall_evidence_id": "evidence_tech",
  310. "platform_content_id": "douyin_tech",
  311. "recall_status": "failed",
  312. "evidence_summary": {
  313. "schema_version": "v4_gemini_query_relevance.v1",
  314. "failure_type": "gemini_client_timeout",
  315. "exception_type": "WriteTimeout",
  316. "error_message": "The write operation timed out",
  317. "retry_count": 2,
  318. "final_status": "failed",
  319. "response_body_summary": {
  320. "http_status_code": 502,
  321. "content_type": "application/json",
  322. "json_top_level_keys": ["error"],
  323. "text_excerpt": "upstream overloaded",
  324. },
  325. "timing_metrics": {
  326. "video_fetch": {
  327. "gemini_video_source": "oss_url",
  328. "download_duration_ms": 1500,
  329. "ffmpeg_duration_ms": 2600,
  330. "total_duration_ms": 4100,
  331. "downloaded_bytes": 10485760,
  332. "compressed_bytes": 1048576,
  333. },
  334. "gemini_request": {
  335. "total_duration_ms": 180000,
  336. "attempts": [
  337. {
  338. "attempt": 1,
  339. "status": "failed",
  340. "duration_ms": 90000,
  341. "failure_type": "gemini_client_timeout",
  342. "exception_type": "WriteTimeout",
  343. "http_status_code": None,
  344. "response_body_summary": {
  345. "http_status_code": 502,
  346. "text_excerpt": "upstream overloaded",
  347. },
  348. }
  349. ],
  350. },
  351. },
  352. },
  353. "raw_payload": {},
  354. },
  355. {
  356. "run_id": run_id,
  357. "policy_run_id": policy_run_id,
  358. "recall_evidence_id": "evidence_no_valid",
  359. "platform_content_id": "shipinhao_no_valid",
  360. "recall_status": "failed",
  361. "evidence_summary": {
  362. "schema_version": "v4_gemini_query_relevance.v1",
  363. "failure_type": "no_valid_play_url",
  364. "final_status": "failed",
  365. "retry_count": 1,
  366. "timing_metrics": {"video_fetch": {"skipped": "no_valid_play_url"}},
  367. },
  368. "raw_payload": {},
  369. },
  370. ],
  371. )
  372. service.runtime.append_jsonl(
  373. run_id,
  374. "rule_decisions.jsonl",
  375. [
  376. {
  377. "run_id": run_id,
  378. "policy_run_id": policy_run_id,
  379. "decision_id": "decision_001",
  380. "search_query_id": "q_001",
  381. "decision_target_id": "content_001",
  382. "decision_action": "ADD_TO_CONTENT_POOL",
  383. "decision_reason_code": "v4_query_and_platform_pass",
  384. "score": 82,
  385. "scorecard": {
  386. "total_score": 82,
  387. "query_relevance_score": 90,
  388. "platform_performance_score": 74,
  389. "platform_performance_components": [
  390. {"field": "statistics.digg_count", "value": 100, "weight": 0.4, "normalized_score": 80},
  391. {"field": "statistics.comment_count", "value": 8, "weight": 0.2, "normalized_score": 70},
  392. ],
  393. },
  394. "decision_replay_data": {
  395. "allow_walk": True,
  396. "walk_gate_snapshot": {
  397. "score": 82,
  398. "query_relevance_score": 90,
  399. "platform_performance_score": 74,
  400. },
  401. },
  402. "raw_payload": {},
  403. },
  404. {
  405. "run_id": run_id,
  406. "policy_run_id": policy_run_id,
  407. "decision_id": "decision_003",
  408. "search_query_id": "q_003",
  409. "decision_target_id": "content_003",
  410. "decision_action": "KEEP_CONTENT_FOR_REVIEW",
  411. "decision_reason_code": "v4_score_review_needed",
  412. "score": 62,
  413. "scorecard": {
  414. "total_score": 62,
  415. "query_relevance_score": 95,
  416. "platform_performance_score": 29,
  417. },
  418. "decision_replay_data": {"allow_walk": False},
  419. "raw_payload": {},
  420. },
  421. {
  422. "run_id": run_id,
  423. "policy_run_id": policy_run_id,
  424. "decision_id": "decision_tech",
  425. "search_query_id": "q_003",
  426. "decision_target_id": "content_tech",
  427. "decision_action": "TECHNICAL_RETRY_REQUIRED",
  428. "decision_reason_code": "v4_technical_retry_needed",
  429. "score": None,
  430. "scorecard": {
  431. "failure_type": "gemini_client_timeout",
  432. "exception_type": "WriteTimeout",
  433. "retry_count": 2,
  434. "final_status": "failed",
  435. "total_score": None,
  436. },
  437. "decision_replay_data": {"allow_walk": False},
  438. "raw_payload": {},
  439. },
  440. {
  441. "run_id": run_id,
  442. "policy_run_id": policy_run_id,
  443. "decision_id": "decision_no_valid",
  444. "search_query_id": "q_003",
  445. "decision_target_id": "content_no_valid",
  446. "decision_action": "TECHNICAL_RETRY_REQUIRED",
  447. "decision_reason_code": "v4_technical_retry_needed",
  448. "score": None,
  449. "scorecard": {
  450. "failure_type": "no_valid_play_url",
  451. "final_status": "failed",
  452. "total_score": None,
  453. },
  454. "decision_replay_data": {"allow_walk": False},
  455. "raw_payload": {},
  456. },
  457. ],
  458. )
  459. service.runtime.append_jsonl(
  460. run_id,
  461. "walk_actions.jsonl",
  462. [
  463. {
  464. "run_id": run_id,
  465. "policy_run_id": policy_run_id,
  466. "walk_action_id": "walk_001",
  467. "edge_id": "hashtag_to_query",
  468. "hashtag": "拉伸",
  469. "depth": 1,
  470. "from_node_type": "Content",
  471. "from_node_id": "douyin_001",
  472. "to_node_id": "q_002",
  473. "walk_status": "success",
  474. "reason_code": "v4_query_and_platform_pass",
  475. "walk_gate_snapshot": {
  476. "score": 82,
  477. "query_relevance_score": 90,
  478. "platform_performance_score": 74,
  479. },
  480. "raw_payload": {},
  481. },
  482. ],
  483. )
  484. service.runtime.write_json(
  485. run_id,
  486. "final_output.json",
  487. {
  488. "run_id": run_id,
  489. "policy_run_id": policy_run_id,
  490. "content_assets": [{"decision_id": "decision_001", "platform_content_id": "douyin_001"}],
  491. "review_records": [{"decision_id": "decision_003", "platform_content_id": "douyin_003"}],
  492. "reject_records": [],
  493. "summary": {},
  494. },
  495. )
  496. monkeypatch.setattr(api, "service", service)
  497. client = TestClient(api.app)
  498. ledger = client.get(f"/runs/{run_id}/flow-ledger").json()
  499. assert ledger["data_origin_label"] == "本地调试缓存"
  500. assert ledger["demand_summary"]["id"] == "demand_123"
  501. assert ledger["demand_summary"]["name"] == "睡前拉伸需求"
  502. assert ledger["demand_summary"]["description"] == "用户希望看到睡前放松内容"
  503. assert ledger["demand_summary"]["itemset_ids"] == ["1608101"]
  504. assert ledger["demand_summary"]["category_paths"] == ["/理念/观念/个人观念/人生观"]
  505. assert ledger["demand_summary"]["extracted_points"] == ["睡前肩颈放松(目的点 / 帖子 post_001)"]
  506. assert len(ledger["rows"]) == 3
  507. assert ledger["summary"]["first_round_query_count"] == 3
  508. assert ledger["summary"]["extension_query_count"] == 1
  509. # M9 游走指标(run 级):键齐全且为数值。
  510. for key in ("walk_video_total", "walk_pooled_count", "walk_action_total", "walk_action_success", "walk_max_depth"):
  511. assert isinstance(ledger["summary"][key], int)
  512. assert ledger["summary"]["walk_action_total"] >= 1 # fixture 有 1 个 hashtag_to_query success
  513. row = next(item for item in ledger["rows"] if item["id"] == "q_001")
  514. assert row["query"]["method_label"] == "从需求单的 pattern 词来"
  515. assert row["source"]["details"] == ["需求单号:demand_123", "种子词:睡前拉伸", "来源帖子:seed_001"]
  516. assert row["first_round_video_total"] == 1
  517. assert row["video_stats"]["oss_uploaded_count"] == 1
  518. assert row["rule_summary"]["pool_count"] == 1
  519. assert row["rule_summary"]["score_summary"]["items"][0]["label"] == "总分"
  520. assert row["rule_summary"]["score_summary"]["items"][0]["score_label"] == "平均 82 分"
  521. assert row["rule_summary"]["score_summary"]["items"][1]["label"] == "是否契合需求"
  522. assert row["rule_summary"]["score_summary"]["items"][1]["score_label"] == "平均 90 分"
  523. assert row["first_round_videos"][0]["decision"]["label"] == "入池"
  524. assert row["first_round_videos"][0]["media_status_label"] == "已保存"
  525. assert row["first_round_videos"][0]["platform_url"] == "https://www.douyin.com/video/douyin_001"
  526. # 这条视频真正向外走过 1 次(walk_001 hashtag success),首页据此显示「看扩展过程」。
  527. assert row["first_round_videos"][0]["walk_out_count"] == 1
  528. assert row["first_round_videos"][0]["decision"]["score_items"][1]["label"] == "是否契合需求"
  529. assert row["first_round_videos"][0]["decision"]["score_items"][3]["label"] == "点赞表现"
  530. piaoquan = next(item for item in ledger["rows"] if item["id"] == "q_003")
  531. assert piaoquan["source"]["label"] == "票圈帖子具体的点"
  532. assert "帖子 ID:post_888" in piaoquan["source"]["details"]
  533. assert "内容点 ID:point_456" in piaoquan["source"]["details"]
  534. assert piaoquan["asset_summary"]["pool_count"] == 0
  535. assert piaoquan["asset_summary"]["review_count"] == 1
  536. assert piaoquan["asset_summary"]["headline"] == "待复看 1 条"
  537. category = next(item for item in ledger["rows"] if item["id"] == "q_004")
  538. assert "分类树:Pattern 执行 581" in category["source"]["details"]
  539. assert "分类路径:/理念/观念/个人观念/人生观" in category["source"]["details"]
  540. assert "所在层级:第 4 层" in category["source"]["details"]
  541. videos = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/videos").json()
  542. assert videos["summary"]["total"] == 1
  543. assert videos["videos"][0]["oss_url"] == "https://oss.example.com/video.mp4"
  544. tech_videos = client.get(f"/runs/{run_id}/flow-ledger/queries/q_003/videos").json()
  545. tech_video = next(item for item in tech_videos["videos"] if item["platform_content_id"] == "douyin_tech")
  546. assert tech_video["decision"]["label"] == "技术问题"
  547. assert tech_video["technical_retry_detail"]["stage_label"] == "OpenRouter/Gemini"
  548. assert tech_video["technical_retry_detail"]["failure_type"] == "gemini_client_timeout"
  549. assert tech_video["technical_retry_detail"]["timings"]["download_seconds"] == 1.5
  550. assert tech_video["technical_retry_detail"]["timings"]["ffmpeg_seconds"] == 2.6
  551. assert tech_video["technical_retry_detail"]["timings"]["gemini_seconds"] == 180.0
  552. assert tech_video["technical_retry_detail"]["sizes"]["downloaded_mb"] == 10.0
  553. assert tech_video["technical_retry_detail"]["attempts"][0]["exception_type"] == "WriteTimeout"
  554. assert tech_video["technical_retry_detail"]["attempts"][0]["response_body_summary"]["http_status_code"] == 502
  555. assert tech_video["technical_retry_detail"]["openrouter"]["response_summary"]["text_excerpt"] == "upstream overloaded"
  556. assert tech_video["technical_retry_detail"]["oss"]["payload_mode"] == "no_referer"
  557. assert tech_video["technical_retry_detail"]["oss"]["response_summary"]["status"] == 10000
  558. assert tech_video["technical_retry_detail"]["video_url"]["selected_host"] == "findermp.video.qq.com"
  559. assert tech_video["technical_retry_detail"]["video_url"]["probe_status"] == "verified"
  560. assert tech_video["technical_retry_detail"]["video_url"]["candidate_counts"] == {"video": 1, "image": 1}
  561. no_valid_video = next(item for item in tech_videos["videos"] if item["platform_content_id"] == "shipinhao_no_valid")
  562. assert no_valid_video["technical_retry_detail"]["failure_type"] == "no_valid_play_url"
  563. assert no_valid_video["technical_retry_detail"]["failure_label"] == "未找到可用正片 URL"
  564. assert no_valid_video["technical_retry_detail"]["brief_reason"] == "平台结果未找到可用正片 URL"
  565. assert no_valid_video["technical_retry_detail"]["video_url"]["probe_status"] == "failed_fallback"
  566. walk = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/walk").json()
  567. assert walk["summary"]["total_actions"] == 1
  568. assert walk["summary"]["extension_query_count"] == 1
  569. assert walk["lanes"]["tag_query"][0]["edge_label"] == "从视频标签继续游走"
  570. assert walk["lanes"]["tag_query"][0]["source_video"]["title"] == "睡前 5 分钟肩颈拉伸"
  571. assert walk["lanes"]["tag_query"][0]["trigger_label"] == "标签:#拉伸"
  572. assert walk["lanes"]["tag_query"][0]["target_query"]["text"] == "肩颈放松"
  573. assert walk["lanes"]["tag_query"][0]["target_videos"][0]["title"] == "扩展搜到的视频"
  574. assert "起点:从视频《睡前 5 分钟肩颈拉伸》触发" in walk["lanes"]["tag_query"][0]["detail_lines"]
  575. assert "结果:已生成标签搜索词《肩颈放松》,带回 1 条视频。" in walk["lanes"]["tag_query"][0]["detail_lines"]
  576. assert walk["lanes"]["tag_query"][0]["score_items"][0]["score_label"] == "82 分"
  577. # M8:query_next_page 边已删,原翻页 lane 断言随之移除。
  578. # M9+ 全 run 游走图:不按 query 过滤,附游走预算总览。
  579. run_walk = client.get(f"/runs/{run_id}/flow-ledger/walk").json()
  580. assert run_walk["summary"]["total_actions"] >= 1
  581. assert run_walk["summary"]["tag_used"] == 1 # walk_001 hashtag success
  582. assert run_walk["summary"]["tag_cap"] == 5
  583. assert run_walk["summary"]["author_used"] == 0
  584. assert run_walk["summary"]["author_cap"] == 5
  585. assert run_walk["summary"]["max_depth_reached"] == 1
  586. assert run_walk["summary"]["max_depth_cap"] == 5
  587. assert run_walk["summary"]["walk_video_total"] == 1 # douyin_002 是游走带回
  588. assert "q_001" in run_walk["summary"]["query_labels"]
  589. assert any(a["source_video"]["title"] == "睡前 5 分钟肩颈拉伸" for a in run_walk["lanes"]["tag_query"])
  590. # M9+ 单视频子树:从 douyin_001 出发,只保留它走出去的那一支并递归。
  591. video_walk = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001/walk").json()
  592. assert video_walk["root_video"]["id"] == "douyin_001"
  593. assert video_walk["summary"]["total_actions"] >= 1
  594. assert video_walk["actions"][0]["edge_id"] == "hashtag_to_query"
  595. assert video_walk["actions"][0]["target_videos"][0]["title"] == "扩展搜到的视频"
  596. # 没向外走过的视频(douyin_003):子树为空。
  597. empty_walk = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_003/walk").json()
  598. assert empty_walk["root_video"]["id"] == "douyin_003"
  599. assert empty_walk["summary"]["total_actions"] == 0
  600. detail = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001").json()
  601. assert detail["video"]["playable_url"] == "https://oss.example.com/video.mp4"
  602. assert detail["decision"]["label"] == "入池"
  603. service.runtime.append_jsonl(
  604. run_id,
  605. "search_queries.jsonl",
  606. [
  607. {
  608. "run_id": run_id,
  609. "policy_run_id": policy_run_id,
  610. "search_query_id": "q_005",
  611. "search_query": "新搜索词",
  612. "search_query_generation_method": "seed_term",
  613. "raw_payload": {},
  614. }
  615. ],
  616. )
  617. cached = client.get(f"/runs/{run_id}/flow-ledger").json()
  618. assert cached["summary"]["first_round_query_count"] == 3
  619. debug_fresh = client.get(f"/runs/{run_id}/flow-ledger?debug=true").json()
  620. assert debug_fresh["summary"]["first_round_query_count"] == 4