test_flow_ledger_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  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_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
  6. _FLOW_LEDGER_CACHE.clear()
  7. service = RunService(runtime_root=tmp_path / "runtime" / "v1")
  8. run_id = "run_flow_ledger"
  9. policy_run_id = "policy_flow_ledger"
  10. service.runtime.prepare_run(run_id)
  11. service.runtime.write_json(
  12. run_id,
  13. "source_context.json",
  14. {
  15. "run_id": run_id,
  16. "demand_content_id": "demand_123",
  17. "name": "睡前拉伸需求",
  18. "suggestion": "用户希望看到睡前放松内容",
  19. "reason": "来自 itemset 123",
  20. "merge_leve2": "PG Pattern",
  21. "score": 0.8,
  22. "dt": "20260615",
  23. "ext_data": {
  24. "desc": "用户希望看到睡前放松内容",
  25. "reason": "来自 itemset 123",
  26. "type": "pattern",
  27. "evidence_pack": {
  28. "source_post_id": "post_001",
  29. "pattern_execution_id": 581,
  30. "itemset_ids": [1608101],
  31. "seed_terms": ["睡前拉伸"],
  32. "absolute_support": 33,
  33. "category_bindings": [
  34. {
  35. "category_id": 76006,
  36. "category_path": "/理念/观念/个人观念/人生观",
  37. "category_level": 4,
  38. "category_name": "人生观",
  39. "itemset_item_id": 6055,
  40. }
  41. ],
  42. "element_bindings": [
  43. {
  44. "itemset_id": 1608101,
  45. "category_id": 76006,
  46. "sample_elements": [
  47. {
  48. "post_id": "post_001",
  49. "point_text": "睡前肩颈放松",
  50. "point_type": "目的点",
  51. }
  52. ],
  53. }
  54. ],
  55. }
  56. },
  57. "raw_demand_content": {
  58. "id": "demand_123",
  59. "name": "睡前拉伸需求",
  60. "suggestion": "用户希望看到睡前放松内容",
  61. "reason": "来自 itemset 123",
  62. "merge_leve2": "PG Pattern",
  63. "score": 0.8,
  64. "dt": "20260615",
  65. "ext_data": {
  66. "desc": "用户希望看到睡前放松内容",
  67. "reason": "来自 itemset 123",
  68. "type": "pattern",
  69. },
  70. },
  71. },
  72. )
  73. service.runtime.write_json(
  74. run_id,
  75. "pattern_seed_pack.json",
  76. {
  77. "run_id": run_id,
  78. "policy_run_id": policy_run_id,
  79. "pattern_execution_id": 581,
  80. "source_post_id": "post_001",
  81. "element_bindings": [],
  82. },
  83. )
  84. service.runtime.append_jsonl(
  85. run_id,
  86. "search_queries.jsonl",
  87. [
  88. {
  89. "run_id": run_id,
  90. "policy_run_id": policy_run_id,
  91. "search_query_id": "q_001",
  92. "search_query": "睡前拉伸",
  93. "search_query_generation_method": "seed_term",
  94. "pattern_seed_ref": {"query_source_text": "睡前拉伸", "source_post_id": "seed_001"},
  95. "query_source_terms": ["睡前拉伸"],
  96. "raw_payload": {"query_source_refs": [{"query_source_type": "seed_term"}]},
  97. },
  98. {
  99. "run_id": run_id,
  100. "policy_run_id": policy_run_id,
  101. "search_query_id": "q_002",
  102. "search_query": "肩颈放松",
  103. "search_query_generation_method": "tag_query",
  104. "raw_payload": {"parent_search_query_id": "q_001", "hashtag": "肩颈放松"},
  105. },
  106. {
  107. "run_id": run_id,
  108. "policy_run_id": policy_run_id,
  109. "search_query_id": "q_001_page_002",
  110. "search_query": "睡前拉伸",
  111. "search_query_generation_method": "query_next_page",
  112. "raw_payload": {"parent_search_query_id": "q_001"},
  113. },
  114. {
  115. "run_id": run_id,
  116. "policy_run_id": policy_run_id,
  117. "search_query_id": "q_003",
  118. "search_query": "情绪放松练习",
  119. "search_query_generation_method": "piaoquan_topic_point",
  120. "pattern_seed_ref": {"query_source_text": "情绪放松练习", "source_post_id": "post_001"},
  121. "raw_payload": {
  122. "query_source_refs": [
  123. {
  124. "query_source_type": "piaoquan_point_text",
  125. "source_ref": {
  126. "post_id": "post_888",
  127. "point_id": "point_456",
  128. "point_type": "灵感点",
  129. "point_text": "情绪放松练习",
  130. },
  131. }
  132. ]
  133. },
  134. },
  135. {
  136. "run_id": run_id,
  137. "policy_run_id": policy_run_id,
  138. "search_query_id": "q_004",
  139. "search_query": "人生观",
  140. "search_query_generation_method": "category_leaf_element",
  141. "pattern_seed_ref": {
  142. "query_source_text": "人生观",
  143. "source_post_id": "post_001",
  144. "pattern_execution_id": 581,
  145. },
  146. "raw_payload": {
  147. "query_source_refs": [
  148. {
  149. "query_source_type": "category_terminal_element",
  150. "source_ref": {
  151. "category_id": 76006,
  152. "element_name": "人生观",
  153. "itemset_item_id": 6055,
  154. },
  155. }
  156. ]
  157. },
  158. },
  159. ],
  160. )
  161. service.runtime.append_jsonl(
  162. run_id,
  163. "discovered_content_items.jsonl",
  164. [
  165. {
  166. "run_id": run_id,
  167. "policy_run_id": policy_run_id,
  168. "content_discovery_id": "content_001",
  169. "platform_content_id": "douyin_001",
  170. "search_query_id": "q_001",
  171. "platform": "douyin",
  172. "description": "睡前 5 分钟肩颈拉伸",
  173. "author_display_name": "健康教练",
  174. "tags": ["肩颈", "拉伸"],
  175. "statistics": {"digg_count": 100, "comment_count": 8, "share_count": 3},
  176. "raw_payload": {},
  177. },
  178. {
  179. "run_id": run_id,
  180. "policy_run_id": policy_run_id,
  181. "content_discovery_id": "content_002",
  182. "platform_content_id": "douyin_002",
  183. "search_query_id": "q_002",
  184. "previous_discovery_step": "hashtag_to_query",
  185. "platform": "douyin",
  186. "description": "扩展搜到的视频",
  187. "statistics": {},
  188. "raw_payload": {},
  189. },
  190. {
  191. "run_id": run_id,
  192. "policy_run_id": policy_run_id,
  193. "content_discovery_id": "content_003",
  194. "platform_content_id": "douyin_003",
  195. "search_query_id": "q_003",
  196. "platform": "douyin",
  197. "description": "情绪放松练习视频",
  198. "author_display_name": "心理教练",
  199. "statistics": {},
  200. "raw_payload": {},
  201. },
  202. {
  203. "run_id": run_id,
  204. "policy_run_id": policy_run_id,
  205. "content_discovery_id": "content_004",
  206. "platform_content_id": "douyin_004",
  207. "search_query_id": "q_001_page_002",
  208. "previous_discovery_step": "query_next_page",
  209. "platform": "douyin",
  210. "description": "翻页搜到的视频",
  211. "author_display_name": "拉伸助手",
  212. "statistics": {},
  213. "raw_payload": {},
  214. },
  215. ],
  216. )
  217. service.runtime.append_jsonl(
  218. run_id,
  219. "content_media_records.jsonl",
  220. [
  221. {
  222. "run_id": run_id,
  223. "policy_run_id": policy_run_id,
  224. "platform": "douyin",
  225. "platform_content_id": "douyin_001",
  226. "play_url": "https://example.com/raw.mp4",
  227. "oss_url": "https://oss.example.com/video.mp4",
  228. "local_path": None,
  229. "content_media_status": "oss_uploaded",
  230. "raw_payload": {"oss_object_key": "video.mp4"},
  231. }
  232. ],
  233. )
  234. service.runtime.append_jsonl(
  235. run_id,
  236. "pattern_recall_evidence.jsonl",
  237. [
  238. {
  239. "run_id": run_id,
  240. "policy_run_id": policy_run_id,
  241. "recall_evidence_id": "evidence_001",
  242. "platform_content_id": "douyin_001",
  243. "decode_status": "ok",
  244. "raw_payload": {},
  245. }
  246. ],
  247. )
  248. service.runtime.append_jsonl(
  249. run_id,
  250. "rule_decisions.jsonl",
  251. [
  252. {
  253. "run_id": run_id,
  254. "policy_run_id": policy_run_id,
  255. "decision_id": "decision_001",
  256. "search_query_id": "q_001",
  257. "decision_target_id": "content_001",
  258. "decision_action": "ADD_TO_CONTENT_POOL",
  259. "decision_reason_code": "v4_query_and_platform_pass",
  260. "score": 82,
  261. "scorecard": {
  262. "total_score": 82,
  263. "query_relevance_score": 90,
  264. "platform_performance_score": 74,
  265. "platform_performance_components": [
  266. {"field": "statistics.digg_count", "value": 100, "weight": 0.4, "normalized_score": 80},
  267. {"field": "statistics.comment_count", "value": 8, "weight": 0.2, "normalized_score": 70},
  268. ],
  269. },
  270. "decision_replay_data": {
  271. "allow_walk": True,
  272. "walk_gate_snapshot": {
  273. "score": 82,
  274. "query_relevance_score": 90,
  275. "platform_performance_score": 74,
  276. },
  277. },
  278. "raw_payload": {},
  279. },
  280. {
  281. "run_id": run_id,
  282. "policy_run_id": policy_run_id,
  283. "decision_id": "decision_003",
  284. "search_query_id": "q_003",
  285. "decision_target_id": "content_003",
  286. "decision_action": "KEEP_CONTENT_FOR_REVIEW",
  287. "decision_reason_code": "v4_score_review_needed",
  288. "score": 62,
  289. "scorecard": {
  290. "total_score": 62,
  291. "query_relevance_score": 95,
  292. "platform_performance_score": 29,
  293. },
  294. "decision_replay_data": {"allow_walk": False},
  295. "raw_payload": {},
  296. }
  297. ],
  298. )
  299. service.runtime.append_jsonl(
  300. run_id,
  301. "walk_actions.jsonl",
  302. [
  303. {
  304. "run_id": run_id,
  305. "policy_run_id": policy_run_id,
  306. "walk_action_id": "walk_001",
  307. "edge_id": "hashtag_to_query",
  308. "hashtag": "拉伸",
  309. "depth": 1,
  310. "from_node_type": "Content",
  311. "from_node_id": "douyin_001",
  312. "to_node_id": "q_002",
  313. "walk_status": "success",
  314. "reason_code": "v4_query_and_platform_pass",
  315. "walk_gate_snapshot": {
  316. "score": 82,
  317. "query_relevance_score": 90,
  318. "platform_performance_score": 74,
  319. },
  320. "raw_payload": {},
  321. },
  322. {
  323. "run_id": run_id,
  324. "policy_run_id": policy_run_id,
  325. "walk_action_id": "walk_page_001",
  326. "edge_id": "query_next_page",
  327. "depth": 1,
  328. "from_node_type": "SearchQuery",
  329. "from_node_id": "q_001",
  330. "to_node_type": "SearchQuery",
  331. "to_node_id": "q_001_page_002",
  332. "decision_id": "decision_001",
  333. "walk_status": "success",
  334. "walk_action": "fetch_next_page",
  335. "page_cursor": "10",
  336. "reason_code": "v4_query_and_platform_pass",
  337. "walk_gate_snapshot": {
  338. "score": 82,
  339. "query_relevance_score": 90,
  340. "platform_performance_score": 74,
  341. },
  342. "raw_payload": {},
  343. },
  344. ],
  345. )
  346. service.runtime.write_json(
  347. run_id,
  348. "final_output.json",
  349. {
  350. "run_id": run_id,
  351. "policy_run_id": policy_run_id,
  352. "content_assets": [{"decision_id": "decision_001", "platform_content_id": "douyin_001"}],
  353. "review_records": [{"decision_id": "decision_003", "platform_content_id": "douyin_003"}],
  354. "reject_records": [],
  355. "summary": {},
  356. },
  357. )
  358. monkeypatch.setattr(api, "service", service)
  359. client = TestClient(api.app)
  360. ledger = client.get(f"/runs/{run_id}/flow-ledger").json()
  361. assert ledger["data_origin_label"] == "本地调试缓存"
  362. assert ledger["demand_summary"]["id"] == "demand_123"
  363. assert ledger["demand_summary"]["name"] == "睡前拉伸需求"
  364. assert ledger["demand_summary"]["description"] == "用户希望看到睡前放松内容"
  365. assert ledger["demand_summary"]["itemset_ids"] == ["1608101"]
  366. assert ledger["demand_summary"]["category_paths"] == ["/理念/观念/个人观念/人生观"]
  367. assert ledger["demand_summary"]["extracted_points"] == ["睡前肩颈放松(目的点 / 帖子 post_001)"]
  368. assert len(ledger["rows"]) == 3
  369. assert ledger["summary"]["first_round_query_count"] == 3
  370. assert ledger["summary"]["extension_query_count"] == 2
  371. row = next(item for item in ledger["rows"] if item["id"] == "q_001")
  372. assert row["query"]["method_label"] == "从需求单的 pattern 词来"
  373. assert row["source"]["details"] == ["需求单号:demand_123", "种子词:睡前拉伸", "来源帖子:seed_001"]
  374. assert row["first_round_video_total"] == 1
  375. assert row["video_stats"]["oss_uploaded_count"] == 1
  376. assert row["rule_summary"]["pool_count"] == 1
  377. assert row["rule_summary"]["score_summary"]["items"][0]["label"] == "总分"
  378. assert row["rule_summary"]["score_summary"]["items"][0]["score_label"] == "平均 82 分"
  379. assert row["rule_summary"]["score_summary"]["items"][1]["label"] == "是否契合需求"
  380. assert row["rule_summary"]["score_summary"]["items"][1]["score_label"] == "平均 90 分"
  381. assert row["first_round_videos"][0]["decision"]["label"] == "入池"
  382. assert row["first_round_videos"][0]["media_status_label"] == "已保存"
  383. assert row["first_round_videos"][0]["platform_url"] == "https://www.douyin.com/video/douyin_001"
  384. assert row["first_round_videos"][0]["decision"]["score_items"][1]["label"] == "是否契合需求"
  385. assert row["first_round_videos"][0]["decision"]["score_items"][3]["label"] == "点赞表现"
  386. piaoquan = next(item for item in ledger["rows"] if item["id"] == "q_003")
  387. assert piaoquan["source"]["label"] == "票圈帖子具体的点"
  388. assert "帖子 ID:post_888" in piaoquan["source"]["details"]
  389. assert "内容点 ID:point_456" in piaoquan["source"]["details"]
  390. assert piaoquan["asset_summary"]["pool_count"] == 0
  391. assert piaoquan["asset_summary"]["review_count"] == 1
  392. assert piaoquan["asset_summary"]["headline"] == "待复看 1 条"
  393. category = next(item for item in ledger["rows"] if item["id"] == "q_004")
  394. assert "分类树:Pattern 执行 581" in category["source"]["details"]
  395. assert "分类路径:/理念/观念/个人观念/人生观" in category["source"]["details"]
  396. assert "所在层级:第 4 层" in category["source"]["details"]
  397. videos = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/videos").json()
  398. assert videos["summary"]["total"] == 1
  399. assert videos["videos"][0]["oss_url"] == "https://oss.example.com/video.mp4"
  400. walk = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/walk").json()
  401. assert walk["summary"]["total_actions"] == 2
  402. assert walk["summary"]["extension_query_count"] == 2
  403. assert walk["lanes"]["tag_query"][0]["edge_label"] == "从视频标签继续游走"
  404. assert walk["lanes"]["tag_query"][0]["source_video"]["title"] == "睡前 5 分钟肩颈拉伸"
  405. assert walk["lanes"]["tag_query"][0]["trigger_label"] == "标签:#拉伸"
  406. assert walk["lanes"]["tag_query"][0]["target_query"]["text"] == "肩颈放松"
  407. assert walk["lanes"]["tag_query"][0]["target_videos"][0]["title"] == "扩展搜到的视频"
  408. assert "起点:从视频《睡前 5 分钟肩颈拉伸》触发" in walk["lanes"]["tag_query"][0]["detail_lines"]
  409. assert "结果:已生成标签搜索词《肩颈放松》,带回 1 条视频。" in walk["lanes"]["tag_query"][0]["detail_lines"]
  410. assert walk["lanes"]["tag_query"][0]["score_items"][0]["score_label"] == "82 分"
  411. page_action = walk["lanes"]["query_next_page"][0]
  412. assert page_action["edge_label"] == "翻页继续找"
  413. assert page_action["source_video"] is None
  414. assert page_action["from_label"] == "从搜索词《睡前拉伸》继续"
  415. assert page_action["target_query"]["id"] == "q_001_page_002"
  416. assert page_action["target_videos"][0]["title"] == "翻页搜到的视频"
  417. assert "起点:从搜索词《睡前拉伸》继续" in page_action["detail_lines"]
  418. detail = client.get(f"/runs/{run_id}/flow-ledger/videos/douyin_001").json()
  419. assert detail["video"]["playable_url"] == "https://oss.example.com/video.mp4"
  420. assert detail["decision"]["label"] == "入池"
  421. service.runtime.append_jsonl(
  422. run_id,
  423. "search_queries.jsonl",
  424. [
  425. {
  426. "run_id": run_id,
  427. "policy_run_id": policy_run_id,
  428. "search_query_id": "q_005",
  429. "search_query": "新搜索词",
  430. "search_query_generation_method": "seed_term",
  431. "raw_payload": {},
  432. }
  433. ],
  434. )
  435. cached = client.get(f"/runs/{run_id}/flow-ledger").json()
  436. assert cached["summary"]["first_round_query_count"] == 3
  437. debug_fresh = client.get(f"/runs/{run_id}/flow-ledger?debug=true").json()
  438. assert debug_fresh["summary"]["first_round_query_count"] == 4