Просмотр исходного кода

Implement V2 M2 query profile config

Sam Lee 4 дней назад
Родитель
Сommit
992c8c6aea

+ 3 - 0
.env.example

@@ -23,6 +23,9 @@ OPENROUTER_API_KEY=<fill-if-enabled>
 OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
 CONTENT_AGENT_QUERY_LLM_MODEL=<fill-openrouter-model>
 CONTENT_AGENT_QUERY_LLM_TIMEOUT_SECONDS=60
+# Query prompt profile is loaded from product_documents/配置/query_prompts.v1.json
+# by platform+strategy_version, currently douyin/V1.
+# Kept for compatibility notes only; profile.prompt_version is the truth source.
 CONTENT_AGENT_QUERY_LLM_PROMPT_VERSION=query_variant.v1
 CONTENT_AGENT_QUERY_LLM_SMOKE_ENABLED=0
 

+ 26 - 6
content_agent/business_modules/search_intent.py

@@ -5,6 +5,7 @@ from typing import Any
 
 from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
 from content_agent.errors import ContentAgentError, ErrorCode
+from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE
 from content_agent.interfaces import QueryVariantClient, QueryVariantResult, RuntimeFileStore
 from content_agent.record_payload import with_raw_payload
 
@@ -50,6 +51,16 @@ def run(
     if not seed_terms:
         raise _query_generation_error("seed_terms_empty")
 
+    profile = getattr(query_variant_client, "profile", DEFAULT_PROFILE)
+    variants_per_seed = int(profile.get("variants_per_seed", 1))
+    if variants_per_seed != 1:
+        raise _query_generation_error(
+            "variants_per_seed_unsupported",
+            {"variants_per_seed": variants_per_seed},
+        )
+    evidence_fields = profile.get("evidence_fields")
+    generic_filter = profile.get("generic_filter")
+
     search_queries: list[dict[str, Any]] = []
     seen_queries: set[str] = set()
 
@@ -76,10 +87,11 @@ def run(
             seed_term,
             seed_index,
             sorted(seen_queries),
+            evidence_fields=evidence_fields,
         )
         variant = _generate_variant(query_variant_client, seed_term, evidence_context)
         variant_query = _normalize_query(variant.query)
-        _validate_variant_query(variant_query, seed_term, seen_queries)
+        _validate_variant_query(variant_query, seed_term, seen_queries, generic_filter=generic_filter)
         llm_variant = _base_query_record(
             run_id=run_id,
             policy_run_id=policy_run_id,
@@ -196,6 +208,8 @@ def _validate_variant_query(
     query: str,
     seed_term: str,
     seen_queries: set[str],
+    *,
+    generic_filter: dict[str, Any] | None = None,
 ) -> None:
     if not query:
         raise _query_generation_error("llm_variant_empty", {"seed_term": seed_term})
@@ -209,7 +223,7 @@ def _validate_variant_query(
                 "search_query": query,
             },
         )
-    if _is_generic_query(query):
+    if _is_generic_query(query, generic_filter=generic_filter):
         raise _query_generation_error(
             "llm_variant_generic",
             {
@@ -271,8 +285,9 @@ def _llm_input_evidence(
     seed_term: str,
     seed_index: int,
     existing_search_queries: list[str],
+    evidence_fields: list[str] | None = None,
 ) -> dict[str, Any]:
-    return {
+    evidence = {
         "seed_term": seed_term,
         "seed_terms": seed_terms,
         "existing_search_queries": existing_search_queries,
@@ -293,6 +308,9 @@ def _llm_input_evidence(
         "absolute_support": pattern_seed_pack.get("absolute_support"),
         "confidence": pattern_seed_pack.get("confidence"),
     }
+    if evidence_fields is None:
+        return evidence
+    return {field: evidence[field] for field in evidence_fields if field in evidence}
 
 
 def _itemset_ids(pattern_seed_pack: dict[str, Any]) -> list[Any]:
@@ -320,16 +338,18 @@ def _normalize_query(value: Any) -> str:
     return " ".join(value.split()).strip()
 
 
-def _is_generic_query(query: str) -> bool:
+def _is_generic_query(query: str, generic_filter: dict[str, Any] | None = None) -> bool:
+    generic_queries = set((generic_filter or {}).get("queries") or GENERIC_QUERIES)
+    generic_tokens = tuple((generic_filter or {}).get("tokens") or GENERIC_QUERY_TOKENS)
     compact = "".join(query.split())
     if not compact or len(compact) <= 1:
         return True
     if not any(char.isalnum() for char in compact):
         return True
-    if compact in GENERIC_QUERIES:
+    if compact in generic_queries:
         return True
     remainder = compact
-    for token in GENERIC_QUERY_TOKENS:
+    for token in generic_tokens:
         remainder = remainder.replace(token, "")
     return not remainder
 

+ 101 - 0
content_agent/integrations/query_prompt_config.py

@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+from typing import Any
+
+from content_agent.integrations import config_store
+
+QUERY_PROMPTS_PATH = Path("product_documents/配置/query_prompts.v1.json")
+
+DEFAULT_PROFILE: dict[str, Any] = {
+    "prompt_version": "query_variant.v1",
+    "system": (
+        "You generate one concise Chinese short-video search query. "
+        "Return exactly one plain query string. Do not return JSON, "
+        "lists, quotes, explanations, or multiple lines."
+    ),
+    "user": (
+        "Seed term:\n{seed_term}\n\n"
+        "Evidence context:\n{evidence_context}\n\n"
+        "Create one adjacent search phrase that stays faithful to the evidence. "
+        "Avoid any phrase listed in existing_search_queries."
+    ),
+    "temperature": 0.4,
+    "max_tokens": 64,
+    "evidence_fields": [
+        "seed_term",
+        "seed_terms",
+        "existing_search_queries",
+        "source_field",
+        "source_index",
+        "itemset_items",
+        "category_bindings",
+        "element_bindings",
+        "pattern_source_system",
+        "pattern_execution_id",
+        "mining_config_id",
+        "source_post_id",
+        "matched_post_ids",
+        "itemset_ids",
+        "support",
+        "absolute_support",
+        "confidence",
+    ],
+    "variants_per_seed": 1,
+    "generic_filter": {
+        "queries": [
+            "内容",
+            "视频",
+            "热门",
+            "推荐",
+            "短视频",
+            "热门视频",
+            "推荐视频",
+            "热门内容",
+            "推荐内容",
+            "相关视频",
+            "相关内容",
+            "热点视频",
+            "热点内容",
+        ],
+        "tokens": [
+            "短视频",
+            "热门",
+            "推荐",
+            "相关",
+            "热点",
+            "内容",
+            "视频",
+            "素材",
+            "资料",
+            "信息",
+            "话题",
+        ],
+    },
+}
+
+
+def load_profile(
+    platform: str,
+    strategy_version: str,
+    root_dir: Path | str = Path("."),
+) -> dict[str, Any]:
+    path = Path(root_dir) / QUERY_PROMPTS_PATH
+    try:
+        config, _raw = config_store.load_json(path)
+    except FileNotFoundError:
+        return _copy_default_profile()
+
+    profiles = config.get("profiles") if isinstance(config, dict) else {}
+    if not isinstance(profiles, dict):
+        return _copy_default_profile()
+
+    profile = profiles.get(f"{platform}/{strategy_version}")
+    if not isinstance(profile, dict):
+        return _copy_default_profile()
+    return copy.deepcopy(profile)
+
+
+def _copy_default_profile() -> dict[str, Any]:
+    return copy.deepcopy(DEFAULT_PROFILE)

+ 27 - 21
content_agent/integrations/query_variant.py

@@ -1,11 +1,14 @@
 from __future__ import annotations
 
+import copy
 import os
+from pathlib import Path
 from typing import Any, Mapping
 
 import httpx
 
 from content_agent.errors import ContentAgentError, ErrorCode
+from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE, load_profile
 from content_agent.interfaces import QueryVariantClient, QueryVariantResult
 
 DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
@@ -44,12 +47,14 @@ class OpenRouterQueryVariantClient:
         base_url: str = DEFAULT_OPENROUTER_BASE_URL,
         timeout_seconds: float = DEFAULT_QUERY_TIMEOUT_SECONDS,
         prompt_version: str = DEFAULT_QUERY_PROMPT_VERSION,
+        profile: dict[str, Any] | None = None,
     ) -> None:
         self.api_key = api_key
         self.model = model
         self.base_url = base_url.rstrip("/")
         self.timeout_seconds = timeout_seconds
-        self.prompt_version = prompt_version
+        self.profile = copy.deepcopy(profile or DEFAULT_PROFILE)
+        self.prompt_version = str(self.profile.get("prompt_version") or prompt_version)
 
     def generate_variant(
         self,
@@ -66,9 +71,9 @@ class OpenRouterQueryVariantClient:
                 },
                 json={
                     "model": self.model,
-                    "messages": _messages(seed_term, evidence_context),
-                    "temperature": 0.4,
-                    "max_tokens": 64,
+                    "messages": _render_messages(self.profile, seed_term, evidence_context),
+                    "temperature": self.profile["temperature"],
+                    "max_tokens": self.profile["max_tokens"],
                 },
                 timeout=self.timeout_seconds,
             )
@@ -105,6 +110,10 @@ class OpenRouterQueryVariantClient:
 
 def query_variant_client_from_env(
     env: Mapping[str, str] | None = None,
+    *,
+    platform: str = "douyin",
+    strategy_version: str = "V1",
+    root_dir: Path | str = Path("."),
 ) -> QueryVariantClient:
     source = os.environ if env is None else env
     api_key = _env_value(source, "OPENROUTER_API_KEY") or _env_value(
@@ -113,10 +122,7 @@ def query_variant_client_from_env(
     )
     model = _env_value(source, "CONTENT_AGENT_QUERY_LLM_MODEL") or _env_value(source, "MODEL")
     base_url = _env_value(source, "OPENROUTER_BASE_URL") or DEFAULT_OPENROUTER_BASE_URL
-    prompt_version = (
-        _env_value(source, "CONTENT_AGENT_QUERY_LLM_PROMPT_VERSION")
-        or DEFAULT_QUERY_PROMPT_VERSION
-    )
+    prompt_version = _env_value(source, "CONTENT_AGENT_QUERY_LLM_PROMPT_VERSION") or DEFAULT_QUERY_PROMPT_VERSION
     timeout_seconds = _float_env(
         source,
         "CONTENT_AGENT_QUERY_LLM_TIMEOUT_SECONDS",
@@ -140,29 +146,29 @@ def query_variant_client_from_env(
         base_url=base_url,
         timeout_seconds=timeout_seconds,
         prompt_version=prompt_version,
+        profile=load_profile(platform, strategy_version, root_dir=root_dir),
     )
 
 
 def _messages(seed_term: str, evidence_context: dict[str, Any]) -> list[dict[str, str]]:
+    return _render_messages(DEFAULT_PROFILE, seed_term, evidence_context)
+
+
+def _render_messages(
+    profile: dict[str, Any],
+    seed_term: str,
+    evidence_context: dict[str, Any],
+) -> list[dict[str, str]]:
     return [
         {
             "role": "system",
-            "content": (
-                "You generate one concise Chinese short-video search query. "
-                "Return exactly one plain query string. Do not return JSON, "
-                "lists, quotes, explanations, or multiple lines."
-            ),
+            "content": str(profile["system"]),
         },
         {
             "role": "user",
-            "content": (
-                "Seed term:\n"
-                f"{seed_term}\n\n"
-                "Evidence context:\n"
-                f"{evidence_context}\n\n"
-                "Create one adjacent search phrase that stays faithful to the evidence. "
-                "Avoid any phrase listed in existing_search_queries."
-            ),
+            "content": str(profile["user"])
+            .replace("{seed_term}", seed_term)
+            .replace("{evidence_context}", str(evidence_context)),
         },
     ]
 

+ 62 - 0
product_documents/配置/query_prompts.v1.json

@@ -0,0 +1,62 @@
+{
+  "schema_version": "query_prompts.v1",
+  "profiles": {
+    "douyin/V1": {
+      "prompt_version": "query_variant.v1",
+      "system": "You generate one concise Chinese short-video search query. Return exactly one plain query string. Do not return JSON, lists, quotes, explanations, or multiple lines.",
+      "user": "Seed term:\n{seed_term}\n\nEvidence context:\n{evidence_context}\n\nCreate one adjacent search phrase that stays faithful to the evidence. Avoid any phrase listed in existing_search_queries.",
+      "temperature": 0.4,
+      "max_tokens": 64,
+      "evidence_fields": [
+        "seed_term",
+        "seed_terms",
+        "existing_search_queries",
+        "source_field",
+        "source_index",
+        "itemset_items",
+        "category_bindings",
+        "element_bindings",
+        "pattern_source_system",
+        "pattern_execution_id",
+        "mining_config_id",
+        "source_post_id",
+        "matched_post_ids",
+        "itemset_ids",
+        "support",
+        "absolute_support",
+        "confidence"
+      ],
+      "variants_per_seed": 1,
+      "generic_filter": {
+        "queries": [
+          "内容",
+          "视频",
+          "热门",
+          "推荐",
+          "短视频",
+          "热门视频",
+          "推荐视频",
+          "热门内容",
+          "推荐内容",
+          "相关视频",
+          "相关内容",
+          "热点视频",
+          "热点内容"
+        ],
+        "tokens": [
+          "短视频",
+          "热门",
+          "推荐",
+          "相关",
+          "热点",
+          "内容",
+          "视频",
+          "素材",
+          "资料",
+          "信息",
+          "话题"
+        ]
+      }
+    }
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 345 - 561
tech_documents/工程落地/06_V2阶段开发计划.md


+ 24 - 5
tech_documents/工程落地/v2_implementation_briefs/M2/00_M2_Brief_Index.md

@@ -34,7 +34,7 @@
 search_intent(应用侧):
 
 - `content_agent/business_modules/search_intent.py:41-117` `run(run_id, policy_run_id, pattern_seed_pack, runtime, query_variant_client)`:每 seed 生 `item_single`(`q_{2i+1:03d}`) + `llm_variant`(`q_{2i+2:03d}`);数量校验 `len(seed_terms)*2`(L104-113)。
-- `search_intent.py:268-295` `_llm_input_evidence`:固定 **16 字段**(顺序:`seed_term` / `seed_terms` / `existing_search_queries` / `source_field` / `source_index` / `itemset_items` / `category_bindings` / `element_bindings` / `pattern_source_system` / `pattern_execution_id` / `mining_config_id` / `source_post_id` / `matched_post_ids` / `itemset_ids` / `support` / `absolute_support` / `confidence`)。
+- `search_intent.py:268-295` `_llm_input_evidence`:固定 **17 字段**(顺序:`seed_term` / `seed_terms` / `existing_search_queries` / `source_field` / `source_index` / `itemset_items` / `category_bindings` / `element_bindings` / `pattern_source_system` / `pattern_execution_id` / `mining_config_id` / `source_post_id` / `matched_post_ids` / `itemset_ids` / `support` / `absolute_support` / `confidence`)。
 - `search_intent.py:11-38` `GENERIC_QUERIES`(13) / `GENERIC_QUERY_TOKENS`(11);`:323-334` `_is_generic_query`。
 - lineage 写入 `:93-99`:`llm_variant_of` / `llm_input_evidence` / `llm_prompt_version` / `llm_generation_model`。
 
@@ -50,9 +50,9 @@ search_intent(应用侧):
 - M0 不受影响:`tests/replay_harness.py:83` 注入 `FakeQueryVariantClient`。
 - `product_documents/配置/` **不存在**(新建,对齐 `规则包/` `抖音游走策略/` 版本化)。`.env.example` 已有 query-LLM 段。
 
-sub-agent 交叉验证结论
+复核基线(执行前必须本轮重新查证)
 
-- **零回归暗礁**:user prompt 内嵌 `evidence_context` dict repr → evidence 字段集合 + 顺序决定 prompt 文本;默认 `evidence_fields` 必须 = 全 16 字段、原顺序。
+- **零回归暗礁**:user prompt 内嵌 `evidence_context` dict repr → evidence 字段集合 + 顺序决定 prompt 文本;默认 `evidence_fields` 必须 = 全 17 字段、原顺序。
 - 默认 profile 须逐字复刻现 system/user 文本、`temperature=0.4`、`max_tokens=64`、`variants_per_seed=1`、现泛词集。
 - profile 挂 client(`getattr(client, "profile", DEFAULT_PROFILE)`)→ 假体 / 缺配置零回归、零穿透 `graph.py`/`run_service`。
 
@@ -64,6 +64,25 @@ sub-agent 交叉验证结论:
 - query 仍只从 `seed_terms` 生成;`itemset_items` / `category_bindings` / `element_bindings` 只作 evidence 上下文。
 - DB / runtime 文件 / 表结构不变;lineage 仍进 `raw_payload`。
 
+## 红线验收
+
+默认零回归定义:M2 第一版要“搬家但不变味”,只把当前 query 生成逻辑搬到配置;默认 profile 接线后,不改搜索词来源、query 数量、query id、prompt 文本、采样参数、evidence 顺序、泛词过滤、runtime 文件和 DB schema。
+
+禁止顺手优化:
+
+- 不改默认 prompt 文本,不把英文 prompt 改写成更顺的版本。
+- 不把 `{evidence_context}` 从当前 `dict repr` 改成 `json.dumps`。
+- 不新增默认 query 类型,尤其不加 `item_combo` / `tag_query`。
+- 不改默认 query 来源,仍只从 `seed_terms` 生成。
+- 不改 `content_agent_queries` 表、runtime 文件名、schema registry 或 SQL schema。
+- 不把 `itemset_items` / `category_bindings` / `element_bindings` 变成 query source;它们只作为 LLM evidence。
+
+多变体编号警示:
+
+- 默认 `variants_per_seed=1` 时,query id 必须仍是 `q_{2i+1}` / `q_{2i+2}`。
+- 如果非默认支持 `variants_per_seed>1`,必须在 M2D 明确定义新增 variant 的编号规则。
+- 在编号规则未定义前,M2E 不把 `variants_per_seed=2` 作为必过项,只能作为待补或 xfail 场景。
+
 ## 实施步骤
 
 1. 每进入一个 M2 子阶段,先读对应 brief,只做该 brief 修改范围。
@@ -85,12 +104,12 @@ uv run pytest -q                                             # 全量回归(
 ## 失败归因
 
 - 默认 profile 改了 prompt 文本 / evidence 字段顺序 → LLM 输入漂移、`test_search_intent` 挂。
-- evidence 白名单默认非全 16 / 改序 → prompt dict repr 变 → 回归。
+- evidence 白名单默认非全 17 / 改序 → prompt dict repr 变 → 回归。
 - profile 选择穿透到 `run()` 签名 / `graph.py` → 超出最小改动、易回归。
 - `query_prompts.v1.json` 不满足 M1D validator schema → gate 红。
 - 误改 `content_agent_queries` / runtime 文件 → 破 schema_registry 21/13。
 
-## sub-agent 交叉验证要点
+## 复核清单
 
 - 确认默认 profile 逐字复刻现 system/user/temperature/max_tokens/evidence/泛词。
 - 确认 profile 经 `getattr(client, "profile", DEFAULT_PROFILE)` 取、不改 `run()` 签名、不动 graph.py。

+ 17 - 6
tech_documents/工程落地/v2_implementation_briefs/M2/M2A_Query_Prompts_Json.md

@@ -12,7 +12,7 @@
 - user prompt 模板(`query_variant.py:158-165`):`"Seed term:\n{seed_term}\n\nEvidence context:\n{evidence_context}\n\nCreate one adjacent search phrase that stays faithful to the evidence. Avoid any phrase listed in existing_search_queries."`(现以 f-string 内嵌 `seed_term` 与 `evidence_context` 的 **dict repr**)。
 - 采样参数(`query_variant.py:70-71`):`temperature=0.4`、`max_tokens=64`。
 - `prompt_version`(`query_variant.py:12`):`"query_variant.v1"`。
-- evidence 16 字段原顺序(`search_intent.py:268-295`):`seed_term, seed_terms, existing_search_queries, source_field, source_index, itemset_items, category_bindings, element_bindings, pattern_source_system, pattern_execution_id, mining_config_id, source_post_id, matched_post_ids, itemset_ids, support, absolute_support, confidence`。
+- evidence 17 字段原顺序(`search_intent.py:268-295`):`seed_term, seed_terms, existing_search_queries, source_field, source_index, itemset_items, category_bindings, element_bindings, pattern_source_system, pattern_execution_id, mining_config_id, source_post_id, matched_post_ids, itemset_ids, support, absolute_support, confidence`。
 - 泛词(`search_intent.py:11-38`):`GENERIC_QUERIES`(13) + `GENERIC_QUERY_TOKENS`(11)。
 - M1D validator schema(`scripts/validate_query_prompts_config.py:17-36`):`profiles.{name}` 须含 `system`/`user`/`temperature`(0-2)/`max_tokens`(>0)/`evidence_fields`/`variants_per_seed`(≥1)。
 - 目录版本化模式:`product_documents/规则包/douyin_rule_packs.v1.json`、`product_documents/抖音游走策略/douyin_walk_strategy.v1.json`。
@@ -58,11 +58,21 @@
 ```
 
 - profile key = `f"{platform}/{strategy_version}"` = `"douyin/V1"`。
-- `evidence_fields` 顺序**必须**等于 `_llm_input_evidence` 的 16 字段插入序(否则 prompt dict repr 漂移)。
+- `evidence_fields` 顺序**必须**等于 `_llm_input_evidence` 的 17 字段插入序(否则 prompt dict repr 漂移)。
 - `user` 模板保留 `{seed_term}` / `{evidence_context}` 两个占位符(M2C 替换)。
 - `generic_filter.queries`/`tokens` 逐项等于 `GENERIC_QUERIES`/`GENERIC_QUERY_TOKENS`(注意源码里 `"热"+"点视频"` 等拼接,落 JSON 为 `热点视频`)。
 - 经 `canonical_dumps`(`json.dumps(indent=2, ensure_ascii=False)+"\n"`)落盘,纳入 M1 配置闸的规范化检查(可选,不强制)。
 
+## 红线验收
+
+- 默认 profile 必须包含 `prompt_version` 和 `generic_filter`;这两个字段不能只停留在文档说明里。
+- `profiles["douyin/V1"]` 必须存在,作为默认 profile 的唯一入口。
+- system / user 文本必须逐字等于当前代码事实;M2A 不做 prompt 文案优化。
+- `temperature=0.4`、`max_tokens=64`、`variants_per_seed=1` 必须逐项等于当前代码事实。
+- `evidence_fields` 必须是 17 个字段,且顺序与当前 `_llm_input_evidence` 插入顺序一致。
+- `generic_filter.queries` / `generic_filter.tokens` 必须逐项等于当前泛词集合。
+- M2A 只新增配置 JSON,不改 `.py`、`.env.example`、DB schema、runtime 文件和测试。
+
 ## 实施步骤
 
 1. 新建 `product_documents/配置/` 目录。
@@ -79,13 +89,14 @@ uv run python -c "import json; d=json.load(open('product_documents/配置/query_
 
 ## 失败归因
 
-- validator fail:缺 6 必填字段之一,或 `temperature` 越界 / `max_tokens`≤0 / `variants_per_seed`<1。
-- `evidence_fields` 字段数 ≠ 16 或顺序错:M2D 接线后会导致 prompt 漂移、`test_search_intent` 回归。
+- validator fail:缺必填字段之一,或 `temperature` 越界 / `max_tokens`≤0 / `variants_per_seed`<1。
+- 默认 profile 缺 `prompt_version` / `generic_filter`:M2C / M2D 接线后版本号或泛词过滤无配置真相源。
+- `evidence_fields` 字段数 ≠ 17 或顺序错:M2D 接线后会导致 prompt 漂移、`test_search_intent` 回归。
 - system/user 文本与源码不逐字一致:M2C 接线后 LLM 输入变化。
 - 泛词遗漏(如把 `"热"+"点视频"` 拼错):M2D 接线后泛词过滤结果变化。
 
-## sub-agent 交叉验证要点
+## 复核清单
 
-- 确认 system/user/temperature/max_tokens/evidence_fields(16,原序)/generic_filter 逐字等于源码。
+- 确认 system/user/temperature/max_tokens/evidence_fields(17,原序)/generic_filter 逐字等于源码。
 - 确认 profile key = `douyin/V1`、schema 满足 M1D validator。
 - 确认只新增 `product_documents/配置/`,不动任何代码 / 测试 / DB。

+ 12 - 4
tech_documents/工程落地/v2_implementation_briefs/M2/M2B_Query_Prompt_Config.md

@@ -11,7 +11,7 @@
 - 读取模式(M1B):`content_agent/integrations/config_store.py` `load_json(path)->(parsed, raw)`、`read_text(path)`。
 - 既有 store 风格:`content_agent/integrations/policy_json.py:16-21`(`config_store.load_json(rule_pack_path)`)、`walk_strategy_json.py:34-36`。
 - profile schema:M2A 的 `query_prompts.v1.json`(`profiles["douyin/V1"]`)。
-- 默认值来源(DEFAULT_PROFILE 内容):见 M2A 数据合同(system/user/0.4/64/16 字段/1/泛词)。
+- 默认值来源(DEFAULT_PROFILE 内容):见 M2A 数据合同(system/user/0.4/64/17 字段/1/泛词)。
 - `DEFAULT_QUERY_PROMPT_VERSION="query_variant.v1"`(`query_variant.py:12`)。
 
 ## 修改范围
@@ -28,7 +28,7 @@
 
 - 新建 `content_agent/integrations/query_prompt_config.py`
   - `QUERY_PROMPTS_PATH = Path("product_documents/配置/query_prompts.v1.json")`
-  - `DEFAULT_PROFILE: dict`(逐字等于 M2A 默认 profile:system/user/temperature/max_tokens/evidence_fields(16)/variants_per_seed/generic_filter/prompt_version)
+  - `DEFAULT_PROFILE: dict`(逐字等于 M2A 默认 profile:system/user/temperature/max_tokens/evidence_fields(17)/variants_per_seed/generic_filter/prompt_version)
   - `load_profile(platform: str, strategy_version: str, root_dir: Path = Path(".")) -> dict`
 - 复用 `content_agent/integrations/config_store.py`(`load_json`)
 
@@ -40,9 +40,17 @@
 - `DEFAULT_PROFILE` 与 `query_prompts.v1.json` 的 `douyin/V1` 内容**一致**(双保险:JSON 是业务编辑面,常量是兜底)。
 - 返回 dict 含键:`prompt_version`/`system`/`user`/`temperature`/`max_tokens`/`evidence_fields`/`variants_per_seed`/`generic_filter`。
 
+## 红线验收
+
+- `DEFAULT_PROFILE` 必须逐项等于 `query_prompts.v1.json` 的 `profiles["douyin/V1"]`。
+- 文件缺失时必须回退 `DEFAULT_PROFILE`,不能抛错。
+- 未知 platform / strategy_version 必须回退 `DEFAULT_PROFILE`,不能临时拼一个半配置 profile。
+- `load_profile` 只负责读取和选择 profile,不引入 query 生成逻辑。
+- `query_prompt_config` 不能反向 import `query_variant` 或 `search_intent`,避免配置层和运行层循环依赖。
+
 ## 实施步骤
 
-1. 写 `DEFAULT_PROFILE` 常量(从 M2A JSON 逐字复制为 Python dict,evidence_fields 16 原序)。
+1. 写 `DEFAULT_PROFILE` 常量(从 M2A JSON 逐字复制为 Python dict,evidence_fields 17 原序)。
 2. 写 `load_profile`:try `config_store.load_json` → 选 `f"{platform}/{strategy_version}"`,缺失回退 `DEFAULT_PROFILE`;文件不存在亦回退。
 3. 加单测雏形(在 M2E 落):默认命中 `douyin/V1`;未知 platform 回退 default;文件缺失回退 default。
 
@@ -60,7 +68,7 @@ uv run python -c "from content_agent.integrations.query_prompt_config import loa
 - profile key 拼接与 M2A 不一致(如 `douyin_V1` vs `douyin/V1`):命中失败、误走默认。
 - import 循环:`query_prompt_config` 误引 `query_variant`/`search_intent`(应单向被它们依赖)。
 
-## sub-agent 交叉验证要点
+## 复核清单
 
 - 确认 `DEFAULT_PROFILE` 逐字等于 M2A `douyin/V1`。
 - 确认缺文件 / 未命中均回退默认、不抛错。

+ 11 - 1
tech_documents/工程落地/v2_implementation_briefs/M2/M2C_Query_Variant_Profile.md

@@ -43,6 +43,15 @@
 - `client.profile` 暴露为属性(M2D 经 `getattr(client, "profile", DEFAULT_PROFILE)` 取)。
 - 自定义 profile(改 temperature / max_tokens / prompt 文本)即时生效。
 
+## 红线验收
+
+- 默认 profile 下 `_render_messages(DEFAULT_PROFILE, seed_term, evidence_context)` 必须与旧 `_messages(seed_term, evidence_context)` 逐字一致。
+- `{evidence_context}` 必须渲染为 `str(dict)`,不能改成 `json.dumps` 或其他规范化 JSON。
+- `temperature` / `max_tokens` 默认值必须仍为 `0.4` / `64`。
+- `prompt_version` 默认来自 profile。本计划默认选择:profile 是真相源,`CONTENT_AGENT_QUERY_LLM_PROMPT_VERSION` 不覆盖 profile 版本号。
+- 不改 `QueryVariantClient` protocol,不改变 `MissingQueryVariantClient` 行为。
+- 不在 M2C 改 `search_intent` 的 evidence 字段、变体数或泛词逻辑。
+
 ## 实施步骤
 
 1. `__init__` 加 `profile`(默认 `DEFAULT_PROFILE`),`prompt_version` 改取自 profile。
@@ -69,8 +78,9 @@ print(m[0]['content'][:40]); print('{seed_term}' not in m[1]['content'])
 - `{evidence_context}` 用 `json.dumps` 而非 `str(dict)`:与现 f-string repr 不一致 → 回归(必须 `str()`)。
 - `prompt_version` 仍取自 `__init__` 参数而非 profile:自定义 prompt 与版本号脱钩。
 - `query_variant_client_from_env` 未传 profile:client 走 `DEFAULT_PROFILE`(不致命,但配置不生效)。
+- env prompt version 仍覆盖 profile:违反本计划的 profile 真相源假设,需要更新 M2C/M2E 合同后才能这样做。
 
-## sub-agent 交叉验证要点
+## 复核清单
 
 - 确认默认 profile 下 messages / 采样参数与现 `_messages`/`generate_variant` 逐字一致。
 - 确认 `{evidence_context}` 渲染用 `str(dict)`(复刻 f-string repr),非 JSON。

+ 21 - 10
tech_documents/工程落地/v2_implementation_briefs/M2/M2D_Search_Intent_Policy.md

@@ -9,11 +9,11 @@
 ## 现有证据
 
 - `search_intent.py:41-117` `run(run_id, policy_run_id, pattern_seed_pack, runtime, query_variant_client)`:seed 循环(L56)、`item_single`(L60-71) + `llm_variant`(L80-102)、数量校验 `len(seed_terms)*2`(L104-113)。
-- `search_intent.py:268-295` `_llm_input_evidence`:固定 16 字段(原序,见 index)。
+- `search_intent.py:268-295` `_llm_input_evidence`:固定 17 字段(原序,见 index)。
 - `search_intent.py:11-38` `GENERIC_QUERIES`/`GENERIC_QUERY_TOKENS`;`:323-334` `_is_generic_query`。
 - lineage 写入 `:93-99`。
 - M2C:`query_variant_client` 携带 `.profile`(真实 client);`FakeQueryVariantClient`(`p1_helpers.py:45-74`)**无** `.profile` → `getattr` 回退 `DEFAULT_PROFILE`。
-- M2B:`DEFAULT_PROFILE`(`evidence_fields` 全 16、`variants_per_seed=1`、`generic_filter` 现集)。
+- M2B:`DEFAULT_PROFILE`(`evidence_fields` 全 17、`variants_per_seed=1`、`generic_filter` 现集)。
 
 ## 修改范围
 
@@ -22,7 +22,7 @@
 ## 不修改范围
 
 - 不改 `run()` 签名、`graph.py` `plan_queries` 节点、`run_service`。
-- 不改 query_id 方案(`q_{2i+1}`/`q_{2i+2}`,变体>1 时编号方案在本阶段顺延)
+- 默认不改 query_id 方案(`q_{2i+1}`/`q_{2i+2}`)。如需支持 `variants_per_seed>1`,必须先在本 brief 明确新增 variant 编号规则
 - 不改 lineage 字段名 / `raw_payload` 写入。
 - 不加 `item_combo` / `tag_query`。
 
@@ -30,24 +30,34 @@
 
 - `search_intent.py`
   - `run()`:`profile = getattr(query_variant_client, "profile", DEFAULT_PROFILE)`(顶部取一次)。
-  - `_llm_input_evidence(..., evidence_fields=None)`:构建全 16 字段后,若给定白名单则**按白名单顺序**保留子集(默认 = 全 16、原序)。
+  - `_llm_input_evidence(..., evidence_fields=None)`:构建全 17 字段后,若给定白名单则**按白名单顺序**保留子集(默认 = 全 17、原序)。
   - 变体生成:循环 `variants_per_seed` 次(默认 1);数量校验泛化 `len(seed_terms)*(1+variants_per_seed)`。
   - `_is_generic_query(query, generic_filter=None)`:用 `profile["generic_filter"]["queries"]/["tokens"]`(默认现集)。
 - 复用 `query_prompt_config.DEFAULT_PROFILE`。
 
 ## 数据合同
 
-- 默认 profile(含假体)下:evidence 16 字段原序、每 seed 1 变体、泛词现集 → query 数量、lineage、过滤结果与现状**完全一致**。
+- 默认 profile(含假体)下:evidence 17 字段原序、每 seed 1 变体、泛词现集 → query 数量、lineage、过滤结果与现状**完全一致**。
 - `evidence_fields` 白名单:仅保留列出的字段、按列出顺序(影响 prompt dict repr)。
-- `variants_per_seed=N`:每 seed 生 N 个变体 → 总 query = `len(seed_terms)*(1+N)`;数量校验同步
+- 默认 `variants_per_seed=1` 时,总 query 仍为 `len(seed_terms)*2`;非默认 `variants_per_seed=N` 只有在编号规则明确后才启用,并同步数量校验为 `len(seed_terms)*(1+N)`
 - 泛词来自 profile;命中仍按现 `_is_generic_query` 逻辑(compact / 单字 / 非字母数字 / 精确命中 / token 剥离)。
 - lineage(`llm_prompt_version` 等)仍写 `raw_payload`,值来自 client 的 `QueryVariantResult`。
 
+## 红线验收
+
+- 默认 profile 下,query 生成方法只允许 `item_single` / `llm_variant`。
+- 默认 profile 下,不得出现 `item_combo` / `tag_query`。
+- 默认 `query_source_fields` 必须仍为 `["seed_terms"]`。
+- `itemset_items` / `category_bindings` / `element_bindings` 只能进入 LLM evidence,不能成为 query source。
+- 默认 `variants_per_seed=1` 时,query id 必须仍是 `q_{2i+1}` / `q_{2i+2}`。
+- 非默认 `variants_per_seed>1` 在编号规则未明确前,不作为 M2 必过验收;如果实现,必须同步补编号规则和测试。
+- 不改 `run()` 签名,不穿透改 `graph.py` / `run_service`。
+
 ## 实施步骤
 
 1. `run()` 顶部取 `profile`。
-2. `_llm_input_evidence` 加 `evidence_fields` 参数,默认全 16;白名单按序裁剪。
-3. 变体循环按 `variants_per_seed`;数量校验改 `len(seed_terms)*(1+variants_per_seed)`。
+2. `_llm_input_evidence` 加 `evidence_fields` 参数,默认全 17;白名单按序裁剪。
+3. 默认保持 1 个 LLM 变体;若本阶段明确多变体编号规则,再按 `variants_per_seed` 泛化数量校验
 4. `_is_generic_query` 加 `generic_filter` 参数,默认现集。
 5. 跑 `test_search_intent.py`:默认零回归(4 query、lineage、泛词);新增自定义用例在 M2E。
 
@@ -67,13 +77,14 @@ print('whitelist:', list(sub.keys()))
 
 ## 失败归因
 
-- 默认 evidence 非全 16 / 改序:prompt dict repr 变 → `test_search_intent` 回归。
+- 默认 evidence 非全 17 / 改序:prompt dict repr 变 → `test_search_intent` 回归。
 - 取 profile 改成穿透 `run()` 签名 / `graph.py`:超出最小改动、易回归、假体不兼容。
 - 变体数校验未泛化:`variants_per_seed=2` 时数量校验误报。
+- 在未定义编号规则时启用 `variants_per_seed>1`:会破坏 query id 血缘,必须先补合同。
 - 泛词默认集与现 `GENERIC_QUERIES`/`TOKENS` 不一致:过滤结果变化。
 - 假体无 `.profile` 时未回退 `DEFAULT_PROFILE`:M0 回放 / 测试报错。
 
-## sub-agent 交叉验证要点
+## 复核清单
 
 - 确认经 `getattr(client, "profile", DEFAULT_PROFILE)` 取、`run()` 签名与 `graph.py` 不变。
 - 确认默认(含 `FakeQueryVariantClient`)下行为与现状逐字一致、M0 回放 snapshot 不变。

+ 20 - 6
tech_documents/工程落地/v2_implementation_briefs/M2/M2E_Env_Gate_Tests.md

@@ -32,8 +32,8 @@
 - 新建 `tests/test_query_prompt_config.py`
   - `test_load_profile_default_matches_constant`
   - `test_load_profile_unknown_returns_default`
-  - `test_default_profile_zero_regression`(默认 messages == 现状 / evidence 16 / 1 变体)
-  - `test_custom_variants_per_seed`(=2 → 每 seed 2 变体、数量 `len(seed)*3`
+  - `test_default_profile_zero_regression`(默认 messages == 现状 / evidence 17 / 1 变体)
+  - `test_custom_variants_per_seed`(仅在 M2D 明确多变体编号规则后启用;否则 xfail / 待补
   - `test_custom_evidence_fields_whitelist`(LLM 输入只含白名单、按序)
   - `test_custom_generic_filter`(命中 profile 泛词被过滤)
   - `test_prompt_version_binds_text`(`llm_prompt_version` 对应 profile)
@@ -42,14 +42,26 @@
 ## 数据合同
 
 - 默认(含 `FakeQueryVariantClient` / 缺 JSON)→ 行为与现状逐字一致;现有测试全绿。
-- 自定义 profile → temperature / max_tokens / 变体数 / evidence 白名单 / 泛词 / prompt_version 即时生效,并体现在 `search_queries` 与 lineage。
+- 自定义 profile → temperature / max_tokens / evidence 白名单 / 泛词 / prompt_version 即时生效,并体现在 `search_queries` 与 lineage;变体数在编号规则明确后再作为必过能力
 - `run_config_gate` 第 5 项 `query_prompts` 由 skip→pass。
 - 不新增 env 变量名(沿用现有);不破 schema_registry 21/13。
 
+## 红线验收
+
+- 新增或补强测试必须覆盖 M2A-M2D 的默认零回归红线。
+- 默认 messages 与旧 messages 逐字一致,尤其 `{evidence_context}` 必须保持 `str(dict)` 表达。
+- 默认 query 生成方法只能是 `item_single` / `llm_variant`,不得出现 `item_combo` / `tag_query`。
+- 默认 `query_source_fields` 只能是 `["seed_terms"]`。
+- 默认 query id 仍是 `q_{2i+1}` / `q_{2i+2}`。
+- `run_config_gate` 中 `query_prompts` 必须从 skip 变 pass。
+- M0 `real_id45` 回放结果不能因为默认 profile 接线而变化。
+- `git diff --name-only` 不得包含 DB schema、schema registry、runtime 文件名常量;`validate_schema_registry.py` 只作为辅助检查。
+- 本计划默认 profile 是 prompt 版本真相源;测试需体现 env 不覆盖 profile `prompt_version`。
+
 ## 实施步骤
 
 1. `.env.example` 补 profile 注释(不加新变量)。
-2. 写 `tests/test_query_prompt_config.py`(默认零回归 + 4 个自定义生效用例)。
+2. 写 `tests/test_query_prompt_config.py`(默认零回归 + evidence 白名单 / 泛词 / prompt_version 等自定义生效用例;多变体用例等 M2D 编号规则明确后再启用)。
 3. 跑现有 `test_search_intent`/`test_query_variant` 确认零回归。
 4. 跑 `run_config_gate` 确认 query_prompts skip→pass、全闸绿。
 5. 全量 `pytest -q`(含 M0 回放不受影响)。
@@ -71,10 +83,12 @@ uv run python scripts/validate_schema_registry.py            # 21 表/13 文件
 - 自定义用例不生效:profile 未经 `client.profile` / `getattr` 正确取用(M2C/M2D 接线)。
 - M0 回放 snapshot 变化:误让真实 profile 影响了 `FakeQueryVariantClient` 路径(应零影响)。
 - 加了新 env 变量:超出范围、与现 env 命名冲突。
+- 多变体测试失败:若 M2D 未定义 `variants_per_seed>1` 编号规则,该测试应保持 xfail / 待补,而不是阻塞 M2 默认接线。
+- schema 相关文件出现在 diff 中:超出 M2 范围,必须撤回或另开阶段。
 
-## sub-agent 交叉验证要点
+## 复核清单
 
 - 确认默认零回归(现有测试全绿、M0 snapshot 不变)。
-- 确认自定义 profile 4 维(变体数 / evidence 白名单 / 泛词 / prompt_version)各有用例且生效
+- 确认自定义 profile 的 evidence 白名单 / 泛词 / prompt_version 有用例且生效;变体数用例在编号规则明确前保持 xfail / 待补
 - 确认 gate 第 5 项 skip→pass、不改 validator / gate 代码。
 - 确认不加新 env 变量、不破 schema_registry。

+ 6 - 4
tests/test_config_case_matrix.py

@@ -1,9 +1,9 @@
-"""config × case matrix (V2-M0E).
+"""config x case matrix (V2-M0E).
 
 Replays the same captured case under different configurations to prove the
 "foolproof config" safety net: changing config changes the case outcome,
 visibly (snapshot diff), without breaking the pipeline. Variants that depend on
-later modules (M2 query profile, M3 per-entity dispatch) are xfail until then.
+later modules (M3 per-entity dispatch) are xfail until then.
 """
 
 from __future__ import annotations
@@ -80,9 +80,11 @@ def test_relaxed_portrait_changes_outcome(tmp_path):
     assert relaxed["effect_status_counts"]["rule_blocked"] == 0
 
 
-@pytest.mark.xfail(reason="V2-M2: query_prompts.v1.json not built yet", strict=True)
 def test_matrix_query_profile_variant():
-    assert (ROOT / "product_documents/配置/query_prompts.v1.json").exists()
+    from scripts.validate_query_prompts_config import validate_query_prompts_config
+
+    config = json.loads((ROOT / "product_documents/配置/query_prompts.v1.json").read_text(encoding="utf-8"))
+    assert validate_query_prompts_config(config) == []
 
 
 @pytest.mark.xfail(reason="V2-M3: per-entity dispatch (Content hardcode) not removed yet", strict=True)

+ 29 - 2
tests/test_config_tooling.py

@@ -38,9 +38,36 @@ def test_rule_pack_fk_validator_has_no_failures():
     assert [f for f in findings if f["level"] == "fail"] == []
 
 
-def test_query_prompts_validator_skips_before_m2():
+def test_query_prompts_validator_passes_after_m2():
     mod = _load_script("validate_query_prompts_config")
-    assert mod.main() == 0  # file absent -> skip/exit 0
+    assert mod.main() == 0
+
+
+def test_query_prompts_validator_rejects_invalid_profiles():
+    mod = _load_script("validate_query_prompts_config")
+
+    assert mod.validate_query_prompts_config({}) == [
+        {"level": "fail", "check_id": "profiles", "message": "no profiles defined"}
+    ]
+
+    findings = mod.validate_query_prompts_config(
+        {
+            "profiles": {
+                "douyin/V1": {
+                    "system": "s",
+                    "user": "u",
+                    "temperature": 3,
+                    "max_tokens": 0,
+                    "variants_per_seed": 0,
+                }
+            }
+        }
+    )
+
+    assert {"level": "fail", "check_id": "missing_field", "message": "douyin/V1 missing evidence_fields"} in findings
+    assert {"level": "fail", "check_id": "temperature", "message": "douyin/V1 temperature out of [0,2]: 3"} in findings
+    assert {"level": "fail", "check_id": "max_tokens", "message": "douyin/V1 max_tokens must be > 0"} in findings
+    assert {"level": "fail", "check_id": "variants_per_seed", "message": "douyin/V1 variants_per_seed must be >= 1"} in findings
 
 
 def test_excel_matches_json_byte_equal():

+ 91 - 0
tests/test_query_prompt_config.py

@@ -0,0 +1,91 @@
+from pathlib import Path
+
+from content_agent.integrations.query_prompt_config import (
+    DEFAULT_PROFILE,
+    QUERY_PROMPTS_PATH,
+    load_profile,
+)
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_load_profile_reads_douyin_v1_from_json():
+    profile = load_profile("douyin", "V1", root_dir=ROOT)
+
+    assert profile["prompt_version"] == "query_variant.v1"
+    assert profile["temperature"] == 0.4
+    assert profile["max_tokens"] == 64
+    assert profile["variants_per_seed"] == 1
+    assert "generic_filter" in profile
+
+
+def test_load_profile_missing_file_returns_default(tmp_path):
+    profile = load_profile("douyin", "V1", root_dir=tmp_path)
+
+    assert profile == DEFAULT_PROFILE
+    profile["temperature"] = 1.0
+    assert DEFAULT_PROFILE["temperature"] == 0.4
+
+
+def test_load_profile_unknown_key_returns_default():
+    assert load_profile("xhs", "V9", root_dir=ROOT) == DEFAULT_PROFILE
+
+
+def test_default_profile_matches_json_profile():
+    assert load_profile("douyin", "V1", root_dir=ROOT) == DEFAULT_PROFILE
+
+
+def test_default_profile_preserves_prompt_sampling_evidence_and_generic_filter():
+    profile = load_profile("douyin", "V1", root_dir=ROOT)
+
+    assert QUERY_PROMPTS_PATH.as_posix() == "product_documents/配置/query_prompts.v1.json"
+    assert profile["system"].startswith("You generate one concise Chinese short-video search query.")
+    assert "{seed_term}" in profile["user"]
+    assert "{evidence_context}" in profile["user"]
+    assert profile["evidence_fields"] == [
+        "seed_term",
+        "seed_terms",
+        "existing_search_queries",
+        "source_field",
+        "source_index",
+        "itemset_items",
+        "category_bindings",
+        "element_bindings",
+        "pattern_source_system",
+        "pattern_execution_id",
+        "mining_config_id",
+        "source_post_id",
+        "matched_post_ids",
+        "itemset_ids",
+        "support",
+        "absolute_support",
+        "confidence",
+    ]
+    assert profile["generic_filter"]["queries"] == [
+        "内容",
+        "视频",
+        "热门",
+        "推荐",
+        "短视频",
+        "热门视频",
+        "推荐视频",
+        "热门内容",
+        "推荐内容",
+        "相关视频",
+        "相关内容",
+        "热点视频",
+        "热点内容",
+    ]
+    assert profile["generic_filter"]["tokens"] == [
+        "短视频",
+        "热门",
+        "推荐",
+        "相关",
+        "热点",
+        "内容",
+        "视频",
+        "素材",
+        "资料",
+        "信息",
+        "话题",
+    ]

+ 64 - 1
tests/test_query_variant.py

@@ -1,6 +1,12 @@
+import copy
+
+from content_agent.integrations import query_variant
+from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE
 from content_agent.integrations.query_variant import (
     MissingQueryVariantClient,
     OpenRouterQueryVariantClient,
+    _messages,
+    _render_messages,
     query_variant_client_from_env,
 )
 
@@ -28,4 +34,61 @@ def test_query_variant_client_uses_model_fallback_without_exposing_key():
     assert isinstance(client, OpenRouterQueryVariantClient)
     assert client.model == "test-model"
     assert client.timeout_seconds == 12
-    assert client.prompt_version == "prompt-test"
+    assert client.prompt_version == "query_variant.v1"
+
+
+def test_default_render_messages_matches_legacy_messages():
+    evidence = {"seed_term": "中医养生", "support": 0.2}
+
+    rendered = _render_messages(DEFAULT_PROFILE, "中医养生", evidence)
+
+    assert rendered == _messages("中医养生", evidence)
+    assert str(evidence) in rendered[1]["content"]
+    assert '"seed_term"' not in rendered[1]["content"]
+
+
+def test_openrouter_client_uses_custom_profile(monkeypatch):
+    profile = copy.deepcopy(DEFAULT_PROFILE)
+    profile.update(
+        {
+            "prompt_version": "custom-query-v2",
+            "system": "custom system",
+            "user": "Seed={seed_term}; Evidence={evidence_context}",
+            "temperature": 0.9,
+            "max_tokens": 23,
+        }
+    )
+    captured = {}
+
+    class FakeResponse:
+        def raise_for_status(self):
+            return None
+
+        def json(self):
+            return {"choices": [{"message": {"content": " 气血食疗 "}}]}
+
+    def fake_post(url, *, headers, json, timeout):
+        captured.update({"url": url, "headers": headers, "json": json, "timeout": timeout})
+        return FakeResponse()
+
+    monkeypatch.setattr(query_variant.httpx, "post", fake_post)
+    client = OpenRouterQueryVariantClient(
+        api_key="secret",
+        model="model-x",
+        base_url="https://example.invalid/api/v1",
+        timeout_seconds=7,
+        prompt_version="ignored-env-version",
+        profile=profile,
+    )
+
+    result = client.generate_variant(seed_term="中医养生", evidence_context={"support": 0.2})
+
+    assert result.query == "气血食疗"
+    assert result.prompt_version == "custom-query-v2"
+    assert captured["timeout"] == 7
+    assert captured["json"]["temperature"] == 0.9
+    assert captured["json"]["max_tokens"] == 23
+    assert captured["json"]["messages"] == [
+        {"role": "system", "content": "custom system"},
+        {"role": "user", "content": "Seed=中医养生; Evidence={'support': 0.2}"},
+    ]

+ 71 - 0
tests/test_search_intent.py

@@ -1,3 +1,10 @@
+import copy
+
+import pytest
+
+from content_agent.business_modules import search_intent
+from content_agent.errors import ContentAgentError
+from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE
 from content_agent.run_service import RunService
 from content_agent.schemas import RunStartRequest
 from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
@@ -12,6 +19,32 @@ FORBIDDEN_FIXED_BUSINESS_TERMS = [
 ]
 
 
+class _Runtime:
+    def __init__(self):
+        self.rows = {}
+
+    def append_jsonl(self, _run_id, filename, rows):
+        self.rows[filename] = rows
+
+
+def _seed_pack():
+    return {
+        "seed_terms": ["中医养生"],
+        "itemset_items": ["补气血"],
+        "category_bindings": [{"category_id": "c1"}],
+        "element_bindings": [{"element_id": "e1"}],
+        "pattern_source_system": "pg_pattern_v2",
+        "pattern_execution_id": 1987,
+        "mining_config_id": 58,
+        "source_post_id": "60219550",
+        "matched_post_ids": ["60219550"],
+        "itemset_ids": [1607977],
+        "support": 0.2,
+        "absolute_support": 31,
+        "confidence": 0.8,
+    }
+
+
 def test_search_seed_and_queries_do_not_inject_fixed_business_terms(tmp_path):
     service = RunService(
         runtime_root=tmp_path / "runtime" / "v1",
@@ -98,3 +131,41 @@ def test_search_queries_preserve_source_terms_for_replay(tmp_path):
         assert query["raw_payload"]["llm_generation_model"] == "fake-query-model"
         assert query["raw_payload"]["llm_input_evidence"]["source_field"] == "seed_terms"
         assert query["raw_payload"]["llm_input_evidence"]["itemset_items"]
+
+
+def test_search_intent_custom_evidence_fields_whitelist():
+    client = FakeQueryVariantClient({"中医养生": "气血食疗"})
+    client.profile = copy.deepcopy(DEFAULT_PROFILE)
+    client.profile["evidence_fields"] = ["seed_term", "support"]
+    runtime = _Runtime()
+
+    queries = search_intent.run("run_1", "policy_1", _seed_pack(), runtime, client)
+
+    llm_query = [row for row in queries if row["search_query_generation_method"] == "llm_variant"][0]
+    assert list(llm_query["llm_input_evidence"].keys()) == ["seed_term", "support"]
+    assert list(llm_query["raw_payload"]["llm_input_evidence"].keys()) == ["seed_term", "support"]
+    assert llm_query["query_source_fields"] == ["seed_terms"]
+
+
+def test_search_intent_custom_generic_filter_blocks_query():
+    client = FakeQueryVariantClient({"中医养生": "禁用泛词"})
+    client.profile = copy.deepcopy(DEFAULT_PROFILE)
+    client.profile["generic_filter"] = {"queries": ["禁用泛词"], "tokens": []}
+
+    with pytest.raises(ContentAgentError) as exc:
+        search_intent.run("run_1", "policy_1", _seed_pack(), _Runtime(), client)
+
+    assert exc.value.error_code == "QUERY_GENERATION_FAILED"
+    assert exc.value.detail["reason"] == "llm_variant_generic"
+
+
+def test_search_intent_rejects_unsupported_variants_per_seed():
+    client = FakeQueryVariantClient({"中医养生": "气血食疗"})
+    client.profile = copy.deepcopy(DEFAULT_PROFILE)
+    client.profile["variants_per_seed"] = 2
+
+    with pytest.raises(ContentAgentError) as exc:
+        search_intent.run("run_1", "policy_1", _seed_pack(), _Runtime(), client)
+
+    assert exc.value.error_code == "QUERY_GENERATION_FAILED"
+    assert exc.value.detail == {"reason": "variants_per_seed_unsupported", "variants_per_seed": 2}

Некоторые файлы не были показаны из-за большого количества измененных файлов