Explorar o código

瘦身到干净基建 + 分类树落地(dump/embed/回扣)

任务一 分类树数据(产物 gitignore 于 scope_trees/,本地+云端各一份):
- scripts/dump_trees.py:dump public.global_category → trees.json(4945 节点) + trees_index.json(1855 live)
- scripts/embed_trees.py:火山 Doubao-embedding-vision 向量化 live 节点 → trees_embeddings.npy (1855,2048)
- scripts/scope_link.py:余弦最近邻回扣,已存在值命中 0.999,新值返回语义近邻
- creation_knowledge/embedding.py:火山 embedding 客户端(基建)

任务二 删业务、只留基建:
- 删 stages/、ingest.py、pipeline.py、cli.py、判断类 prompts、sql/、旧业务文档、旧测试与脚本
- 裁 models.py(只剩 Post/Card/ExtractedContent)、db.py(纯 PG 连接 helper)、
  api.py(纯静态服务)、config.py(去 knowhub/ingest)
- 留:取数/媒体落盘/多模态读取(extract*.txt)/llm/embedding/web+frameworks.json

验证:13/13 模块 import 通过、pytest 31 passed、web 8123 / 与 /frameworks.json 200、
真实媒体 /data 200;两个 infra-auditor 子代理交叉验证无业务残留
(agent 定义见 .claude/agents/infra-auditor.md)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SamLee hai 1 mes
pai
achega
b15e8e041e

+ 22 - 0
.claude/agents/infra-auditor.md

@@ -0,0 +1,22 @@
+---
+name: infra-auditor
+description: 审计 Create-knowledge-find-decode 仓库是否「只剩干净基建、零业务逻辑」。读真实代码与真实本地数据(不是凭印象),核对业务已删尽、保留模块可编译可导入、无悬挂引用、基建产物齐全,并列出任何残留。用于代码瘦身后的交叉验证。
+tools: Read, Bash, Grep, Glob
+model: sonnet
+---
+
+你是「基建审计员」。本仓库刚做完一次大瘦身:删掉了旧的知识提取业务(筛选/拆分/解构/组装/入库 + 旧业务文档),只应保留**基建**——多平台取数、媒体落盘、多模态读取、config/llm/db 连接、web 静态服务、火山 embedding 客户端、分类树 dump/embed/回扣 脚本。
+
+**你的判定标准**——基于真实读取,逐条给证据(文件:行 或命令输出):
+
+1. **业务已删尽**:以下不应再存在——`creation_knowledge/stages/`、`ingest.py`、`pipeline.py`、`cli.py`、`prompts/{screen,split,deconstruct}.txt`、`sql/`、旧业务文档(`技术文档/`、`创作知识-重构设计.md`)、对应的旧测试/脚本(test_stages、test_pipeline_e2e、smoke_stages、run_batch、inspect_multi、validate_db、smoke_video_pipeline)。
+2. **无悬挂引用**:grep 全仓,确认没有保留文件还 import 或引用已删符号:`stages`、`pipeline`、`ingest`、`run_pipeline`、`CkStore`、`KnowledgeItem`、`IngestPayload`、`Deconstruction`、`ScreeningResult`、`build_ingest_payload`、`knowhub_api`、`ingest_enabled`。
+3. **保留模块可编译可导入**:对 `creation_knowledge/**.py` 与 `scripts/*.py` 跑 `py_compile`(语法)。如能连云端副本(见下),跑真实 import 与 `pytest -q`。
+4. **基建产物齐全**:本地存在 `scope_trees/trees.json`、`trees_index.json`、`trees_embeddings.npy`(≈1855×2048);`data/` 下有媒体;`web/frameworks.json` 在。
+5. **prompts 边界正确**:`prompts/` 只剩 `extract.txt`、`extract_video.txt`(多模态读取用,属基建),不应有判断类提示词。
+
+**云端副本(带 venv,可跑真实 import / pytest)**:
+- key:`/Users/samlee/Documents/工作环境配置/阿里云海外lsh开发机/ali-denet.pem`
+- `ssh -i "$KEY" sam@47.245.103.121`,仓库在 `~/Create-knowledge-find-decode`,跑命令需 `PYTHONPATH=. .venv/bin/python ...`。
+
+**输出格式**:每条标准给 PASS / FAIL + 证据;最后一节「残留清单」逐条列出任何业务残留或风险(没有就写「无」)。只读审计,不要改动任何文件。

+ 3 - 0
.gitignore

@@ -21,6 +21,9 @@ dist/
 data/
 runtime/
 
+# 分类树 dump + embedding —— 本地保留不入库,可由 scripts/dump_trees.py + embed_trees.py 重生
+scope_trees/
+
 # 杂项
 .DS_Store
 *.log

+ 5 - 3
creation_knowledge/__init__.py

@@ -1,7 +1,9 @@
-"""创作知识 · 从帖子拆出可指导创作的知识
+"""创作知识 · 基建
 
-流水线:fetch -> extract(多模态) -> screen -> split -> deconstruct -> assemble -> [ingest]
-详见 技术文档/技术架构.md 与 技术文档/开发顺序.md。
+提供:多平台取数(integrations.crawler)、媒体落盘(media)、多模态读取
+(integrations.extractor / video_extract / video_frames)、PG 连接(integrations.db)、
+LLM 客户端(integrations.llm)、火山 embedding(embedding)、web 静态服务(api)。
+分类树 dump/embed/回扣 见 scripts/。知识提取本身由外部 skill 完成。
 """
 
 __version__ = "0.1.0"

+ 6 - 57
creation_knowledge/api.py

@@ -1,8 +1,8 @@
-"""FastAPI:把 ck_post / ck_knowledge_item 暴露给 web 可视化,并托管单页前端
+"""FastAPI 静态服务:托管单页前端 + 媒体/抽帧落盘目录
 
 启动:
-    CK_ENV_FILE=/path/.env uvicorn creation_knowledge.api:app --host 0.0.0.0 --port 8900
-API 在 /api/*,前端单页在 /
+    CK_ENV_FILE=/path/.env uvicorn creation_knowledge.api:app --host 0.0.0.0 --port 8123
+前端单页在 /,媒体在 /data,视频帧在 /frames
 """
 from __future__ import annotations
 
@@ -10,17 +10,11 @@ import os
 from functools import lru_cache
 from pathlib import Path
 
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 
-from creation_knowledge.config import PgConfig, Settings
-from creation_knowledge.integrations import extractor as extractor_mod
-from creation_knowledge.integrations.db import CkStore
-from creation_knowledge.prompts import PROMPT_VERSION, load_prompt
-from creation_knowledge.stages import deconstruct as decon_stage
-from creation_knowledge.stages import screen as screen_stage
-from creation_knowledge.stages import split as split_stage
+from creation_knowledge.config import Settings
 
 WEB_DIR = Path(__file__).resolve().parent.parent / "web"
 
@@ -29,11 +23,6 @@ def _env_file() -> str:
     return os.getenv("CK_ENV_FILE", ".env")
 
 
-@lru_cache(maxsize=1)
-def _store() -> CkStore:
-    return CkStore(PgConfig.from_env(_env_file()))
-
-
 @lru_cache(maxsize=1)
 def _settings() -> Settings:
     return Settings.from_env(_env_file())
@@ -44,46 +33,6 @@ 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)
-
-
-@app.get("/api/prompts")
-def get_prompts() -> dict:
-    """各环节用到的提示词(system + user 模板),供前端弹窗查看。"""
-    s = _settings()
-    return {
-        "version": PROMPT_VERSION,
-        "items": [
-            {"key": "extract", "label": "图文提取", "model": s.video_model,
-             "system": extractor_mod._SYSTEM_PROMPT, "user": load_prompt("extract")},
-            {"key": "extract_video", "label": "视频提炼(原生整段)", "model": s.video_model,
-             "system": "(无独立 system,提示词自含)", "user": load_prompt("extract_video")},
-            {"key": "screen", "label": "筛选", "model": s.llm_model,
-             "system": screen_stage.SYSTEM, "user": load_prompt("screen")},
-            {"key": "split", "label": "拆分", "model": s.llm_model,
-             "system": split_stage.SYSTEM, "user": load_prompt("split")},
-            {"key": "deconstruct", "label": "解构", "model": s.llm_model,
-             "system": decon_stage.SYSTEM, "user": load_prompt("deconstruct")},
-        ],
-    }
-
-
 # 视频帧静态服务(card.url = /frames/<post_id>/<file>)
 _frames_dir = Path(_settings().frames_dir)
 _frames_dir.mkdir(parents=True, exist_ok=True)
@@ -94,6 +43,6 @@ _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),不影响上面的 /api、/frames、/data
+# 单页前端挂在最后(catch-all),不影响 /frames、/data
 if WEB_DIR.exists():
     app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")

+ 0 - 44
creation_knowledge/cli.py

@@ -1,44 +0,0 @@
-"""命令行入口:python -m creation_knowledge.cli run --urls <...>"""
-from __future__ import annotations
-
-import argparse
-import json
-
-from creation_knowledge.pipeline import run_pipeline
-
-
-def build_parser() -> argparse.ArgumentParser:
-    parser = argparse.ArgumentParser(prog="creation_knowledge")
-    sub = parser.add_subparsers(dest="command", required=True)
-    run = sub.add_parser("run", help="跑流水线:拉取->提取->筛选->拆分->解构->组装[->入库]")
-    run.add_argument("--urls", nargs="+", required=True, help="帖子链接或 content_id")
-    run.add_argument("--env-file", default=".env", help="环境变量文件路径")
-    run.add_argument("--ingest", action="store_true",
-                     help="开启真实入库(默认关闭,只组装+存库)")
-    run.add_argument("--run-id", default=None,
-                     help="运行批次 id(媒体落盘目录 data/<run_id>/...,默认时间戳)")
-    return parser
-
-
-def main() -> int:
-    args = build_parser().parse_args()
-    if args.command == "run":
-        results = run_pipeline(
-            args.urls,
-            env_file=args.env_file,
-            ingest_enabled=True if args.ingest else False,
-            run_id=args.run_id,
-        )
-        for r in results:
-            print(json.dumps(r, ensure_ascii=False))
-        ok = sum(1 for r in results if r["status"] == "done")
-        rej = sum(1 for r in results if r["status"] == "rejected")
-        skip = sum(1 for r in results if r["status"] == "skipped")
-        fail = len(results) - ok - rej - skip
-        print(f"\n汇总:done={ok} rejected={rej} skipped={skip} failed={fail} "
-              f"/ 共 {len(results)}")
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 1 - 8
creation_knowledge/config.py

@@ -81,11 +81,8 @@ class Settings:
     gemini_api_key: str
     openrouter_base_url: str
     openrouter_api_key: str
-    # 判断/拆分/解构 LLM(M4
+    # 文本步 LLM(多模态读取的辅助文本调用
     llm_model: str
-    # 入库(M5)
-    knowhub_api: str
-    ingest_enabled: bool
     # 卡片 / 抽帧
     max_cards: int
     frames_dir: str
@@ -121,10 +118,6 @@ class Settings:
                 env_value("CONTENT_AGENT_VIDEO_LLM_MODEL", file_env,
                           "google/gemini-3-flash-preview"),
             ),
-            knowhub_api=env_value("KNOWHUB_API", file_env, "http://localhost:8000"),
-            # 开发期默认关闭真实入库;显式置 true 才发送
-            ingest_enabled=env_value("INGEST_ENABLED", file_env, "false").lower()
-            in ("1", "true", "yes"),
             max_cards=int(env_value("CK_MAX_CARDS", file_env, "12")),
             frames_dir=env_value("CK_FRAMES_DIR", file_env, "runtime/frames"),
             douyin_ratio=env_value("CK_DOUYIN_RATIO", file_env, "540p"),

+ 59 - 0
creation_knowledge/embedding.py

@@ -0,0 +1,59 @@
+"""火山方舟 Doubao-embedding-vision 文本向量化客户端(scope-link 用)。
+
+契约见《数据接口与来源/embedding接口.md》:
+  - Endpoint: POST .../api/v3/embeddings/multimodal(vision 版必须走 multimodal)
+  - model = 预置推理接入点 ep-id
+  - 输入 input=[{"type":"text","text":...}],输出 data.embedding = 2048 维
+  - 单条调用(一次请求一个向量),批量靠并发多次请求。
+
+密钥/ep 全部从 .env 读,不硬编码。
+"""
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+import httpx
+
+from creation_knowledge.config import env_value, load_env_file
+
+_DEFAULT_URL = "https://ark.cn-beijing.volces.com/api/v3/embeddings/multimodal"
+
+
+@dataclass
+class ArkEmbedConfig:
+    api_key: str
+    endpoint: str  # ep-id,作为 model 字段传入
+    url: str = _DEFAULT_URL
+    dim: int = 2048
+    timeout: int = 30
+
+    @classmethod
+    def from_env(cls, env_file: str | Path = ".env") -> "ArkEmbedConfig":
+        fe = load_env_file(env_file)
+        return cls(
+            api_key=env_value("ARK_API_KEY", fe, required=True),
+            endpoint=env_value("ARK_EMBEDDING_EP", fe, required=True),
+            url=env_value("ARK_EMBEDDING_URL", fe, _DEFAULT_URL),
+            dim=int(env_value("ARK_EMBEDDING_DIM", fe, "2048")),
+        )
+
+
+def embed_text(text: str, cfg: ArkEmbedConfig, retries: int = 4) -> list[float]:
+    """文本 → 2048 维向量。失败指数退避重试,最终仍失败则抛出。"""
+    payload = {"model": cfg.endpoint, "input": [{"type": "text", "text": text}]}
+    headers = {
+        "Authorization": f"Bearer {cfg.api_key}",
+        "Content-Type": "application/json",
+    }
+    last_err: Exception | None = None
+    for attempt in range(retries):
+        try:
+            resp = httpx.post(cfg.url, json=payload, headers=headers, timeout=cfg.timeout)
+            resp.raise_for_status()
+            return resp.json()["data"]["embedding"]
+        except Exception as err:  # noqa: BLE001 — 网络/限流统一退避重试
+            last_err = err
+            time.sleep(1.0 * (attempt + 1))
+    raise RuntimeError(f"ark embed failed for {text!r}: {last_err}")

+ 0 - 41
creation_knowledge/ingest.py

@@ -1,41 +0,0 @@
-"""入库:调 KnowHub ingest API。开发期默认关闭,只组装+存库(见 技术文档/技术架构.md)。"""
-from __future__ import annotations
-
-from typing import Any, Callable, Optional
-
-import httpx
-
-from creation_knowledge.config import Settings
-from creation_knowledge.models import IngestPayload
-
-INGEST_PATH = "/api/v1/knowledge/ingest"
-
-
-class IngestError(RuntimeError):
-    pass
-
-
-def ingest(
-    payload: IngestPayload,
-    *,
-    settings: Optional[Settings] = None,
-    env_file: str = ".env",
-    http_post: Callable[..., Any] = httpx.post,
-    timeout: float = 30.0,
-) -> dict:
-    """真实发送一条知识到 KnowHub。仅在 INGEST_ENABLED=true 时允许调用。"""
-    settings = settings or Settings.from_env(env_file)
-    if not settings.ingest_enabled:
-        raise IngestError("INGEST_ENABLED=false:开发期拒绝真实入库")
-    url = settings.knowhub_api.rstrip("/") + INGEST_PATH
-    try:
-        resp = http_post(
-            url,
-            json=payload.model_dump(),
-            headers={"Content-Type": "application/json"},
-            timeout=timeout,
-        )
-        resp.raise_for_status()
-        return resp.json()
-    except httpx.HTTPError as exc:
-        raise IngestError(f"ingest_http_error: {exc}") from exc

+ 16 - 178
creation_knowledge/integrations/db.py

@@ -1,25 +1,20 @@
-"""过程库 CkStore:写 / 读 creation_knowledge.ck_post、ck_knowledge_item
+"""PG 连接基建:连 open_aigc(Greenplum / PG12 内核),供只读查询复用
 
-目标实例是 Greenplum(PG12 内核),注意:
-  - 不用 INSERT ... ON CONFLICT(Greenplum 分布表支持不稳):upsert = 先 UPDATE,rowcount=0 再 INSERT。
-  - 不更新分布键 id。
-  - JSONB 列用 psycopg2.extras.Json 包装。
-连接 / JSONB / 参数化风格对齐 ContentFindAgentNew 的 database_runtime.py。
+目前唯一用途是分类树 dump(scripts/dump_trees.py 查 public.global_category)。
+连接 / 参数化风格对齐 ContentFindAgentNew 的 database_runtime.py。
 """
 from __future__ import annotations
 
-from typing import Any, Callable, Optional
+from typing import Any
 
 import psycopg2
 import psycopg2.extras
 
 from creation_knowledge.config import PgConfig
-from creation_knowledge.models import Post
 
-ConnectionFactory = Callable[[], Any]
 
-
-def _connect(cfg: PgConfig) -> Any:
+def connect(cfg: PgConfig, schema: str | None = None) -> Any:
+    """建连;schema 缺省用 cfg.schema,可显式传 public 查全局分类树。"""
     return psycopg2.connect(
         host=cfg.host,
         port=cfg.port,
@@ -27,173 +22,16 @@ def _connect(cfg: PgConfig) -> Any:
         password=cfg.password,
         dbname=cfg.database,
         connect_timeout=cfg.timeout,
-        options=f"-c search_path={cfg.schema}",
+        options=f"-c search_path={schema or cfg.schema}",
     )
 
 
-class CkStore:
-    def __init__(
-        self,
-        config: PgConfig,
-        connection_factory: ConnectionFactory | None = None,
-    ) -> None:
-        self.config = config
-        self._connection_factory = connection_factory or (lambda: _connect(config))
-
-    # ---------- 写 ----------
-    def upsert_post(self, post: Post) -> None:
-        """落 fetch 产物:raw / cards 等基础字段,stage=fetched。先 UPDATE 后 INSERT。"""
-        cards = psycopg2.extras.Json([c.model_dump() for c in post.cards])
-        with self._connection_factory() as conn:
-            with conn.cursor() as cur:
-                cur.execute(
-                    "UPDATE ck_post SET platform=%s, url=%s, raw=%s, cards=%s, "
-                    "updated_at=now() WHERE id=%s",
-                    (post.platform, post.url, psycopg2.extras.Json(post.raw),
-                     cards, post.id),
-                )
-                if cur.rowcount == 0:
-                    cur.execute(
-                        "INSERT INTO ck_post (id, platform, url, raw, cards, stage) "
-                        "VALUES (%s, %s, %s, %s, %s, 'fetched')",
-                        (post.id, post.platform, post.url,
-                         psycopg2.extras.Json(post.raw), cards),
-                    )
-            conn.commit()
-
-    def set_extracted(self, post_id: str, extracted: dict, stage: str = "extracted") -> None:
-        self._set_json(post_id, "extracted", extracted, stage)
-
-    def set_screening(self, post_id: str, screening: dict, stage: str = "screened") -> None:
-        self._set_json(post_id, "screening", screening, stage)
-
-    def _set_json(self, post_id: str, column: str, value: dict, stage: str) -> None:
-        with self._connection_factory() as conn:
-            with conn.cursor() as cur:
-                cur.execute(
-                    f"UPDATE ck_post SET {column}=%s, stage=%s, updated_at=now() "
-                    "WHERE id=%s",
-                    (psycopg2.extras.Json(value), stage, post_id),
-                )
-            conn.commit()
-
-    def update_stage(self, post_id: str, stage: str) -> None:
-        with self._connection_factory() as conn:
-            with conn.cursor() as cur:
-                cur.execute(
-                    "UPDATE ck_post SET stage=%s, updated_at=now() WHERE id=%s",
-                    (stage, post_id),
-                )
-            conn.commit()
-
-    def save_item(
-        self,
-        post_id: str,
-        item: dict,
-        deconstruction: Optional[dict] = None,
-        ingest_payload: Optional[dict] = None,
-    ) -> int:
-        """插入一条知识片段,返回自增 id。ingest_status 默认 pending。"""
-        with self._connection_factory() as conn:
-            with conn.cursor() as cur:
-                cur.execute(
-                    "INSERT INTO ck_knowledge_item "
-                    "(post_id, item, deconstruction, ingest_payload, ingest_status) "
-                    "VALUES (%s, %s, %s, %s, 'pending') RETURNING id",
-                    (
-                        post_id,
-                        psycopg2.extras.Json(item),
-                        psycopg2.extras.Json(deconstruction) if deconstruction else None,
-                        psycopg2.extras.Json(ingest_payload) if ingest_payload else None,
-                    ),
-                )
-                item_id = cur.fetchone()[0]
-            conn.commit()
-            return int(item_id)
-
-    def update_item_ingest(
-        self, item_id: int, status: str, knowledge_id: Optional[str] = None
-    ) -> None:
-        with self._connection_factory() as conn:
-            with conn.cursor() as cur:
-                cur.execute(
-                    "UPDATE ck_knowledge_item SET ingest_status=%s, knowledge_id=%s, "
-                    "updated_at=now() WHERE id=%s",
-                    (status, knowledge_id, item_id),
-                )
-            conn.commit()
-
-    def clear_items(self, post_id: str) -> int:
-        """删除某帖的全部知识片段,返回删除行数。用于重跑幂等(先清后写)。"""
-        with self._connection_factory() as conn:
-            with conn.cursor() as cur:
-                cur.execute("DELETE FROM ck_knowledge_item WHERE post_id=%s", (post_id,))
-                n = cur.rowcount
-            conn.commit()
-            return n
-
-    def delete_post(self, post_id: str) -> None:
-        """删除帖子及其片段(用于校验探测的清理)。"""
-        with self._connection_factory() as conn:
-            with conn.cursor() as cur:
-                cur.execute("DELETE FROM ck_knowledge_item WHERE post_id=%s", (post_id,))
-                cur.execute("DELETE FROM ck_post WHERE id=%s", (post_id,))
-            conn.commit()
-
-    # ---------- 读 ----------
-    def read_post(self, post_id: str) -> Optional[dict]:
-        return self._fetch_one(
-            "SELECT * FROM ck_post WHERE id=%s", (post_id,)
-        )
-
-    def read_items(self, post_id: str) -> list[dict]:
-        return self._fetch_all(
-            "SELECT * FROM ck_knowledge_item WHERE post_id=%s ORDER BY id", (post_id,)
-        )
-
-    def list_posts(self, limit: int = 100) -> list[dict]:
-        return self._fetch_all(
-            "SELECT id, platform, url, stage, created_at, updated_at "
-            "FROM ck_post ORDER BY created_at DESC LIMIT %s",
-            (limit,),
-        )
-
-    def posts_overview(self, limit: int = 100) -> list[dict]:
-        """列表页用:每帖基础字段 + 标题/类型/批次(run) + 知识片段数。
-
-        title/content_type 取自 raw 信封;run 从首张卡片本地路径 /data/<run>/... 解析,
-        无媒体则回退处理日期。GROUP BY 主键即可(PG12 函数依赖)。
-        """
-        return self._fetch_all(
-            "SELECT p.id, p.platform, p.url, p.stage, "
-            "p.raw->'data'->'data'->>'title' AS title, "
-            "p.raw->'data'->'data'->>'content_type' AS content_type, "
-            "COALESCE(NULLIF(split_part(p.cards->0->>'url','/',3),''), "
-            "         to_char(p.created_at,'YYYY-MM-DD')) AS run, "
-            "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 "
-            "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 "
-            "WHERE table_schema=%s AND table_name=%s ORDER BY ordinal_position",
-            (self.config.schema, table),
-        )
-        return [r["column_name"] for r in rows]
-
-    def _fetch_one(self, sql: str, params: tuple) -> Optional[dict]:
-        with self._connection_factory() as conn:
-            with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
-                cur.execute(sql, params)
-                row = cur.fetchone()
-                return dict(row) if row else None
-
-    def _fetch_all(self, sql: str, params: tuple) -> list[dict]:
-        with self._connection_factory() as conn:
-            with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
-                cur.execute(sql, params)
-                return [dict(r) for r in cur.fetchall()]
+def fetch_all(cfg: PgConfig, sql: str, params: tuple = (), schema: str | None = None) -> list[dict]:
+    """只读查询,返回 dict 列表。"""
+    conn = connect(cfg, schema)
+    try:
+        with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+            cur.execute(sql, params)
+            return [dict(r) for r in cur.fetchall()]
+    finally:
+        conn.close()

+ 2 - 2
creation_knowledge/media.py

@@ -1,7 +1,7 @@
 """媒体本地落盘:把图文帖的图片、视频帖的整段 mp4 存到 data/<run_id>/<platform>/<post_id>/。
 
