test_topic_table_query_generation.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. from __future__ import annotations
  2. from typing import Any
  3. import pytest
  4. from fastapi.testclient import TestClient
  5. from app.api import app
  6. from app.routes import query_generation
  7. from query_planning.sources.pattern_topic_build import (
  8. PatternTopicBuildConfig,
  9. PatternTopicBuildSource,
  10. )
  11. from query_planning.topic_table import (
  12. ROUTES,
  13. TopicTableGenerationError,
  14. generate_topic_table_preview,
  15. preview_to_dict,
  16. )
  17. def _topic_payload() -> dict[str, Any]:
  18. return {
  19. "success": True,
  20. "source_url": "https://pattern.aiddit.com/api/pattern/topic_builds/1229",
  21. "build": {
  22. "id": 1229,
  23. "demand": "为小羊糊涂啊创作新选题",
  24. "demand_constraints": None,
  25. "strategies_config": {"always_on": []},
  26. "status": "success",
  27. },
  28. "topics": [
  29. {
  30. "id": 1392,
  31. "status": "mature",
  32. "result": "樱花树下低角度拍少女与小羊,传递治愈感并分享穿搭。",
  33. "topic_direction": {
  34. "account_name": "小羊糊涂啊",
  35. "persona_dimensions": {
  36. "实质偏好": "樱花与穿搭",
  37. "形式偏好": "低角度和动态摆拍",
  38. "意图偏好": "治愈和种草",
  39. },
  40. },
  41. "points": [
  42. {
  43. "id": 8371,
  44. "point_type": "灵感点",
  45. "point_result": "低角度仰拍小羊和彩虹光晕",
  46. "is_active": True,
  47. "item_ids": [25684, 25688],
  48. }
  49. ],
  50. "composition_items": [
  51. {
  52. "id": 25684,
  53. "item_level": "element",
  54. "dimension": "形式",
  55. "point_type": "灵感点",
  56. "element_name": "低角度仰拍",
  57. "category_path": "/呈现/视觉/低角度",
  58. "reason": "增强视觉张力",
  59. "is_active": True,
  60. "sources": [
  61. {
  62. "derivation_type": "external_search",
  63. "source_type": "search_case",
  64. "source_reference_id": "case-1",
  65. "reason": "草坪案例证明低角度有动感",
  66. "dataset_from": "",
  67. "source_reference_data": {"large": "must not enter prompt"},
  68. }
  69. ],
  70. },
  71. {
  72. "id": 25688,
  73. "item_level": "element",
  74. "dimension": "实质",
  75. "point_type": "灵感点",
  76. "element_name": "羊",
  77. "reason": "与账号名形成双关",
  78. "is_active": True,
  79. "sources": [],
  80. },
  81. {
  82. "id": 25700,
  83. "item_level": "element",
  84. "dimension": "意图",
  85. "point_type": "目的点",
  86. "element_name": "治愈感",
  87. "reason": "满足粉丝对美好生活的向往",
  88. "is_active": True,
  89. "sources": [],
  90. },
  91. ],
  92. "item_relations": [
  93. {
  94. "source_item_id": 25684,
  95. "target_item_id": 25688,
  96. "reason": "低角度用于突出人与小羊互动",
  97. }
  98. ],
  99. }
  100. ],
  101. }
  102. def _llm_output() -> dict[str, Any]:
  103. needs = []
  104. for index, (route, config) in enumerate(ROUTES.items()):
  105. needs.append(
  106. {
  107. "need_key": f"need_{route}",
  108. "route": route,
  109. "decision_context": f"决定{config['label']}怎么做",
  110. "unknown_information": f"缺少{config['label']}的可执行方法",
  111. "priority": config["priority"],
  112. "source_ref": {"point_id": 8371, "item_ids": [25684, 25688]},
  113. "queries": [
  114. f"{config['label']}有哪些可执行方法",
  115. "逆光人像怎么拍出彩虹光晕" if index == 0 else f"{config['label']}常见失败原因",
  116. ],
  117. }
  118. )
  119. return {"knowledge_needs": needs}
  120. def test_topic_source_calls_read_only_detail_endpoint():
  121. calls = []
  122. class Response:
  123. def raise_for_status(self):
  124. return None
  125. def json(self):
  126. return _topic_payload()
  127. source = PatternTopicBuildSource(
  128. PatternTopicBuildConfig(base_url="https://pattern.example", timeout_seconds=8),
  129. http_get=lambda url, **kwargs: calls.append((url, kwargs)) or Response(),
  130. )
  131. payload = source.fetch(1229)
  132. assert calls == [
  133. ("https://pattern.example/api/pattern/topic_builds/1229", {"timeout": 8})
  134. ]
  135. assert payload["build"]["id"] == 1229
  136. assert payload["source_url"].endswith("/1229")
  137. def test_topic_table_generation_uses_six_routes_and_unified_budget():
  138. seen = {}
  139. def chat(system, user):
  140. seen["system"] = system
  141. seen["user"] = user
  142. return _llm_output()
  143. preview = generate_topic_table_preview(
  144. _topic_payload(),
  145. topic_id=1392,
  146. max_queries=8,
  147. chat_fn=chat,
  148. )
  149. data = preview_to_dict(preview)
  150. assert data["generator_kind"] == "topic_table"
  151. assert len(data["knowledge_needs"]) == 6
  152. assert set(data["summary"]["route_counts"]) == set(ROUTES)
  153. assert data["summary"]["selected_count"] == 8
  154. assert data["summary"]["persisted"] is False
  155. assert data["summary"]["search_started"] is False
  156. assert data["topic"]["dimensions"] == {
  157. "实质": ["羊"],
  158. "形式": ["低角度仰拍"],
  159. "意图": ["治愈感"],
  160. }
  161. assert "source_reference_data" not in seen["user"]
  162. assert "正好有 6 项" in seen["user"]
  163. assert "废弃" not in seen["user"]
  164. assert "What、How、Why" in seen["system"]
  165. assert data["queries"][0]["route"] == "unique_hook"
  166. def test_topic_table_generation_rejects_missing_or_duplicate_routes():
  167. malformed = _llm_output()
  168. malformed["knowledge_needs"][-1]["route"] = "unique_hook"
  169. with pytest.raises(TopicTableGenerationError, match="不完整或有重复"):
  170. generate_topic_table_preview(
  171. _topic_payload(),
  172. topic_id=1392,
  173. max_queries=18,
  174. chat_fn=lambda _system, _user: malformed,
  175. )
  176. def test_topic_table_prompt_and_preview_api_do_not_persist_or_search(monkeypatch):
  177. class Source:
  178. def fetch(self, topic_build_id):
  179. assert topic_build_id == 1229
  180. return _topic_payload()
  181. monkeypatch.setattr(query_generation, "_topic_source", lambda _env: Source())
  182. monkeypatch.setattr(
  183. query_generation,
  184. "_topic_query_chat",
  185. lambda system, user, settings: _llm_output(),
  186. )
  187. monkeypatch.setattr(
  188. query_generation.Settings,
  189. "from_env",
  190. classmethod(lambda cls, _env: object()),
  191. )
  192. client = TestClient(app)
  193. prompt = client.get("/api/query-generation/topic-table/prompt")
  194. assert prompt.status_code == 200
  195. assert prompt.json()["example_request"]["topic_build_id"] == 1229
  196. assert len(prompt.json()["routes"]) == 6
  197. response = client.post(
  198. "/api/query-generation/topic-table/preview",
  199. json={"topic_build_id": 1229, "topic_id": 1392, "max_queries": 10},
  200. )
  201. assert response.status_code == 200
  202. body = response.json()
  203. assert body["source"]["topic_id"] == 1392
  204. assert body["summary"]["selected_count"] == 10
  205. assert body["summary"]["persisted"] is False
  206. assert body["summary"]["search_started"] is False