ソースを参照

M7 API + M8 Web 可视化

- api.py:FastAPI,GET /api/posts、/api/posts/{id}、/api/posts/{id}/items;托管单页前端
- db.py:posts_overview(每帖 + 知识片段数)
- web/index.html:单页 React(CDN,无构建),左列表 + 右流水线面板
  (原文→多模态提取→筛选→知识片段→ingest payload)
- 云端验证:5 帖列表/详情/片段接口全 200,页面 200

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lisihan 1 ヶ月 前
コミット
920e769d33
3 ファイル変更216 行追加0 行削除
  1. 55 0
      creation_knowledge/api.py
  2. 11 0
      creation_knowledge/integrations/db.py
  3. 150 0
      web/index.html

+ 55 - 0
creation_knowledge/api.py

@@ -0,0 +1,55 @@
+"""FastAPI:把 ck_post / ck_knowledge_item 暴露给 web 可视化,并托管单页前端。
+
+启动:
+    CK_ENV_FILE=/path/.env uvicorn creation_knowledge.api:app --host 0.0.0.0 --port 8900
+API 在 /api/*,前端单页在 /。
+"""
+from __future__ import annotations
+
+import os
+from functools import lru_cache
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+
+from creation_knowledge.config import PgConfig
+from creation_knowledge.integrations.db import CkStore
+
+WEB_DIR = Path(__file__).resolve().parent.parent / "web"
+
+
+@lru_cache(maxsize=1)
+def _store() -> CkStore:
+    env_file = os.getenv("CK_ENV_FILE", ".env")
+    return CkStore(PgConfig.from_env(env_file))
+
+
+app = FastAPI(title="创作知识")
+app.add_middleware(
+    CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
+)
+
+
+@app.get("/api/posts")
+def list_posts() -> list[dict]:
+    return _store().posts_overview()
+
+
+@app.get("/api/posts/{post_id}")
+def get_post(post_id: str) -> dict:
+    post = _store().read_post(post_id)
+    if not post:
+        raise HTTPException(status_code=404, detail="post not found")
+    return post
+
+
+@app.get("/api/posts/{post_id}/items")
+def get_items(post_id: str) -> list[dict]:
+    return _store().read_items(post_id)
+
+
+# 单页前端挂在最后(catch-all),不影响上面的 /api 路由
+if WEB_DIR.exists():
+    app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")

+ 11 - 0
creation_knowledge/integrations/db.py

@@ -147,6 +147,17 @@ class CkStore:
             (limit,),
         )
 