-解耦设计:只在 pipeline 默认 dispatch 里、且 settings.data_dir 非空时调用;视频落盘在
-extract_video 内(复用其内存字节),这里只管图片下载 + 卡片 url 改写为本地 /data 路径。
+解耦设计:仅当 settings.data_dir 非空时调用;视频落盘在 extract_video 内(复用其内存
+字节),这里只管图片下载 + 卡片 url 改写为本地 /data 路径。
 """
 from __future__ import annotations
 

+ 5 - 58
creation_knowledge/models.py

@@ -1,13 +1,13 @@
-"""流水线各环节的数据结构(pydantic)。字段对齐业务设计 创作知识-重构设计.md。"""
+"""取数 / 多模态读取的数据结构(pydantic)。
+
+只保留基建用到的类型:帖子(Post)、视觉卡片(Card)、多模态提取产物(ExtractedContent)。
+"""
 from __future__ import annotations
 
 from typing import Literal, Optional
 
 from pydantic import BaseModel, Field
 
-KnowledgeType = Literal["what", "why", "how"]
-ScopeType = Literal["substance", "form", "feeling", "effect", "intent"]
-Stage = Literal["灵感", "选题", "脚本"]
 CardKind = Literal["image", "frame", "segment"]
 
 
@@ -39,7 +39,7 @@ class Post(BaseModel):
     cards: list[Card] = Field(default_factory=list)  # 统一视觉卡片(图/帧)
     author_id: Optional[str] = None
     author_name: Optional[str] = None
-    raw: dict = Field(default_factory=dict)  # 原始响应,整体落 ck_post.raw
+    raw: dict = Field(default_factory=dict)  # 原始响应
 
 
 class CardExtract(BaseModel):
@@ -57,56 +57,3 @@ class ExtractedContent(BaseModel):
     from_image: str = ""  # 兼容保留:图片里提取到的知识要点
     from_video: str = ""  # 兼容保留:视频里提取到的知识要点
     is_empty: bool = False  # 多模态提取后仍无有效内容
-
-
-class ScreeningResult(BaseModel):
-    """screen_post 的产物。"""
-
-    passed: bool
-    score: int = 0
-    reason: str = ""
-
-
-class Evidence(BaseModel):
-    """一条原文证据,链接到它出自的卡片(纯正文证据 card=None)。"""
-
-    text: str
-    card: Optional[int] = None
-
-
-class KnowledgeItem(BaseModel):
-    """split_post 拆出的一个知识片段。knowledge_types 必须与非空的 what/why/how 一致。"""
-
-    title: str
-    knowledge_types: list[KnowledgeType]
-    what: Optional[str] = None
-    why: Optional[str] = None
-    how: Optional[str] = None
-    source_cards: list[int] = Field(default_factory=list)  # 这条知识出自哪些卡片
-    evidence: list[Evidence] = Field(default_factory=list)
-
-
-class Scope(BaseModel):
-    scope_type: ScopeType
-    value: str  # 具体标签,不只是大类名
-
-
-class Deconstruction(BaseModel):
-    """deconstruct_item 的产物:阶段 + 作用域。"""
-
-    stages: list[Stage] = Field(default_factory=list)
-    scopes: list[Scope] = Field(default_factory=list)
-    stage_reason: str = ""
-    scope_reason: str = ""
-
-
-class IngestPayload(BaseModel):
-    """build_ingest_payload 的产物:对齐 创作知识-重构设计.md §6 的 ingest 请求体。"""
-
-    source: dict
-    title: str
-    content: str  # JSON 字符串 {"what":...,"why":...,"how":...}
-    dim_attributes: list[str]
-    dim_creations: list[str]
-    scopes: list[dict]
-    custom_ext: list[dict] = Field(default_factory=list)

+ 0 - 153
creation_knowledge/pipeline.py

@@ -1,153 +0,0 @@
-"""流水线:把 6 步串起来,每步落库。INGEST_ENABLED=False 时只组装+存,不真实发送。
-
-依赖都可注入(fetch/extract/chat/store),便于离线测试与替换平台。
-"""
-from __future__ import annotations
-
-import logging
-from typing import Callable, Optional
-
-from creation_knowledge import media
-from creation_knowledge.config import Settings
-from creation_knowledge.integrations.crawler import CrawlerError, fetch_post_detail
-from creation_knowledge.integrations.db import CkStore
-from creation_knowledge.integrations.extractor import ExtractorError, GeminiExtractor
-from creation_knowledge.integrations.llm import ChatFn, default_chat
-from creation_knowledge.integrations.video_extract import VideoExtractError, extract_video
-from creation_knowledge.ingest import IngestError, ingest as real_ingest
-from creation_knowledge.models import ExtractedContent, Post
-from creation_knowledge.stages import (
-    build_ingest_payload,
-    deconstruct_item,
-    screen_post,
-    split_post,
-)
-
-logger = logging.getLogger(__name__)
-
-FetchFn = Callable[[str], Post]
-ExtractFn = Callable[[Post], ExtractedContent]
-
-
-def _skip_reason(post: Post) -> Optional[str]:
-    """拿不到可提炼素材的帖子 → 跳过(不报错、不留脏数据)。在提取前判定。
-
-    - video_no_direct_url:声明 video 但无视频直链(小红书视频帖结构性如此,详情只给封面)。
-    - empty_media:既无图也无视频(接口降级 / 空帖)。
-    """
-    if (post.content_type or "").lower() == "video" and not post.video_urls:
-        return "video_no_direct_url"
-    if not post.image_urls and not post.video_urls:
-        return "empty_media"
-    return None
-
-
-def _process_one(
-    url: str,
-    *,
-    settings: Settings,
-    store: CkStore,
-    fetch_fn: FetchFn,
-    extract_fn: ExtractFn,
-    chat: ChatFn,
-    ingest_enabled: bool,
-) -> dict:
-    # 1) 拉取
-    try:
-        post = fetch_fn(url)
-    except CrawlerError as exc:
-        return {"url": url, "status": "fetch_failed", "error": str(exc)}
-    store.upsert_post(post)  # 图文卡片在此落;视频段卡在提取后补落
-
-    # 1.5) 跳过判定:素材够不着的帖子直接 skip(如小红书视频帖)
-    skip = _skip_reason(post)
-    if skip:
-        store.clear_items(post.id)  # 幂等:本帖若是重跑,清掉上轮派生的片段
-        store.update_stage(post.id, "skipped")
-        return {"url": url, "post_id": post.id, "status": "skipped", "reason": skip}
-
-    # 2) 多模态提取(图文=逐图;视频=原生整段视频→段卡,extract 内写 post.cards)
-    try:
-        content = extract_fn(post)
-    except (ExtractorError, VideoExtractError) as exc:
-        store.update_stage(post.id, "failed")
-        return {"url": url, "post_id": post.id, "status": "extract_failed", "error": str(exc)}
-    if post.cards:
-        store.upsert_post(post)  # 视频段卡落库(图文重复 upsert 无害)
-    store.set_extracted(post.id, content.model_dump())  # stage=extracted
-    store.clear_items(post.id)  # 幂等:重跑先清旧片段,再写本轮(rejected 路径也保持干净)
-
-    # 3) 筛选
-    screening = screen_post(post, content, chat=chat)
-    store.set_screening(post.id, screening.model_dump())  # stage=screened
-    if not screening.passed:
-        store.update_stage(post.id, "rejected")
-        return {"url": url, "post_id": post.id, "status": "rejected",
-                "score": screening.score, "reason": screening.reason}
-
-    # 4) 拆分 -> 5) 解构 -> 6) 组装(+可选入库)
-    items = split_post(post, content, chat=chat)
-    item_ids = []
-    for item in items:
-        deco = deconstruct_item(item, chat=chat)
-        payload = build_ingest_payload(post, item, deco)
-        item_id = store.save_item(
-            post.id, item.model_dump(), deco.model_dump(), payload.model_dump()
-        )
-        item_ids.append(item_id)
-        if ingest_enabled:
-            try:
-                res = real_ingest(payload, settings=settings)
-                store.update_item_ingest(item_id, "ingested", res.get("knowledge_id"))
-            except IngestError:
-                store.update_item_ingest(item_id, "failed", None)
-    store.update_stage(post.id, "done")
-    return {"url": url, "post_id": post.id, "status": "done", "items": len(item_ids)}
-
-
-def run_pipeline(
-    urls: list[str],
-    *,
-    settings: Optional[Settings] = None,
-    env_file: str = ".env",
-    ingest_enabled: Optional[bool] = None,
-    run_id: Optional[str] = None,
-    store: Optional[CkStore] = None,
-    fetch_fn: Optional[FetchFn] = None,
-    extract_fn: Optional[ExtractFn] = None,
-    chat: Optional[ChatFn] = None,
-) -> list[dict]:
-    settings = settings or Settings.from_env(env_file)
-    ingest_enabled = settings.ingest_enabled if ingest_enabled is None else ingest_enabled
-    batch = run_id or media.run_id()
-    store = store or CkStore(settings.pg)
-    fetch_fn = fetch_fn or (lambda url: fetch_post_detail(url, settings=settings))
-    if extract_fn is None:
-        _image_client = GeminiExtractor.from_env(env_file=env_file)
-
-        def _dispatch_extract(post: Post) -> ExtractedContent:
-            # settings.data_dir 非空 → 媒体落盘到 data/<batch>/<platform>/<post_id>/(解耦开关)
-            tgt = (media.media_target(settings.data_dir, batch, post.platform, post.id)
-                   if settings.data_dir else None)
-            # 视频帖 → 原生整段视频(OpenRouter base64);图文帖 → 逐图提取
-            if (post.content_type or "").lower() == "video" or post.video_urls:
-                return extract_video(
-                    post, settings=settings,
-                    save_path=(tgt.dir / "video.mp4") if tgt else None,
-                    public_url=(f"{tgt.public_base}/video.mp4") if tgt else None,
-                )
-            content = _image_client.extract(post)
-            if tgt:  # 图文帖:下载图片到本地并把 card.url 改写为 /data 路径
-                media.save_images(post, tgt.dir, tgt.public_base)
-            return content
-
-        extract_fn = _dispatch_extract
-    chat = chat or default_chat(env_file)
-
-    results = []
-    for url in urls:
-        results.append(_process_one(
-            url, settings=settings, store=store, fetch_fn=fetch_fn,
-            extract_fn=extract_fn, chat=chat, ingest_enabled=ingest_enabled,
-        ))
-    return results

+ 4 - 4
creation_knowledge/prompts.py

@@ -1,13 +1,13 @@
-"""提示词加载:从 prompts/<name>.txt 读取模板,带版本号(便于 A/B 与审计回溯)。"""
+"""提示词加载:从 prompts/<name>.txt 读取模板。
+
+只剩多模态读取用的 extract / extract_video(图文、视频提炼成结构化内容)。
+"""
 from __future__ import annotations
 
 from pathlib import Path
 
 PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
 
-# 提示词版本:改提示词时手动 +1,写进 ck 记录便于回溯
-PROMPT_VERSION = "v5"
-
 
 def load_prompt(name: str) -> str:
     return (PROMPTS_DIR / f"{name}.txt").read_text(encoding="utf-8")

+ 0 - 7
creation_knowledge/stages/__init__.py

@@ -1,7 +0,0 @@
-"""四个环节:screen / split / deconstruct / assemble。"""
-from creation_knowledge.stages.assemble import build_ingest_payload
-from creation_knowledge.stages.deconstruct import deconstruct_item
-from creation_knowledge.stages.screen import screen_post
-from creation_knowledge.stages.split import split_post
-
-__all__ = ["screen_post", "split_post", "deconstruct_item", "build_ingest_payload"]

+ 0 - 43
creation_knowledge/stages/_common.py

@@ -1,43 +0,0 @@
-"""stage 共用小工具。"""
-from __future__ import annotations
-
-from typing import Optional
-
-from creation_knowledge.models import ExtractedContent, KnowledgeType, Post
-
-VALID_TYPES = {"what", "why", "how"}
-_EMPTY = {"", "null", "none", "无", "n/a", "na"}
-
-
-def content_for_llm(post: Post, content: ExtractedContent) -> str:
-    """喂给 LLM 的内容 = 多模态提取结果(不是 body_text),含按卡片归因的分块。"""
-    parts = []
-    if content.text:
-        parts.append("【综合】" + content.text)
-    # 按卡片分块,让拆分步骤能把知识溯源到【卡片N】
-    for c in content.cards:
-        if c.content:
-            parts.append(f"【卡片{c.index}】{c.content}")
-    if not content.cards:  # 兼容旧产物
-        if content.from_image:
-            parts.append("【图片】" + content.from_image)
-        if content.from_video:
-            parts.append("【视频】" + content.from_video)
-    return "\n".join(parts) or post.body_text
-
-
-def norm_text(value) -> Optional[str]:
-    """把 'null' / '' / '无' 之类归一成 None。"""
-    if value is None:
-        return None
-    s = str(value).strip()
-    return None if s.lower() in _EMPTY else s
-
-
-def norm_types(values) -> list[KnowledgeType]:
-    out: list[KnowledgeType] = []
-    for v in values or []:
-        t = str(v).strip().lower()
-        if t in VALID_TYPES and t not in out:
-            out.append(t)  # type: ignore[arg-type]
-    return out

+ 0 - 57
creation_knowledge/stages/assemble.py

@@ -1,57 +0,0 @@
-"""组装:把帖子+知识片段+解构,组装成 ingest 请求体(对齐 创作知识-重构设计.md §6)。
-
-纯函数,无 LLM、无 IO。同一帖子拆出的多条知识复用同一 source.id。
-"""
-from __future__ import annotations
-
-import json
-
-from creation_knowledge.models import (
-    Deconstruction,
-    IngestPayload,
-    KnowledgeItem,
-    Post,
-)
-
-
-def build_ingest_payload(
-    post: Post, item: KnowledgeItem, deco: Deconstruction
-) -> IngestPayload:
-    content = json.dumps(
-        {"what": item.what, "why": item.why, "how": item.how},
-        ensure_ascii=False,
-    )
-    custom_ext: list[dict] = []
-    if item.source_cards:
-        custom_ext.append(
-            {"key": "来源卡片", "type": "str",
-             "value": "、".join(f"卡片{n}" for n in item.source_cards)}
-        )
-    if item.evidence:
-        ev = " / ".join(
-            f"{e.text}(卡片{e.card})" if e.card else e.text for e in item.evidence
-        )
-        custom_ext.append({"key": "原文证据", "type": "str", "value": ev})
-    if deco.stage_reason:
-        custom_ext.append(
-            {"key": "阶段判断理由", "type": "str", "value": deco.stage_reason}
-        )
-    if deco.scope_reason:
-        custom_ext.append(
-            {"key": "作用域判断理由", "type": "str", "value": deco.scope_reason}
-        )
-    return IngestPayload(
-        source={
-            "id": post.id,
-            "source_type": "post",
-            "title": post.title,
-            "author": post.author_name,
-            "source_metadata": {"platform": post.platform, "url": post.url},
-        },
-        title=item.title,
-        content=content,
-        dim_attributes=list(item.knowledge_types),
-        dim_creations=list(deco.stages),
-        scopes=[{"scope_type": s.scope_type, "value": s.value} for s in deco.scopes],
-        custom_ext=custom_ext,
-    )

+ 0 - 38
creation_knowledge/stages/deconstruct.py

@@ -1,38 +0,0 @@
-"""解构:给知识片段补上阶段(灵感/选题/脚本)和作用域(五棵树)。"""
-from __future__ import annotations
-
-from typing import Optional
-
-from creation_knowledge.integrations.llm import ChatFn, default_chat
-from creation_knowledge.models import Deconstruction, KnowledgeItem, Scope
-from creation_knowledge.prompts import load_prompt
-
-SYSTEM = "你是严谨的创作知识解构器,按阶段和五棵分类树归类。"
-
-VALID_STAGES = {"灵感", "选题", "脚本"}
-VALID_SCOPES = {"substance", "form", "feeling", "effect", "intent"}
-
-
-def deconstruct_item(
-    item: KnowledgeItem,
-    *,
-    chat: Optional[ChatFn] = None,
-    env_file: str = ".env",
-) -> Deconstruction:
-    chat = chat or default_chat(env_file)
-    user = load_prompt("deconstruct").format(
-        item=item.model_dump_json(indent=2)
-    )
-    data = chat(SYSTEM, user)
-    stages = [s for s in (data.get("stages") or []) if s in VALID_STAGES]
-    scopes = [
-        Scope(scope_type=s["scope_type"], value=str(s["value"]).strip())
-        for s in (data.get("scopes") or [])
-        if isinstance(s, dict) and s.get("scope_type") in VALID_SCOPES and s.get("value")
-    ]
-    return Deconstruction(
-        stages=stages,
-        scopes=scopes,
-        stage_reason=str(data.get("stage_reason") or ""),
-        scope_reason=str(data.get("scope_reason") or ""),
-    )

+ 0 - 33
creation_knowledge/stages/screen.py

@@ -1,33 +0,0 @@
-"""筛选:判断帖子是否值得提取为创作知识。"""
-from __future__ import annotations
-
-from typing import Optional
-
-from creation_knowledge.integrations.llm import ChatFn, default_chat
-from creation_knowledge.jsonio import to_bool
-from creation_knowledge.models import ExtractedContent, Post, ScreeningResult
-from creation_knowledge.prompts import load_prompt
-from creation_knowledge.stages._common import content_for_llm
-
-SYSTEM = "你是严谨的创作知识筛选器,只认能指导内容创作的知识,作品本身不算。"
-
-
-def screen_post(
-    post: Post,
-    content: ExtractedContent,
-    *,
-    chat: Optional[ChatFn] = None,
-    env_file: str = ".env",
-) -> ScreeningResult:
-    chat = chat or default_chat(env_file)
-    user = load_prompt("screen").format(
-        title=post.title or "(无)",
-        topics="、".join(post.topic_list) or "(无)",
-        content=content_for_llm(post, content) or "(空)",
-    )
-    data = chat(SYSTEM, user)
-    return ScreeningResult(
-        passed=to_bool(data.get("passed")),
-        score=int(data.get("score") or 0),
-        reason=str(data.get("reason") or ""),
-    )

+ 0 - 64
creation_knowledge/stages/split.py

@@ -1,64 +0,0 @@
-"""拆分:把帖子拆成一个或多个 What/Why/How 知识片段。"""
-from __future__ import annotations
-
-from typing import Optional
-
-from creation_knowledge.integrations.llm import ChatFn, default_chat
-from creation_knowledge.models import Evidence, ExtractedContent, KnowledgeItem, Post
-from creation_knowledge.prompts import load_prompt
-from creation_knowledge.stages._common import content_for_llm, norm_text, norm_types
-
-SYSTEM = "你是创作知识拆分助手,原文有什么提什么,不编造。"
-
-
-def _to_int(value) -> Optional[int]:
-    try:
-        return int(value)
-    except (TypeError, ValueError):
-        return None
-
-
-def _to_evidence(raw) -> Optional[Evidence]:
-    """证据可能是字符串(旧)或 {text, card}(新)。"""
-    if isinstance(raw, dict):
-        text = norm_text(raw.get("text"))
-        return Evidence(text=text, card=_to_int(raw.get("card"))) if text else None
-    text = norm_text(raw)
-    return Evidence(text=str(raw).strip()) if text else None
-
-
-def _to_item(raw: dict) -> Optional[KnowledgeItem]:
-    what = norm_text(raw.get("what"))
-    why = norm_text(raw.get("why"))
-    how = norm_text(raw.get("how"))
-    # knowledge_types 以非空字段为准(模型给的若不一致,用实际有内容的对齐)
-    types = norm_types(raw.get("knowledge_types"))
-    actual = [t for t, v in (("what", what), ("why", why), ("how", how)) if v]
-    types = [t for t in types if t in actual] or actual
-    if not types:
-        return None  # 三个都空,丢弃
-    title = norm_text(raw.get("title")) or (what or why or how or "")[:20]
-    evidence = [e for e in (_to_evidence(x) for x in (raw.get("evidence") or [])) if e]
-    source_cards = [n for n in (_to_int(x) for x in (raw.get("source_cards") or [])) if n is not None]
-    return KnowledgeItem(
-        title=title, knowledge_types=types, what=what, why=why, how=how,
-        source_cards=source_cards, evidence=evidence,
-    )
-
-
-def split_post(
-    post: Post,
-    content: ExtractedContent,
-    *,
-    chat: Optional[ChatFn] = None,
-    env_file: str = ".env",
-) -> list[KnowledgeItem]:
-    chat = chat or default_chat(env_file)
-    user = load_prompt("split").format(
-        title=post.title or "(无)",
-        topics="、".join(post.topic_list) or "(无)",
-        content=content_for_llm(post, content) or "(空)",
-    )
-    data = chat(SYSTEM, user)
-    items = [_to_item(raw) for raw in (data.get("items") or [])]
-    return [it for it in items if it is not None]

+ 0 - 63
prompts/deconstruct.txt

@@ -1,63 +0,0 @@
-你在做「创作知识解构」:给一条已经拆好的知识片段,标注它的【阶段】和【作用域】。不改动知识内容本身。
-
-<阶段>
-创作 = 灵感 + 选题 + 脚本,可多选,只标它真正服务的:
-- 灵感:帮助发现方向、素材、切口、洞察("从哪找内容")。
-- 选题:帮助判断写什么/拍什么、从哪个角度切("做哪个题")。
-- 脚本:帮助组织表达顺序、文案、镜头、结构("怎么写/怎么拍")。
-</阶段>
-
-<作用域>
-五棵分类树,范畴互斥,可多选;每条 = 大类 + 具体的点:
-- substance 实质:内容讲的对象、主题、事实、问题本身("讲了什么")。
-- form 形式:结构、模板、公式、套路、载体、呈现形态("怎么呈现 / 什么结构")。
-- feeling 感受:观众的情绪、体验、氛围、感知("让观众怎么觉得")。
-- effect 作用:解决什么创作问题、带来什么效果("对创作有什么用")。
-- intent 意图:创作者目的、传播目的、商业目标("想要什么结果")。
-最易混:form 是"怎么呈现"、substance 是"呈现了什么";effect 是"对内容的功效"(如 生成选题)、intent 是"创作者想要的结果"(如 涨粉变现)。
-</作用域>
-
-<value 要求>
-- value 是具体标签,不是大类名本身。为什么:大类名("形式")无法作为可检索的知识标签,只有具体点("评论区选题流程")才能复用。
-- value 短、是一个独立语义点,不用连接词拼接多个。
-- 只标真正命中的;不要为凑满五棵树、或在一棵树下多堆 value 而硬填——宁少勿滥。
-</value 要求>
-
-<示例>
-<example>
-知识片段:{{"title": "评论区选题法", "knowledge_types": ["how"], "how": "收集高赞评论→归类痛点→改写成标题或脚本切入点"}}
-输出:{{"stages": ["选题", "脚本"],
-  "scopes": [{{"scope_type": "form", "value": "评论区选题流程"}}, {{"scope_type": "effect", "value": "生成选题"}}],
-  "stage_reason": "既能定选题,也能改写成脚本切入点",
-  "scope_reason": "提供了一套选题流程(形式),解决的是'选什么题'这个创作问题(作用)"}}
-</example>
-<example>  effect vs intent 辨析
-知识片段:{{"title": "羞耻感选题更易爆", "knowledge_types": ["why"], "why": "撕裂大众共识、让你写完感到羞耻的选题更能激发转发"}}
-输出:{{"stages": ["选题"],
-  "scopes": [{{"scope_type": "feeling", "value": "羞耻感"}}, {{"scope_type": "effect", "value": "提升转发"}}],
-  "stage_reason": "用于判断选哪个题",
-  "scope_reason": "靠观众的羞耻感(感受)来提升转发(作用)。注意'提升转发'是对内容的功效=effect,不是'涨粉变现'那种创作者目的=intent"}}
-</example>
-</示例>
-
-<不应做>
-- value 写成大类名("形式"/"作用")。
-- 为铺满而给不相关的 scope。
-- effect 和 intent 混用("生成选题"=effect;"涨粉变现"=intent)。
-- stage 硬凑(纯脚本技巧硬标上"灵感")。
-</不应做>
-
-<输入>(一条知识片段)
-{item}
-</输入>
-
-<输出>
-只输出一个 JSON 对象:
-{{"stages": ["灵感/选题/脚本,只列真正服务的"],
-  "scopes": [{{"scope_type": "substance/form/feeling/effect/intent", "value": "具体的点"}}],
-  "stage_reason": "...", "scope_reason": "..."}}
-</输出>
-
-<自查>
-输出前核对:value 是否都是具体的点而非大类名?有没有为凑数硬填的 scope 或 stage?
-</自查>

+ 0 - 49
prompts/screen.txt

@@ -1,49 +0,0 @@
-你在做「创作知识筛选」:判断一篇帖子值不值得被提取、入库为"能指导内容创作的知识"。只做通过/淘汰,不做拆分和归类。判断对象是【多模态提取后的内容】,不是原始正文(知识可能只在图片/视频里)。
-
-<通过标准>
-4 条同时满足才通过:
-1. 不是空内容:多模态提取后仍有实际内容,不是纯话题串、纯无关。
-2. 是"方法知识"而不是"作品本身":讲"怎么做内容"的方法/原理/经验/清单,能迁移到别的创作上;不是一条具体作品(一段文案、一张图、一个具体选题、纯展示内容)。
-   这条最容易错、也最关键。实践中最常见的误收就是把"一件作品"当成"一条知识"。判断窍门——问自己:"别人看完,能不能学到一个可以用在**自己**创作上的做法/道理?" 能 → 方法知识;只得到一件成品 → 作品本身。
-3. 至少含一点 What(是什么/有哪些)/ Why(为什么有效)/ How(怎么做),三者有其一即可。
-4. 至少能拆出一条可用的创作知识。
-</通过标准>
-
-<评分>
-0-10,给运营一个强弱参考(passed 仍以上面 4 条硬标准为准):
-- 8-10:方法清晰、可直接照做、迁移性强(明确的 How,或成体系的 What)。
-- 5-7:有价值但偏零散/偏泛,或只有 Why 没有落地做法。
-- 1-4:勉强沾边,方法含糊。
-- 0:作品本身,或与内容创作无关。
-</评分>
-
-<示例>
-<example>
-内容:7 种编剧常用叙事结构——三幕式、英雄之旅、非线性、环形、多线、嵌套、反结构,各适合不同题材。
-输出:{{"passed": true, "score": 8, "reason": "方法知识:给出可复用的叙事结构清单(What),能迁移到不同题材"}}
-</example>
-<example>
-内容:评论区选题法——收集高赞评论,归类用户痛点,再改写成标题或脚本切入点。
-输出:{{"passed": true, "score": 9, "reason": "方法知识:可直接照做的选题方法(How)"}}
-</example>
-<example>
-内容:夏日雨后彩虹草原。核心主体:广袤草原开满野花,一道七色彩虹横跨天际……(一段画面描述 / AI 出图提示词)
-输出:{{"passed": false, "score": 0, "reason": "作品本身:这是某张图的出图提示词,不是教别人怎么创作的方法"}}
-</example>
-<example>
-内容:今天天气真好,分享我新买的裙子~(纯生活展示)
-输出:{{"passed": false, "score": 0, "reason": "与内容创作无关,是作品/生活展示本身"}}
-</example>
-</示例>
-
-<输入>
-帖子标题:{title}
-话题:{topics}
-多模态提取内容:
-{content}
-</输入>
-
-<输出>
-只输出一个 JSON 对象:
-{{"passed": true 或 false, "score": 0 到 10 的整数, "reason": "一句话,点明是方法知识还是作品本身"}}
-</输出>

+ 0 - 71
prompts/split.txt

@@ -1,71 +0,0 @@
-你在做「创作知识拆分」:把多模态提取后的内容拆成一条或多条知识片段,每条标好 What / Why / How。不判断好坏(已筛选过),也不归类阶段/作用域(那是下一步)。
-
-<知识类型>
-- what(是什么 / 有哪些):类型、分类、构成要素、清单、特征、模板。
-- why(为什么 / 凭什么有效):原理、底层逻辑、机制、依据、原因。
-- how(怎么做 / 怎么用):方法、步骤、技巧、流程、公式框架。
-对照:"脚本含 10 个要素" = what;"逐个把这 10 个要素填好" = how;"要素齐全能让观众一眼看懂故事" = why。
-</知识类型>
-
-<拆分规则>
-- 一条片段 = 一个知识对象:同一个知识对象的 what/why/how 放进同一条;多个相互独立的知识对象拆成多条。
-  为什么:知识库里一条记录应是一个能独立复用的知识单元——合并无关知识会让它无法被精准检索,过度拆分会把一个完整方法打碎。
-- 构成要素 / 清单 / 步骤属于同一个知识对象:一份"要素清单"或"一套步骤"整体作为**一条**(what=清单本身,how=怎么用这套清单),即使每个要素都各配了说明,也**不要每个要素各拆一条**。
-  判断标准:这些点是不是在共同支撑同一个方法/框架?是 → 合并成一条;只有当它们是彼此独立、能各自单独使用的方法时,才拆成多条。
-- 忠实:原文有什么提什么,没有的字段填 null,不为凑齐 what/why/how 而编。
-- knowledge_types 与非空字段一致(顺序 what→why→how)。
-- title 用能概括这条知识的具体短语,别用泛词("技巧""干货")。
-- 溯源:提取内容按【卡片N】分块(【综合】块来自正文/汇总)。每条知识填 source_cards=它出自哪些卡片号(可多张;纯来自【综合】/正文则填 [])。
-- evidence:先摘出支持这条知识的原句放进 evidence,每条写成 {{"text": "原句", "card": 出自的卡片号或 null}};先摘引、再判断,证据必须来自素材。
-</拆分规则>
-
-<示例>
-<example>  清单型:每个要素一张卡,仍合并为 1 条;source_cards 列全部相关卡
-提取内容:【综合】短视频脚本含 10 个要素。【卡片1】标题:视频灵魂,精准点题。【卡片2】拍摄地点:给故事搭舞台。【卡片3】分镜:规划镜头顺序……(每张卡讲一个要素)
-输出:{{"items": [
-  {{"title": "短视频脚本的 10 个核心要素",
-    "knowledge_types": ["what", "how"],
-    "what": "短视频脚本由标题、拍摄地点、分镜……等 10 个要素构成",
-    "why": null,
-    "how": "按这份清单逐一规划每个要素,把脚本搭完整",
-    "source_cards": [1, 2, 3],
-    "evidence": [{{"text": "标题:视频灵魂,精准点题", "card": 1}}, {{"text": "拍摄地点:给故事搭舞台", "card": 2}}]}}
-]}}
-(10 个要素共同支撑"脚本结构"一个方法 → 合并 1 条;source_cards 列出所有相关卡片。)
-</example>
-<example>  多个独立知识对象 → 多条片段
-提取内容:【卡片1】爆款选题要撕裂大众共识,越让你写完感到羞耻越能引发转发。【卡片2】起号初期要垂直,连续发同一垂类系统才能给账号打标签。
-输出:{{"items": [
-  {{"title": "羞耻感选题法", "knowledge_types": ["why"], "what": null,
-    "why": "撕裂大众共识、让你写完感到羞耻的选题更能激发转发", "how": null,
-    "source_cards": [1], "evidence": [{{"text": "越让你写完感到羞耻越能引发转发", "card": 1}}]}},
-  {{"title": "起号初期垂直打标签", "knowledge_types": ["why", "how"], "what": null,
-    "why": "连续发同一垂类系统才能打标签", "how": "起号初期持续发同一垂直领域内容",
-    "source_cards": [2], "evidence": [{{"text": "连续发同一垂类才能让系统打标签", "card": 2}}]}}
-]}}
-</example>
-</示例>
-
-<不应做>
-- 把作品本身当知识(把一条具体文案原样塞进 how)。
-- 把帖子主题复述成知识(how 写成"讲了短视频脚本")。
-- 为结构完整编造 why。
-- 过度合并("叙事结构"+"运镜技巧"合一条)。
-- 过度拆分:把一份要素清单按要素拆成多条(如把"脚本 10 要素"拆成 10 条),或把一个方法的步骤各拆一条。
-</不应做>
-
-<输入>
-帖子标题:{title}
-话题:{topics}
-多模态提取内容:
-{content}
-</输入>
-
-<输出>
-只输出一个 JSON 对象:
-{{"items": [{{"title": "...", "knowledge_types": ["what/why/how"], "what": "...或null", "why": "...或null", "how": "...或null", "source_cards": [卡片号...], "evidence": [{{"text": "原句", "card": 卡片号或null}}]}}]}}
-</输出>
-
-<自查>
-输出前核对:每条 knowledge_types 是否与非空 what/why/how 一致?有没有编造内容?每条是否是一个独立、可复用的知识对象?
-</自查>

+ 62 - 0
scripts/dump_trees.py

@@ -0,0 +1,62 @@
+"""Dump 5 棵作用域分类树(PG public.global_category)→ 本地 JSON。
+
+产物(scope_trees/):
+  - trees.json        整个分类树,按 source_type 分组(含已退役节点,live 标记)
+  - trees_index.json  仅 live 节点(scope-link 回扣目标),与 trees_embeddings.npy 行对齐
+
+须在能连 RDS 的环境跑(云端)。用法:python scripts/dump_trees.py [env_file]
+"""
+from __future__ import annotations
+
+import json
+import sys
+from collections import Counter
+from pathlib import Path
+
+from creation_knowledge.config import PgConfig
+from creation_knowledge.integrations.db import fetch_all
+
+OUT = Path("scope_trees")
+
+
+def fetch_nodes(cfg: PgConfig) -> list[dict]:
+    return fetch_all(
+        cfg,
+        "SELECT stable_id, name, source_type, path, level, parent_stable_id, "
+        "(retired_at_execution_id IS NULL) AS live "
+        "FROM global_category ORDER BY source_type, level, stable_id",
+        schema="public",
+    )
+
+
+def main(env_file: str = ".env") -> None:
+    cfg = PgConfig.from_env(env_file)
+    rows = fetch_nodes(cfg)
+    OUT.mkdir(exist_ok=True)
+
+    trees: dict[str, list[dict]] = {}
+    for r in rows:
+        trees.setdefault(r["source_type"], []).append({
+            "stable_id": r["stable_id"], "name": r["name"], "path": r["path"],
+            "level": r["level"], "parent_stable_id": r["parent_stable_id"],
+            "live": r["live"],
+        })
+    (OUT / "trees.json").write_text(
+        json.dumps(trees, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    live_rows = [r for r in rows if r["live"]]
+    index = [{
+        "row": i, "stable_id": r["stable_id"], "source_type": r["source_type"],
+        "name": r["name"], "path": r["path"],
+    } for i, r in enumerate(live_rows)]
+    (OUT / "trees_index.json").write_text(
+        json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    total = Counter(r["source_type"] for r in rows)
+    live = Counter(r["source_type"] for r in live_rows)
+    print("trees.json  (全部):", dict(total), "总计", sum(total.values()))
+    print("trees_index (live):", dict(live), "总计", len(index))
+
+
+if __name__ == "__main__":
+    main(sys.argv[1] if len(sys.argv) > 1 else ".env")

+ 46 - 0
scripts/embed_trees.py

@@ -0,0 +1,46 @@
+"""把 live 分类树节点名向量化(火山 multimodal)→ scope_trees/trees_embeddings.npy。
+
+行序与 trees_index.json 一一对应。须先跑 dump_trees.py,且能连火山。
+用法:python scripts/embed_trees.py [env_file]
+"""
+from __future__ import annotations
+
+import json
+import sys
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import numpy as np
+
+from creation_knowledge.embedding import ArkEmbedConfig, embed_text
+
+OUT = Path("scope_trees")
+
+
+def main(env_file: str = ".env") -> None:
+    cfg = ArkEmbedConfig.from_env(env_file)
+    index = json.loads((OUT / "trees_index.json").read_text(encoding="utf-8"))
+    names = [it["name"] for it in index]
+    n = len(names)
+    embs: list[list[float] | None] = [None] * n
+
+    def work(i: int) -> int:
+        embs[i] = embed_text(names[i], cfg)
+        return i
+
+    done = 0
+    with ThreadPoolExecutor(max_workers=8) as ex:
+        for _ in ex.map(work, range(n)):
+            done += 1
+            if done % 100 == 0 or done == n:
+                print(f"  embedded {done}/{n}")
+
+    arr = np.asarray(embs, dtype=np.float32)
+    if arr.shape != (n, cfg.dim):
+        raise AssertionError(f"shape {arr.shape} != {(n, cfg.dim)}")
+    np.save(OUT / "trees_embeddings.npy", arr)
+    print("saved", arr.shape, "->", OUT / "trees_embeddings.npy")
+
+
+if __name__ == "__main__":
+    main(sys.argv[1] if len(sys.argv) > 1 else ".env")

+ 0 - 46
scripts/inspect_multi.py

@@ -1,46 +0,0 @@
-"""读回多平台跑批结果:确认平台/阶段/卡片类型/段卡时间区间/知识点溯源。"""
-from __future__ import annotations
-
-import json
-import sys
-
-from creation_knowledge.config import PgConfig
-from creation_knowledge.integrations.db import CkStore
-
-
-def main() -> int:
-    store = CkStore(PgConfig.from_env(".env"))
-    ids = sys.argv[1:] or [
-        "xhs_67e4bdf50000000006028a59",
-        "dy_7612631899479648866",
-        "ks_3xepqgwddgbickc",
-    ]
-    for pid in ids:
-        p = store.read_post(pid)
-        if not p:
-            print(f"{pid}: NOT FOUND")
-            continue
-        cards = p.get("cards") or []
-        kinds: dict = {}
-        for c in cards:
-            kinds[c.get("kind")] = kinds.get(c.get("kind"), 0) + 1
-        seg = [c for c in cards if c.get("kind") == "segment"]
-        seg_times = ["{}-{}".format(c.get("start"), c.get("end")) for c in seg[:4]]
-        items = store.read_items(pid)
-        srcs = []
-        for it in items[:4]:
-            d = it["item"] if isinstance(it["item"], dict) else json.loads(it["item"])
-            srcs.append({"title": (d.get("title") or "")[:18],
-                         "source_cards": d.get("source_cards")})
-        print(f"\n{pid}")
-        print(f"  platform={p.get('platform')} stage={p.get('stage')} "
-              f"url={(p.get('url') or '')[:50]}")
-        print(f"  cards={kinds} seg_times={seg_times}")
-        print(f"  items={len(items)}")
-        for s in srcs:
-            print(f"    - {s['title']!r} source_cards={s['source_cards']}")
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 0 - 49
scripts/run_batch.py

@@ -1,49 +0,0 @@
-"""M5 真实 e2e:把 5 个样例帖跑完整流水线,落 DB(INGEST_ENABLED=False),再回读校验。
-
-须在能连 DB + OpenRouter 的云端跑(会有 crawler/Gemini/claude 调用费)。
-用法:python scripts/run_batch.py [env_file] [content_id ...]
-"""
-from __future__ import annotations
-
-import json
-import sys
-
-from creation_knowledge.config import PgConfig
-from creation_knowledge.integrations.db import CkStore
-from creation_knowledge.pipeline import run_pipeline
-
-DEFAULT_IDS = [
-    "67e4bdf50000000006028a59",  # HOW+WHAT 海狸
-    "698481e1000000000a02a7c1",  # HOW+WHAT Irvin
-    "67e2e39b0000000003028ff0",  # HOW 拾意
-    "699308fa0000000016009697",  # WHY 方圆
-    "680659e8000000001a007a11",  # HOW+WHY+WHAT 拾意
-]
-
-
-def main() -> int:
-    args = sys.argv[1:]
-    env_file = args[0] if args and args[0].endswith(".env") else ".env"
-    ids = [a for a in args if not a.endswith(".env")] or DEFAULT_IDS
-
-    print(f"=== 跑流水线:{len(ids)} 个样例(ingest 关闭)===")
-    results = run_pipeline(ids, env_file=env_file)
-    for r in results:
-        print(json.dumps(r, ensure_ascii=False))
-
-    print("\n=== DB 回读校验 ===")
-    store = CkStore(PgConfig.from_env(env_file))
-    for r in results:
-        pid = r.get("post_id")
-        if not pid:
-            print(f"{r['url']}  -> {r['status']}")
-            continue
-        post = store.read_post(pid)
-        items = store.read_items(pid)
-        statuses = [it["ingest_status"] for it in items]
-        print(f"{pid}  stage={post['stage']}  items={len(items)}  ingest_status={statuses}")
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 59 - 0
scripts/scope_link.py

@@ -0,0 +1,59 @@
+"""作用域回扣(scope-link):候选值 → 本地树 embedding 余弦最近邻 → top-K。
+
+对得上(高分)→ 复用现有节点原名,ingest 时按名挂靠;对不上 → 保留为新建值(丰富树)。
+本地内存 + numpy 暴力最近邻,无需向量数据库。
+
+编程接口:
+    from scripts.scope_link import ScopeLinker
+    ScopeLinker().link("撕裂共识", source_type="作用", top_k=5)
+
+自测:python scripts/scope_link.py
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+
+from creation_knowledge.embedding import ArkEmbedConfig, embed_text
+
+OUT = Path("scope_trees")
+
+
+class ScopeLinker:
+    def __init__(self, env_file: str = ".env") -> None:
+        self.index = json.loads((OUT / "trees_index.json").read_text(encoding="utf-8"))
+        emb = np.load(OUT / "trees_embeddings.npy")
+        self.norm = emb / (np.linalg.norm(emb, axis=1, keepdims=True) + 1e-9)
+        self.src = np.array([it["source_type"] for it in self.index])
+        self.cfg = ArkEmbedConfig.from_env(env_file)
+
+    def link(self, candidate: str, source_type: str | None = None, top_k: int = 5) -> list[dict]:
+        q = np.asarray(embed_text(candidate, self.cfg), dtype=np.float32)
+        q = q / (np.linalg.norm(q) + 1e-9)
+        sims = self.norm @ q
+        idxs = (np.where(self.src == source_type)[0] if source_type
+                else np.arange(len(sims)))
+        order = idxs[np.argsort(-sims[idxs])][:top_k]
+        return [{
+            "name": self.index[i]["name"], "path": self.index[i]["path"],
+            "source_type": self.index[i]["source_type"],
+            "score": round(float(sims[i]), 4),
+        } for i in order]
+
+
+def _selftest() -> None:
+    sl = ScopeLinker()
+    cases = [
+        ("撕裂共识", "作用"), ("撕裂共识", "意图"), ("三幕结构", "形式"),
+        ("角色代入共鸣", "感受"), ("反转", "形式"), ("引发讨论", "作用"),
+    ]
+    for cand, st in cases:
+        print(f"\n[{cand}] @ {st}:")
+        for hit in sl.link(cand, st, 3):
+            print(f"  {hit['score']:.3f}  {hit['name']}   ({hit['path']})")
+
+
+if __name__ == "__main__":
+    _selftest()

