소스 검색

feat(query):老「多轴正交组合」query 接入 query_filter 创作筛选层

- acquisition/query_filter.py:把筛选逻辑抽成共享函数 filter_queries(调 query_filter.txt 批量 keep/排除),
  build_creation_demo 改用它(去掉重复的 _filter)。
- scripts/filter_multiaxis.py:读 DB 里 method=多轴正交组合 的 query,批量过滤,把 keep+filter_reason
  写回各自 axes JSON(不重生成、不动搜索结果)。实测 36 条留 11、排除 25(政务公文题材被正确剔除)。
- 前端 QueriesPage 加「筛选」列:✓留 / ✕排除+理由,排除行淡化删除线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SamLee 2 주 전
부모
커밋
29c234579c

+ 46 - 0
acquisition/query_filter.py

@@ -0,0 +1,46 @@
+"""创作 query 筛选器:调用 acquisition/query_filter.txt(LLM 只做 keep/排除),批量判一组 query。
+
+命中 A–E 任一即排除:A 语义不通 / B 题材本身 / C 制作工具 / D 应试学术公务政企 / E 作品素材。
+返回与输入等长的 [{keep: bool, reason: str}]。供 build_creation_demo、filter_multiaxis 等共用。
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import httpx
+
+from core.config import Settings
+
+PROMPT = Path(__file__).resolve().parent / "query_filter.txt"
+
+
+def filter_queries(queries: list[str], settings: Settings, *, batch: int = 40) -> list[dict]:
+    """批量过滤(分批调用,避免一次喂太多)。返回 [{keep, reason}],与 queries 对齐。"""
+    out: list[dict] = []
+    for i in range(0, len(queries), batch):
+        out.extend(_filter_batch(queries[i:i + batch], settings))
+    return out
+
+
+def _filter_batch(queries: list[str], settings: Settings) -> list[dict]:
+    if not queries:
+        return []
+    user = json.dumps([{"idx": i, "query": q} for i, q in enumerate(queries)], ensure_ascii=False)
+    api = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
+    headers = {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"}
+    body = {"model": settings.llm_model, "messages": [
+        {"role": "system", "content": PROMPT.read_text("utf-8")},
+        {"role": "user", "content": user}], "response_format": {"type": "json_object"}}
+    try:
+        resp = httpx.post(api, headers=headers, json=body, timeout=120)
+        resp.raise_for_status()
+        txt = resp.json()["choices"][0]["message"]["content"]
+        # query_filter 要求输出数组;有的模型会包一层 {"result":[...]},都兜住
+        data = json.loads(txt)
+        arr = data if isinstance(data, list) else next((v for v in data.values() if isinstance(v, list)), [])
+        by = {d.get("idx"): d for d in arr if isinstance(d, dict)}
+        return [{"keep": bool(by.get(i, {}).get("keep", True)),
+                 "reason": str(by.get(i, {}).get("reason", ""))[:50]} for i in range(len(queries))]
+    except Exception as exc:
+        return [{"keep": True, "reason": f"筛选失败:{str(exc)[:30]}"} for _ in queries]

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
acquisition/web/app/dist/assets/index-B38eQxy6.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
acquisition/web/app/dist/assets/index-BweFrMrP.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
acquisition/web/app/dist/assets/index-_EYHoleW.css


+ 2 - 2
acquisition/web/app/dist/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>创作知识 · 找帖子</title>
-    <script type="module" crossorigin src="/app/assets/index-BAjoUM6G.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-BweFrMrP.css">
+    <script type="module" crossorigin src="/app/assets/index-B38eQxy6.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-_EYHoleW.css">
   </head>
   <body>
     <div id="root"></div>

+ 18 - 10
acquisition/web/app/src/pages/QueriesPage.jsx

@@ -50,18 +50,26 @@ export default function QueriesPage() {
       ) : (
         <table>
           <thead>
-            <tr><th>正交轴</th><th>query</th><th>真实搜索结果</th></tr>
+            <tr><th>正交轴</th><th>query</th><th>筛选</th><th>真实搜索结果</th></tr>
           </thead>
           <tbody>
-            {data.items.map((it) => (
-              <tr key={it.id}>
-                <td><div className="axes">{Object.entries(it.axes || {}).map(([k, v]) => (
-                  <span className="tag k" key={k}>{k}:{v}</span>
-                ))}</div></td>
-                <td className="q">{it.query}</td>
-                <td><ResultBtn s={summary[it.query]} query={it.query} /></td>
-              </tr>
-            ))}
+            {data.items.map((it) => {
+              const { keep, filter_reason, ...axes } = it.axes || {}   // keep/filter_reason 是过滤层打的标,单独渲染
+              const filtered = keep !== undefined
+              return (
+                <tr key={it.id} className={filtered && !keep ? 'qdrop' : ''}>
+                  <td><div className="axes">{Object.entries(axes).map(([k, v]) => (
+                    <span className="tag k" key={k}>{k}:{v}</span>
+                  ))}</div></td>
+                  <td className="q">{it.query}</td>
+                  <td className="fcell">{filtered ? (
+                    <><span className={`fbadge ${keep ? 'keep' : 'drop'}`}>{keep ? '✓ 留' : '✕ 排除'}</span>
+                      {!keep && filter_reason && <span className="freason">{filter_reason}</span>}</>
+                  ) : <span className="muted">—</span>}</td>
+                  <td><ResultBtn s={summary[it.query]} query={it.query} /></td>
+                </tr>
+              )
+            })}
           </tbody>
         </table>
       )}

+ 9 - 0
acquisition/web/app/src/styles.css

@@ -82,6 +82,15 @@ td.q { font-weight: 600; }
 .tag { display: inline-block; background: var(--accent-soft); color: var(--accent);
   border-radius: 6px; padding: 1px 7px; font-size: 12px; margin: 1px 3px 1px 0; white-space: nowrap; }
 .tag.k { background: #f0f0f3; color: #555; }
+
+/* 多轴正交 query 的创作筛选层(留/排除) */
+.qdrop { opacity: 0.5; }
+.qdrop .q { text-decoration: line-through; }
+.fcell { white-space: nowrap; }
+.fbadge { font-size: 12px; font-weight: 700; padding: 1px 8px; border-radius: 10px; }
+.fbadge.keep { color: var(--ok); background: #e6f6ee; }
+.fbadge.drop { color: var(--bad); background: #fbeaea; }
+.freason { display: block; font-size: 11.5px; color: var(--muted); margin-top: 3px; max-width: 220px; white-space: normal; }
 /* 正交轴标签:横向拉开间距 */
 .axes { display: flex; flex-wrap: wrap; gap: 6px 18px; }
 .axes .tag { margin: 0; }

+ 2 - 26
scripts/build_creation_demo.py

@@ -16,13 +16,11 @@ import json
 import random
 from pathlib import Path
 
-import httpx
-
+from acquisition.query_filter import filter_queries   # 共享筛选器(query_filter.txt)
 from core.config import Settings
 
 ROOT = Path(__file__).resolve().parent.parent
 TREES = ROOT / "scope_trees" / "trees_index.json"
-FILTER_PROMPT = ROOT / "acquisition" / "query_filter.txt"   # 筛选词在 acquisition/
 OUT = ROOT / "data" / "queries" / "creation_demo.json"
 PER = 20
 BATCH_N = 16        # 全 demo 统一抽这么多个「实质 / 形式」,各族共用同一批,方便切页签比较
@@ -81,28 +79,6 @@ def _nonleaf(idx, source_type, depths=(3, 4), under=None):
     return out
 
 
-def _filter(queries, settings):
-    """把一批原串喂 query_filter.txt(LLM 只做 keep/排除)。返回 [{keep,reason}] 对齐顺序。"""
-    user = json.dumps([{"idx": i, "query": q} for i, q in enumerate(queries)], ensure_ascii=False)
-    api = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
-    headers = {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"}
-    body = {"model": settings.llm_model, "messages": [
-        {"role": "system", "content": FILTER_PROMPT.read_text("utf-8")},
-        {"role": "user", "content": user}], "response_format": {"type": "json_object"}}
-    try:
-        resp = httpx.post(api, headers=headers, json=body, timeout=120)
-        resp.raise_for_status()
-        txt = resp.json()["choices"][0]["message"]["content"]
-        # query_filter 要求输出数组;有的模型会包一层 {"result":[...]},都兜住
-        data = json.loads(txt)
-        arr = data if isinstance(data, list) else next((v for v in data.values() if isinstance(v, list)), [])
-        by = {d.get("idx"): d for d in arr if isinstance(d, dict)}
-        return [{"keep": bool(by.get(i, {}).get("keep", True)),
-                 "reason": str(by.get(i, {}).get("reason", ""))[:50]} for i in range(len(queries))]
-    except Exception as exc:
-        return [{"keep": True, "reason": f"筛选失败:{str(exc)[:30]}"} for _ in queries]
-
-
 def main():
     settings = Settings.from_env()
     rng = random.Random(7)
@@ -155,7 +131,7 @@ def main():
                 continue
             seen.add(q)
             items.append({"query": q, "parts": parts})
-        verdicts = _filter([it["query"] for it in items], settings)
+        verdicts = filter_queries([it["query"] for it in items], settings)
         for it, v in zip(items, verdicts):
             it.update(v)
         kept = sum(1 for it in items if it["keep"])

+ 45 - 0
scripts/filter_multiaxis.py

@@ -0,0 +1,45 @@
+"""给老的「多轴正交组合」query 加一层创作筛选(query_filter.txt)。
+
+读 DB 里 method=多轴正交组合 的每条 query,批量过 filter,把 keep + filter_reason 写回各自的
+axes JSON(前端据此显示 留/排除)。不重生成、不动搜索结果,纯就地打标。
+用法:PYTHONPATH=. CK_ENV_FILE=.env python scripts/filter_multiaxis.py [method]
+"""
+from __future__ import annotations
+
+import json
+import sys
+
+from acquisition import store
+from acquisition.query_filter import filter_queries
+from core.config import Settings
+
+METHOD = sys.argv[1] if len(sys.argv) > 1 else "多轴正交组合"
+
+
+def main() -> None:
+    settings = Settings.from_env()
+    conn = store.connect()
+    try:
+        rows = conn.execute("SELECT id, query, axes FROM queries WHERE method=?", (METHOD,)).fetchall()
+        if not rows:
+            print(f"DB 里没有 method={METHOD} 的 query")
+            return
+        queries = [r["query"] for r in rows]
+        print(f"{METHOD}: {len(queries)} 条 → 过滤中…")
+        verdicts = filter_queries(queries, settings)
+        kept = 0
+        for r, v in zip(rows, verdicts):
+            axes = json.loads(r["axes"] or "{}")
+            axes["keep"] = v["keep"]
+            axes["filter_reason"] = v["reason"]
+            conn.execute("UPDATE queries SET axes=? WHERE id=?",
+                         (json.dumps(axes, ensure_ascii=False), r["id"]))
+            kept += 1 if v["keep"] else 0
+        conn.commit()
+        print(f"完成:保留 {kept} / {len(queries)}(排除 {len(queries) - kept})")
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    main()

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.