+    def posts_overview(self, limit: int = 100) -> list[dict]:
+        """列表页用:每帖基础字段 + 知识片段数。"""
+        return self._fetch_all(
+            "SELECT p.id, p.platform, p.url, p.stage, p.created_at, p.updated_at, "
+            "count(k.id) AS item_count "
+            "FROM ck_post p LEFT JOIN ck_knowledge_item k ON k.post_id = p.id "
+            "GROUP BY p.id, p.platform, p.url, p.stage, p.created_at, p.updated_at "
+            "ORDER BY p.created_at DESC LIMIT %s",
+            (limit,),
+        )
+
     def table_columns(self, table: str) -> list[str]:
         rows = self._fetch_all(
             "SELECT column_name FROM information_schema.columns "

+ 150 - 0
web/index.html

@@ -0,0 +1,150 @@
+<!doctype html>
+<html lang="zh">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>创作知识 · 流水线可视化</title>
+<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
+<style>
+  :root{--bg:#0f1115;--panel:#171a21;--panel2:#1e222b;--line:#2a2f3a;--fg:#e6e8ec;--mut:#8b93a1;--ac:#5b9dff;--ok:#36c08a;--no:#e0556b;--warn:#e0a23a;}
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif}
+  header{padding:14px 20px;border-bottom:1px solid var(--line);font-weight:600;display:flex;gap:10px;align-items:center}
+  header .sub{color:var(--mut);font-weight:400;font-size:13px}
+  .wrap{display:flex;height:calc(100vh - 51px)}
+  .side{width:300px;border-right:1px solid var(--line);overflow:auto;flex:none}
+  .main{flex:1;overflow:auto;padding:20px}
+  .pitem{padding:12px 16px;border-bottom:1px solid var(--line);cursor:pointer}
+  .pitem:hover{background:var(--panel)}
+  .pitem.sel{background:var(--panel2);border-left:3px solid var(--ac)}
+  .pitem .id{font-size:12px;color:var(--mut);word-break:break-all}
+  .pitem .row{display:flex;gap:8px;align-items:center;margin-top:4px}
+  .badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;background:var(--panel2);color:var(--mut);border:1px solid var(--line)}
+  .badge.ok{background:rgba(54,192,138,.15);color:var(--ok);border-color:transparent}
+  .badge.no{background:rgba(224,85,107,.15);color:var(--no);border-color:transparent}
+  .badge.ac{background:rgba(91,157,255,.15);color:var(--ac);border-color:transparent}
+  .badge.warn{background:rgba(224,162,58,.15);color:var(--warn);border-color:transparent}
+  .stage{background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:16px;overflow:hidden}
+  .stage>h3{margin:0;padding:10px 16px;background:var(--panel2);font-size:13px;display:flex;gap:8px;align-items:center}
+  .stage>h3 .n{width:20px;height:20px;border-radius:50%;background:var(--ac);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px}
+  .stage .body{padding:14px 16px}
+  .kv{color:var(--mut);font-size:12px;margin:0 0 4px}
+  .imgs{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
+  .imgs img{width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--line)}
+  .card{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:12px;margin-bottom:10px}
+  .card h4{margin:0 0 8px;font-size:14px}
+  .chips{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0}
+  pre{white-space:pre-wrap;word-break:break-word;background:#0c0e12;border:1px solid var(--line);border-radius:6px;padding:10px;font-size:12px;color:#c8cdd6;margin:6px 0 0;max-height:260px;overflow:auto}
+  details summary{cursor:pointer;color:var(--mut);font-size:12px;margin-top:8px}
+  .whh{margin:8px 0}
+  .whh b{color:var(--ac);font-size:12px}
+  a{color:var(--ac)}
+  .empty{color:var(--mut);padding:40px;text-align:center}
+</style>
+</head>
+<body>
+<div id="root"></div>
+<script type="text/babel">
+const {useState,useEffect} = React;
+const j = (u)=>fetch(u).then(r=>r.json());
+
+function Badge({kind,children}){return <span className={"badge "+(kind||"")}>{children}</span>;}
+
+function PostList({posts,sel,onSel}){
+  return <div className="side">
+    {posts.map(p=>{
+      const stageKind = p.stage==="done"?"ok":p.stage==="rejected"?"no":p.stage==="failed"?"no":"warn";
+      return <div key={p.id} className={"pitem"+(sel===p.id?" sel":"")} onClick={()=>onSel(p.id)}>
+        <div className="id">{p.id}</div>
+        <div className="row">
+          <Badge kind={stageKind}>{p.stage}</Badge>
+          <Badge kind="ac">{p.item_count} 片段</Badge>
+        </div>
+      </div>;
+    })}
+  </div>;
+}
+
+function Stage({n,title,children}){
+  return <div className="stage"><h3><span className="n">{n}</span>{title}</h3><div className="body">{children}</div></div>;
+}
+
+function ItemCard({it}){
+  const item=it.item||{}, deco=it.deconstruction||{}, payload=it.ingest_payload||{};
+  const ist=it.ingest_status;
+  return <div className="card">
+    <h4>{item.title||"(无标题)"}</h4>
+    <div className="chips">
+      {(item.knowledge_types||[]).map(t=><Badge key={t} kind="ac">{t}</Badge>)}
+      {(deco.stages||[]).map(s=><Badge key={s}>{s}</Badge>)}
+      <Badge kind={ist==="ingested"?"ok":ist==="failed"?"no":"warn"}>{ist}</Badge>
+    </div>
+    {item.what&&<div className="whh"><b>What</b><div>{item.what}</div></div>}
+    {item.why&&<div className="whh"><b>Why</b><div>{item.why}</div></div>}
+    {item.how&&<div className="whh"><b>How</b><div>{item.how}</div></div>}
+    <div className="chips">
+      {(deco.scopes||[]).map((s,i)=><Badge key={i}>{s.scope_type}: {s.value}</Badge>)}
+    </div>
+    <details><summary>ingest payload</summary>
+      <pre>{JSON.stringify(payload,null,2)}</pre>
+    </details>
+  </div>;
+}
+
+function Pipeline({postId}){
+  const [post,setPost]=useState(null);
+  const [items,setItems]=useState([]);
+  useEffect(()=>{ if(!postId)return;
+    setPost(null);setItems([]);
+    j("/api/posts/"+postId).then(setPost);
+    j("/api/posts/"+postId+"/items").then(setItems);
+  },[postId]);
+  if(!postId) return <div className="empty">← 左边选一个帖子</div>;
+  if(!post) return <div className="empty">加载中…</div>;
+  const inner=(post.raw&&post.raw.data&&post.raw.data.data)||{};
+  const ex=post.extracted||{}, scr=post.screening||{};
+  const imgs=(inner.image_url_list||[]).map(x=>x.image_url||x).slice(0,9);
+  return <div>
+    <Stage n="1" title="原文(拉取)">
+      <p className="kv">作者 {inner.channel_account_name} · 类型 {inner.content_type} · <a href={post.url} target="_blank">原链接</a></p>
+      <div><b>{inner.title||"(无标题)"}</b></div>
+      <pre>{inner.body_text||"(正文为空)"}</pre>
+      <div className="imgs">{imgs.map((u,i)=><img key={i} src={u} onError={e=>e.target.style.display='none'}/>)}</div>
+    </Stage>
+    <Stage n="2" title="多模态提取(Gemini)">
+      <div className="chips"><Badge kind={ex.is_empty?"no":"ok"}>is_empty: {String(ex.is_empty)}</Badge></div>
+      {ex.from_image&&<div className="whh"><b>图片提取</b><div>{ex.from_image}</div></div>}
+      {ex.from_video&&<div className="whh"><b>视频提取</b><div>{ex.from_video}</div></div>}
+      {ex.text&&<pre>{ex.text}</pre>}
+    </Stage>
+    <Stage n="3" title="筛选">
+      <div className="chips">
+        <Badge kind={scr.passed?"ok":"no"}>{scr.passed?"passed":"rejected"}</Badge>
+        <Badge kind="ac">score {scr.score}</Badge>
+      </div>
+      <div>{scr.reason}</div>
+    </Stage>
+    <Stage n="4" title={"知识片段("+items.length+")"}>
+      {items.length? items.map((it,i)=><ItemCard key={i} it={it}/>) : <div className="kv">无(被筛选淘汰或未拆出)</div>}
+    </Stage>
+  </div>;
+}
+
+function App(){
+  const [posts,setPosts]=useState([]);
+  const [sel,setSel]=useState(null);
+  useEffect(()=>{ j("/api/posts").then(ps=>{setPosts(ps); if(ps[0])setSel(ps[0].id);}); },[]);
+  return <div>
+    <header>创作知识 <span className="sub">· 帖子知识流水线可视化({posts.length} 帖)</span></header>
+    <div className="wrap">
+      <PostList posts={posts} sel={sel} onSel={setSel}/>
+      <div className="main"><Pipeline postId={sel}/></div>
+    </div>
+  </div>;
+}
+ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
+</script>
+</body>
+</html>