+ 0 - 55
scripts/smoke_stages.py

@@ -1,55 +0,0 @@
-"""M4 真实验收(省 Gemini 费):用 海狸 帖的富文本 body_text 当提取内容,
-真调 claude-sonnet-4.5 跑 screen/split/deconstruct/assemble,打印结果。
-
-只产生文本 LLM 费用,不调 Gemini。须在能联网调 OpenRouter 的云端跑。
-用法:python scripts/smoke_stages.py [env_file] [content_id]
-"""
-from __future__ import annotations
-
-import json
-import sys
-from pathlib import Path
-
-from creation_knowledge.integrations.crawler import parse_detail_response
-from creation_knowledge.integrations.llm import default_chat
-from creation_knowledge.models import ExtractedContent
-from creation_knowledge.stages import (
-    build_ingest_payload,
-    deconstruct_item,
-    screen_post,
-    split_post,
-)
-
-FIXTURES = Path(__file__).resolve().parent.parent / "tests" / "fixtures"
-
-
-def main() -> int:
-    args = sys.argv[1:]
-    env_file = args[0] if args and args[0].endswith(".env") else ".env"
-    cid = next((a for a in args if not a.endswith(".env")), "67e4bdf50000000006028a59")
-
-    resp = json.loads((FIXTURES / f"xhs_case_{cid}.json").read_text("utf-8"))
-    post = parse_detail_response(resp, fallback_content_id=cid)
-    # 用富文本 body_text 当已提取内容,避免再调 Gemini
-    content = ExtractedContent(text=post.body_text, is_empty=not post.body_text.strip())
-    chat = default_chat(env_file)
-
-    print(f"==== {post.id}  {post.title} ====\n")
-    scr = screen_post(post, content, chat=chat)
-    print(f"[screen] passed={scr.passed} score={scr.score} reason={scr.reason}")
-    if not scr.passed:
-        return 0
-
-    items = split_post(post, content, chat=chat)
-    print(f"[split] {len(items)} 个知识片段")
-    for i, item in enumerate(items, 1):
-        print(f"\n  片段{i}: {item.title}  types={item.knowledge_types}")
-        deco = deconstruct_item(item, chat=chat)
-        print(f"    stages={deco.stages}  scopes={[(s.scope_type, s.value) for s in deco.scopes]}")
-        payload = build_ingest_payload(post, item, deco)
-        print(f"    ingest.content={payload.content[:120]}")
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 0 - 72
scripts/smoke_video_pipeline.py

