| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- from __future__ import annotations
- from typing import Any
- import pytest
- from fastapi.testclient import TestClient
- from app.api import app
- from app.routes import query_generation
- from query_planning.sources.pattern_topic_build import (
- PatternTopicBuildConfig,
- PatternTopicBuildSource,
- )
- from query_planning.topic_table import (
- ROUTES,
- TopicTableGenerationError,
- generate_topic_table_preview,
- preview_to_dict,
- )
- def _topic_payload() -> dict[str, Any]:
- return {
- "success": True,
- "source_url": "https://pattern.aiddit.com/api/pattern/topic_builds/1229",
- "build": {
- "id": 1229,
- "demand": "为小羊糊涂啊创作新选题",
- "demand_constraints": None,
- "strategies_config": {"always_on": []},
- "status": "success",
- },
- "topics": [
- {
- "id": 1392,
- "status": "mature",
- "result": "樱花树下低角度拍少女与小羊,传递治愈感并分享穿搭。",
- "topic_direction": {
- "account_name": "小羊糊涂啊",
- "persona_dimensions": {
- "实质偏好": "樱花与穿搭",
- "形式偏好": "低角度和动态摆拍",
- "意图偏好": "治愈和种草",
- },
- },
- "points": [
- {
- "id": 8371,
- "point_type": "灵感点",
- "point_result": "低角度仰拍小羊和彩虹光晕",
- "is_active": True,
- "item_ids": [25684, 25688],
- },
- {
- "id": 8372,
- "point_type": "目的点",
- "point_result": "传递治愈感",
- "is_active": True,
- "item_ids": [25700],
- },
- {
- "id": 8374,
- "point_type": "关键点",
- "point_result": "逆光氛围感特写",
- "is_active": True,
- "item_ids": [25684],
- },
- ],
- "composition_items": [
- {
- "id": 25684,
- "item_level": "element",
- "dimension": "形式",
- "point_type": "灵感点",
- "element_name": "低角度仰拍",
- "category_path": "/呈现/视觉/低角度",
- "reason": "增强视觉张力",
- "is_active": True,
- "sources": [
- {
- "derivation_type": "external_search",
- "source_type": "search_case",
- "source_reference_id": "case-1",
- "reason": "草坪案例证明低角度有动感",
- "dataset_from": "",
- "source_reference_data": {"large": "must not enter prompt"},
- }
- ],
- },
- {
- "id": 25688,
- "item_level": "element",
- "dimension": "实质",
- "point_type": "灵感点",
- "element_name": "羊",
- "reason": "与账号名形成双关",
- "is_active": True,
- "sources": [],
- },
- {
- "id": 25700,
- "item_level": "element",
- "dimension": "意图",
- "point_type": "目的点",
- "element_name": "治愈感",
- "reason": "满足粉丝对美好生活的向往",
- "is_active": True,
- "sources": [],
- },
- ],
- "item_relations": [
- {
- "source_item_id": 25684,
- "target_item_id": 25688,
- "reason": "低角度用于突出人与小羊互动",
- }
- ],
- }
- ],
- }
- def _llm_output() -> dict[str, Any]:
- fields = [
- ("point:8371:point_result", "灵感点怎么拍出来"),
- ("point:8372:point_result", "治愈感怎么落实到画面"),
- ("point:8374:point_result", "逆光特写怎么执行"),
- ("item:25688:element_name", "小羊怎么自然入镜"),
- ("item:25684:element_name", "低角度仰拍怎么执行"),
- ("item:25700:element_name", "治愈感怎么通过画面表现"),
- ("topic_direction.persona_dimensions.实质偏好", "怎么保持账号原来的选材"),
- ("topic_direction.persona_dimensions.形式偏好", "怎么保持账号原来的拍法"),
- ("topic_direction.persona_dimensions.意图偏好", "怎么保持账号原来的表达效果"),
- ("item:25684:source:0:reason", "旧案例换场景后怎么调整"),
- ]
- return {
- "field_mappings": [
- {
- "field_key": field_key,
- "judgement": judgement,
- "queries": [
- f"{judgement}有哪些方法",
- "逆光人像怎么拍出彩虹光晕" if index == 0 else f"{judgement}常见失败原因",
- ],
- }
- for index, (field_key, judgement) in enumerate(fields)
- ]
- }
- def test_topic_source_calls_read_only_detail_endpoint():
- calls = []
- class Response:
- def raise_for_status(self):
- return None
- def json(self):
- return _topic_payload()
- source = PatternTopicBuildSource(
- PatternTopicBuildConfig(base_url="https://pattern.example", timeout_seconds=8),
- http_get=lambda url, **kwargs: calls.append((url, kwargs)) or Response(),
- )
- payload = source.fetch(1229)
- assert calls == [
- ("https://pattern.example/api/pattern/topic_builds/1229", {"timeout": 8})
- ]
- assert payload["build"]["id"] == 1229
- assert payload["source_url"].endswith("/1229")
- def test_topic_table_generation_maps_real_fields_and_uses_unified_budget():
- seen = {}
- def chat(system, user):
- seen["system"] = system
- seen["user"] = user
- return _llm_output()
- preview = generate_topic_table_preview(
- _topic_payload(),
- topic_id=1392,
- max_queries=8,
- chat_fn=chat,
- )
- data = preview_to_dict(preview)
- assert data["generator_kind"] == "topic_table"
- assert len(data["knowledge_needs"]) == 10
- assert set(data["summary"]["route_counts"]) == set(ROUTES)
- assert data["summary"]["selected_count"] == 8
- assert data["summary"]["persisted"] is False
- assert data["summary"]["search_started"] is False
- assert len(data["source_groups"]) == 6
- assert all(group["field_mappings"] for group in data["source_groups"])
- assert [group["field_count"] for group in data["source_groups"]] == [1, 1, 1, 5, 3, 1]
- assert sum(len(group["fields"]) for group in data["source_groups"]) == 12
- topic_result_field = next(
- field
- for field in data["source_groups"][3]["fields"]
- if field["field_key"] == "topic.result"
- )
- assert topic_result_field["used_for_query"] is False
- assert topic_result_field["judgement"] is None
- assert topic_result_field["queries"] == []
- assert data["topic"]["dimensions"] == {
- "实质": ["羊"],
- "形式": ["低角度仰拍"],
- "意图": ["治愈感"],
- }
- assert "source_reference_data" not in seen["user"]
- assert "field_catalog" in seen["user"]
- assert "topics[id=1392].composition_items[id=25688].element_name" in seen["user"]
- assert "废弃" not in seen["user"]
- assert "大模型要判断什么" in seen["system"]
- assert data["topic"]["input_groups"]["points"]["灵感点"] == [
- "低角度仰拍小羊和彩虹光晕"
- ]
- assert data["topic"]["input_groups"]["reference_notes"] == [
- {"element": "低角度仰拍", "note": "草坪案例证明低角度有动感"}
- ]
- assert data["field_mappings"][0] == {
- "field_key": "point:8371:point_result",
- "field_path": "topics[id=1392].points[id=8371].point_result",
- "field_label": "灵感点字段",
- "field_value": "低角度仰拍小羊和彩虹光晕",
- "judgement": "灵感点怎么拍出来",
- "source_group_key": "unique_hook",
- "source_group_label": "灵感点",
- "queries": [
- "灵感点怎么拍出来有哪些方法",
- "逆光人像怎么拍出彩虹光晕",
- ],
- }
- def test_topic_table_generation_rejects_unknown_field_key():
- malformed = _llm_output()
- malformed["field_mappings"][-1]["field_key"] = "invented-field"
- with pytest.raises(TopicTableGenerationError, match="不存在的选题表字段"):
- generate_topic_table_preview(
- _topic_payload(),
- topic_id=1392,
- max_queries=18,
- chat_fn=lambda _system, _user: malformed,
- )
- def test_topic_table_generation_rejects_duplicate_field_key():
- malformed = _llm_output()
- malformed["field_mappings"][-1]["field_key"] = malformed["field_mappings"][0][
- "field_key"
- ]
- with pytest.raises(TopicTableGenerationError, match="重复引用"):
- generate_topic_table_preview(
- _topic_payload(),
- topic_id=1392,
- max_queries=18,
- chat_fn=lambda _system, _user: malformed,
- )
- def test_topic_table_generation_rejects_missing_source_group():
- malformed = _llm_output()
- malformed["field_mappings"] = malformed["field_mappings"][:-1]
- with pytest.raises(TopicTableGenerationError, match="全部六种数据来源"):
- generate_topic_table_preview(
- _topic_payload(),
- topic_id=1392,
- max_queries=18,
- chat_fn=lambda _system, _user: malformed,
- )
- def test_topic_table_generation_retries_once_when_a_source_group_is_missing():
- malformed = _llm_output()
- malformed["field_mappings"] = malformed["field_mappings"][:-1]
- outputs = iter([malformed, _llm_output()])
- prompts = []
- preview = generate_topic_table_preview(
- _topic_payload(),
- topic_id=1392,
- max_queries=18,
- chat_fn=lambda _system, user: prompts.append(user) or next(outputs),
- )
- assert len(preview.result.selected_queries) == 18
- assert len(prompts) == 2
- assert "上一次输出没有满足字段覆盖要求" in prompts[1]
- def test_topic_table_prompt_and_preview_api_do_not_persist_or_search(monkeypatch):
- class Source:
- def fetch(self, topic_build_id):
- assert topic_build_id == 1229
- return _topic_payload()
- monkeypatch.setattr(query_generation, "_topic_source", lambda _env: Source())
- monkeypatch.setattr(
- query_generation,
- "_topic_query_chat",
- lambda system, user, settings: _llm_output(),
- )
- monkeypatch.setattr(
- query_generation.Settings,
- "from_env",
- classmethod(lambda cls, _env: object()),
- )
- client = TestClient(app)
- prompt = client.get("/api/query-generation/topic-table/prompt")
- assert prompt.status_code == 200
- assert prompt.json()["example_request"]["topic_build_id"] == 1229
- assert len(prompt.json()["routes"]) == 6
- response = client.post(
- "/api/query-generation/topic-table/preview",
- json={"topic_build_id": 1229, "topic_id": 1392, "max_queries": 10},
- )
- assert response.status_code == 200
- body = response.json()
- assert body["source"]["topic_id"] == 1392
- assert body["summary"]["selected_count"] == 10
- assert body["summary"]["persisted"] is False
- assert body["summary"]["search_started"] is False
- def test_topic_table_source_preview_returns_every_field_without_calling_llm(monkeypatch):
- class Source:
- def fetch(self, topic_build_id):
- assert topic_build_id == 1229
- return _topic_payload()
- monkeypatch.setattr(query_generation, "_topic_source", lambda _env: Source())
- monkeypatch.setattr(
- query_generation,
- "_topic_query_chat",
- lambda *_args, **_kwargs: (_ for _ in ()).throw(
- AssertionError("source preview must not call the LLM")
- ),
- )
- client = TestClient(app)
- response = client.post(
- "/api/query-generation/topic-table/source-preview",
- json={"topic_build_id": 1229, "topic_id": 1392, "max_queries": 18},
- )
- assert response.status_code == 200
- body = response.json()
- assert body["summary"] == {
- "field_count": 12,
- "selected_count": 0,
- "query_generation_completed": False,
- "persisted": False,
- "search_started": False,
- }
- assert [group["field_count"] for group in body["source_groups"]] == [1, 1, 1, 5, 3, 1]
- assert all(
- not field["used_for_query"]
- for group in body["source_groups"]
- for field in group["fields"]
- )
|