Browse Source

feat(query):创作Query正交 Demo——5家族机械正交 + query_filter 排除 + 制作同款UI

彻底重做创作 query 生成的 demo(先不真搜,只看 query):
- scripts/build_creation_demo.py:5 家族机械正交,取分类树创作支——实质=理念支、形式=架构支、
  目的池=作用+感受+意图合一、阶段=灵感/选题/脚本、句尾=知识类型。每族 20 条原串过
  acquisition/query_filter.txt(LLM 只做 keep/排除),存 data/queries/creation_demo.json。
- 前端新增 #/cdemo 页(CreationDemo.jsx):仿制作 query 的多列 UI(轴列 + Query 词列),
  顶部 5 家族切换;Query 列标 ✓留/✕扔 + 排除理由。App 顶栏加「找帖子 / 创作Query正交Demo」切换。
不真实搜索,不改 skill/creation_knowledge。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SamLee 2 weeks ago
parent
commit
9f8ee965a2

+ 14 - 0
acquisition/query_filter.txt

@@ -0,0 +1,14 @@
+你是「内容创作知识库」的检索词筛选器。我们要从小红书 / 公众号 / 抖音 / B站等公共平台,搜到"怎么创作内容"的方法 / 教程——图文 / 短视频 / 脚本 / 剧本 / 小说 / 短剧 / 解说等内容的:选题、构思、钩子开头、脚本与结构、标题、封面、叙事呈现、人物 / 冲突设计、起号涨粉等。题材不限(美食 / 游戏 / 健身 / 历史 / 穿搭…都行)。
+
+你的任务:逐条审候选 query,**直接决定保留还是排除**(只看 query 本身,不管它是怎么生成的)。命中下面任一条 → 排除(keep=false):
+
+A. 【语义不通】词组合本身说不通——动作和对象不匹配、动词用错对象类型(例如对象本身是抽象 / 文本类信息,却用了只适用于画面 / 镜头类的操作动作)。
+B. 【题材本身,而非关于题材的内容】教你做"题材本身"——游戏玩法 / 做菜 / 做产品 / 搞运营本身;而"教你做游戏解说 / 美食视频 / 剧本 / 小说"这种做"关于它的内容"要保留。
+C. 【制作 / 工具操作】用 AI / 某 App / 提示词 / 参数把内容生成或做出来、剪辑 / 排版 / 导出——这是让工具替你做(制作),不是教你怎么创作。
+D. 【应试 / 学术 / 公务 / 政企写作】申论、高考 / 中考作文、答题模板、范文 / 作文素材、论文、公文 / 讲话稿、政务 / 企业材料——目标是拿分数 / 合规范 / 办公务,不是内容创作。
+E. 【成品 / 作品 / 素材本身】一篇具体作品、一份素材或金句合集、纯展示赏析。
+
+只有"语义通顺 且 指向教人怎么创作内容的方法(选题 / 构思 / 脚本 / 结构 / 标题 / 封面 / 叙事 / 起号 / 涨粉)"才保留(keep=true)。把握不准、但确实在讲"怎么创作内容"的,倾向保留;明显命中 A–E 任一,才排除。
+
+严格只输出 JSON 数组,每个候选一项,idx 对应输入序号,不要任何额外文字 / 解释 / markdown 围栏:
+[{"idx":0,"keep":true,"reason":"≤30字,说明保留或排除的原因"}]

File diff suppressed because it is too large
+ 0 - 0
acquisition/web/app/dist/assets/index-C_LZCGg-.css


File diff suppressed because it is too large
+ 1 - 1
acquisition/web/app/dist/assets/index-D_CDTkr4.js


File diff suppressed because it is too large
+ 0 - 0
acquisition/web/app/dist/assets/index-DlEf_eIX.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-0mMR3Azo.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-DlEf_eIX.css">
+    <script type="module" crossorigin src="/app/assets/index-D_CDTkr4.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-C_LZCGg-.css">
   </head>
   <body>
     <div id="root"></div>

+ 18 - 8
acquisition/web/app/src/App.jsx