@@ -1,72 +0,0 @@
-"""真机验证:抖音视频走完整流水线(原生提取→筛选→拆分→解构→落库),用本地 mp4 绕过下载网络。
-
-用法:python scripts/smoke_video_pipeline.py <env_file> <video.mp4> [content_id]
-"""
-from __future__ import annotations
-
-import sys
-
-import httpx
-
-from creation_knowledge.config import Settings
-from creation_knowledge.integrations.db import CkStore
-from creation_knowledge.integrations.llm import default_chat
-from creation_knowledge.integrations.video_extract import extract_video
-from creation_knowledge.models import Post
-from creation_knowledge.stages import (
-    build_ingest_payload, deconstruct_item, screen_post, split_post)
-
-
-def main() -> int:
-    args = sys.argv[1:]
-    env_file = next((a for a in args if a.endswith(".env")), ".env")
-    video = next((a for a in args if a.endswith((".mp4", ".bin"))), None)
-    cid = next((a for a in args if not a.endswith((".env", ".mp4", ".bin"))),
-               "7612631899479648866")
-    settings = Settings.from_env(env_file)
-    chat = default_chat(env_file)
-
-    det = httpx.post("http://crawler.aiddit.com/crawler/dou_yin/detail",
-                     json={"content_id": cid}, timeout=40).json()["data"]["data"]
-    post = Post(id=f"dy_{cid}", platform="douyin",
-                url=f"https://www.douyin.com/video/{cid}", content_id=cid,
-                title=det.get("title") or "", content_type="video",
-                body_text=det.get("body_text") or "",
-                video_urls=[det["video_url_list"][0]["video_url"]],
-                raw={"data": {"data": det}})
-
-    store = CkStore(settings.pg)
-    store.delete_post(post.id)
-    store.upsert_post(post)
-
-    print("[extract] 原生整段视频 …")
-    content = extract_video(post, settings=settings, video_path=video)
-    store.upsert_post(post)
-    store.set_extracted(post.id, content.model_dump())
-    print(f"  段数={len(post.cards)}  overall={content.text[:50]}")
-    print(f"  段卡示例={[(c.index, c.start, c.end) for c in post.cards[:3]]}")
-
-    scr = screen_post(post, content, chat=chat)
-    store.set_screening(post.id, scr.model_dump())
-    print(f"[screen] passed={scr.passed} score={scr.score}")
-    if scr.passed:
-        items = split_post(post, content, chat=chat)
-        print(f"[split] {len(items)} 条知识")
-        for it in items:
-            deco = deconstruct_item(it, chat=chat)
-            payload = build_ingest_payload(post, it, deco)
-            store.save_item(post.id, it.model_dump(), deco.model_dump(), payload.model_dump())
-        store.update_stage(post.id, "done")
-
-    p = store.read_post(post.id)
-    its = store.read_items(post.id)
-    print(f"[DB] stage={p['stage']} cards={len(p.get('cards') or [])} "
-          f"kinds={set(c['kind'] for c in (p.get('cards') or []))} items={len(its)}")
-    if its:
-        print(f"  item0: source_cards={its[0]['item'].get('source_cards')} "
-              f"evidence0={(its[0]['item'].get('evidence') or [None])[0]}")
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 0 - 79
scripts/validate_db.py

@@ -1,79 +0,0 @@
-"""M1 校验:连库、确认 schema/表/列齐全、做一次可逆写探测。
-
-用法(在能连到 RDS 的机器上,如海外开发机):
-    python scripts/validate_db.py [path/to/.env]
-"""
-from __future__ import annotations
-
-import sys
-
-from creation_knowledge.config import PgConfig
-from creation_knowledge.integrations.db import CkStore, _connect
-from creation_knowledge.models import Post
-
-EXPECTED = {
-    "ck_post": {"id", "platform", "url", "raw", "cards", "extracted", "screening",
-                "stage", "created_at", "updated_at"},
-    "ck_knowledge_item": {"id", "post_id", "item", "deconstruction",
-                          "ingest_payload", "ingest_status", "knowledge_id",
-                          "created_at", "updated_at"},
-}
-PROBE_ID = "_validate_probe"
-
-
-def main() -> int:
-    env_file = sys.argv[1] if len(sys.argv) > 1 else ".env"
-    cfg = PgConfig.from_env(env_file)
-    print(f"[cfg] host={cfg.host} db={cfg.database} schema={cfg.schema} user={cfg.user}")
-
-    # 1) 连通 + 版本
-    with _connect(cfg) as conn:
-        with conn.cursor() as cur:
-            cur.execute("SELECT version()")
-            print("[conn] OK:", cur.fetchone()[0][:70])
-
-    store = CkStore(cfg)
-
-    # 2) 表/列齐全
-    ok = True
-    for table, expected_cols in EXPECTED.items():
-        cols = set(store.table_columns(table))
-        if not cols:
-            print(f"[schema] MISSING table: {cfg.schema}.{table}")
-            ok = False
-            continue
-        missing = expected_cols - cols
-        if missing:
-            print(f"[schema] {table} 缺列: {sorted(missing)}")
-            ok = False
-        else:
-            print(f"[schema] {table} OK ({len(cols)} cols)")
-    if not ok:
-        print("RESULT: FAIL(表结构不符)")
-        return 1
-
-    # 3) 可逆写探测:插入一帖+一片段,读回,删除
-    store.delete_post(PROBE_ID)  # 清理上次残留
-    try:
-        store.upsert_post(Post(id=PROBE_ID, url="probe://x", content_id="x",
-                               raw={"probe": True}))
-        store.set_extracted(PROBE_ID, {"text": "probe", "is_empty": False})
-        item_id = store.save_item(PROBE_ID, {"title": "probe item",
-                                             "knowledge_types": ["how"]},
-                                  {"stages": ["脚本"]}, {"title": "probe"})
-        post = store.read_post(PROBE_ID)
-        items = store.read_items(PROBE_ID)
-        assert post and post["stage"] == "extracted", post
-        assert items and items[0]["id"] == item_id, items
-        print(f"[write] OK: post.stage={post['stage']}, item_id={item_id}, "
-              f"ingest_status={items[0]['ingest_status']}")
-    finally:
-        store.delete_post(PROBE_ID)
-        print("[write] 探测数据已清理")
-
-    print("RESULT: PASS")
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 0 - 52
sql/creation_knowledge.sql

@@ -1,52 +0,0 @@
--- 创作知识 · 过程库建表 DDL
--- 目标实例:open_aigc(阿里云 AnalyticDB for PostgreSQL / Greenplum 7.0.0,PG12 内核,新加坡)
--- 连接:OPEN_AIGC_PG_*(用户 aiddit_aigc 已确认有 CREATE 权限)
---
--- Greenplum 注意事项:
---   1. 每张表需 DISTRIBUTED BY;主键/唯一键必须包含分布列。
---   2. 不依赖外键强约束(Greenplum 不强制 FK),post_id ↔ ck_post.id 的关系由应用维护。
---   3. updated_at 由应用在更新时写入(不用触发器,保持简单)。
---   4. 本实例无 pgvector;embedding 一期不做,需要时另议(非 pgvector 路线)。
-
-CREATE SCHEMA IF NOT EXISTS creation_knowledge;
-SET search_path TO creation_knowledge;
-
--- 每帖一行:各环节产物存 JSONB,stage 记录处理到哪一步
-CREATE TABLE IF NOT EXISTS ck_post (
-    id          text        NOT NULL,                 -- xhs_/dy_/ks_/bili_<content_id>,同帖唯一,复用为 ingest source.id
-    platform    text,                                 -- xiaohongshu/douyin/kuaishou/bilibili
-    url         text,                                 -- 原始链接
-    raw         jsonb,                                 -- fetch_post_detail 原始响应
-    cards       jsonb,                                 -- 统一卡片列表 [{index,kind,url,timestamp,start,end}](图/帧/段)
-    extracted   jsonb,                                -- extract_content 多模态产物
-    screening   jsonb,                                -- screen_post 结果 {passed,score,reason}
-    stage       text        NOT NULL DEFAULT 'fetched', -- fetched/extracted/screened/split/done/rejected/failed/skipped
-    created_at  timestamptz NOT NULL DEFAULT now(),
-    updated_at  timestamptz NOT NULL DEFAULT now(),
-    PRIMARY KEY (id)
-) DISTRIBUTED BY (id);
-
-CREATE INDEX IF NOT EXISTS idx_ck_post_stage    ON ck_post (stage);
-CREATE INDEX IF NOT EXISTS idx_ck_post_platform ON ck_post (platform);
-
--- 每条知识片段一行:拆分+解构产物,以及组装好的 ingest payload(开发期的最终落点)
-CREATE TABLE IF NOT EXISTS ck_knowledge_item (
-    id              bigserial,                          -- 片段自增 ID
-    post_id         text        NOT NULL,               -- → ck_post.id(同帖多片段复用同一 source)
-    item            jsonb,                              -- 拆分结果 title/knowledge_types/what/why/how/evidence
-    deconstruction  jsonb,                              -- 解构结果 stages/scopes/reason
-    ingest_payload  jsonb,                              -- 组装后的 ingest 请求体(开发期最终产物)
-    ingest_status   text        NOT NULL DEFAULT 'pending', -- pending(未发送) / ingested / failed
-    knowledge_id    text,                               -- ingest API 返回的知识 ID(上线后才有)
-    created_at      timestamptz NOT NULL DEFAULT now(),
-    updated_at      timestamptz NOT NULL DEFAULT now(),
-    PRIMARY KEY (id)
-) DISTRIBUTED BY (id);
-
-CREATE INDEX IF NOT EXISTS idx_ck_item_post   ON ck_knowledge_item (post_id);
-CREATE INDEX IF NOT EXISTS idx_ck_item_status ON ck_knowledge_item (ingest_status);
-
--- 迁移(表已存在时补列,幂等):卡片溯源
-ALTER TABLE ck_post ADD COLUMN IF NOT EXISTS cards jsonb;
-
--- 回滚:DROP SCHEMA creation_knowledge CASCADE;

+ 0 - 157
tests/test_pipeline_e2e.py

@@ -1,157 +0,0 @@
-"""M5 离线编排测试:注入 fake store/fetch/extract/chat,验证 done 与 rejected 两条路径。
-
-真实落库的 e2e 见 scripts/run_batch.py(在能连 DB 的云端跑)。
-"""
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-from creation_knowledge.config import PgConfig, Settings
-from creation_knowledge.integrations.crawler import parse_detail_response
-from creation_knowledge.models import ExtractedContent, Post
-from creation_knowledge.pipeline import run_pipeline
-
-FIXTURES = Path(__file__).parent / "fixtures"
-CID = "67e4bdf50000000006028a59"
-
-
-def _settings(data_dir: str = "") -> Settings:
-    return Settings(
-        pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
-        crawler_base_url="http://x", crawler_key="", crawler_timeout=30,
-        video_model="m", gemini_api_key="", openrouter_base_url="http://x",
-        openrouter_api_key="k", llm_model="m", knowhub_api="http://x",
-        ingest_enabled=False, max_cards=12, frames_dir="runtime/frames",
-        douyin_ratio="540p", data_dir=data_dir,
-    )
-
-
-class FakeStore:
-    def __init__(self):
-        self.posts: dict = {}
-        self.items: list = []
-
-    def upsert_post(self, post):
-        self.posts[post.id] = {"stage": "fetched"}
-
-    def set_extracted(self, pid, ex):
-        self.posts[pid].update(stage="extracted", extracted=ex)
-
-    def set_screening(self, pid, sc):
-        self.posts[pid].update(stage="screened", screening=sc)
-
-    def update_stage(self, pid, stage):
-        self.posts[pid]["stage"] = stage
-
-    def save_item(self, pid, item, deco, payload):
-        self.items.append({"post_id": pid, "item": item, "deco": deco, "payload": payload})
-        return len(self.items)
-
-    def update_item_ingest(self, iid, status, kid):
-        self.items[iid - 1].update(ingest_status=status, knowledge_id=kid)
-
-    def clear_items(self, pid):
-        before = len(self.items)
-        self.items = [it for it in self.items if it["post_id"] != pid]
-        return before - len(self.items)
-
-
-def _fetch(url):
-    resp = json.loads((FIXTURES / f"xhs_case_{CID}.json").read_text("utf-8"))
-    return parse_detail_response(resp, fallback_content_id=CID)
-
-
-def _extract(post):
-    return ExtractedContent(text=post.body_text or "内容", is_empty=False)
-
-
-def _chat_pass(system, user):
-    if "筛选" in system:
-        return {"passed": True, "score": 8, "reason": "ok"}
-    if "拆分" in system:
-        return {"items": [{"title": "脚本要素", "knowledge_types": ["how"],
-                           "what": "null", "why": "null", "how": "逐个填要素",
-                           "evidence": ["原句"]}]}
-    if "解构" in system:
-        return {"stages": ["脚本"], "scopes": [{"scope_type": "form", "value": "操作流程"}],
-                "stage_reason": "r", "scope_reason": "r"}
-    raise AssertionError(f"unexpected system: {system}")
-
-
-def _chat_reject(system, user):
-    if "筛选" in system:
-        return {"passed": False, "score": 2, "reason": "只是作品本身"}
-    raise AssertionError("rejected post 不应进入拆分/解构")
-
-
-def test_pipeline_done_path():
-    store = FakeStore()
-    results = run_pipeline(
-        ["https://www.xiaohongshu.com/explore/" + CID],
-        settings=_settings(), ingest_enabled=False, store=store,
-        fetch_fn=_fetch, extract_fn=_extract, chat=_chat_pass,
-    )
-    r = results[0]
-    assert r["status"] == "done" and r["items"] == 1
-    pid = f"xhs_{CID}"
-    assert store.posts[pid]["stage"] == "done"
-    assert len(store.items) == 1
-    saved = store.items[0]
-    assert saved["payload"]["source"]["id"] == pid
-    assert saved["payload"]["dim_attributes"] == ["how"]
-
-
-def test_pipeline_rejected_path():
-    store = FakeStore()
-    results = run_pipeline(
-        [CID], settings=_settings(), ingest_enabled=False, store=store,
-        fetch_fn=_fetch, extract_fn=_extract, chat=_chat_reject,
-    )
-    r = results[0]
-    assert r["status"] == "rejected"
-    assert store.posts[f"xhs_{CID}"]["stage"] == "rejected"
-    assert store.items == []
-
-
-def _extract_must_not_run(post):
-    raise AssertionError("skip 的帖子不应进入提取")
-
-
-def test_pipeline_skip_xhs_video():
-    """小红书视频帖:content_type=video 但无视频直链 → skip,不进提取/筛选。"""
-    store = FakeStore()
-    post = Post(id="xhs_vid1", platform="xiaohongshu", url="u", content_id="vid1",
-                content_type="video", video_urls=[], image_urls=["http://cover.jpg"])
-    results = run_pipeline(
-        ["vid1"], settings=_settings(), ingest_enabled=False, store=store,
-        fetch_fn=lambda url: post, extract_fn=_extract_must_not_run, chat=_chat_pass,
-    )
-    r = results[0]
-    assert r["status"] == "skipped" and r["reason"] == "video_no_direct_url"
-    assert store.posts["xhs_vid1"]["stage"] == "skipped"
-    assert store.items == []
-
-
-def test_pipeline_skip_empty_media():
-    """既无图也无视频 → skip(empty_media)。"""
-    store = FakeStore()
-    post = Post(id="xhs_empty", platform="xiaohongshu", url="u", content_id="empty",
-                content_type="normal", video_urls=[], image_urls=[])
-    results = run_pipeline(
-        ["empty"], settings=_settings(), ingest_enabled=False, store=store,
-        fetch_fn=lambda url: post, extract_fn=_extract_must_not_run, chat=_chat_pass,
-    )
-    assert results[0]["status"] == "skipped" and results[0]["reason"] == "empty_media"
-    assert store.items == []
-
-
-def test_pipeline_idempotent_rerun():
-    """同帖重跑:知识片段先清后写,不叠加。"""
-    store = FakeStore()
-    for _ in range(3):
-        run_pipeline(
-            [CID], settings=_settings(), ingest_enabled=False, store=store,
-            fetch_fn=_fetch, extract_fn=_extract, chat=_chat_pass,
-        )
-    assert len(store.items) == 1, "重跑三次仍应只有 1 条(先清后写)"

+ 0 - 118
tests/test_stages.py

