Procházet zdrojové kódy

前端:流程流水线带 + 提示词真源路由 + 作用域树名 + 空态卡

- 顶部常驻「从帖子拆解知识」4 步流水线(读懂/拆出/归类/入库),点步看「为什么这么做」+ 提示词
- api.py 挂 /prompt-skill /prompt-read 只读路由,前端直接 fetch .md/.txt 真源(不再写死副本、不漂移)
- 颗级作用域 chip 补树名前缀(感受/实质/作用…)并放大;删主/组件徽章、业务阶段写全
- knowledges 为空时显示「这帖没有可提取的创作知识」卡

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SamLee před 1 měsícem
rodič
revize
048af5e9cf
3 změnil soubory, kde provedl 83 přidání a 20 odebrání
  1. 4 0
      .gitignore
  2. 11 2
      creation_knowledge/api.py
  3. 68 18
      web/index.html

+ 4 - 0
.gitignore

@@ -27,3 +27,7 @@ scope_trees/
 # 杂项
 .DS_Store
 *.log
+
+# 本地 preview 用的提示词真源软链(云端由 FastAPI 挂载)
+web/prompt-skill
+web/prompt-read

+ 11 - 2
creation_knowledge/api.py

@@ -16,7 +16,10 @@ from fastapi.staticfiles import StaticFiles
 
 from creation_knowledge.config import Settings
 
-WEB_DIR = Path(__file__).resolve().parent.parent / "web"
+_ROOT = Path(__file__).resolve().parent.parent
+WEB_DIR = _ROOT / "web"
+SKILL_EX_DIR = _ROOT / "创作知识提取-skill" / "extraction"  # ②③ 提示词真源
+READ_PROMPT_DIR = _ROOT / "prompts"  # ① 读懂提示词真源
 
 
 def _env_file() -> str:
@@ -43,6 +46,12 @@ _data_dir = Path(_settings().data_dir or "data")
 _data_dir.mkdir(parents=True, exist_ok=True)
 app.mount("/data", StaticFiles(directory=str(_data_dir)), name="data")
 
-# 单页前端挂在最后(catch-all),不影响 /frames、/data
+# 提示词真源(前端「看提示词」直接 fetch,不再写死副本,避免漂移)
+if SKILL_EX_DIR.exists():
+    app.mount("/prompt-skill", StaticFiles(directory=str(SKILL_EX_DIR)), name="prompt-skill")
+if READ_PROMPT_DIR.exists():
+    app.mount("/prompt-read", StaticFiles(directory=str(READ_PROMPT_DIR)), name="prompt-read")
+
+# 单页前端挂在最后(catch-all),不影响 /frames、/data、/prompt-*
 if WEB_DIR.exists():
     app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")

+ 68 - 18
web/index.html