@@ -1,11 +1,13 @@
 import React, { useEffect, useState } from 'react'
 import QueriesPage from './pages/QueriesPage.jsx'
 import QueryDetail from './pages/QueryDetail.jsx'
+import CreationDemo from './pages/CreationDemo.jsx'
 
-// 一页式:主体是「生成的 query」列表,每条 query 后面挂它的真实搜索结果按钮。
-// 点按钮经 hash 路由「真实跳转」进该 query 的视频详情(#/q/<query>),浏览器后退可回列表。
+// 路由:#/cdemo = 创作Query正交Demo;#/q/<query> = 某条query的真实搜索详情;其余 = 找帖子列表。
 function parseHash() {
-  const m = (window.location.hash || '').match(/^#\/q\/(.+)$/)
+  const h = window.location.hash || ''
+  if (h.startsWith('#/cdemo')) return { view: 'cdemo' }
+  const m = h.match(/^#\/q\/(.+)$/)
   return m ? { view: 'detail', query: decodeURIComponent(m[1]) } : { view: 'list' }
 }
 
@@ -19,11 +21,19 @@ export default function App() {
 
   return (
     <div className="wrap">
-      <h1>创作知识 · 找帖子</h1>
-      <div className="sub">每条生成的 query 直接挂它搜到的真实帖子/视频 · 数据走 /api(SQLite 分页)</div>
-      {route.view === 'detail'
-        ? <QueryDetail query={route.query} />
-        : <QueriesPage />}
+      <div className="topnav">
+        <a className={route.view === 'cdemo' ? '' : 'on'} href="#/">找帖子</a>
+        <a className={route.view === 'cdemo' ? 'on' : ''} href="#/cdemo">创作Query正交Demo</a>
+      </div>
+      {route.view === 'cdemo' ? (
+        <CreationDemo />
+      ) : (
+        <>
+          <h1>创作知识 · 找帖子</h1>
+          <div className="sub">每条生成的 query 直接挂它搜到的真实帖子/视频 · 数据走 /api(SQLite 分页)</div>
+          {route.view === 'detail' ? <QueryDetail query={route.query} /> : <QueriesPage />}
+        </>
+      )}
     </div>
   )
 }

+ 59 - 0
acquisition/web/app/src/pages/CreationDemo.jsx

@@ -0,0 +1,59 @@
+import React, { useEffect, useState } from 'react'
+
+// 创作Query正交 Demo:仿"制作query"的多列 UI——左侧若干「轴列」(实质/形式/目的池/阶段/知识类型),
+// 右侧「Query 词」列出该家族机械正交出的原串(过 query_filter 后标 留/扔)。顶部 5 家族切换。
+const AXIS_KEY = {        // 家族里写的轴名 → axis_values 里的键
+  '实质': '实质', '形式': '形式', '作用/感受/意图': '目的池',
+  '创作阶段': '创作阶段', '知识类型': '知识类型',
+}
+
+export default function CreationDemo() {
+  const [data, setData] = useState(null)
+  const [fi, setFi] = useState(0)
+  useEffect(() => {
+    fetch('/data/queries/creation_demo.json?v=' + Date.now())
+      .then(r => r.ok ? r.json() : null).then(setData).catch(() => setData(null))
+  }, [])
+
+  if (!data) {
+    return <div className="empty">暂无 demo 数据。先跑 <code>scripts/build_creation_demo.py</code></div>
+  }
+  const fam = data.families[fi]
+  const kept = fam.items.filter(it => it.keep).length
+
+  return (
+    <div>
+      <h1>创作知识 · Query 正交 Demo</h1>
+      <div className="sub">5 套家族机械正交(取分类树创作支)→ LLM 只做排除(query_filter)→ 原串直接可搜(本 demo 不真搜)</div>
+
+      <div className="nav">
+        {data.families.map((f, i) => (
+          <button key={f.key} className={i === fi ? 'on' : ''} onClick={() => setFi(i)}>{f.name}</button>
+        ))}
+      </div>
+
+      <div className="cdemo">
+        {fam.axes.map(ax => (
+          <div className="axcol" key={ax}>
+            <div className="axhd">{ax}<span className="gn">{(data.axis_values[AXIS_KEY[ax]] || []).length}</span></div>
+            <div className="axlist">
+              {(data.axis_values[AXIS_KEY[ax]] || []).map((v, i) => <div className="axv" key={i}>{v}</div>)}
+            </div>
+          </div>
+        ))}
+        <div className="axcol qcol">
+          <div className="axhd qhd">Query 词<span className="gn">留 {kept}/{fam.items.length}</span></div>
+          <div className="axlist">
+            {fam.items.map((it, i) => (
+              <div className={`qrow ${it.keep ? 'keep' : 'drop'}`} key={i} title={it.reason || ''}>
+                <span className="qmark">{it.keep ? '✓' : '✕'}</span>
+                <span className="qtext">{it.query}</span>
+                {!it.keep && <span className="qreason">{it.reason}</span>}
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 24 - 1
acquisition/web/app/src/styles.css

@@ -23,8 +23,31 @@ a:hover { text-decoration: underline; }
 h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
 .sub { color: var(--muted); font-size: 12.5px; margin-bottom: 18px; }
 
+/* 页面切换顶栏 */
+.topnav { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
+.topnav a { padding: 8px 2px; color: var(--muted); font-size: 14px; border-bottom: 2px solid transparent; margin-bottom: -1px; }
+.topnav a.on { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
+
+/* 创作Query Demo:多列(轴列 + Query列) */
+.cdemo { display: flex; gap: 10px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
+.axcol { flex: 0 0 auto; width: 150px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); overflow: hidden; }
+.axcol.qcol { flex: 1 1 auto; min-width: 300px; }
+.axhd { font-size: 13px; font-weight: 700; padding: 9px 11px; border-bottom: 1px solid var(--line); background: #fafafb;
+  display: flex; align-items: center; justify-content: space-between; gap: 6px; }
+.axhd.qhd { color: var(--accent); }
+.axhd .gn { font-size: 11px; font-weight: 600; color: var(--muted); background: #f0f0f3; border-radius: 10px; padding: 1px 7px; }
+.axlist { max-height: 62vh; overflow: auto; }
+.axv { font-size: 12.5px; padding: 5px 11px; border-bottom: 1px solid #f3f3f5; color: #555; white-space: nowrap; }
+.qrow { display: flex; align-items: baseline; gap: 7px; padding: 6px 11px; border-bottom: 1px solid #f3f3f5; font-size: 13px; }
+.qrow.keep .qmark { color: var(--ok); font-weight: 700; }
+.qrow.drop { color: var(--muted); }
+.qrow.drop .qmark { color: var(--bad); }
+.qrow.drop .qtext { text-decoration: line-through; }
+.qtext { flex: 0 0 auto; }
+.qreason { color: var(--muted); font-size: 11.5px; }
+
 /* 顶部主导航 */
-.nav { display: flex; gap: 8px; margin-bottom: 16px; }
+.nav { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
 .nav button {
   border: 1px solid var(--line); background: var(--card); color: var(--ink);
   padding: 7px 16px; border-radius: 9px; cursor: pointer; font-size: 13.5px;

+ 127 - 0
scripts/build_creation_demo.py

@@ -0,0 +1,127 @@
+"""创作知识 query 正交 demo:5 套家族机械正交 → LLM 只做排除(query_filter.txt) → 存 JSON。
+
+不真实搜,只产 query 供前端看。轴严格取分类树的"创作支":
+  实质 = 实质树·理念支(排除表象)   形式 = 形式树·架构支(排除呈现)
+  目的池 = 作用树 + 感受树 + 意图树 全部合一(随机取)
+  创作阶段 = 灵感/选题/脚本(不补)  知识类型 = 怎么做/有哪些/为什么
+5 家族:① 实质×形式 ② 实质×目的池 ③ 纯形式 ④ 形式×目的池 ⑤ 创作阶段×实质,各 20 条。
+每条原串过 query_filter.txt(keep/排除),存 keep+reason 供前端展示。
+用法:PYTHONPATH=. CK_ENV_FILE=.env python scripts/build_creation_demo.py
+"""
+from __future__ import annotations
+
+import json
+import random
+from pathlib import Path
+
+import httpx
+
+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
+STAGES = ["灵感", "选题", "脚本"]
+KTYPE = ["怎么做", "有哪些", "为什么"]
+
+
+def _segs(p):
+    return [x for x in (p or "").split("/") if x]
+
+
+def _leaves(idx, source_type, under=None):
+    """某树某支下的叶子节点名(没有更深子节点的)。under 限定分支(如 实质 限 理念、形式 限 架构)。"""
+    paths = [(_segs(n["path"]), n.get("name")) for n in idx if n.get("source_type") == source_type]
+    if under:
+        paths = [(s, nm) for s, nm in paths if under in s]
+    allp = {"/".join(s) for s, _ in paths}
+    out, seen = [], set()
+    for s, nm in paths:
+        if len(s) < 2:
+            continue
+        full = "/".join(s)
+        is_leaf = not any(o != full and o.startswith(full + "/") for o in allp)
+        name = nm or s[-1]
+        if is_leaf and name and name not in seen:
+            seen.add(name)
+            out.append(name)
+    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)
+    idx = json.loads(TREES.read_text("utf-8"))
+    SHI = _leaves(idx, "实质", under="理念")
+    XING = _leaves(idx, "形式", under="架构")
+    POOL = _leaves(idx, "作用") + _leaves(idx, "感受") + _leaves(idx, "意图")
+    print(f"实质 {len(SHI)} / 形式 {len(XING)} / 目的池 {len(POOL)} / 阶段 {len(STAGES)}")
+
+    def pick(seq):
+        return rng.choice(seq)
+
+    # 每家族:生成器 + 用到的轴(给前端标列)
+    families = [
+        {"key": "f1", "name": "实质 × 形式", "axes": ["实质", "形式", "知识类型"],
+         "gen": lambda: {"parts": {"实质": pick(SHI), "形式": pick(XING), "知识类型": pick(KTYPE)}}},
+        {"key": "f2", "name": "实质 × 目的池", "axes": ["实质", "作用/感受/意图", "知识类型"],
+         "gen": lambda: {"parts": {"实质": pick(SHI), "目的": pick(POOL), "知识类型": pick(KTYPE)}}},
+        {"key": "f3", "name": "纯形式", "axes": ["形式", "知识类型"],
+         "gen": lambda: {"parts": {"形式": pick(XING), "知识类型": pick(KTYPE)}}},
+        {"key": "f4", "name": "形式 × 目的池", "axes": ["形式", "作用/感受/意图", "知识类型"],
+         "gen": lambda: {"parts": {"形式": pick(XING), "目的": pick(POOL), "知识类型": pick(KTYPE)}}},
+        {"key": "f5", "name": "创作阶段 × 实质", "axes": ["实质", "创作阶段", "知识类型"],
+         "gen": lambda: {"parts": {"实质": pick(SHI), "创作阶段": pick(STAGES), "知识类型": pick(KTYPE)}}},
+    ]
+    # 各部件按固定顺序拼成原串
+    order = ["实质", "形式", "目的", "创作阶段", "知识类型"]
+
+    out = {"axis_values": {"实质": SHI, "形式": XING, "目的池": POOL, "创作阶段": STAGES, "知识类型": KTYPE},
+           "families": []}
+    for fam in families:
+        seen, items = set(), []
+        while len(items) < PER and len(seen) < PER * 40:
+            parts = fam["gen"]()["parts"]
+            q = " ".join(parts[k] for k in order if k in parts)
+            if q in seen:
+                continue
+            seen.add(q)
+            items.append({"query": q, "parts": parts})
+        verdicts = _filter([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"])
+        print(f"[{fam['name']}] 生成 {len(items)} 条, 筛后保留 {kept}")
+        out["families"].append({"key": fam["key"], "name": fam["name"], "axes": fam["axes"], "items": items})
+
+    OUT.parent.mkdir(parents=True, exist_ok=True)
+    OUT.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
+    print(f"→ {OUT}")
+
+
+if __name__ == "__main__":
+    main()

Some files were not shown because too many files changed in this diff