@@ -1,118 +0,0 @@
-"""M4 离线测试:注入假 chat,逐环节断言;assemble 纯函数对齐设计文档 §6。"""
-from __future__ import annotations
-
-import json
-
-from creation_knowledge.models import (
-    Deconstruction,
-    Evidence,
-    ExtractedContent,
-    KnowledgeItem,
-    Post,
-    Scope,
-)
-from creation_knowledge.stages import (
-    build_ingest_payload,
-    deconstruct_item,
-    screen_post,
-    split_post,
-)
-
-
-def _post() -> Post:
-    return Post(
-        id="xhs_abc", url="https://www.xiaohongshu.com/explore/abc", content_id="abc",
-        title="只要学会这几样,写短视频脚本真的不难", platform="xiaohongshu",
-        author_name="海狸教自媒体运营", topic_list=["短视频"],
-    )
-
-
-def _content() -> ExtractedContent:
-    return ExtractedContent(
-        text="短视频脚本包含标题、拍摄地点、分镜等要素",
-        from_image="九宫格图讲了脚本6要素", is_empty=False,
-    )
-
-
-def test_screen_passed():
-    r = screen_post(_post(), _content(),
-                    chat=lambda s, u: {"passed": True, "score": 8, "reason": "含明确 How"})
-    assert r.passed and r.score == 8
-
-
-def test_screen_feeds_extracted_not_body():
-    seen = {}
-
-    def chat(s, u):
-        seen["u"] = u
-        return {"passed": True, "score": 7, "reason": "x"}
-
-    screen_post(_post(), _content(), chat=chat)
-    assert "九宫格" in seen["u"]  # 多模态提取内容进了 prompt,而非空 body_text
-
-
-def test_split_consistency_and_drop_empty():
-    def chat(s, u):
-        return {"items": [
-            {"title": "脚本要素", "knowledge_types": ["what", "how", "why"],
-             "what": "脚本含6要素", "why": "null", "how": "逐个填要素",
-             "evidence": ["原句"]},
-            {"title": "空", "knowledge_types": ["what"],
-             "what": "null", "why": "null", "how": "null", "evidence": []},
-        ]}
-
-    items = split_post(_post(), _content(), chat=chat)
-    assert len(items) == 1  # 三个都空的片段被丢弃
-    it = items[0]
-    assert it.why is None  # "null" 归一为 None
-    assert set(it.knowledge_types) == {"what", "how"}  # 与非空字段对齐,why 被剔除
-
-
-def test_split_source_cards_and_evidence():
-    def chat(s, u):
-        return {"items": [{"title": "评论区选题法", "knowledge_types": ["how"],
-                           "what": "null", "why": "null", "how": "收集高赞评论改写",
-                           "source_cards": [1, 3],
-                           "evidence": [{"text": "高赞评论反映痛点", "card": 1},
-                                        "无卡片的纯字符串证据"]}]}
-    it = split_post(_post(), _content(), chat=chat)[0]
-    assert it.source_cards == [1, 3]
-    assert it.evidence[0].card == 1 and it.evidence[0].text
-    assert it.evidence[1].card is None  # 字符串证据 → card=None
-
-
-def test_deconstruct_filters_invalid():
-    def chat(s, u):
-        return {"stages": ["选题", "脚本", "乱写"],
-                "scopes": [{"scope_type": "form", "value": "操作流程"},
-                           {"scope_type": "bad", "value": "x"},
-                           {"scope_type": "effect", "value": ""}],
-                "stage_reason": "r1", "scope_reason": "r2"}
-
-    d = deconstruct_item(KnowledgeItem(title="t", knowledge_types=["how"], how="做法"),
-                         chat=chat)
-    assert d.stages == ["选题", "脚本"]  # 非法阶段过滤
-    assert [s.scope_type for s in d.scopes] == ["form"]  # 非法 scope / 空 value 过滤
-
-
-def test_assemble_matches_design_doc():
-    item = KnowledgeItem(title="评论区选题法", knowledge_types=["what", "how"],
-                         what="从评论提取痛点", why=None, how="收集高赞评论改写",
-                         source_cards=[2], evidence=[Evidence(text="原句a", card=2)])
-    deco = Deconstruction(
-        stages=["选题", "脚本"],
-        scopes=[Scope(scope_type="form", value="公式框架"),
-                Scope(scope_type="effect", value="生成选题")],
-        stage_reason="既选题又脚本", scope_reason="形式+作用")
-    p = build_ingest_payload(_post(), item, deco)
-
-    assert p.source["id"] == "xhs_abc"
-    assert p.source["source_type"] == "post"
-    assert p.source["source_metadata"]["platform"] == "xiaohongshu"
-    assert p.dim_attributes == ["what", "how"]
-    assert p.dim_creations == ["选题", "脚本"]
-    assert {"scope_type": "form", "value": "公式框架"} in p.scopes
-    assert json.loads(p.content) == {"what": "从评论提取痛点", "why": None,
-                                     "how": "收集高赞评论改写"}
-    keys = [e["key"] for e in p.custom_ext]
-    assert "原文证据" in keys and "来源卡片" in keys

+ 1 - 1
tests/test_video_extract.py

@@ -29,7 +29,7 @@ def _settings() -> Settings:
         crawler_base_url="x", crawler_key="", crawler_timeout=30,
         video_model="google/gemini-3-flash-preview", gemini_api_key="",
         openrouter_base_url="https://openrouter.ai/api/v1", openrouter_api_key="k",
-        llm_model="m", knowhub_api="x", ingest_enabled=False, max_cards=12,
+        llm_model="m", max_cards=12,
         frames_dir="runtime/frames", douyin_ratio="540p", data_dir="")
 
 

+ 0 - 350
web/index_pipeline.html