@@ -30,6 +30,21 @@
   .srcimgs{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
   .srcimgs img{width:80px;height:80px;object-fit:cover;border-radius:4px;border:1px solid #e2e8f0;cursor:zoom-in}
   .barhint{margin:8px 18px 0;font-size:11.5px;color:#6b7280}
+  .ribbon{margin:11px 18px 0;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:10px 14px}
+  .rcap{font-size:11.5px;color:#6b7280;margin-bottom:8px}
+  .rsteps{display:flex;align-items:stretch;flex-wrap:wrap}
+  .rstep{flex:1;min-width:148px;border:1px solid #e2e8f0;border-radius:8px;padding:9px 11px;cursor:pointer;background:#f8fafc}
+  .rstep:hover{background:#ecfeff;border-color:#99d5d0}
+  .rstep.auto{cursor:default;background:#f1f5f9;opacity:.9}.rstep.auto:hover{background:#f1f5f9;border-color:#e2e8f0}
+  .rtop{display:flex;align-items:center;gap:7px}
+  .rn{width:18px;height:18px;border-radius:50%;background:#0e7490;color:#fff;font-size:11px;display:flex;align-items:center;justify-content:center;flex:none}
+  .rstep.auto .rn{background:#94a3b8}
+  .rlab{font-size:13.5px;font-weight:600}
+  .rdesc{font-size:11.5px;color:#6b7280;margin:5px 0 7px;line-height:1.5}
+  .rbtn{font-size:11.5px;color:#0e7490}.rstep.auto .rbtn{color:#94a3b8}
+  .rarrow{display:flex;align-items:center;color:#cbd5e1;font-size:18px;padding:0 4px;flex:none}
+  .pmwhy{font-size:12.5px;color:#92400e;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;padding:8px 11px;margin-bottom:12px;line-height:1.6}
+  .pmwhy b{color:#78350f}
   .lbtn{padding:3px 9px;border:1px solid #cbd5e1;border-radius:5px;background:#fff;cursor:pointer;font-size:11.5px;color:#334155;font-family:inherit}
   .lbtn:hover{background:#f1f5f9}.lbtn.on{background:#fef3c7;border-color:#d97706;color:#92400e;font-weight:600}
   /* 颗卡 */
@@ -85,7 +100,8 @@
   .kscopes .lab{font-size:11px;color:#9ca3af}
   .sv{display:inline-block;cursor:pointer;font-size:11.5px;padding:1px 5px;border-radius:3px;border:1px solid rgba(0,0,0,.08);background:rgba(255,255,255,.7);margin:1px 0}
   .sv:hover{box-shadow:0 0 0 1px #1e293b}
-  .sv .k{font-size:9px;opacity:.6;font-weight:700;margin-right:3px}
+  .sv .k{font-size:11.5px;opacity:.85;font-weight:700;margin-right:4px}
+  table.proc th.c-scope{font-size:13.5px}
   .sv .mk{font-size:9px;padding:0 3px;border-radius:2px;margin-left:3px;vertical-align:top}
   .mk-new{background:#fde68a;color:#92400e}.mk-reuse{background:#bbf7d0;color:#166534}
   .sv.s-substance{background:#fef2f2}.sv.s-form{background:#eff6ff}.sv.s-feeling{background:#fdf2f8}.sv.s-effect{background:#f0fdf4}.sv.s-intent{background:#fffbeb}
@@ -116,21 +132,45 @@
 const {useState,useEffect}=React;
 const j=u=>fetch(u,{cache:"no-store"}).then(r=>r.json());
 const SCOPES=[["substance","实质"],["form","形式"],["feeling","感受"],["effect","作用"],["intent","意图"]];
+const SC_CN={substance:"实质",form:"形式",feeling:"感受",effect:"作用",intent:"意图"};
 const TYPECN={how:"How 工序",what:"What 构成",why:"Why 原理"};
 
-const PROMPTS={
-  read:{title:"① 读懂内容 · 提示词",text:`把帖子的图片/视频忠实读成文字,作为后面拆颗的原料。\n· 只记录帖子真实讲了什么——不评价、不替它发挥、不补帖子没有的例子。\n· 图里的要点、视频里的讲解都转写成文字。\n· 这一步只读、不判断、不拆分归类。`},
-  frame:{title:"② 判颗 + 类型闸 + 三 lane 成形 · 提示词",text:`颗是单位,一帖 = N 颗(可混 how/what/why)。先判类型闸,再按类型成形。\n\n【拆颗顺序】\n有 how 工序?→ 抽 how 骨架;how 步骤里嵌的 what/why 既显示在步骤里,又【单列成组件颗】(parent 指回第 N 步);how 没覆盖的独立 what/why 也单列;没 how 就是纯 what/why。\n别硬造假 how("7 种叙事结构"当菜单或单列成 what,别掰成 how 框架)。\n\n【三类成形】\n· How:目标 + 不可跳步 steps(目的/怎么做/产出,原帖例子原文保留)\n· What:界定 + 构成要素[{要素,说明}]\n· Why:主张 + 依据 + 对创作的影响\n\n【组件判据】脱离这个 how 还独立成立、能单独检索复用吗?是→单列成颗。\n\n【轻标签】知识类型 / 业务阶段(颗级) / 创作阶段(仅how逐步) / 动作(仅how逐步)。`},
-  scope:{title:"③ 作用域 · 提示词(重)",text:`给每颗标作用域:5 类里相关的几类,每类一个具体短值,再回扣分类树。how 逐步标、what/why 颗级标。\n\n【五类+互斥边界】实质=讲什么(对象);形式=怎么呈现(结构/手法);感受=勾什么情绪;作用=起什么功能(段内干的活);意图=图什么(最终目的)。\n· 实质 vs 作用:「赛道共识」实质、「撕裂共识」作用。\n· 作用 vs 意图:「制造悬念」作用、「引爆传播」意图。\n· 感受 vs 意图:「羞耻感」观众感受、「引爆传播」作者意图。\n\n【值】短词、原子、不跨类重复、不用泛词;判不准就留空。\n【回扣】每个值用火山 embedding 找最近邻:≥0.90 复用树原名,否则新值(丰富树)。点作用域值看回扣详情。`}
-};
+const STEPS=[
+  {key:"read",label:"读懂帖子",desc:"把图、视频读成文字",
+   why:"创作知识常在图里、不在正文;这一步只忠实转写、不判断,避免一步干两件事。",
+   src:["/prompt-read/extract.txt"]},
+  {key:"frame",label:"拆出知识",desc:"从文字里拆出一条条知识,理清每条是 怎么做 / 是什么 / 为什么",
+   why:"先判类型再拆——三类成形不同,默认 how 会把纯 what 硬掰成假 how;how 先吸进步骤里的 what/why,吸不进的才独立成一条。",
+   src:["/prompt-skill/phase1-frame.md"]},
+  {key:"scope",label:"给知识归类",desc:"标好 讲什么 / 起什么作用 / 哪个创作环节,再和已有分类库对上号",
+   why:"这 5 类标签最容易串味,又要中途调向量回扣工具;单独一步写厚,避免塞进一个提示词复杂到死循环。",
+   src:["/prompt-skill/phase2-scope.md"]},
+  {key:"store",label:"存进知识库",desc:"整理成统一格式存起来,供以后调用",
+   why:"纯拼接 + 校验的确定性活,程序自动做——没有提示词,让 AI 来干只会帮倒忙。",
+   src:null}
+];
+const STEPMAP={}; STEPS.forEach(s=>STEPMAP[s.key]=s);
 
-function ScopeChip({x,onScope}){
-  return <span className={"sv s-"+x.scope_type} onClick={()=>onScope(x)}>{x.value}<span className={"mk "+(x.link==="复用"?"mk-reuse":"mk-new")}>{x.link==="复用"?"复":"新"}</span></span>;
+function Ribbon({onOpen}){
+  return <div className="ribbon">
+    <div className="rcap">这套系统怎么「从帖子拆解知识」——点任一步看它的提示词</div>
+    <div className="rsteps">{STEPS.map((s,i)=><React.Fragment key={s.key}>
+      {i>0&&<span className="rarrow">→</span>}
+      <div className={"rstep"+(s.src?"":" auto")} onClick={()=>onOpen(s.key)}>
+        <div className="rtop"><span className="rn">{i+1}</span><span className="rlab">{s.label}</span></div>
+        <div className="rdesc">{s.desc}</div>
+        <div className="rbtn">{s.src?"📄 看提示词":"⚙️ 程序自动"}</div>
+      </div></React.Fragment>)}</div>
+  </div>;
+}
+
+function ScopeChip({x,onScope,showType}){
+  return <span className={"sv s-"+x.scope_type} onClick={()=>onScope(x)}>{showType&&<span className="k">{SC_CN[x.scope_type]||x.scope_type}</span>}{x.value}<span className={"mk "+(x.link==="复用"?"mk-reuse":"mk-new")}>{x.link==="复用"?"复":"新"}</span></span>;
 }
 function KScopes({list,onScope}){
   if(!list||!list.length) return null;
-  return <div className="kscopes"><span className="lab">作用域:</span>
-    {list.map((x,i)=><ScopeChip key={i} x={{...x}} onScope={onScope}/>)}</div>;
+  return <div className="kscopes"><span className="lab">作用域(5 棵树):</span>
+    {list.map((x,i)=><ScopeChip key={i} x={{...x}} onScope={onScope} showType={true}/>)}</div>;
 }
 
 function HowTable({k,onScope,onPrompt}){
@@ -179,9 +219,8 @@ function KnowledgeCard({k,idx,onScope,onPrompt,onJson,payload,how_titles}){
       <span className="kno">第{idx+1}颗</span>
       <span className={"ktype ty-"+k.type}>{TYPECN[k.type]}</span>
       <span className="ktitle">{k.title}</span>
-      <span className="krole">{k.role}</span>
       {par&&<span className="kref" onClick={()=>{var e=document.getElementById("k-"+par.how_id);if(e)e.scrollIntoView({behavior:"smooth",block:"center"});}}>↑ 出自「{how_titles[par.how_id]||par.how_id}」第{par.step}步</span>}
-      {(k.业务阶段||[]).map((b,i)=><span key={i} className="kbiz">{b}</span>)}
+      {(k.业务阶段||[]).map((b,i)=><span key={i} className="kbiz">业务阶段:{b}</span>)}
       <span className="kacts">{payload&&<button className="lbtn" onClick={()=>onJson(payload)}>⚙️ 入库 payload</button>}</span>
     </div>
     <div className="kbody">
@@ -196,13 +235,20 @@ function App(){
   const [items,setItems]=useState([]);
   const [sel,setSel]=useState(0);
   const [scope,setScope]=useState(null);
-  const [prompt,setPrompt]=useState(null);
+  const [pm,setPm]=useState(null);
   const [json,setJson]=useState(null);
   const [zoom,setZoom]=useState(null);
   const [hl,setHl]=useState(false);
   useEffect(()=>{Promise.all([j("/frameworks.json"),j("/payloads.json").catch(()=>[])]).then(([f,pl])=>{
     setItems((f.posts||[]).map(p=>({...p,payloads:(pl||[]).filter(x=>(x.source||{}).id==="xhs_"+p.post_id)})));});},[]);
   useEffect(()=>{document.body.classList.toggle("hl",hl);},[hl]);
+  function openPrompt(key){
+    const st=STEPMAP[key]; if(!st) return;
+    if(!st.src){ setPm({label:st.label,why:st.why,text:"(这一步是程序自动完成的,没有提示词。)"}); return; }
+    setPm({label:st.label,why:st.why,text:"加载中…"});
+    Promise.all(st.src.map(u=>fetch(u,{cache:"no-store"}).then(r=>r.ok?r.text():"(未找到 "+u+")").catch(()=>"(加载失败 "+u+")")))
+      .then(txts=>setPm({label:st.label,why:st.why,text:txts.join("\n\n──────\n\n")}));
+  }
   if(!items.length) return <div className="empty">加载中…</div>;
   const it=items[sel];
   const ks=it.knowledges||[];
@@ -217,7 +263,8 @@ function App(){
       </div>
     </header>
     <div className="scroll">
-      <details className="src"><summary>① 读懂 · 原帖:{it.title} <span className="srcprompt" onClick={e=>{e.preventDefault();e.stopPropagation();setPrompt("read");}}>▸ 读懂提示词</span></summary>
+      <Ribbon onOpen={openPrompt}/>
+      <details className="src"><summary>原帖:{it.title}(这一帖的原料)</summary>
         <div className="srcbody"><a href={it.url} target="_blank" rel="noreferrer">{it.url}</a> · {it.platform}
           <div className="srcimgs">{imgs.map((u,i)=><img key={i} src={u} onClick={()=>setZoom(u)} onError={e=>e.target.style.display='none'}/>)}</div></div>
       </details>
@@ -225,7 +272,8 @@ function App(){
         <span>这一帖拆出 <b>{ks.length}</b> 颗知识(how 工序表 / what 要素表 / why 论点卡);组件颗标「出自某 how 第 N 步」可点跳。</span>
         <button className={"lbtn"+(hl?" on":"")} onClick={()=>setHl(!hl)} style={{marginLeft:"auto"}}>高亮「新值」</button>
       </div>
-      {ks.map((k,i)=><KnowledgeCard key={i} k={k} idx={i} onScope={setScope} onPrompt={setPrompt} onJson={setJson} payload={payloadOf(k)} how_titles={how_titles}/>)}
+      {ks.length===0 && <div className="kcard"><div className="kbody" style={{padding:"20px 16px",color:"#9ca3af",fontSize:"13px"}}>🚫 这帖<b style={{color:"#6b7280"}}>没有可提取的创作知识</b>(纯作品 / 展示 / 无关)——① 读懂时 <code>is_empty=true</code>,后面拆颗/归类/入库已自动跳过。</div></div>}
+      {ks.map((k,i)=><KnowledgeCard key={i} k={k} idx={i} onScope={setScope} onPrompt={openPrompt} onJson={setJson} payload={payloadOf(k)} how_titles={how_titles}/>)}
     </div>
     {scope&&<div><div className="ov" onClick={()=>setScope(null)}></div>
       <div className="drawer"><h2>作用域回扣详情<span className="x" onClick={()=>setScope(null)}>×</span></h2>
@@ -237,9 +285,11 @@ function App(){
           {(scope.top||[]).map((h,i)=><div className="nn" key={i}><span className="sc">{h.score}</span><span className="nm">{h.name}</span><div className="pth">{h.path}</div></div>)}
           {(!scope.top||!scope.top.length)&&<div style={{color:"#9ca3af"}}>无最近邻</div>}
         </div></div></div>}
-    {prompt&&<div className="modal" onClick={()=>setPrompt(null)}><div className="mbox" onClick={e=>e.stopPropagation()}>
-      <h2>📄 {PROMPTS[prompt].title}<span className="x" onClick={()=>setPrompt(null)}>×</span></h2>
-      <div className="body"><pre>{PROMPTS[prompt].text}</pre></div></div></div>}
+    {pm&&<div className="modal" onClick={()=>setPm(null)}><div className="mbox" onClick={e=>e.stopPropagation()}>
+      <h2>📄 {pm.label} · 这一步的提示词<span className="x" onClick={()=>setPm(null)}>×</span></h2>
+      <div className="body">
+        <div className="pmwhy"><b>为什么这么做:</b>{pm.why}</div>
+        <pre>{pm.text}</pre></div></div></div>}
     {json&&<div className="modal" onClick={()=>setJson(null)}><div className="mbox" onClick={e=>e.stopPropagation()}>
       <h2>⚙️ ingest payload(给机器看)<span className="x" onClick={()=>setJson(null)}>×</span></h2>
       <div className="body"><pre>{JSON.stringify(json,null,2)}</pre></div></div></div>}