@@ -1,350 +0,0 @@
-<!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:#f3efe4;        /* 暖黄页面背景 */
-    --panel:#ffffff;     /* 白卡 */
-    --panel2:#faf7ef;    /* 略暖的内卡 */
-    --line:#e7e2d4;      /* 浅暖描边 */
-    --line2:#ece7d9;
-    --fg:#2c2c2a;        /* 近黑正文 */
-    --mut:#6f6e68;       /* 次要文字 */
-    --tert:#9a978d;      /* 提示文字 */
-    --ac:#185fa5;        /* 蓝 */
-    --what:#185fa5; --why:#534ab7; --how:#0f6e56;
-    --ok:#0f6e56; --no:#a32d2d; --warn:#854f0b;
-  }
-  *{box-sizing:border-box}
-  body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif}
-  header{padding:14px 20px;background:var(--panel);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;background:var(--panel);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:#f7f3ea}
-  .pitem.sel{background:#eef4fb;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 9px;border-radius:10px;font-size:12px;background:#efeadd;color:#5f5e5a}
-  .badge.ok{background:#e1f5ee;color:var(--ok)}
-  .badge.no{background:#fcebeb;color:var(--no)}
-  .badge.ac{background:#e6f1fb;color:var(--ac)}
-  .badge.warn{background:#faeeda;color:var(--warn)}
-  .plhead{position:sticky;top:0;background:var(--panel);border-bottom:1px solid var(--line);padding:8px 12px 8px;z-index:2}
-  .plsum{font-size:12px;color:var(--mut);margin:2px 2px 6px} .plsum b{color:var(--fg)}
-  .plf-g{display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin:4px 0}
-  .plf-l{font-size:11px;color:var(--tert);width:28px;flex:none}
-  .plf{border:1px solid var(--line);background:#fff;border-radius:12px;padding:2px 9px;font-size:11.5px;cursor:pointer;font-family:inherit;color:var(--fg)}
-  .plf.on{background:#2c2c2a;color:#fff;border-color:#2c2c2a}
-  .pl-r1{display:flex;align-items:center;gap:6px}
-  .pbg{font-size:10.5px;font-weight:700;padding:1px 6px;border-radius:3px}
-  .pb-xhs{background:#ffe4e6;color:#be123c}.pb-dy{background:#e5e7eb;color:#111}.pb-ks{background:#ffedd5;color:#c2410c}.pb-bili{background:#fce7f3;color:#be185d}.pb-sph{background:#dcfce7;color:#15803d}.pb-x{background:#eee;color:#555}
-  .pl-ty{font-size:12px;color:var(--mut)}
-  .pl-dot{width:8px;height:8px;border-radius:50%;margin-left:auto}
-  .pl-dot.done{background:#16a34a}.pl-dot.skip{background:#d97706}.pl-dot.fail{background:#dc2626}.pl-dot.other{background:#cbd5e1}
-  .pl-ti{font-size:13px;font-weight:550;line-height:1.4;margin-top:3px}
-  .pl-mt{font-size:11px;color:var(--tert);margin-top:2px}
-  .stage{background:var(--panel);border:1px solid var(--line);border-radius:12px;margin-bottom:16px;overflow:hidden}
-  .stage>h3{margin:0;padding:11px 16px;background:#f7f3ea;border-bottom:1px solid var(--line);font-size:13px;font-weight:600;display:flex;gap:8px;align-items:center}
-  .stage>h3 .n{width:20px;height:20px;border-radius:50%;background:#3f86d6;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}
-  .segwrap{display:flex;flex-direction:column;gap:3px;margin-top:4px}
-  .segvid{width:240px;max-width:100%;border-radius:6px;border:1px solid var(--line);background:#000}
-  .segcap{font-size:11px;color:var(--mut)}
-  .imgs img{width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in;transition:transform .1s}
-  .imgs img:hover{transform:scale(1.05);border-color:var(--ac)}
-  .card{background:var(--panel2);border:1px solid var(--line2);border-radius:10px;padding:12px 14px;margin-bottom:10px}
-  .kptitle{margin:0;font-size:15px}
-  .part{margin-top:12px}
-  .part ~ .part{border-top:1px solid var(--line2);padding-top:12px}
-  .phead{font-size:12px;color:var(--mut);font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:8px}
-  .pn{display:inline-flex;width:18px;height:18px;border-radius:50%;background:#e6f1fb;color:#185fa5;align-items:center;justify-content:center;font-size:11px;flex:none}
-  .ktype{font-size:12px;padding:2px 9px;border-radius:8px;background:#e6f1fb;color:#0c447c;white-space:nowrap;margin-left:auto}
-  .chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:0}
-  pre{white-space:pre-wrap;word-break:break-word;background:#f6f2e8;border:1px solid var(--line);border-radius:6px;padding:10px;font-size:12px;color:#444441;margin:6px 0 0;max-height:260px;overflow:auto}
-  details summary{cursor:pointer;color:var(--mut);font-size:12px}
-  .whh{margin:8px 0;padding-left:10px;border-left:2px solid var(--line)}
-  .whh.w{border-color:var(--what)} .whh.y{border-color:var(--why)} .whh.h{border-color:var(--how)}
-  .whh b{font-size:12px}
-  .whh.w b{color:var(--what)} .whh.y b{color:var(--why)} .whh.h b{color:var(--how)}
-  .sect{border-top:1px solid var(--line2);margin-top:12px;padding-top:10px;display:flex;flex-direction:column;gap:10px}
-  .srow{display:flex;align-items:flex-start;gap:10px}
-  .slabel{font-size:13px;color:var(--mut);min-width:62px;flex:none;padding-top:2px}
-  .scope{display:flex;align-items:baseline;gap:8px}
-  .scn{font-size:12px;padding:2px 8px;border-radius:8px;flex:none}
-  .foot{border-top:1px solid var(--line2);margin-top:10px;padding-top:8px;display:flex;gap:18px;flex-wrap:wrap}
-  .link{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:#e6f1fb;border:1px solid #9cc3ee;border-radius:8px;color:var(--ac);text-decoration:none;font-size:13px;margin-bottom:10px}
-  .crit{background:var(--panel2);border:1px solid var(--line2);border-radius:8px;padding:10px 14px;margin-bottom:12px}
-  .crit .t{color:var(--mut);font-size:12px;margin-bottom:6px}
-  .crit ul{margin:0;padding-left:18px} .crit li{margin:3px 0;font-size:13px}
-  .lb{position:fixed;inset:0;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center;z-index:50;cursor:zoom-out}
-  .lb img{max-width:92vw;max-height:92vh;border-radius:8px}
-  a{color:var(--ac)}
-  .empty{color:var(--mut);padding:40px;text-align:center}
-  .sub2{color:var(--mut);font-size:12px;margin:-2px 0 8px}
-  .sx{margin-left:auto;display:flex;gap:6px}
-  .pbtn{font-size:12px;padding:2px 10px;border-radius:7px;background:#fff;border:1px solid var(--line);color:var(--mut);cursor:pointer;font-family:inherit}
-  .pbtn:hover{border-color:var(--ac);color:var(--ac)}
-  .pmodal{background:var(--panel);border:1px solid var(--line);border-radius:12px;max-width:760px;width:90vw;max-height:86vh;overflow:auto;padding:16px 18px;cursor:auto}
-  .pmhead{display:flex;align-items:center;gap:10px;margin-bottom:6px}
-  .pmhead b{font-size:15px}
-  .pmmodel{font-size:12px;color:var(--ac);background:#eef4fb;padding:1px 8px;border-radius:7px}
-  .pmx{margin-left:auto;cursor:pointer;color:var(--mut);font-size:16px;line-height:1}
-  .pmlabel{font-size:12px;color:var(--mut);font-weight:600;margin:12px 0 4px}
-  .pmnote{font-size:12px;color:var(--warn);background:#faeeda;padding:6px 10px;border-radius:6px;margin-top:4px}
-  .srcrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
-  .srcrow .lbl{font-size:12px;color:var(--mut);flex:none}
-  .srcthumb{position:relative}
-  .srcthumb img{width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in}
-  .srcthumb img:hover{border-color:var(--ac)}
-  .srcthumb .cn{position:absolute;left:2px;bottom:2px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;padding:0 4px;border-radius:4px}
-  .cardthumb{position:relative}
-  .cardthumb .cn{position:absolute;left:3px;bottom:3px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;padding:0 5px;border-radius:4px}
-  .evrow{display:flex;gap:8px;align-items:baseline;margin:5px 0;font-size:13px}
-  .evcard{font-size:11px;padding:1px 7px;border-radius:6px;background:#e6f1fb;color:#185fa5;cursor:zoom-in;flex:none}
-  .segchip{display:inline-flex;align-items:center;font-size:12px;padding:4px 10px;border-radius:8px;background:#eef4fb;color:#185fa5;border:1px solid #cfe0f5;flex:none}
-</style>
-</head>
-<body>
-<div id="root"></div>
-<script type="text/babel">
-const {useState,useEffect} = React;
-const j = (u)=>fetch(u).then(r=>r.json());
-const SCOPE_CN = {substance:"实质",form:"形式",feeling:"感受",effect:"作用",intent:"意图"};
-const SCOPE_COLOR = {
-  substance:["#e6f1fb","#0c447c"], form:["#e1f5ee","#0f6e56"],
-  feeling:["#eeedfe","#3c3489"], effect:["#faeeda","#854f0b"],
-  intent:["#faece7","#993c1d"]};
-
-function Badge({kind,children}){return <span className={"badge "+(kind||"")}>{children}</span>;}
-
-const PLAT={xiaohongshu:["小红书","pb-xhs"],douyin:["抖音","pb-dy"],kuaishou:["快手","pb-ks"],bilibili:["B站","pb-bili"],shipinhao:["视频号","pb-sph"]};
-const statBucket=s=>s==="done"?"done":s==="skipped"?"skip":(s==="failed"||s==="rejected")?"fail":"other";
-function PostList({posts,sel,onSel}){
-  const [fp,setFp]=useState("all"),[fs,setFs]=useState("all");
-  const plats=[...new Set(posts.map(p=>p.platform))];
-  const shown=posts.filter(p=>(fp==="all"||p.platform===fp)&&(fs==="all"||statBucket(p.stage)===fs));
-  const Chip=({on,onClick,children})=><button className={"plf"+(on?" on":"")} onClick={onClick}>{children}</button>;
-  return <div className="side">
-    <div className="plhead">
-      <div className="plsum">显示 <b>{shown.length}</b> / {posts.length} 条</div>
-      <div className="plf-g"><span className="plf-l">平台</span>
-        <Chip on={fp==="all"} onClick={()=>setFp("all")}>全部</Chip>
-        {plats.map(p=><Chip key={p} on={fp===p} onClick={()=>setFp(p)}>{(PLAT[p]||[p])[0]}</Chip>)}</div>
-      <div className="plf-g"><span className="plf-l">状态</span>
-        <Chip on={fs==="all"} onClick={()=>setFs("all")}>全部</Chip>
-        {[["done","已完成"],["skip","已跳过"],["fail","失败"]].map(([v,l])=><Chip key={v} on={fs===v} onClick={()=>setFs(v)}>{l}</Chip>)}</div>
-    </div>
-    {shown.map(p=>{
-      const pb=PLAT[p.platform]||[p.platform,"pb-x"], st=statBucket(p.stage);
-      const vid=(p.content_type||"").toLowerCase()==="video";
-      return <div key={p.id} className={"pitem"+(sel===p.id?" sel":"")} onClick={()=>onSel(p.id)}>
-        <div className="pl-r1"><span className={"pbg "+pb[1]}>{pb[0]}</span>
-          <span className="pl-ty" title={vid?"视频":"图文"}>{vid?"▶":"▤"}</span>
-          <span className={"pl-dot "+st} title={p.stage}></span></div>
-        <div className="pl-ti">{p.title||p.id}</div>
-        <div className="pl-mt">{p.item_count} 个知识点 · {p.run||"—"}</div>
-      </div>;
-    })}
-  </div>;
-}
-
-function Stage({n,title,sub,extra,children}){
-  return <div className="stage"><h3><span className="n">{n}</span>{title}{extra&&<span className="sx">{extra}</span>}</h3>
-    <div className="body">{sub&&<div className="sub2">{sub}</div>}{children}</div></div>;
-}
-
-function PromptBtn({label,onClick}){
-  return <button className="pbtn" onClick={onClick}>📄 {label||"提示词"}</button>;
-}
-
-function PromptModal({item,onClose}){
-  if(!item) return null;
-  return <div className="lb" onClick={onClose}>
-    <div className="pmodal" onClick={e=>e.stopPropagation()}>
-      <div className="pmhead"><b>{item.label} · 提示词</b><span className="pmmodel">{item.model}</span><span className="pmx" onClick={onClose}>✕</span></div>
-      {item.note&&<div className="pmnote">{item.note}</div>}
-      <div className="pmlabel">System(角色设定)</div><pre>{item.system}</pre>
-      <div className="pmlabel">User(输入模板)</div><pre>{item.user}</pre>
-    </div>
-  </div>;
-}
-
-function fmtTs(s){s=Math.floor(s||0);return String(Math.floor(s/60)).padStart(2,'0')+":"+String(s%60).padStart(2,'0');}
-function cardTime(c){
-  if(!c) return "";
-  if(c.kind==="segment"&&c.start!=null) return fmtTs(c.start)+"–"+fmtTs(c.end);
-  if(c.kind==="frame"&&c.timestamp!=null) return fmtTs(c.timestamp);
-  return "";
-}
-// 段卡:图片用缩略图,视频段卡用内联 <video> 只播 [start,end] 区间
-const isSeg=c=>!!c&&c.kind==="segment";
-const segSrc=c=>c.url+"#t="+(c.start||0)+","+(c.end||0);
-function SegVideo({c}){
-  const end=c.end;
-  return <video className="segvid" src={segSrc(c)} controls preload="metadata"
-    onTimeUpdate={e=>{ if(end!=null && e.target.currentTime>=end) e.target.pause(); }}/>;
-}
-
-function KPCard({it,idx,cardMap,onZoom}){
-  const item=it.item||{}, deco=it.deconstruction||{}, payload=it.ingest_payload||{};
-  const types=(item.knowledge_types||[]).map(t=>t.toUpperCase()).join(" + ");
-  const stages=deco.stages||[], scopes=deco.scopes||[];
-  const srcCards=(item.source_cards||[]).map(n=>cardMap[n]).filter(Boolean);
-  const evidence=item.evidence||[];
-  return <div className="card">
-    <h4 className="kptitle">{idx}. {item.title||"(无标题)"}</h4>
-
-    {srcCards.length>0 && <div className="srcrow">
-      <span className="lbl">来源卡片</span>
-      {srcCards.map(c=> (c.url&&!isSeg(c))
-        ? <span className="srcthumb" key={c.index}>
-            <img src={c.url} title={"卡片"+c.index} onClick={()=>onZoom(c.url)} onError={e=>e.target.style.display='none'}/>
-            <span className="cn">{cardTime(c)||("卡片"+c.index)}</span></span>
-        : <span className="segchip" key={c.index}>片段{c.index}{cardTime(c)?" · "+cardTime(c):""}</span>
-      )}
-    </div>}
-
-    <div className="part">
-      <div className="phead"><span className="pn">1</span>知识类型拆解<span className="ktype">{types||"—"}</span></div>
-      {item.what&&<div className="whh w"><b>What · 是什么</b><div>{item.what}</div></div>}
-      {item.why&&<div className="whh y"><b>Why · 为什么</b><div>{item.why}</div></div>}
-      {item.how&&<div className="whh h"><b>How · 怎么做</b><div>{item.how}</div></div>}
-    </div>
-
-    <div className="part">
-      <div className="phead"><span className="pn">2</span>解构 · 阶段</div>
-      <div className="chips">
-        {stages.length? stages.map(s=><Badge key={s}>{s}</Badge>) : <span className="kv">—</span>}
-        {stages.length>1 && <span className="kv">({stages.length} 个阶段都 cover)</span>}
-      </div>
-    </div>
-
-    <div className="part">
-      <div className="phead"><span className="pn">3</span>解构 · 作用域</div>
-      <div style={{display:'flex',flexDirection:'column',gap:6}}>
-        {scopes.length? scopes.map((s,i)=>{
-          const c=SCOPE_COLOR[s.scope_type]||["#efeadd","#5f5e5a"];
-          return <div className="scope" key={i}>
-            <span className="scn" style={{background:c[0],color:c[1]}}>{SCOPE_CN[s.scope_type]||s.scope_type}</span>
-            <span>{s.value}</span></div>;
-        }) : <span className="kv">—</span>}
-      </div>
-    </div>
-
-    <div className="foot">
-      {evidence.length>0 &&
-        <details><summary>原文证据({evidence.length})</summary>
-          <div>{evidence.map((e,i)=>{
-            const text=typeof e==="string"?e:(e.text||"");
-            const card=(typeof e==="object"&&e)?e.card:null;
-            const c=card!=null?cardMap[card]:null;
-            return <div className="evrow" key={i}>
-              {c&&((c.url&&!isSeg(c))
-                ? <span className="evcard" onClick={()=>onZoom(c.url)}>卡片{card}</span>
-                : <span className="evcard" style={{cursor:"default"}} title="视频片段">片段{card}{cardTime(c)?" · "+cardTime(c):""}</span>)}
-              <span>{text}</span></div>;
-          })}</div>
-        </details>}
-      <details><summary>入库数据(ingest payload)</summary><pre>{JSON.stringify(payload,null,2)}</pre></details>
-    </div>
-  </div>;
-}
-
-function Pipeline({postId,onZoom,prompts,onPrompt}){
-  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 scr=post.screening||{};
-  const rawImgs=(inner.image_url_list||[]).map(x=>x.image_url||x);
-  // 统一卡片:优先 post.cards(含图/帧),旧数据回退 image_url_list
-  const cards=(post.cards&&post.cards.length)? post.cards
-    : rawImgs.map((u,i)=>({index:i+1,kind:"image",url:u}));
-  const cardMap={}; cards.forEach(c=>{cardMap[c.index]=c;});
-  return <div>
-    <Stage n="1" title="原文">
-      <a className="link" href={post.url} target="_blank" rel="noreferrer">🔗 打开原帖</a>
-      <p className="kv">作者 {inner.channel_account_name||"—"} · 类型 {inner.content_type||"—"} · {cards.length} 张卡片</p>
-      <div><b>{inner.title||"(无标题)"}</b></div>
-      <pre>{inner.body_text||"(正文为空,知识在卡片里)"}</pre>
-      <div className="imgs">{cards.map(c=>
-        (isSeg(c)&&c.url)
-        ? <div className="segwrap" key={c.index}>
-            <SegVideo c={c}/>
-            <span className="segcap">片段{c.index} · {cardTime(c)}</span></div>
-        : c.url
-        ? <span className="cardthumb" key={c.index}>
-            <img src={c.url} title={"卡片"+c.index} onClick={()=>onZoom(c.url)} onError={e=>e.target.style.display='none'}/>
-            <span className="cn">{c.index}{cardTime(c)?" · "+cardTime(c):""}</span></span>
-        : <span className="segchip" key={c.index}>片段{c.index}{cardTime(c)?" · "+cardTime(c):""}</span>
-      )}</div>
-    </Stage>
-
-    <Stage n="2" title="筛选" sub="判断这篇帖子值不值得提取成创作知识"
-      extra={prompts.screen && <PromptBtn label="提示词" onClick={()=>onPrompt('screen')}/>}>
-      <div className="crit">
-        <div className="t">筛选标准(4 条都满足才通过)</div>
-        <ul>
-          <li>内容不是空的</li>
-          <li>讲的是"怎么做内容"的方法或经验,不是一条作品本身(比如一段文案、一张图)</li>
-          <li>至少包含一种:是什么(What)/ 为什么(Why)/ 怎么做(How)</li>
-          <li>至少能拆出一条能用的创作知识</li>
-        </ul>
-      </div>
-      <div className="chips">
-        <Badge kind={scr.passed?"ok":"no"}>{scr.passed?"✓ 通过":"✕ 淘汰"}</Badge>
-        <Badge kind="ac">评分 {scr.score}/10</Badge>
-      </div>
-      <div className="kv" style={{marginTop:6}}>判断理由</div>
-      <div>{scr.reason||"—"}</div>
-    </Stage>
-
-    <Stage n="3" title={"知识点("+items.length+" 条)"}
-      sub="每条 = 知识类型 + What/Why/How + 对应阶段 + 作用域"
-      extra={<React.Fragment>
-        {prompts.split && <PromptBtn label="拆分提示词" onClick={()=>onPrompt('split')}/>}
-        {prompts.deconstruct && <PromptBtn label="解构提示词" onClick={()=>onPrompt('deconstruct')}/>}
-      </React.Fragment>}>
-      {items.length? items.map((it,i)=><KPCard key={i} it={it} idx={i+1} cardMap={cardMap} onZoom={onZoom}/>) : <div className="kv">无(被淘汰或未拆出)</div>}
-    </Stage>
-  </div>;
-}
-
-function App(){
-  const [posts,setPosts]=useState([]);
-  const [sel,setSel]=useState(null);
-  const [zoom,setZoom]=useState(null);
-  const [prompts,setPrompts]=useState({});
-  const [pm,setPm]=useState(null);
-  useEffect(()=>{ j("/api/posts").then(ps=>{setPosts(ps); if(ps[0])setSel(ps[0].id);}); },[]);
-  useEffect(()=>{ j("/api/prompts").then(r=>{const m={};(r.items||[]).forEach(it=>m[it.key]=it);setPrompts(m);}); },[]);
-  return <div>
-    <header>创作知识的搜索和解构</header>
-    <div className="wrap">
-      <PostList posts={posts} sel={sel} onSel={setSel}/>
-      <div className="main"><Pipeline postId={sel} onZoom={setZoom} prompts={prompts} onPrompt={setPm}/></div>
-    </div>
-    {zoom&&<div className="lb" onClick={()=>setZoom(null)}><img src={zoom}/></div>}
-    {pm&&<PromptModal item={prompts[pm]} onClose={()=>setPm(null)}/>}
-  </div>;
-}
-ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
-</script>
-</body>
-</html>

+ 0 - 493
创作知识-重构设计.md

@@ -1,493 +0,0 @@
-# 创作知识 · 知识重构设计
-
-> 当前版本先不做 Query、不做搜索、不做游走。  
-> 输入直接是已经搜到的帖子链接,输出是可以调用入库接口的知识数据。
-
-## 1. 主流程
-
-```text
-帖子链接
-  -> 0. 多模态内容理解(文本 + 图片 + 视频 -> Gemini(gemini-3-flash-preview))
-  -> 1. 筛选
-  -> 2. 帖子拆分
-  -> 3. 帖子解构
-  -> 4. 组装入库数据
-```
-
-各环节分别做:
-
-| 环节 | 做什么 | 产出 |
-|---|---|---|
-| 多模态内容理解 | 把帖子的文本、图片、视频一起交给 Gemini(gemini-3-flash-preview),提取出真正的内容 | extracted_content |
-| 筛选 | 判断帖子是不是有效创作知识 | passed / rejected |
-| 帖子拆分 | 把帖子拆成 What / Why / How 知识片段 | knowledge_items |
-| 帖子解构 | 判断每个知识片段属于哪些阶段和作用域 | stages / scopes |
-| 组装入库数据 | 转成 `POST /api/v1/knowledge/ingest` 请求体 | ingest payload |
-
-> ⚠️ 关键约束:**知识内容主要在图片或视频里,不在 `body_text` 里**。小红书图文/视频笔记的 `body_text` 往往只是话题串(例如 `#短剧编剧# #写作#`),正文几乎为空。因此**不可以只读 `body_text`**——文本、图片、视频都必须交给 Gemini(gemini-3-flash-preview) 多模态理解,由它提取真正的内容,后续筛选 / 拆分都基于这份提取结果,而不是原始 `body_text`。
-
-## 2. 输入案例
-
-这几个链接先作为标注样例,用来校准 What / Why / How 的拆分规则。共 4 种案例(5 个链接):
-
-| 标注 | content_id | 链接 |
-|---|---|---|
-| HOW + WHAT | `67e4bdf50000000006028a59` | https://www.xiaohongshu.com/explore/67e4bdf50000000006028a59?xsec_token=ABvi6o6tDVgqSVV7eLImIcQxoVhayPv3GGE7mK9I28EqI=&xsec_source=pc_like |
-| HOW + WHAT | `698481e1000000000a02a7c1` | https://www.xiaohongshu.com/explore/698481e1000000000a02a7c1?xsec_token=ABrLeB8aB0ggoeNA0cmGv0E6NDl0CLhuLl4KqS-Mhd4js=&xsec_source=pc_like |
-| HOW | `67e2e39b0000000003028ff0` | https://www.xiaohongshu.com/explore/67e2e39b0000000003028ff0?xsec_token=ABcF_yNIoadKLJ4LfS3EhDo0GVaz-nFK2mlMN9qr-0j_4=&xsec_source=pc_like |
-| WHY | `699308fa0000000016009697` | https://www.xiaohongshu.com/explore/699308fa0000000016009697?xsec_token=ABLOnWGgs67c1SediQp7db7vZVmdOFs9Fm1m46ONG7GCA=&xsec_source=pc_like |
-| HOW + WHY + WHAT | `680659e8000000001a007a11` | https://www.xiaohongshu.com/explore/680659e8000000001a007a11?xsec_token=ABrdoPHvLAxNV-RI0zHL5Pg6nMe98RL32BksjutBYHvvY=&xsec_source=pc_like |
-
-> 抓取方式:`POST http://crawler.aiddit.com/crawler/xiao_hong_shu/detail`,body 仅需 `content_id`(即上表 content_id 列),无 token,限流 ≥15s。重抓样本落在 `数据接口与来源/captures/xhs_case_<content_id>.json`。
-
-### 2.1 视频测试样例(抖音,未人工标注)
-
-用来测试**视频链路**(小红书拿不到视频直链,抖音可以)。与上面 5 条不同,这条**没有提前做人工拆解**——正好用来检验"系统在无标注情况下能否自己提炼"。
-
-| 平台 | content_id | 视频 | 备注 |
-|---|---|---|---|
-| 抖音 | `7612631899479648866` | 「上期32万观看被催爆的AI视频教程来了」时长 281s | 口播教程类 |
-
-- 详情:`POST http://crawler.aiddit.com/crawler/dou_yin/detail`,body `{"content_id"}`,返回 `video_url_list[].video_url`(多码率)。
-- 视频可下载:play 链接带 iOS UA + `Referer: https://www.douyin.com/` → 302 到 CDN,返回真 `video/mp4`、支持 Range。
-- 服务端抓取,**不经过任何登录账号/cookie**。
-- 抓取样本:`数据接口与来源/captures/douyin_probe.json`。
-
-输入结构可以保持很简单:
-
-```jsonc
-[
-  {
-    "source": "xiaohongshu",
-    "url": "https://www.xiaohongshu.com/explore/...",
-    "label": ["how", "what"]
-  }
-]
-```
-
-`label` 只是人工样例标注,用于调试和验收,不是线上必填字段。
-
-## 3. 核心定义
-
-### 3.1 知识类型:What / Why / How
-
-这里的“知识类型”只指 What / Why / How。
-
-| 类型 | 判断标准 | 常见内容 |
-|---|---|---|
-| What | 这是什么、有哪些、由什么组成 | 类型、分类、要素、清单、特征、模板 |
-| Why | 为什么这样做、为什么有效 | 原理、底层逻辑、机制、依据、原因 |
-| How | 具体怎么做、怎么用 | 方法、步骤、技巧、流程、公式框架、实操 case |
-
-一个帖子可以包含一种、两种或三种知识类型:
-
-```text
-What
-Why
-How
-What + Why
-What + How
-Why + How
-What + Why + How
-```
-
-规则很简单:
-
-1. 原文有哪个就提取哪个。
-2. 原文没有的不要补。
-3. 不为了结构完整而编造。
-4. 一个帖子可以拆成多个知识片段。
-
-### 3.2 阶段:灵感 / 选题 / 脚本
-
-灵感、选题、脚本的总和叫“创作”。
-
-```text
-创作 = 灵感 + 选题 + 脚本
-```
-
-| 阶段 | 判断标准 |
-|---|---|
-| 灵感 | 帮助发现方向、素材、切口、洞察 |
-| 选题 | 帮助判断写什么、拍什么、从哪个角度切入 |
-| 脚本 | 帮助组织表达顺序、文案、镜头、结构 |
-
-每个知识片段可以对应一个、两个或三个阶段。
-
-### 3.3 作用域:五棵分类树
-
-作用域来自五棵分类树:
-
-```text
-实质 / 形式 / 感受 / 作用 / 意图
-```
-
-| 作用域 | API `scope_type` | 判断标准 |
-|---|---|---|
-| 实质 | `substance` | 内容真正讲的对象、主题、事实、问题 |
-| 形式 | `form` | 内容结构、模板、公式、表达形式 |
-| 感受 | `feeling` | 用户情绪、体验、氛围、感知 |
-| 作用 | `effect` | 解决什么创作问题、带来什么效果 |
-| 意图 | `intent` | 创作者目的、传播目的、商业目标 |
-
-每个知识片段可以命中一个或多个作用域。
-
-## 4. 四个环节
-
-### 4.1 筛选
-
-筛选判断帖子是否值得处理。
-
-**输入是多模态内容理解后的提取结果,不是原始 `body_text`。** 知识可能只存在于图片或视频里,所以必须先把文本 + 图片 + 视频交给 Gemini(gemini-3-flash-preview) 提取内容,再判断。只读 `body_text` 会把「正文为空、知识在图里」的帖子误判成空内容而错误淘汰。
-
-通过条件(针对提取后的内容判断):
-
-1. 帖子不是空内容(指多模态提取后仍无有效内容,而非仅 `body_text` 为空)。
-2. 帖子和创作相关。
-3. 帖子至少包含一点 What / Why / How。
-4. 帖子能拆出至少一个知识片段。
-
-输出:
-
-```jsonc
-{
-  "passed": true,
-  "score": 8,
-  "reason": "内容完整,包含明确 How,且和创作相关"
-}
-```
-
-### 4.2 帖子拆分
-
-拆分目标:从帖子里拆出一个或多个知识片段。
-
-一个知识片段的结构:
-
-```jsonc
-{
-  "title": "评论区选题法",
-  "knowledge_types": ["what", "how"],
-  "what": "从用户评论里提取问题和痛点,用来生成内容选题的方法。",
-  "why": null,
-  "how": "收集高赞评论,归类问题,再改写成标题或脚本切入点。",
-  "evidence": ["原文中支持该知识片段的句子"]
-}
-```
-
-拆分规则:
-
-1. 如果 What / Why / How 都在讲同一个知识对象,放在同一个知识片段里。
-2. 如果帖子里有多个独立知识对象,拆成多个知识片段。
-3. `knowledge_types` 必须和非空的 `what / why / how` 一致。
-
-### 4.3 帖子解构
-
-解构目标:给每个知识片段补上阶段和作用域。
-
-```jsonc
-{
-  "title": "评论区选题法",
-  "knowledge_types": ["what", "how"],
-  "stages": ["选题", "脚本"],
-  "scopes": [
-    { "scope_type": "form", "value": "评论区选题流程" },
-    { "scope_type": "effect", "value": "生成选题" }
-  ],
-  "stage_reason": "该知识既能生成选题,也能改写成脚本切入点。",
-  "scope_reason": "它提供了流程形式,也说明了解决的创作问题。"
-}
-```
-
-注意:
-
-1. `stages` 是灵感 / 选题 / 脚本,可以多选。
-2. `scopes` 是五棵分类树,可以多选。
-3. `scopes.value` 是具体标签,不只是五个大类名。
-
-### 4.4 组装入库数据
-
-每个知识片段组装成一次入库请求:
-
-```text
-POST /api/v1/knowledge/ingest
-```
-
-同一个帖子拆出的多条知识,复用同一个 `source.id`。
-
-## 5. 入库字段映射
-
-| 本项目字段 | 入库接口字段 | 说明 |
-|---|---|---|
-| 帖子 ID | `source.id` | 同一帖子保持同一个来源 ID |
-| 平台 | `source.source_metadata.platform` | 例如 `xiaohongshu` |
-| 帖子链接 | `source.source_metadata.url` | 原始 URL |
-| 帖子标题 | `source.title` | 来源标题 |
-| 知识标题 | `title` | 每个知识片段的标题 |
-| What / Why / How 内容 | `content` | 建议存成 JSON 字符串 |
-| What / Why / How 标签 | `dim_attributes` | 例如 `["what", "how"]` |
-| 灵感 / 选题 / 脚本 | `dim_creations` | 例如 `["选题", "脚本"]` |
-| 作用域 | `scopes` | 使用 `scope_type + value` |
-| 证据、理由、置信度 | `custom_ext` | 用扩展字段保存 |
-
-## 6. 入库请求示例
-
-### 6.1 HOW + WHAT 示例
-
-```jsonc
-{
-  "source": {
-    "id": "xhs_67e4bdf50000000006028a59",
-    "source_type": "post",
-    "title": "帖子标题",
-    "author": null,
-    "source_metadata": {
-      "platform": "xiaohongshu",
-      "url": "https://www.xiaohongshu.com/explore/67e4bdf50000000006028a59"
-    }
-  },
-  "title": "知识片段标题",
-  "content": "{\"what\":\"这里放 What 内容\",\"why\":null,\"how\":\"这里放 How 内容\"}",
-  "dim_attributes": ["what", "how"],
-  "dim_creations": ["选题", "脚本"],
-  "scopes": [
-    { "scope_type": "form", "value": "公式框架" },
-    { "scope_type": "effect", "value": "生成选题" }
-  ],
-  "custom_ext": [
-    { "key": "原文证据", "type": "str", "value": "原文中支持该知识片段的句子" },
-    { "key": "阶段判断理由", "type": "str", "value": "该知识同时服务选题和脚本" }
-  ]
-}
-```
-
-### 6.2 HOW 示例
-
-```jsonc
-{
-  "source": {
-    "id": "xhs_67e2e39b0000000003028ff0",
-    "source_type": "post",
-    "title": "帖子标题",
-    "source_metadata": {
-      "platform": "xiaohongshu",
-      "url": "https://www.xiaohongshu.com/explore/67e2e39b0000000003028ff0"
-    }
-  },
-  "title": "知识片段标题",
-  "content": "{\"what\":null,\"why\":null,\"how\":\"这里放 How 内容\"}",
-  "dim_attributes": ["how"],
-  "dim_creations": ["脚本"],
-  "scopes": [
-    { "scope_type": "form", "value": "操作流程" }
-  ],
-  "custom_ext": [
-    { "key": "原文证据", "type": "str", "value": "原文中支持 How 的句子" }
-  ]
-}
-```
-
-### 6.3 WHY 示例
-
-```jsonc
-{
-  "source": {
-    "id": "xhs_699308fa0000000016009697",
-    "source_type": "post",
-    "title": "帖子标题",
-    "source_metadata": {
-      "platform": "xiaohongshu",
-      "url": "https://www.xiaohongshu.com/explore/699308fa0000000016009697"
-    }
-  },
-  "title": "知识片段标题",
-  "content": "{\"what\":null,\"why\":\"这里放 Why 内容\",\"how\":null}",
-  "dim_attributes": ["why"],
-  "dim_creations": ["灵感", "选题"],
-  "scopes": [
-    { "scope_type": "intent", "value": "内容传播目的" },
-    { "scope_type": "feeling", "value": "用户感受" }
-  ],
-  "custom_ext": [
-    { "key": "原文证据", "type": "str", "value": "原文中支持 Why 的句子" }
-  ]
-}
-```
-
-## 7. 最小函数职责
-
-这里只定义职责,不展开复杂技术实现。
-
-```python
-fetch_post_detail(url) -> Post                 # 含 body_text、image_url_list、video_url_list
-extract_content(post) -> ExtractedContent       # 文本+图片+视频 -> Gemini(gemini-3-flash-preview) 多模态提取
-screen_post(post, content) -> ScreeningResult
-split_post(post, content) -> list[KnowledgeItem]
-deconstruct_item(item) -> Deconstruction
-build_ingest_payload(post, item, deconstruction) -> dict
-ingest(payload) -> IngestResult
-```
-
-`extract_content` 是新流程的前置环节:把帖子的文本、图片、视频一起喂给 Gemini(gemini-3-flash-preview),输出统一的 `ExtractedContent`;筛选和拆分都消费它,不直接读 `body_text`。
-
-处理流程:
-
-```text
-for url in post_urls:
-    post = fetch_post_detail(url)
-    content = extract_content(post)        # 多模态提取,知识可能只在图片/视频里
-    screening = screen_post(post, content)
-
-    if not screening.passed:
-        save_rejected(post, screening)
-        continue
-
-    items = split_post(post, content)
-
-    for item in items:
-        deconstruction = deconstruct_item(item)
-        payload = build_ingest_payload(post, item, deconstruction)
-        ingest(payload)
-```
-
-## 8. 与数据工程血缘关系
-
-这里参考 `/Users/samlee/Documents/works/数据工程` 里的 Clustering / Pattern V2 血缘口径,只补充和本项目有关的部分。
-
-### 8.1 已有数据工程主线
-
-已有数据工程大致是:
-
-```text
-原始内容解构元素
-  -> Clustering / 分类归一
-  -> global_category / global_element / element_classification_mapping
-  -> Pattern V2 snapshot / mining
-  -> 下游构建或分析系统
-```
-
-其中和我们最相关的是 Clustering 的五类分类树:
-
-```text
-实质 / 形式 / 感受 / 作用 / 意图
-```
-
-这些分类树在数据工程里属于长期稳定的业务语言。Pattern V2 会消费这些树和标准元素,再去挖组合规律。
-
-### 8.2 本项目在血缘里的位置
-
-本项目不是 Pattern V2,也不参与原有聚类链路。它是一条新的“帖子知识入库”链路:
-
-```text
-帖子链接
-  -> 拉取帖子详情
-  -> 筛选
-  -> 拆分 What / Why / How
-  -> 解构阶段和作用域
-  -> 组装 ingest payload
-  -> POST /api/v1/knowledge/ingest
-  -> knowledge_source / knowledge / knowledge_tag / custom_ext
-```
-
-简单说:
-
-1. 数据工程的 Clustering 负责沉淀“五棵分类树”的业务口径。
-2. 本项目负责把新帖子里的知识拆出来,并挂到这五类作用域上。
-3. 本项目通过 ingest API 入库,不直接写 `global_category`、`global_element` 或 `element_classification_mapping`。
-4. 如果后续需要参考已有分类树,只做只读查询,不在当前流程里改树。
-
-### 8.3 字段级对应
-
-| 本项目 | 数据工程 / 入库侧 | 说明 |
-|---|---|---|
-| 帖子链接 | `source.source_metadata.url` | 原始来源 URL |
-| 帖子 ID | `source.id` / `knowledge_source.id` | 同一帖子拆出的多条知识复用同一来源 |
-| What / Why / How | `dim_attributes` | 当前知识片段真实包含的知识类型 |
-| 灵感 / 选题 / 脚本 | `dim_creations` | 创作阶段,可多选 |
-| 实质 / 形式 / 感受 / 作用 / 意图 | `scopes.scope_type` | 对齐数据工程五棵分类树 |
-| 具体作用域标签 | `scopes.value` / `knowledge_tag` | ingest API 会自动维护标签字典和向量 |
-| 原文证据、判断理由 | `custom_ext` | 用扩展字段保留可追溯信息 |
-
-### 8.4 作用域映射
-
-| 中文作用域 | API `scope_type` | 数据工程口径 |
-|---|---|---|
-| 实质 | `substance` | 内容对象、主题、实体、问题本身 |
-| 形式 | `form` | 表达结构、模板、公式、载体、内容形态 |
-| 感受 | `feeling` | 情绪、观感、氛围、体验 |
-| 作用 | `effect` | 功能、用途、解决的问题、产生的效果 |
-| 意图 | `intent` | 创作者目的、传播目的、商业目标 |
-
-### 8.5 当前边界
-
-当前只对齐血缘口径,不接入复杂链路:
-
-1. 不跑 Clustering。
-2. 不跑 Pattern V2。
-3. 不直接改五棵分类树。
-4. 不把 ingest API 自动生成的 `knowledge_tag` 反写为 `global_category`。
-5. 后续如果要把本项目纳入全局血缘图,再补 `domain_interface` 和字段级 lineage。
-
-## 9. 当前边界
-
-当前版本只做:
-
-1. 从帖子链接开始。
-2. 拉取帖子详情(文本 + 图片 + 视频),把图文每张图、视频每帧统一成「卡片」(见 §10)。
-3. 多模态内容理解:文本 + 各卡片交给 Gemini(gemini-3-flash-preview),**按卡片归因**提取真正内容,不只读 `body_text`。
-4. 筛选有效帖子。
-5. 拆分 What / Why / How,每条知识记录它出自哪些卡片(source_cards)、证据链接到卡片。
-6. 解构阶段和作用域。
-7. 组装并调用入库接口。
-
-当前版本不做:
-
-1. Query 生成。
-2. 平台搜索。
-3. 多跳游走。
-4. 作者主页分析。
-5. LangGraph。
-6. 自动改写五棵分类树。
-
-## 10. 卡片溯源(card traceability)
-
-让每条知识能追溯到它出自帖子的哪些「卡片」,运营/审计可对着原图原帧核验。
-
-### 10.1 卡片(card)抽象
-
-把一篇帖子的视觉单元统一成「卡片」,下游只认卡片号(1-based):
-
-- 图文帖:`image_url_list` 里**每张图 = 一张卡片**(卡片1、卡片2…)。
-- 视频帖:**抽帧**后**每一帧 = 一张卡片**(卡片号 + 时间戳,见 §10.4)。
-
-卡片结构:`{index, kind: image|frame, url, timestamp?}`(timestamp 仅帧有)。
-
-### 10.2 归因发生在「多模态提取」步
-
-只有提取这一步看得到图/帧。提取时给每张卡片打 `【卡片N】` 标签,让 Gemini **按卡片输出**知识:`cards: [{index, content}]`。拆分步据此把每条知识标上 `source_cards`,并把每条 `evidence` 写成 `{text, card}`(纯正文证据 card=None)。
-
-### 10.3 入库映射
-
-`source_cards` 与带卡片号的 `evidence` 进 `custom_ext`(「来源卡片」「原文证据」),不破坏 ingest 契约。
-
-### 10.4 视频:原生整段视频(取代抽帧)
-
-视频内容提炼**走原生整段视频**,不抽帧。原因(真机验证):知识类视频多为口播,知识在**语音**里;抽帧只取画面会丢核心。把整段 mp4 交给 Gemini(经 OpenRouter base64),它能听口播、看画面字幕,按**时间段**输出 What/Why/How。
-
-- 每个时间段 → 一张**段卡**(`kind=segment`,带 start/end 秒)。溯源即"溯源到 02:07–03:43 这一段"。
-- 通道:`OPENROUTER_API_KEY`(无直连 GEMINI key);模型 `google/gemini-3-flash-preview`;下载偏好 540p 码率控成本。
-- **网络(实测)**:新加坡开发机下载抖音、调 Gemini/OpenRouter、连库都通,运行期下载→提炼→落库可一条龙(已在线跑通);下载仍做成可注入/可传本地文件以备换平台/换环境。
-- **抽帧降级**:`video_frames.py` 保留为可选缩略图/兜底,不接主流程。
-- **已知限制**:原生默认 1 FPS 采样,快速小字可能漏;段卡默认无缩略图(纯时间段)。
-
-### 10.4.1 媒体本地落盘 + 前端片段播放
-
-把**抖音 + 小红书**处理过的帖子的图片、视频**真实下载保存**到项目 `data/` 目录,前端从本地展示:图文帖显示真实图片(不再依赖平台图床、规避防盗链/过期)、视频段卡能**内联播放该时间段的片段**。
-
-- 存储按**运行批次**:`data/<run_id>/<platform>/<post_id>/`(`image_N.jpg` + `video.mp4`),`run_id` 默认时间戳、`cli run --run-id` 可指定;重跑生成新批次、保留历史。§2 案例媒体即落于此。
-- 播放:段卡共用整段 `video.mp4`,前端用 `#t=start,end` 只播该段,**不做 ffmpeg 切片、零转码**。
-- 工程上解耦:媒体逻辑由 `CK_DATA_DIR` 开关控制(默认开、置空即关),对提取/筛选/解构零影响。技术细节见技术架构 §12.1。
-- 范围:本轮仅抖音+小红书;不新建搜索爬取(沿用传入的帖子链接);B站/快手不在本轮。
-
-### 10.5 卡片上限
-
-提取一次最多送 `MAX_CARDS`(默认 12,env `CK_MAX_CARDS` 可调)张卡片;超出截断并记日志(不静默丢卡)。解决了早期 `MAX_IMAGES=6` 漏卡的问题。

+ 0 - 148
技术文档/开发顺序.md

@@ -1,148 +0,0 @@
-# 创作知识 · 开发顺序步骤
-
-> 配套:业务设计 [创作知识-重构设计.md](../创作知识-重构设计.md)、架构 [技术架构.md](技术架构.md)、DDL [creation_knowledge.sql](creation_knowledge.sql)。
-> 本文是**落地施工顺序**:从搭 DB → 跑通测试 → web 可视化,每步细到组件/文件/主要函数/验证。
-> 原则:简洁、可延展、能复用已有代码,不堆架构。
-
-## 0. 现状与复用
-
-**现状**:本项目目前零代码。已就绪的是——业务/架构文档、数据库(Greenplum `open_aigc` 的 schema `creation_knowledge` + 表 `ck_post` / `ck_knowledge_item` 已建好)、5 个小红书样例(`数据接口与来源/captures/xhs_case_*.json`)。
-
-**复用模板**:开发机 `/home/sam/ContentFindAgentNew`(Python + uv + FastAPI + httpx + pydantic)。三个客户端直接当蓝本,不重造:
-
-| 要写的 | 复用蓝本(CFA 路径) | 关键符号 |
-|---|---|---|
-| `integrations/extractor.py` | `content_agent/integrations/gemini_video.py` | `GeminiVideoClient`(OpenRouter,`google/gemini-3-flash-preview`,多模态出 JSON) |
-| `integrations/crawler.py` | `content_agent/integrations/crawapi_http.py` | `post_crawapi_json()`、`RateLimiter`、`_load_env_file()`、`content_format()`(含 ≥15s 限流) |
-| `integrations/db.py` | `content_agent/integrations/database_runtime.py` | `DatabaseRuntimeStore`(psycopg + JSONB 列处理) |
-| `api.py` / `cli.py` / `scripts/validate_db.py` | `content_agent/api.py`、`cli.py`、`scripts/validate_content_agent_db.py` | FastAPI、argparse、DB 校验 |
-
-**两条已定决策**:① web = 简洁版 FastAPI + 单页 React;② 本地 repo 开发,部署到 `/home/sam`。
-
-## 1. 目标项目结构(最小化镜像 CFA)
-
-```
-pyproject.toml                # uv;deps: httpx, pydantic, psycopg[binary], fastapi, uvicorn, python-dotenv, pytest
-creation_knowledge/
-  config.py                   # Settings:从 .env 读 crawler/gemini/llm/pg/ingest 配置
-  models.py                   # pydantic: Post, ExtractedContent, ScreeningResult, KnowledgeItem, Deconstruction, IngestPayload
-  integrations/
-    crawler.py                # fetch_post_detail(content_id|url) -> Post
-    extractor.py              # extract_content(post) -> ExtractedContent(文本+图片+视频)
-    llm.py                    # chat_json(system, user, model=CK_STAGE_MODEL 默认 gemini-3-flash-preview) -> dict (OpenRouter)
-    db.py                     # CkStore:upsert_post / update_stage / save_item / update_item_ingest / read_*
-  stages/
-    screen.py                 # screen_post(post, content) -> ScreeningResult
-    split.py                  # split_post(post, content) -> list[KnowledgeItem]
-    deconstruct.py            # deconstruct_item(item) -> Deconstruction
-    assemble.py               # build_ingest_payload(post, item, deco) -> IngestPayload(对齐设计文档 §6)
-  ingest.py                   # ingest(payload) -> dict;INGEST_ENABLED 开关,开发期 False(只存库不发)
-  pipeline.py                 # run_pipeline(urls, ingest_enabled=False):串 6 步 + 每步落库
-  cli.py                      # python -m creation_knowledge.cli run --urls ...
-  api.py                      # FastAPI:GET /posts, /posts/{id}, /posts/{id}/items, /posts/{id}/audit
-  audit/
-    auditor.py                # cross_audit(post_id) -> AuditReport:多 sub-agent 交叉审计(扩展面,见 M9)
-sql/creation_knowledge.sql    # 从 技术文档/ 移来(DDL 已在库上执行)
-prompts/                      # screen/split/deconstruct/audit 提示词模板(带版本号)
-scripts/validate_db.py        # 连库断言 schema/表/列
-scripts/run_batch.py          # 跑 5 个样例
-web/                          # 简洁版:单页 React(Vite) 读 FastAPI;帖子列表 + 流水线面板
-tests/fixtures/               # 5 个 xhs_case_*.json
-tests/{test_crawler,test_extractor,test_stages,test_pipeline_e2e}.py
-```
-
-## 2. 开发顺序(里程碑)
-
-> 每个里程碑都能独立验证后再进下一个。函数签名以 `models.py` 的 pydantic 类型为准。
->
-> **进度(as-built)**:M0–M8 ✅ 已全量实现并真机验证(拉取→多模态→筛选→拆分→解构→组装→落库 + FastAPI + 暖黄单页 React + 提示词 v4 + `/api/prompts`)。M9 多 sub-agent 审计 ⏳ 待做。M10 卡片溯源 + M11 视频抽帧 ✅ 已实现(见下,含 `MAX_IMAGES→MAX_CARDS`)。详细的 UI/API/提示词/部署 as-built 见 [技术架构.md](技术架构.md) §9–§12。
-
-### M0 · 脚手架
-- **文件**:`pyproject.toml`(uv)、包目录骨架、`creation_knowledge/config.py`、`creation_knowledge/models.py`。
-- **主要函数/类**:`config.Settings`(读 `.env`:`CONTENTFIND_API_CRAWAPI_*`、`CONTENT_AGENT_VIDEO_LLM_MODEL`、`GEMINI_API_KEY`、`OPENROUTER_*`、`MODEL`、`OPEN_AIGC_PG_*`、`KNOWHUB_API`、`INGEST_ENABLED`);`models` 里六个 pydantic 模型。
-- **验证**:`uv sync && uv run python -c "import creation_knowledge"`。
-
-### M1 · DB 层(库已建好,本步只接线 + 校验)
-- **文件**:`integrations/db.py`、`scripts/validate_db.py`、把 `技术文档/creation_knowledge.sql` 移到 `sql/`。
-- **主要函数**:`CkStore.upsert_post(post)`、`update_stage(id, stage)`、`save_item(post_id, item, deco, payload)`、`update_item_ingest(item_id, status, knowledge_id)`、`read_post(id)` / `read_items(post_id)`(供 api & audit)。连接走 `OPEN_AIGC_PG_*`,`search_path=creation_knowledge`。
-- **验证**:`uv run python scripts/validate_db.py` → 打印两表列齐全;写一行再回滚(可逆)。
-
-### M2 · 拉取
-- **文件**:`integrations/crawler.py`。
-- **主要函数**:`fetch_post_detail(content_id|url) -> Post`。`POST {crawler}/crawler/xiao_hong_shu/detail`,body `{content_id}`,复用 `RateLimiter`(≥15s)。从 url 解析 `content_id`。
-- **验证**:`tests/test_crawler.py` 用 5 个 capture 当 fixture 断言字段映射;跑 1 次真实 smoke。
-
-### M3 · 多模态提取(关键环节)
-- **文件**:`integrations/extractor.py`。
-- **主要函数**:`extract_content(post) -> ExtractedContent`。把 `body_text` + `image_url_list` + `video_url_list` 一起交给 `GeminiVideoClient`(`google/gemini-3-flash-preview`),产出 `{text, from_image, from_video, is_empty}`。**不只读 body_text**。
-- **验证**:对「拾意」两条(`67e2e39b`、`680659e8`,`body_text` 几乎空)能从图片提出非空 `from_image`、`is_empty=false`。
-
-### M4 · 四个环节(筛选/拆分/解构/组装)
-- **文件**:`stages/{screen,split,deconstruct,assemble}.py`、`prompts/`。
-- **主要函数**:`screen_post`→`ScreeningResult{passed,score,reason}`;`split_post`→`KnowledgeItem[]`(What/Why/How + evidence);`deconstruct_item`→`Deconstruction{stages,scopes,reason}`;`build_ingest_payload`→严格对齐设计文档 §6 的 ingest 请求体。screen/split/deconstruct 走 `llm.chat_json`。
-- **验证**:`tests/test_stages.py` 在 fixture 上逐环节断言;5 样例的 HOW/WHY/WHAT 标注与人工标注一致。
-
-### M5 · 流水线 + CLI(= 跑通测试)
-- **文件**:`pipeline.py`、`cli.py`、`ingest.py`。
-- **主要函数**:`run_pipeline(urls, ingest_enabled=False)` 串 6 步,每步 `CkStore` 落库;`ingest(payload)` 由 `INGEST_ENABLED` 控制,开发期 False(只存 `ingest_payload`,`ingest_status=pending`)。
-- **验证**:`python -m creation_knowledge.cli run --urls <5个>` → 库里 5 帖 + 知识片段,`ingest_status=pending`、`knowledge_id` 空。
-
-### M6 · 测试
-- **文件**:`tests/`(fixture 单测无网络 + e2e 5 样例落库)。
-- **验证**:`uv run pytest` 全绿;查库确认 5 帖及片段。
-
-### M7 · API
-- **文件**:`api.py`(FastAPI)。
-- **主要函数/路由**:`GET /posts`、`GET /posts/{id}`(含各环节产物)、`GET /posts/{id}/items`、`GET /posts/{id}/audit`。`uvicorn creation_knowledge.api:app`。
-- **验证**:`curl /posts`、`/posts/{id}` 返回 raw/extracted/screening/items/payload。
-
-### M8 · Web 可视化(简洁版)
-- **文件**:`web/`(单页 React + Vite,读 FastAPI)。
-- **内容**:左侧帖子列表,右侧流水线面板:原文 → 多模态提取 → 筛选(passed/score) → 知识片段(What/Why/How) → ingest payload。
-- **验证**:本地起 api + web,浏览器看到 5 帖完整流水线。
-
-### M9 · 多 sub-agent 交叉审计(核心扩展面)
-- **文件**:`audit/auditor.py`;新表 `ck_audit`。
-- **主要函数**:`cross_audit(post_id) -> AuditReport`。**读真实数据**:`ck_post`/`ck_knowledge_item`(DB) + 原始 capture/raw + 对应 stage 的 prompt/代码;**fan-out N 个独立 sub-agent,每个一个审计视角**:
-  1. 知识类型 What/Why/How 判定是否准确;
-  2. 每条 `evidence` 是否真支撑该片段;
-  3. 阶段(灵感/选题/脚本)、作用域(五棵树)归类是否正确;
-  4. 是「方法知识」还是「作品本身」——彩虹草原型应被否决。
-  多数表决,写 `ck_audit(post_id, item_id, dimension, verdict, reason, model, ext jsonb)`,web 标红/标绿。
-- **验证**:对「海狸 HOW+WHAT」判通过、对「彩虹草原」判否决。
-
-### M10 · 卡片溯源 ✅(已实现)
-- **文件**:`models.py`(Card/cards/source_cards/Evidence)、`crawler.py`(图文 cards)、`extractor.py`+`prompts/extract.txt`(按【卡片N】归因)、`stages/split.py`+`prompts/split.txt`(source_cards+evidence{text,card})、`assemble.py`(写 custom_ext)、`db.py`(cards 列)、`api.py`(返回 cards)、`web/index.html`(来源卡片缩略图+证据跳卡片)、`sql`(ALTER add cards)。
-- **统一卡片抽象**:图文每张图、视频每帧 = 卡片(1-based);下游只认卡片号。
-- **验证**:`tests/test_stages.py::test_split_source_cards_and_evidence`、`test_extractor.py::test_parse_per_card_output`;真机重跑 5 帖,知识卡内嵌来源卡片、证据可点开。
-
-### M11 · 视频:原生整段视频 ✅(已实现,取代抽帧)
-- **文件**:`integrations/video_extract.py::extract_video`;`prompts/extract_video.txt`;`pipeline.py`(提取分发:视频→原生,图文→逐图;移除抽帧步);`config.py`(douyin_ratio);前端 `web/index.html`(段卡=时间段 chip)。
-- **策略**:整段 mp4 → base64 → OpenRouter `video_url` → `gemini-3-flash-preview` 返回带时间戳的 segments → 段卡(start/end) + 每段内容;下游 split 照常溯源到段。
-- **通道**:`OPENROUTER_API_KEY`(无直连 GEMINI key);下载偏好 540p;下载可注入/可传本地文件以应对网络分裂。
-- **抽帧降级**:`video_frames.py` 保留为可选缩略图/兜底,不接主流程;`MAX_IMAGES→MAX_CARDS` 仍约束图片卡。
-- **验证**:`tests/test_video_extract.py`(注入假响应断言段卡 start/end + 请求含 base64 video_url);真机用本地 mp4 注入跑通 extract→split→落库。
-- **限制**:1 FPS 采样快速小字可能漏;段卡默认无缩略图。
-
-### M12 · 媒体本地落盘 + 前端真实展示 ✅(已实现,抖音+小红书)
-- **文件**:新增 `creation_knowledge/media.py`(run_id/media_target/save_images);`video_extract.py`(extract_video 加 save_path/public_url 可选参,整段落盘 + 段卡 url);`pipeline.py`(run_id + 默认 dispatch 接媒体,`CK_DATA_DIR` 开关);`cli.py`(--run-id);`config.py`(data_dir);`api.py`(/data 静态挂载);`web/index.html`(段卡内联 `<video #t>`、图片走本地);`.gitignore`(data/ runtime/)。
-- **布局**:`data/<run_id>/<platform>/<post_id>/`(image_N.jpg + video.mp4,按运行批次)。
-- **播放**:段卡共用整段 mp4,前端 `#t=start,end` 只播该段,不切片、零转码。
-- **解耦**:`settings.data_dir` 非空才触发;注入式 extract_fn 不触发;DB/提取/筛选/解构零改动。
-- **验证**:`tests/test_media.py`(save_images 注入下载器断言落盘 + url 改写);`test_video_extract.py` 加 save_path 用例;`test_pipeline_e2e.py` 加 data_dir e2e;真机重跑 5 小红书 + 1 抖音,`data/` 出真图真视频、前端图文显示 + 抖音段卡可播。
-- **范围/非目标**:仅抖音+小红书;不建搜索爬取;不做 ffmpeg 切片;不上 OSS;不加 DB run_id 列(run 在路径里)。
-
-## 3. 可延展点(现在不做,留好接口)
-
-- **多平台**:`fetch_post_detail` 按 `platform` 分发不同 crawler path,下游环节不动。
-- **真实入库**:翻 `INGEST_ENABLED` 即调 KnowHub `POST {KNOWHUB_API}/api/v1/knowledge/ingest`,payload 结构不变。
-- **审计加深**:`cross_audit` 的视角数/投票数可调;prompt 版本写库可做 A/B。
-- **部署**:`rsync` 本 repo 到 `/home/sam/Create-knowledge-find-decode`,`uv sync`(或复用 `demand-agent-new/.venv`);DB 直连(开发机→RDS 5432 已验证可达)。
-
-## 4. 整套端到端验证
-
-1. `scripts/validate_db.py`:库连通 + 表结构 OK。
-2. `uv run pytest`:单测 + e2e 全绿。
-3. `cli run --urls <5样例>`:查库 5 帖 + 片段、`ingest_status=pending`。
-4. 起 api + web:浏览器看 5 帖流水线。
-5. `cross_audit` 对正/反例给出正确 verdict 并落 `ck_audit`、web 可见。

+ 0 - 248
技术文档/技术架构.md

@@ -1,248 +0,0 @@
-# 创作知识 · 技术架构
-
-> 配套业务设计见 [创作知识-重构设计.md](../创作知识-重构设计.md)。
-> 本文只讲「怎么落地」:流水线、数据存储、外部依赖、部署。保持简单、可延展,不堆架构。
-
-## 1. 定位
-
-一条**线性流水线**:输入一批帖子链接,输出组装好的创作知识(ingest payload)。
-
-```
-帖子链接[] → [拉取 → 多模态理解 → 筛选 → 拆分 → 解构 → 组装 ingest payload] → 存 PostgreSQL
-                                                                              ↘(上线后再)调 ingest API
-```
-
-- 一次只做一件事,每个环节是一个**纯函数**,输入输出都是明确的数据结构。
-- 不引入消息队列、微服务、编排框架(LangGraph 等)。先把单进程批处理跑通,需要扩展时再加。
-- **终态**:知识通过 ingest API(`KNOWHUB_API` + `/api/v1/knowledge/ingest`)入库,本项目**不直接写**知识库底层表。
-- **开发/测试期(当前)**:**不真实调用 ingest**。把数据组装成 ingest 要的结构,存进 PostgreSQL,流水线即结束。上线时只需打开真实调用的开关,payload 结构不变。
-
-## 2. 总体架构
-
-```text
-pipeline(单进程,可按帖子分片并发)
-
-  url ─► fetch ─► extract ─► screen ─► split ─► deconstruct ─► build_payload ─►[ingest]
-          │         │          │         │          │            │             (上线后)
-       crawler   Gemini      LLM        LLM        LLM         组装        开发期只存 PG
-       .aiddit   多模态                                       ingest payload   不真实调用
-          │         │          │         │          │            │
-          ▼         ▼          ▼         ▼          ▼            ▼
-  ┌──────────────────── 过程库(我们自己的表)─────────────────────┐
-  │  ck_post            每帖一行,各环节产物存 JSONB                │
-  │  ck_knowledge_item  每条知识片段一行,含入库状态               │
-  └────────────────────────────────────────────────────────────────┘
-```
-
-三类参与方:
-
-| 角色 | 是什么 | 谁提供 |
-|---|---|---|
-| 流水线 | 本项目的代码,6 个函数串起来 | 我们写 |
-| 过程库 | 记录每帖每片段的处理过程和状态 | 我们建表(见 §4) |
-| 外部能力 | 爬虫、多模态模型、入库 API | 已有,`.env` 配置(见 §5) |
-
-## 3. 处理流水线
-
-6 个函数,对应业务设计 §7。每个函数职责单一、可单独替换或重跑。
-
-| 步骤 | 函数 | 输入 → 输出 | 依赖 |
-|---|---|---|---|
-| 1 拉取 | `fetch_post_detail(url)` | url → `Post`(text+image+video) | crawler.aiddit.com |
-| 2 多模态理解 | `extract_content(post)` | `Post` → `ExtractedContent` | Gemini(多模态) |
-| 3 筛选 | `screen_post(post, content)` | → `ScreeningResult{passed,score,reason}` | LLM |
-| 4 拆分 | `split_post(post, content)` | → `KnowledgeItem[]` (What/Why/How) | LLM |
-| 5 解构 | `deconstruct_item(item)` | → `Deconstruction{stages,scopes}` | LLM |
-| 6 组装 | `build_ingest_payload(post,item,deco)` | → ingest payload(存 `ck_knowledge_item.ingest_payload`) | 无(纯组装) |
-| 7 入库 | `ingest(payload)` | → 调 ingest API 写知识库 | KnowHub API · **开发期关闭** |
-
-> 步骤 6 是开发期的终点:组装出 ingest payload 并存 PG 即算完成。步骤 7(真实调用 ingest)由开关控制,开发/测试期默认关闭,`ingest_status` 停在 `pending`。
-
-### 3.1 关键约束:内容在图片/视频里
-
-知识主体常在图片或视频里,`body_text` 往往只是话题串(例如 `#短剧编剧# #写作#`)。
-所以 **`extract_content` 是流水线的第一等公民**:把文本 + 图片 + 视频一起交给多模态模型,产出统一的 `ExtractedContent`;筛选和拆分都消费它,**不直接读 `body_text`**。
-
-`ExtractedContent` 建议结构(先简单,后可扩):
-
-```jsonc
-{
-  "text": "多模态汇总后的正文/讲解",
-  "from_image": "图片里提取到的知识要点",
-  "from_video": "视频里提取到的知识要点(抽帧或整段)",
-  "is_empty": false
-}
-```
-
-### 3.2 失败与重跑
-
-- 每个环节的产物独立落库(JSONB),任一环节可基于上一环节产物**单独重跑**,不必从头爬。
-- 失败只标记状态(见 `ck_post.stage` / `status`),不阻塞同批其他帖子。
-
-## 4. 数据存储
-
-只建 **2 张表**。开发期知识的最终落点就是这里——`ck_knowledge_item.ingest_payload` 存的就是组装好、待发送的 ingest 请求体;上线后再由步骤 7 把它发给 ingest API。
-
-**落点(已建好 ✅)**:`open_aigc` 实例新建独立 schema `creation_knowledge`,两张表已创建。
-- 实例实为 **AnalyticDB for PostgreSQL / Greenplum 7.0.0(PG12 内核)**,不是单机 PG——建表需 `DISTRIBUTED BY`,主键须含分布列,不强制外键(关系由应用维护)。
-- 写权限**已确认**:用户 `aiddit_aigc` 对 `open_aigc` 有 CREATE 权限,schema owner = `aiddit_aigc`。
-- 本实例**无 pgvector**(`vector` 扩展不可用),embedding 一期不做;将来要做语义检索需另选方案,不走 pgvector。
-- DDL 见 [`sql/creation_knowledge.sql`](../sql/creation_knowledge.sql);机器上副本 `/home/sam/creation_knowledge.sql`。回滚:`DROP SCHEMA creation_knowledge CASCADE;`。
-- 操作账号统一用 **sam**(部署机 `47.245.103.121`),数据库角色用 `aiddit_aigc`。
-
-### 4.1 `ck_post`(每帖一行)
-
-| 字段 | 类型 | 说明 |
-|---|---|---|
-| `id` | text PK | `xhs_<content_id>`,同帖唯一,复用为 ingest `source.id` |
-| `platform` | text | `xiaohongshu` 等 |
-| `url` | text | 原始链接 |
-| `raw` | jsonb | `fetch_post_detail` 原始响应 |
-| `extracted` | jsonb | `extract_content` 多模态产物 |
-| `screening` | jsonb | `screen_post` 结果 |
-| `stage` | text | `fetched/extracted/screened/split/done/rejected/failed` |
-| `created_at` / `updated_at` | timestamptz | |
-
-### 4.2 `ck_knowledge_item`(每条知识片段一行)
-
-| 字段 | 类型 | 说明 |
-|---|---|---|
-| `id` | bigserial PK | |
-| `post_id` | text FK→`ck_post.id` | 同帖多片段复用同一 source |
-| `item` | jsonb | 拆分结果(title/knowledge_types/what/why/how/evidence) |
-| `deconstruction` | jsonb | 解构结果(stages/scopes/reason) |
-| `ingest_payload` | jsonb | 组装后的 ingest 请求体(**开发期的最终产物**) |
-| `ingest_status` | text | `pending`(开发期默认,未发送) / `ingested` / `failed` |
-| `knowledge_id` | text | ingest API 返回的知识 ID(上线后才有,开发期为空) |
-| `created_at` / `updated_at` | timestamptz | |
-
-> 这两表用 JSONB 承载各环节产物,字段稳定后再按需「晋升成列」。这是过程库,不是对外契约表。
-
-## 5. 外部依赖与配置
-
-全部来自 `.env`,代码只读环境变量,不硬编码。
-
-| 能力 | 用途 | `.env` 变量 | 状态 |
-|---|---|---|---|
-| 帖子详情爬虫 | 步骤 1 拉取 | `CONTENTFIND_API_CRAWAPI_BASE_URL` / `_KEY`,host `crawler.aiddit.com` | 小红书 detail 已验证 ✅,限流 ≥15s |
-| 多模态模型 | 步骤 2 提取 | `CONTENT_AGENT_VIDEO_LLM_MODEL=google/gemini-3-flash-preview`(走 `OPENROUTER_BASE_URL`+`OPENROUTER_API_KEY`)或直连 `GEMINI_API_KEY` | 已确定 |
-| 判断/拆分/解构 LLM | 步骤 3–5 | `CK_STAGE_MODEL`(默认 `google/gemini-3-flash-preview` via OpenRouter;全工程统一 Gemini) | 已真机验证 |
-| 知识入库 | 步骤 7 | `KNOWHUB_API`,调 `POST {KNOWHUB_API}/api/v1/knowledge/ingest` | 终态依赖,**开发期不真实调用** |
-| 过程库 | 落库 | `OPEN_AIGC_PG_*`(Greenplum,schema `creation_knowledge`) | 已建表 ✅,写权限已确认 |
-| 媒体暂存(可选) | 大图/视频转存后给模型 | `ALIYUN_OSS_*` | bucket 未配,**默认直接传 URL,OSS 暂不接** |
-
-### 5.1 多模态模型说明
-
-- 多模态理解统一用 **`google/gemini-3-flash-preview`**(已确定)。
-- 代码统一读 `CONTENT_AGENT_VIDEO_LLM_MODEL`,不写死模型名;走 OpenRouter,必要时可切直连 `GEMINI_API_KEY`。
-
-## 6. 运行与部署
-
-### 6.1 本地/开发机运行
-
-单进程批处理脚本,读一批 url,串行(或按帖子并发)跑完流水线:
-
-```text
-for url in urls:
-    post     = fetch_post_detail(url)        # 失败→标 failed,跳过
-    content  = extract_content(post)         # 多模态
-    result   = screen_post(post, content)
-    if not result.passed:  mark rejected; continue
-    for item in split_post(post, content):
-        deco    = deconstruct_item(item)
-        payload = build_ingest_payload(post, item, deco)
-        save_knowledge_item(post, item, deco, payload)   # 存 PG,ingest_status=pending
-        if INGEST_ENABLED:        # 开发/测试期 = False,只组装+存库
-            ingest(payload)       # 上线后才真实调用
-```
-
-### 6.2 部署目标
-
-- 阿里云海外开发机 `47.245.103.121`,密钥 `工作环境配置/阿里云海外lsh开发机/ali-denet.pem`。
-- 过程库与本流水线**同机/同内网**,本机直连即可;ingest API(`KNOWHUB_API`)同机,但**开发期不调用**,上线再开 `INGEST_ENABLED`。
-- 部署形态:先一个可手动/定时触发的 Python 进程;`.env` 随项目走,不进仓库。
-
-## 7. 延展点(需要时再加,现在不做)
-
-| 想扩展 | 怎么加(不改主结构) |
-|---|---|
-| 多平台(抖音/B站/视频号…) | `fetch_post_detail` 按 `platform` 分发到对应 crawler path,其余环节不变 |
-| 提速 | 按帖子分片并发,受 crawler ≥15s 限流约束;过程库已支持断点重跑 |
-| 接搜索/游走 | 在 `fetch` 前面接一段「链接来源」,流水线下游不动 |
-| 多套规则实验 | `screen/split/deconstruct` 的 prompt/规则版本号写进 `ck_post` JSONB |
-| 异步/服务化 | 过程库的 `stage` 已是状态机,可直接换成队列驱动 |
-
-## 8. 不做
-
-1. 不做 Query 生成、平台搜索、多跳游走、作者主页分析。
-2. 不引入 LangGraph / 消息队列 / 微服务。
-3. 不直接写 KnowHub 底层表,也不改数据工程的五棵分类树。
-4. 不为结构完整而编造知识;原文(含图/视频)没有的不补。
-
----
-
-> 以下 §9–§12 为 as-built(已实现并真机验证),补齐之前滞后的文档。
-
-## 9. Web 可视化(as-built)
-
-单页 React(CDN 引入 React + Babel,**零构建**),由 FastAPI 同实例托管,文件 `web/index.html`。
-
-- **主题**:暖黄背景 `#f3efe4` + 白卡 + 柔和文字色(CSS 变量 `--bg/--panel/--what/--why/--how`)。
-- **布局**:左侧帖子列表(stage + 知识条数),右侧选中帖子的流水线:① 原文 → ② 筛选 → ③ 知识点(N 条)。
-- **知识卡**:一条知识一张卡,卡内三段——「来源卡片」缩略图条 + ① 知识类型拆解(What/Why/How 分色)+ ② 解构·阶段 + ③ 解构·作用域(五棵树分色);底部折叠「原文证据」「入库数据」。
-- **交互**:图片/卡片点击放大(lightbox);筛选/知识点环节带「📄 提示词」按钮弹窗看该环节提示词;证据上的「卡片N」可点开对应卡片。
-
-## 10. API 与提示词系统(as-built)
-
-FastAPI(`creation_knowledge/api.py`),`uvicorn creation_knowledge.api:app`:
-
-| 路由 | 用途 |
-|---|---|
-| `GET /api/posts` | 帖子列表(id/platform/stage/item_count) |
-| `GET /api/posts/{id}` | 帖子详情(raw/cards/extracted/screening/stage) |
-| `GET /api/posts/{id}/items` | 该帖知识片段(item/deconstruction/ingest_payload/status) |
-| `GET /api/prompts` | 各环节提示词(system+user+model+version),供前端弹窗 |
-| `/frames/*`(静态) | 视频帧文件(card.url 指向) |
-| `/`(静态) | 单页前端 |
-
-**提示词系统**:四个环节的 user 模板在 `prompts/{extract,screen,split,deconstruct}.txt`(`.format()` 占位 + `{{}}` 转义);各 stage 的 system 角色串在代码里(`screen.py`/`split.py`/`deconstruct.py` 的 `SYSTEM`,extract 的 `_SYSTEM_PROMPT`);`prompts.py::PROMPT_VERSION` 手动维护版本号,随产物可溯。改提示词只动 `prompts/*.txt`,GUI 即时可见(`/api/prompts` 每次读盘)。
-
-## 6'. 运行与部署(as-built,修订 §6.2)
-
-- **跑流水线**:`PYTHONPATH=. python -m creation_knowledge.cli run --urls <...>`(或 `scripts/run_batch.py`)。
-- **起 Web/API**:`CK_ENV_FILE=<.env> PYTHONPATH=. uvicorn creation_knowledge.api:app --host 0.0.0.0 --port 8900`。
-- **云端**:开发机连不到内网 GitLab,代码走 **rsync** 上机;复用 `~/demand-agent-new/.venv`(自带 httpx/pydantic/psycopg2/fastapi/uvicorn/imageio-ffmpeg);`.env` 用 `~/ContentFindAgentNew/.env`(同库同 key)。
-- **本机看 Web**:`ssh -i <pem> -N -L 8900:localhost:8900 sam@47.245.103.121` 后开 http://localhost:8900。
-
-## 11. 卡片溯源(card traceability)
-
-把图文每张图、视频每帧统一成「卡片」(1-based),每条知识溯源到来源卡片。详见业务设计 §10。
-
-- 卡片三种 `kind`:`image`(图文每张图)/ `frame`(抽帧,已降级)/ `segment`(原生视频的时间段,带 `start`/`end` 秒)。`url`:开启媒体落盘后(§12.1)image 卡指向本地 `/data/.../image_N.jpg`、segment 卡指向整段 `/data/.../video.mp4`;未开启时 image 卡为平台 CDN URL、segment 卡为空。
-- 数据模型:`Post.cards: [Card{index,kind,url?,timestamp?,start?,end?}]`;`ExtractedContent.cards: [{index,content}]`;`KnowledgeItem.source_cards: [int]`;`evidence: [{text, card}]`。
-- 归因在提取步:图文给每图打 `【卡片N】` 标签按卡输出;视频原生提炼时模型按时间段输出(每段=一张段卡);`split` 据此填 source_cards + evidence.card。视频的溯源即"溯源到 02:07–03:43 这一段"。
-- 存储:`ck_post` 加 `cards jsonb` 列(迁移 `ALTER TABLE … ADD COLUMN IF NOT EXISTS cards jsonb`);source_cards/evidence 在 `ck_knowledge_item.item` JSONB 内,无需改列。
-- 前端:知识卡内嵌来源卡片缩略图、证据→卡片可点开。
-
-## 12. 视频:原生整段视频提炼(native video)
-
-视频内容提炼**走原生整段视频**,不抽帧(抽帧丢口播=丢核心,真机已验证原生更优)。
-
-- 模块 `integrations/video_extract.py::extract_video`:取/传入 mp4 → base64 → 经 **OpenRouter** `video_url` 发给 `google/gemini-3-flash-preview` → 返回 `segments[]`(start/end MM:SS、title、knowledge_types、what/why/how)。
-- 映射:每个 segment → 一张段卡 `Card(kind="segment", start, end)` 写入 `post.cards`;`ExtractedContent.cards=[{index,content}]`、`text=overall`。下游 screen/split/deconstruct 照常复用。
-- 提取分发(`pipeline`):`post.content_type=="video"` 或 `post.video_urls` 非空 → `extract_video`;否则图文逐图 `GeminiExtractor`。
-- 请求契约:`POST {OPENROUTER_BASE_URL}/chat/completions`,content=`[{type:text},{type:video_url,video_url:{url:"data:video/mp4;base64,..."}}]`;provider 实际落 Google AI Studio,按 video_tokens 计费(¥0.1 量级/4 分钟)。**无直连 `GEMINI_API_KEY`,只走 `OPENROUTER_API_KEY`。**
-- 下载:抖音详情 `video_url_list[0].video_url`(按 `CK_DOUYIN_RATIO` 默认 540p 改码率),iOS UA + `Referer` + redirects;下载做成可注入(`downloader`)或传入本地 `video_path`,以应对网络分裂。
-- 网络(实测,开发机 47.245.103.121 新加坡):下载抖音 CDN(douyinvod.com)、调 OpenRouter/Gemini、连 RDS、调 crawler **均通** → 运行期"下载→提炼→落库"可全在这台云端一条龙(已真机在线跑通)。唯一不通的是内网 GitLab(仅构建期 rsync 取代码用,不影响运行)。下载仍做成可注入/可传本地文件,便于将来换平台/换网络环境。其他平台(B站/快手/视频号)视频 CDN 未逐个实测。
-- 抽帧 `integrations/video_frames.py` **保留但不接主流程**,仅作可选缩略图/兜底。
-- 已知限制:原生视频默认 1 FPS 采样,快速小字可能漏(口播教程无碍);段卡默认无缩略图(纯时间段)。`MAX_CARDS` 仅约束图片卡,不限视频段数。
-
-## 12.1 媒体本地存储(local media)
-
-把图文帖的图片、视频帖的整段 mp4 **真实下载落盘**到项目内 `data/`,前端从本地加载(图片不再依赖平台图床/防盗链,视频段卡可内联播放)。**仅抖音 + 小红书**接入;B站/快手不在本轮。
-
-- 模块 `creation_knowledge/media.py`:`run_id()` 时间戳批次;`media_target()` 算目录 + `/data` 公网前缀;`save_images()` 下载图文每张图→改写 `card.url` 为本地路径(按文件头嗅探扩展名,单张失败只告警跳过)。
-- 目录布局(**按运行批次**):`data/<run_id>/<platform>/<post_id>/`,下放 `image_<idx>.jpg` 与 `video.mp4`。`run_id` 默认时间戳,可 `cli run --run-id` 覆盖;重跑生成新批次目录、保留历史,DB 里 `card.url` 指向最新一次。
-- 视频落盘在 `extract_video`(复用其内存字节,单次下载,避免抖音链接过期):`save_path`/`public_url` 两个**默认 None** 的可选参数;段卡 `url=/data/.../video.mp4`,整段共用,前端按 `start/end` 加 `#t=start,end` 只播该片段(HTML5 Media Fragments,零转码,抖音/小红书均 H.264/AAC)。
-- 接线点:`pipeline.run_pipeline` 默认 dispatch 闭包,仅当 `settings.data_dir` 非空时触发(`CK_DATA_DIR` 默认 `data`;置空=关闭,**解耦开关**,注入式 `extract_fn` 不触发)。DB 写入零改动(`Card.url` 经现有 `upsert_post` 落 `cards` JSONB)。
-- 服务:`api.py` 加 `/data` 静态挂载(同 `/frames`)。`data/`、`runtime/` 已 gitignore。