Jelajahi Sumber

feat: formalize creation pipeline workbench

SamLee 2 minggu lalu
induk
melakukan
44200efaaf
67 mengubah file dengan 4292 tambahan dan 2361 penghapusan
  1. 2 0
      .env.example
  2. 43 1
      acquisition/platforms/weixin.py
  3. 41 8
      acquisition/queries/builder.py
  4. 3 0
      acquisition/repositories/base.py
  5. 211 13
      acquisition/repositories/postgres.py
  6. 174 91
      acquisition/runner.py
  7. 11 8
      app/dependencies.py
  8. 0 8
      app/frontend/dist/assets/index-Bx7KAD4Y.js
  9. 0 0
      app/frontend/dist/assets/index-C-saPy1c.css
  10. 8 0
      app/frontend/dist/assets/index-CxkG5OsZ.js
  11. 0 0
      app/frontend/dist/assets/index-QFmrwHTU.css
  12. 3 3
      app/frontend/dist/index.html
  13. 1 1
      app/frontend/index.html
  14. 34 1
      app/frontend/package-lock.json
  15. 2 1
      app/frontend/package.json
  16. 0 38
      app/frontend/src/App.jsx
  17. 21 0
      app/frontend/src/api/client.js
  18. 5 0
      app/frontend/src/api/decode.js
  19. 13 0
      app/frontend/src/api/workbench.js
  20. 25 0
      app/frontend/src/app/App.jsx
  21. 7 0
      app/frontend/src/app/routes.js
  22. 3 0
      app/frontend/src/components/ErrorState.jsx
  23. 3 0
      app/frontend/src/components/LoadingState.jsx
  24. 95 0
      app/frontend/src/features/decode/DecodeKnowledgeModal.jsx
  25. 24 107
      app/frontend/src/features/decode/components.jsx
  26. 132 0
      app/frontend/src/features/item-detail/ItemDetailPage.jsx
  27. 106 0
      app/frontend/src/features/query-board/AxisColumn.jsx
  28. 122 0
      app/frontend/src/features/query-board/QueryBoardPage.jsx
  29. 100 0
      app/frontend/src/features/query-board/QueryColumn.jsx
  30. 74 0
      app/frontend/src/features/query-board/SearchResultColumn.jsx
  31. 155 0
      app/frontend/src/features/query-board/model.js
  32. 109 0
      app/frontend/src/features/query-board/useQueryBoard.js
  33. 1 1
      app/frontend/src/main.jsx
  34. 0 288
      app/frontend/src/pages/CreationDemo.jsx
  35. 0 310
      app/frontend/src/pages/CreationQueryDetail.jsx
  36. 6 1349
      app/frontend/src/styles.css
  37. 3 0
      app/frontend/src/styles/decode.css
  38. 281 0
      app/frontend/src/styles/item-detail.css
  39. 14 0
      app/frontend/src/styles/layout.css
  40. 1453 0
      app/frontend/src/styles/legacy.css
  41. 29 0
      app/frontend/src/styles/query-board.css
  42. 17 0
      app/frontend/src/styles/tokens.css
  43. 10 2
      app/routes/decode.py
  44. 68 12
      app/routes/query_generation.py
  45. 2 0
      app/schemas.py
  46. 4 0
      core/config.py
  47. 61 0
      core/db_session.py
  48. 11 0
      decode_content/repositories/postgres.py
  49. 1 1
      pipeline/acquisition_runner.py
  50. 32 2
      pipeline/decode_runner.py
  51. 84 34
      prompts/classify_imgtext.txt
  52. 84 34
      prompts/classify_video.txt
  53. 7 3
      prompts/extract.txt
  54. 1 0
      prompts/extract_video.txt
  55. 50 10
      prompts/gate_admit.txt
  56. 36 7
      prompts/gate_refute.txt
  57. 38 7
      prompts/gate_tiebreak.txt
  58. 2 2
      scripts/build_creation_demo.py
  59. 122 0
      scripts/run_creation_pipeline.py
  60. 4 4
      scripts/run_creation_singleton.py
  61. 112 4
      tests/test_acquisition_runner.py
  62. 72 0
      tests/test_app_api.py
  63. 4 0
      tests/test_formal_state_models.py
  64. 30 0
      tests/test_platform_adapters.py
  65. 63 0
      tests/test_postgres_repository_contract.py
  66. 57 5
      tests/test_query_builder.py
  67. 6 6
      创作知识提取-skill/extraction/phase1-frame.md

+ 2 - 0
.env.example

@@ -12,6 +12,8 @@ CK_DB_USER=ck_app
 CK_DB_PASSWORD=
 CK_DB_PASSWORD=
 CK_DB_SCHEMA=creation_knowledge
 CK_DB_SCHEMA=creation_knowledge
 CK_DB_SSH_TUNNEL=
 CK_DB_SSH_TUNNEL=
+CK_DB_POOL_MIN=1
+CK_DB_POOL_MAX=10
 
 
 # Optional migration/admin path. Runtime writes should use CK_DB_USER above;
 # Optional migration/admin path. Runtime writes should use CK_DB_USER above;
 # schema migrations should use an owner/admin channel such as root SSH -> postgres.
 # schema migrations should use an owner/admin channel such as root SSH -> postgres.

+ 43 - 1
acquisition/platforms/weixin.py

@@ -2,6 +2,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import hashlib
 import hashlib
+from urllib.parse import parse_qs, urlparse
 from typing import Any, Iterable
 from typing import Any, Iterable
 
 
 from acquisition.content_mode import ARTICLE
 from acquisition.content_mode import ARTICLE
@@ -14,6 +15,47 @@ def _hash(value: str, n: int = 16) -> str:
     return hashlib.md5(value.encode("utf-8")).hexdigest()[:n]
     return hashlib.md5(value.encode("utf-8")).hexdigest()[:n]
 
 
 
 
+def _dedupe_key(url: str) -> str:
+    parsed = urlparse(url)
+    return parsed._replace(query="", fragment="").geturl() or url
+
+
+def _is_decorative_weixin_image(url: str) -> bool:
+    parsed = urlparse(url)
+    query = parse_qs(parsed.query)
+    wx_fmt = (query.get("wx_fmt") or [""])[0].lower()
+    path = parsed.path.lower()
+    if wx_fmt == "gif" or path.endswith(".gif"):
+        return True
+    if "mmbiz_gif" in path:
+        return True
+    return False
+
+
+def filter_weixin_article_images(image_urls: list[str]) -> list[str]:
+    """Keep only article images that should become decode cards.
+
+    Weixin detail returns all article image resources, including duplicated
+    images and common account UI/animation GIFs such as "在看" prompts. Those
+    should stay in the original article text, but they should not become
+    knowledge trace cards.
+    """
+
+    kept: list[str] = []
+    seen: set[str] = set()
+    for url in image_urls:
+        if not url:
+            continue
+        key = _dedupe_key(url)
+        if key in seen:
+            continue
+        seen.add(key)
+        if _is_decorative_weixin_image(url):
+            continue
+        kept.append(url)
+    return kept
+
+
 class WeixinAdapter:
 class WeixinAdapter:
     platform = "weixin"
     platform = "weixin"
 
 
@@ -116,6 +158,6 @@ class WeixinAdapter:
             title=candidate.title,
             title=candidate.title,
             author=candidate.author,
             author=candidate.author,
             body_text=body_text,
             body_text=body_text,
-            image_urls=images or [candidate.cover_url],
+            image_urls=filter_weixin_article_images(images) or [candidate.cover_url],
             raw=candidate.raw,
             raw=candidate.raw,
         )
         )

+ 41 - 8
acquisition/queries/builder.py

@@ -26,7 +26,7 @@ class QueryBuildOptions:
     per: int = 0
     per: int = 0
     batch_n: int = 0
     batch_n: int = 0
     seed: int = 7
     seed: int = 7
-    dry: bool = False
+    enable_query_filter: bool = False
     active_family_keys: tuple[str, ...] = DEFAULT_ACTIVE_FAMILY_KEYS
     active_family_keys: tuple[str, ...] = DEFAULT_ACTIVE_FAMILY_KEYS
 
 
 
 
@@ -74,6 +74,38 @@ def _nodes_at_depth(
     return out
     return out
 
 
 
 
+def _axis_tree(idx: list[dict[str, Any]], source_type: str) -> list[dict[str, Any]]:
+    nodes: dict[str, dict[str, Any]] = {}
+    for row in idx:
+        if row.get("source_type") != source_type:
+            continue
+        segs = _segs(row.get("path"))
+        if len(segs) not in (3, 4):
+            continue
+        path = "/" + "/".join(segs)
+        nodes[path] = {
+            "name": row.get("name") or segs[-1],
+            "path": path,
+            "level": len(segs),
+            "children": [],
+        }
+
+    roots: list[dict[str, Any]] = []
+    for path, node in nodes.items():
+        if node["level"] == 3:
+            roots.append(node)
+            continue
+        parent_path = "/" + "/".join(_segs(path)[:-1])
+        parent = nodes.get(parent_path)
+        if parent:
+            parent["children"].append(node)
+
+    roots.sort(key=lambda node: node["path"])
+    for node in roots:
+        node["children"].sort(key=lambda child: child["path"])
+    return roots
+
+
 def _sample_axis(values: list[str], limit: int, rng: random.Random) -> list[str]:
 def _sample_axis(values: list[str], limit: int, rng: random.Random) -> list[str]:
     if limit <= 0 or limit >= len(values):
     if limit <= 0 or limit >= len(values):
         return values
         return values
@@ -196,11 +228,15 @@ def build_creation_query_batch(
             "动作": ACTIONS,
             "动作": ACTIONS,
             "作用": f6_zy,
             "作用": f6_zy,
         },
         },
+        "axis_trees": {
+            "实质": _axis_tree(idx, "实质"),
+            "形式": _axis_tree(idx, "形式"),
+        },
         "metadata": {
         "metadata": {
             "seed": opts.seed,
             "seed": opts.seed,
             "per": opts.per,
             "per": opts.per,
             "batch_n": opts.batch_n,
             "batch_n": opts.batch_n,
-            "dry": opts.dry,
+            "query_filter_enabled": opts.enable_query_filter,
             "active_family_keys": list(opts.active_family_keys),
             "active_family_keys": list(opts.active_family_keys),
             "query_filter_prompt_version": prompt_version(),
             "query_filter_prompt_version": prompt_version(),
         },
         },
@@ -227,12 +263,9 @@ def build_creation_query_batch(
             if opts.per > 0 and len(items) >= opts.per:
             if opts.per > 0 and len(items) >= opts.per:
                 break
                 break
         verdicts = (
         verdicts = (
-            [
-                {"keep": True, "valid": None, "relevant": True, "reason": "(dry:未过筛)"}
-                for _ in items
-            ]
-            if opts.dry
-            else filter_queries([it["query"] for it in items], settings)
+            filter_queries([it["query"] for it in items], settings)
+            if opts.enable_query_filter
+            else [{"keep": True, "valid": None, "relevant": True, "reason": ""} for _ in items]
         )
         )
         for item, verdict in zip(items, verdicts):
         for item, verdict in zip(items, verdicts):
             item.update(verdict)
             item.update(verdict)

+ 3 - 0
acquisition/repositories/base.py

@@ -175,6 +175,9 @@ class AcquisitionRepository(Protocol):
     def get_query_detail_for_batch(self, *, batch_id: UUID, query_id: UUID) -> dict[str, Any]:
     def get_query_detail_for_batch(self, *, batch_id: UUID, query_id: UUID) -> dict[str, Any]:
         ...
         ...
 
 
+    def get_latest_query_result_list(self, query_id: UUID) -> dict[str, Any]:
+        ...
+
     def list_creation_candidate_items(
     def list_creation_candidate_items(
         self,
         self,
         *,
         *,

+ 211 - 13
acquisition/repositories/postgres.py

@@ -505,12 +505,25 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT ci.id)::int AS candidate_count,
                 COUNT(DISTINCT ci.id)::int AS candidate_count,
                 COUNT(DISTINCT ic.id) FILTER (
                 COUNT(DISTINCT ic.id) FILTER (
                     WHERE ic.is_creation_knowledge IS TRUE
                     WHERE ic.is_creation_knowledge IS TRUE
-                )::int AS creation_hit_count
+                )::int AS creation_hit_count,
+                COUNT(DISTINCT ci.id) FILTER (
+                    WHERE ic.is_creation_knowledge IS TRUE
+                      AND dr.status = 'decoded'
+                      AND kp.id IS NOT NULL
+                )::int AS decoded_count,
+                COUNT(DISTINCT pd.id) FILTER (
+                    WHERE ic.is_creation_knowledge IS TRUE
+                      AND dr.status = 'decoded'
+                      AND kp.id IS NOT NULL
+                )::int AS payload_count
             FROM acquisition_runs ar
             FROM acquisition_runs ar
             LEFT JOIN acquisition_jobs aj ON aj.run_id = ar.id
             LEFT JOIN acquisition_jobs aj ON aj.run_id = ar.id
             LEFT JOIN queries q ON q.id = aj.query_id
             LEFT JOIN queries q ON q.id = aj.query_id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
+            LEFT JOIN decode_results dr ON dr.item_id = ci.id
+            LEFT JOIN knowledge_particles kp ON kp.item_id = ci.id
+            LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
             WHERE ar.id = %s
             WHERE ar.id = %s
             GROUP BY ar.id
             GROUP BY ar.id
             """,
             """,
@@ -526,6 +539,16 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT ic.id) FILTER (
                 COUNT(DISTINCT ic.id) FILTER (
                     WHERE ic.is_creation_knowledge IS TRUE
                     WHERE ic.is_creation_knowledge IS TRUE
                 )::int AS creation_hit_count,
                 )::int AS creation_hit_count,
+                COUNT(DISTINCT ci.id) FILTER (
+                    WHERE ic.is_creation_knowledge IS TRUE
+                      AND dr.status = 'decoded'
+                      AND kp.id IS NOT NULL
+                )::int AS decoded_count,
+                COUNT(DISTINCT pd.id) FILTER (
+                    WHERE ic.is_creation_knowledge IS TRUE
+                      AND dr.status = 'decoded'
+                      AND kp.id IS NOT NULL
+                )::int AS payload_count,
                 jsonb_object_agg(
                 jsonb_object_agg(
                     aj.platform,
                     aj.platform,
                     jsonb_build_object(
                     jsonb_build_object(
@@ -540,6 +563,9 @@ class PostgresAcquisitionRepository:
             JOIN acquisition_jobs aj ON aj.query_id = q.id
             JOIN acquisition_jobs aj ON aj.query_id = q.id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
+            LEFT JOIN decode_results dr ON dr.item_id = ci.id
+            LEFT JOIN knowledge_particles kp ON kp.item_id = ci.id
+            LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
             WHERE aj.run_id = %s
             WHERE aj.run_id = %s
             GROUP BY q.id, q.query_text, q.sort_order
             GROUP BY q.id, q.query_text, q.sort_order
             ORDER BY q.sort_order, q.query_text
             ORDER BY q.sort_order, q.query_text
@@ -593,8 +619,10 @@ class PostgresAcquisitionRepository:
                 SELECT DISTINCT ON (dr.item_id)
                 SELECT DISTINCT ON (dr.item_id)
                     dr.item_id,
                     dr.item_id,
                     dr.status AS decode_status,
                     dr.status AS decode_status,
+                    COUNT(DISTINCT kp.id)::int AS particle_count,
                     COUNT(DISTINCT pd.id)::int AS payload_count
                     COUNT(DISTINCT pd.id)::int AS payload_count
                 FROM decode_results dr
                 FROM decode_results dr
+                LEFT JOIN knowledge_particles kp ON kp.item_id = dr.item_id
                 LEFT JOIN payload_drafts pd ON pd.item_id = dr.item_id
                 LEFT JOIN payload_drafts pd ON pd.item_id = dr.item_id
                 WHERE dr.item_id = ANY(%s)
                 WHERE dr.item_id = ANY(%s)
                 GROUP BY dr.item_id, dr.status, dr.created_at
                 GROUP BY dr.item_id, dr.status, dr.created_at
@@ -661,8 +689,10 @@ class PostgresAcquisitionRepository:
                 SELECT DISTINCT ON (dr.item_id)
                 SELECT DISTINCT ON (dr.item_id)
                     dr.item_id,
                     dr.item_id,
                     dr.status AS decode_status,
                     dr.status AS decode_status,
+                    COUNT(DISTINCT kp.id)::int AS particle_count,
                     COUNT(DISTINCT pd.id)::int AS payload_count
                     COUNT(DISTINCT pd.id)::int AS payload_count
                 FROM decode_results dr
                 FROM decode_results dr
+                LEFT JOIN knowledge_particles kp ON kp.item_id = dr.item_id
                 LEFT JOIN payload_drafts pd ON pd.item_id = dr.item_id
                 LEFT JOIN payload_drafts pd ON pd.item_id = dr.item_id
                 WHERE dr.item_id = ANY(%s)
                 WHERE dr.item_id = ANY(%s)
                 GROUP BY dr.item_id, dr.status, dr.created_at
                 GROUP BY dr.item_id, dr.status, dr.created_at
@@ -679,6 +709,112 @@ class PostgresAcquisitionRepository:
             "decode_summaries": decode_summaries,
             "decode_summaries": decode_summaries,
         }
         }
 
 
+    def get_latest_query_result_list(self, query_id: UUID) -> dict[str, Any]:
+        query = self._one("SELECT * FROM queries WHERE id = %s", (query_id,))
+        run = self._one_or_none(
+            """
+            SELECT ar.* FROM acquisition_runs ar
+            JOIN acquisition_jobs aj ON aj.run_id = ar.id
+            WHERE aj.query_id = %s
+            ORDER BY aj.created_at DESC, ar.created_at DESC
+            LIMIT 1
+            """,
+            (query_id,),
+        )
+        if run is None:
+            return {
+                "query": query,
+                "run": None,
+                "jobs": [],
+                "items": [],
+                "media_assets": [],
+                "classifications": [],
+                "decode_summaries": [],
+            }
+        jobs = self._all(
+            """
+            SELECT aj.* FROM acquisition_jobs aj
+            WHERE aj.query_id = %s
+            ORDER BY aj.created_at, aj.platform
+            """,
+            (query_id,),
+        )
+        items = self._all(
+            """
+            SELECT
+                ci.id,
+                ci.query_id,
+                ci.job_id,
+                ci.platform,
+                ci.title,
+                LEFT(ci.raw_summary, 700) AS raw_summary,
+                ci.status,
+                ci.content_mode,
+                ci.metadata,
+                ci.created_at,
+                ci.updated_at
+            FROM candidate_items ci
+            JOIN acquisition_jobs aj ON aj.id = ci.job_id
+            WHERE aj.query_id = %s
+            ORDER BY ci.platform, ci.created_at
+            """,
+            (query_id,),
+        )
+        item_ids = [row["id"] for row in items]
+        media: list[dict[str, Any]] = []
+        classifications: list[dict[str, Any]] = []
+        decode_summaries: list[dict[str, Any]] = []
+        if item_ids:
+            media = self._all(
+                """
+                SELECT DISTINCT ON (item_id)
+                    id, item_id, media_type, source_url, oss_url, cdn_url, position, status
+                FROM media_assets
+                WHERE item_id = ANY(%s)
+                ORDER BY
+                    item_id,
+                    CASE WHEN media_type IN ('cover', 'image', 'frame') THEN 0 ELSE 1 END,
+                    position,
+                    created_at
+                """,
+                (item_ids,),
+            )
+            classifications = self._all(
+                """
+                SELECT DISTINCT ON (item_id)
+                    id, item_id, is_creation_knowledge, label, confidence, status, error_message
+                FROM item_classifications
+                WHERE item_id = ANY(%s)
+                ORDER BY item_id, created_at DESC
+                """,
+                (item_ids,),
+            )
+            decode_summaries = self._all(
+                """
+                SELECT DISTINCT ON (dr.item_id)
+                    dr.item_id,
+                    dr.status AS decode_status,
+                    COUNT(DISTINCT kp.id)::int AS particle_count,
+                    COUNT(DISTINCT pd.id)::int AS payload_count
+                FROM decode_results dr
+                LEFT JOIN knowledge_particles kp ON kp.item_id = dr.item_id
+                LEFT JOIN payload_drafts pd ON pd.item_id = dr.item_id
+                WHERE dr.item_id = ANY(%s)
+                GROUP BY dr.item_id, dr.status, dr.created_at
+                ORDER BY dr.item_id, dr.created_at DESC
+                """,
+                (item_ids,),
+            )
+        return {
+            "query": query,
+            "run": run,
+            "jobs": jobs,
+            "items": items,
+            "media_assets": media,
+            "classifications": classifications,
+            "decode_summaries": decode_summaries,
+        }
+
     def get_latest_singleton_overview(self) -> dict[str, Any]:
     def get_latest_singleton_overview(self) -> dict[str, Any]:
         batch = self._one_or_none(
         batch = self._one_or_none(
             """
             """
@@ -711,7 +847,9 @@ class PostgresAcquisitionRepository:
                     q.metadata->>'family_key' AS family_key,
                     q.metadata->>'family_key' AS family_key,
                     q.sort_order,
                     q.sort_order,
                     0::int AS candidate_count,
                     0::int AS candidate_count,
-                    0::int AS creation_hit_count
+                    0::int AS creation_hit_count,
+                    0::int AS decoded_count,
+                    0::int AS payload_count
                 FROM queries q
                 FROM queries q
                 WHERE q.batch_id = %s
                 WHERE q.batch_id = %s
                 ORDER BY q.sort_order, q.created_at
                 ORDER BY q.sort_order, q.created_at
@@ -720,8 +858,48 @@ class PostgresAcquisitionRepository:
             )
             )
             return {"batch": batch, "run": None, "queries": queries, "decoded_items": []}
             return {"batch": batch, "run": None, "queries": queries, "decoded_items": []}
 
 
+        latest_queries_cte = """
+            WITH query_activity AS (
+                SELECT
+                    q.id AS query_id,
+                    q.query_text,
+                    q.created_at AS query_created_at,
+                    MAX(ar.created_at) AS activity_at
+                FROM queries q
+                LEFT JOIN acquisition_jobs aj ON aj.query_id = q.id
+                LEFT JOIN acquisition_runs ar ON ar.id = aj.run_id
+                GROUP BY q.id, q.query_text, q.created_at
+            ),
+            query_stats AS (
+                SELECT
+                    qa.query_id,
+                    qa.query_text,
+                    qa.query_created_at,
+                    qa.activity_at,
+                    COUNT(DISTINCT pd.id) FILTER (
+                        WHERE ic.is_creation_knowledge IS TRUE
+                          AND dr.status = 'decoded'
+                          AND kp.id IS NOT NULL
+                    )::int AS payload_count
+                FROM query_activity qa
+                LEFT JOIN acquisition_jobs aj ON aj.query_id = qa.query_id
+                LEFT JOIN candidate_items ci ON ci.job_id = aj.id
+                LEFT JOIN item_classifications ic ON ic.item_id = ci.id
+                LEFT JOIN decode_results dr ON dr.item_id = ci.id
+                LEFT JOIN knowledge_particles kp ON kp.item_id = ci.id
+                LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
+                GROUP BY qa.query_id, qa.query_text, qa.query_created_at, qa.activity_at
+            ),
+            latest_queries AS (
+                SELECT DISTINCT ON (query_text)
+                    query_id
+                FROM query_stats
+                ORDER BY query_text, (payload_count > 0) DESC, activity_at DESC NULLS LAST, query_created_at DESC
+            )
+        """
         queries = self._all(
         queries = self._all(
-            """
+            latest_queries_cte
+            + """
             SELECT
             SELECT
                 q.id AS query_id,
                 q.id AS query_id,
                 q.query_text,
                 q.query_text,
@@ -731,40 +909,50 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT ic.id) FILTER (
                 COUNT(DISTINCT ic.id) FILTER (
                     WHERE ic.is_creation_knowledge IS TRUE
                     WHERE ic.is_creation_knowledge IS TRUE
                 )::int AS creation_hit_count,
                 )::int AS creation_hit_count,
-                COUNT(DISTINCT dr.id)::int AS decoded_count,
-                COUNT(DISTINCT pd.id)::int AS payload_count
-            FROM queries q
+                COUNT(DISTINCT ci.id) FILTER (
+                    WHERE ic.is_creation_knowledge IS TRUE
+                      AND dr.status = 'decoded'
+                      AND kp.id IS NOT NULL
+                )::int AS decoded_count,
+                COUNT(DISTINCT pd.id) FILTER (
+                    WHERE ic.is_creation_knowledge IS TRUE
+                      AND dr.status = 'decoded'
+                      AND kp.id IS NOT NULL
+                )::int AS payload_count
+            FROM latest_queries lq
+            JOIN queries q ON q.id = lq.query_id
             LEFT JOIN acquisition_jobs aj ON aj.query_id = q.id
             LEFT JOIN acquisition_jobs aj ON aj.query_id = q.id
-            LEFT JOIN acquisition_runs ar ON ar.id = aj.run_id AND ar.batch_id = q.batch_id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
             LEFT JOIN decode_results dr ON dr.item_id = ci.id
             LEFT JOIN decode_results dr ON dr.item_id = ci.id
+            LEFT JOIN knowledge_particles kp ON kp.item_id = ci.id
             LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
             LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
-            WHERE q.batch_id = %s
             GROUP BY q.id, q.query_text, q.metadata, q.sort_order
             GROUP BY q.id, q.query_text, q.metadata, q.sort_order
             ORDER BY q.sort_order, q.created_at
             ORDER BY q.sort_order, q.created_at
             """,
             """,
-            (batch["id"],),
+            (),
         )
         )
         decoded_items = self._all(
         decoded_items = self._all(
-            """
+            latest_queries_cte
+            + """
             SELECT
             SELECT
                 ci.id AS item_id,
                 ci.id AS item_id,
                 ci.query_id,
                 ci.query_id,
                 ci.title,
                 ci.title,
                 ci.platform,
                 ci.platform,
                 dr.status AS decode_status,
                 dr.status AS decode_status,
+                COUNT(DISTINCT kp.id)::int AS particle_count,
                 COUNT(DISTINCT pd.id)::int AS payload_count
                 COUNT(DISTINCT pd.id)::int AS payload_count
             FROM candidate_items ci
             FROM candidate_items ci
+            JOIN latest_queries lq ON lq.query_id = ci.query_id
             JOIN acquisition_jobs aj ON aj.id = ci.job_id
             JOIN acquisition_jobs aj ON aj.id = ci.job_id
-            JOIN acquisition_runs ar ON ar.id = aj.run_id
             JOIN decode_results dr ON dr.item_id = ci.id
             JOIN decode_results dr ON dr.item_id = ci.id
+            LEFT JOIN knowledge_particles kp ON kp.item_id = ci.id
             LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
             LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
-            WHERE ar.batch_id = %s
             GROUP BY ci.id, ci.query_id, ci.title, ci.platform, dr.status, dr.created_at
             GROUP BY ci.id, ci.query_id, ci.title, ci.platform, dr.status, dr.created_at
             ORDER BY dr.created_at DESC
             ORDER BY dr.created_at DESC
             """,
             """,
-            (batch["id"],),
+            (),
         )
         )
         return {
         return {
             "batch": batch,
             "batch": batch,
@@ -785,6 +973,11 @@ class PostgresAcquisitionRepository:
                 SELECT ci.* FROM candidate_items ci
                 SELECT ci.* FROM candidate_items ci
                 JOIN item_classifications ic ON ic.item_id = ci.id
                 JOIN item_classifications ic ON ic.item_id = ci.id
                 WHERE ic.is_creation_knowledge IS TRUE
                 WHERE ic.is_creation_knowledge IS TRUE
+                  AND NOT EXISTS (
+                      SELECT 1 FROM decode_results dr
+                      WHERE dr.item_id = ci.id
+                        AND dr.status IN ('decoded', 'rejected', 'skipped')
+                  )
                 ORDER BY ci.created_at
                 ORDER BY ci.created_at
                 LIMIT %s
                 LIMIT %s
                 """,
                 """,
@@ -797,6 +990,11 @@ class PostgresAcquisitionRepository:
                 JOIN acquisition_jobs aj ON aj.id = ci.job_id
                 JOIN acquisition_jobs aj ON aj.id = ci.job_id
                 JOIN item_classifications ic ON ic.item_id = ci.id
                 JOIN item_classifications ic ON ic.item_id = ci.id
                 WHERE aj.run_id = %s AND ic.is_creation_knowledge IS TRUE
                 WHERE aj.run_id = %s AND ic.is_creation_knowledge IS TRUE
+                  AND NOT EXISTS (
+                      SELECT 1 FROM decode_results dr
+                      WHERE dr.item_id = ci.id
+                        AND dr.status IN ('decoded', 'rejected', 'skipped')
+                  )
                 ORDER BY ci.created_at
                 ORDER BY ci.created_at
                 LIMIT %s
                 LIMIT %s
                 """,
                 """,

+ 174 - 91
acquisition/runner.py

@@ -24,8 +24,10 @@ from core.text_limits import (
 
 
 DEFAULT_PLATFORMS = ("xiaohongshu", "weixin", "douyin")
 DEFAULT_PLATFORMS = ("xiaohongshu", "weixin", "douyin")
 PAGINATION_STRATEGY = "creation_ratio_two_page_v1"
 PAGINATION_STRATEGY = "creation_ratio_two_page_v1"
-PAGINATION_CREATION_THRESHOLD = 0.5
+PAGINATION_CREATION_THRESHOLD = 0.4
 PAGINATION_MAX_PAGES = 2
 PAGINATION_MAX_PAGES = 2
+LOW_RESULT_RETRY_THRESHOLD = 5
+LOW_RESULT_MAX_RETRIES = 1
 
 
 AdapterFactory = Callable[[str], PlatformAdapter]
 AdapterFactory = Callable[[str], PlatformAdapter]
 Classifier = Callable[..., ClassificationResult]
 Classifier = Callable[..., ClassificationResult]
@@ -158,6 +160,7 @@ def _record_candidate(
     media_stabilizer: MediaStabilizer,
     media_stabilizer: MediaStabilizer,
     classifier: Classifier,
     classifier: Classifier,
     classify: bool,
     classify: bool,
+    attempt_index: int = 1,
 ) -> RecordCandidateResult:
 ) -> RecordCandidateResult:
     source_payload = _source_payload(candidate, detail)
     source_payload = _source_payload(candidate, detail)
     platform_item_id = detail.source_id or candidate.source_id or None
     platform_item_id = detail.source_id or candidate.source_id or None
@@ -171,6 +174,7 @@ def _record_candidate(
         "candidate_rank": candidate.rank,
         "candidate_rank": candidate.rank,
         "acquisition_match_status": "created",
         "acquisition_match_status": "created",
         "content_mode": content_mode,
         "content_mode": content_mode,
+        "retry_attempt_index": attempt_index,
         **_candidate_page_metadata(candidate),
         **_candidate_page_metadata(candidate),
     }
     }
     search_provider = _candidate_provider(candidate)
     search_provider = _candidate_provider(candidate)
@@ -285,6 +289,17 @@ def _candidate_unique_key(platform: str, candidate: PlatformCandidate) -> str |
     )
     )
 
 
 
 
+def _candidate_dedupe_key(platform: str, candidate: PlatformCandidate) -> str | None:
+    unique_key = _candidate_unique_key(platform, candidate)
+    if unique_key:
+        return f"unique:{unique_key}"
+    if candidate.source_id:
+        return f"source:{platform}:{candidate.source_id}"
+    if candidate.url:
+        return f"url:{platform}:{candidate.url}"
+    return None
+
+
 def _attach_existing_candidate(
 def _attach_existing_candidate(
     repo: AcquisitionRepository,
     repo: AcquisitionRepository,
     *,
     *,
@@ -292,6 +307,7 @@ def _attach_existing_candidate(
     query: Query,
     query: Query,
     candidate: PlatformCandidate,
     candidate: PlatformCandidate,
     unique_key: str,
     unique_key: str,
+    attempt_index: int = 1,
 ) -> bool:
 ) -> bool:
     get_by_key = getattr(repo, "get_candidate_item_by_unique_key", None)
     get_by_key = getattr(repo, "get_candidate_item_by_unique_key", None)
     attach = getattr(repo, "attach_existing_candidate_item", None)
     attach = getattr(repo, "attach_existing_candidate_item", None)
@@ -309,6 +325,7 @@ def _attach_existing_candidate(
             "matched_unique_key": unique_key,
             "matched_unique_key": unique_key,
             "matched_candidate": candidate.model_dump(),
             "matched_candidate": candidate.model_dump(),
             "candidate_rank": candidate.rank,
             "candidate_rank": candidate.rank,
+            "retry_attempt_index": attempt_index,
             **_candidate_page_metadata(candidate),
             **_candidate_page_metadata(candidate),
             **({"search_provider": _candidate_provider(candidate)} if _candidate_provider(candidate) else {}),
             **({"search_provider": _candidate_provider(candidate)} if _candidate_provider(candidate) else {}),
         },
         },
@@ -382,6 +399,8 @@ def run_batch(
     rate_limiter_factory: RateLimiterFactory | None = None,
     rate_limiter_factory: RateLimiterFactory | None = None,
     pagination_creation_threshold: float = PAGINATION_CREATION_THRESHOLD,
     pagination_creation_threshold: float = PAGINATION_CREATION_THRESHOLD,
     pagination_max_pages: int = PAGINATION_MAX_PAGES,
     pagination_max_pages: int = PAGINATION_MAX_PAGES,
+    low_result_retry_threshold: int = LOW_RESULT_RETRY_THRESHOLD,
+    low_result_max_retries: int = LOW_RESULT_MAX_RETRIES,
 ) -> RunBatchResult:
 ) -> RunBatchResult:
     """Run query x platform acquisition and write formal cloud-state rows."""
     """Run query x platform acquisition and write formal cloud-state rows."""
     queries = repo.list_queries_for_batch(batch_id, keep=True)
     queries = repo.list_queries_for_batch(batch_id, keep=True)
@@ -399,6 +418,10 @@ def run_batch(
                 pagination_creation_threshold,
                 pagination_creation_threshold,
                 pagination_max_pages,
                 pagination_max_pages,
             ),
             ),
+            "low_result_retry": {
+                "threshold": low_result_retry_threshold,
+                "max_retries": low_result_max_retries,
+            },
         },
         },
     )
     )
     if run.id is None:
     if run.id is None:
@@ -453,68 +476,100 @@ def run_batch(
                         else PlatformRateLimiter(platform)
                         else PlatformRateLimiter(platform)
                     )
                     )
                     gates[platform] = gate
                     gates[platform] = gate
-                pages = _search_pages(
-                    adapter,
-                    query.query_text,
-                    settings=settings,
-                    limit=search_limit,
-                    rate_limiter=gate,
-                    max_pages=pagination_max_pages,
+                retry_enabled = (
+                    low_result_max_retries > 0
+                    and low_result_retry_threshold >= 0
+                    and search_limit > low_result_retry_threshold
                 )
                 )
-                for page in pages:
-                    search_page_count += 1
-                    raw_searched_count += page.raw_count
-                    searched_count += len(page.candidates)
-                    page_classified_count = 0
-                    page_creation_count = 0
-
-                    for candidate in page.candidates[:search_limit]:
-                        try:
-                            unique_key = _candidate_unique_key(platform, candidate)
-                            if unique_key and _attach_existing_candidate(
-                                repo,
-                                job=job,
-                                query=query,
-                                candidate=candidate,
-                                unique_key=unique_key,
-                            ):
-                                display_count += 1
+                max_attempts = 1 + (low_result_max_retries if retry_enabled else 0)
+                seen_candidate_keys: set[str] = set()
+                retry_attempts: list[dict[str, Any]] = []
+                retry_triggered = False
+
+                for attempt_index in range(1, max_attempts + 1):
+                    attempt_display_start = display_count
+                    attempt_searched_start = searched_count
+                    attempt_raw_start = raw_searched_count
+                    attempt_pages_start = search_page_count
+                    attempt_page_summaries: list[dict[str, Any]] = []
+                    attempt_first_page_classified_count = 0
+                    attempt_first_page_creation_count = 0
+                    attempt_first_page_creation_ratio: float | None = None
+                    attempt_requested_second_page = False
+                    attempt_stop_reason = ""
+
+                    pages = _search_pages(
+                        adapter,
+                        query.query_text,
+                        settings=settings,
+                        limit=search_limit,
+                        rate_limiter=gate,
+                        max_pages=pagination_max_pages,
+                    )
+                    for page in pages:
+                        search_page_count += 1
+                        raw_searched_count += page.raw_count
+                        page_classified_count = 0
+                        page_creation_count = 0
+                        unique_candidate_count = 0
+
+                        for candidate in page.candidates[:search_limit]:
+                            dedupe_key = _candidate_dedupe_key(platform, candidate)
+                            if dedupe_key and dedupe_key in seen_candidate_keys:
+                                continue
+                            if dedupe_key:
+                                seen_candidate_keys.add(dedupe_key)
+                            unique_candidate_count += 1
+                            searched_count += 1
+                            try:
+                                unique_key = _candidate_unique_key(platform, candidate)
+                                if unique_key and _attach_existing_candidate(
+                                    repo,
+                                    job=job,
+                                    query=query,
+                                    candidate=candidate,
+                                    unique_key=unique_key,
+                                    attempt_index=attempt_index,
+                                ):
+                                    display_count += 1
+                                    continue
+                                detail = adapter.fetch_detail(
+                                    candidate,
+                                    settings=settings,
+                                    rate_limiter=gate,
+                                )
+                                recorded = _record_candidate(
+                                    repo,
+                                    job=job,
+                                    query=query,
+                                    platform=platform,
+                                    candidate=candidate,
+                                    detail=detail,
+                                    settings=settings,
+                                    media_stabilizer=media_stabilizer,
+                                    classifier=classifier,
+                                    classify=classify,
+                                    attempt_index=attempt_index,
+                                )
+                                if recorded.displayed:
+                                    display_count += 1
+                                    if recorded.skipped_processing:
+                                        skipped_item_count += 1
+                                if recorded.classified:
+                                    page_classified_count += 1
+                                    if recorded.is_creation_knowledge is True:
+                                        page_creation_count += 1
+                            except Exception as exc:
+                                errors.append(clip_text(exc, ERROR_MESSAGE_MAX_CHARS))
                                 continue
                                 continue
-                            detail = adapter.fetch_detail(
-                                candidate,
-                                settings=settings,
-                                rate_limiter=gate,
-                            )
-                            recorded = _record_candidate(
-                                repo,
-                                job=job,
-                                query=query,
-                                platform=platform,
-                                candidate=candidate,
-                                detail=detail,
-                                settings=settings,
-                                media_stabilizer=media_stabilizer,
-                                classifier=classifier,
-                                classify=classify,
-                            )
-                            if recorded.displayed:
-                                display_count += 1
-                                if recorded.skipped_processing:
-                                    skipped_item_count += 1
-                            if recorded.classified:
-                                page_classified_count += 1
-                                if recorded.is_creation_knowledge is True:
-                                    page_creation_count += 1
-                        except Exception as exc:
-                            errors.append(clip_text(exc, ERROR_MESSAGE_MAX_CHARS))
-                            continue
 
 
-                    page_ratio = _ratio(page_creation_count, page_classified_count)
-                    page_summaries.append(
-                        {
+                        page_ratio = _ratio(page_creation_count, page_classified_count)
+                        page_summary = {
+                            "attempt_index": attempt_index,
                             "page_index": page.page_index,
                             "page_index": page.page_index,
                             "raw_count": page.raw_count,
                             "raw_count": page.raw_count,
                             "candidate_count": len(page.candidates),
                             "candidate_count": len(page.candidates),
+                            "unique_candidate_count": unique_candidate_count,
                             "classified_count": page_classified_count,
                             "classified_count": page_classified_count,
                             "creation_count": page_creation_count,
                             "creation_count": page_creation_count,
                             "creation_ratio": page_ratio,
                             "creation_ratio": page_ratio,
@@ -522,40 +577,67 @@ def run_batch(
                             "next_cursor": page.next_cursor,
                             "next_cursor": page.next_cursor,
                             "provider": page.provider,
                             "provider": page.provider,
                         }
                         }
+                        page_summaries.append(page_summary)
+                        attempt_page_summaries.append(page_summary)
+
+                        if page.page_index == 1:
+                            attempt_first_page_classified_count = page_classified_count
+                            attempt_first_page_creation_count = page_creation_count
+                            attempt_first_page_creation_ratio = page_ratio
+                            if pagination_max_pages <= 1:
+                                attempt_stop_reason = "max_pages_reached"
+                                break
+                            if not page.has_more or not page.next_cursor:
+                                attempt_stop_reason = "no_more_pages"
+                                break
+                            if page_classified_count <= 0:
+                                attempt_stop_reason = "no_classified_candidates"
+                                break
+                            if page_ratio is None or page_ratio < pagination_creation_threshold:
+                                attempt_stop_reason = "below_threshold"
+                                break
+                            attempt_requested_second_page = True
+                            continue
+
+                        attempt_stop_reason = "max_pages_reached"
+                        break
+
+                    if not attempt_stop_reason:
+                        attempt_stop_reason = "pages_exhausted"
+
+                    first_page_classified_count = attempt_first_page_classified_count
+                    first_page_creation_count = attempt_first_page_creation_count
+                    first_page_creation_ratio = attempt_first_page_creation_ratio
+                    requested_second_page = attempt_requested_second_page
+                    pagination_stop_reason = attempt_stop_reason
+                    attempt_display_count = display_count - attempt_display_start
+                    retry_attempts.append(
+                        {
+                            "attempt_index": attempt_index,
+                            "display_count": attempt_display_count,
+                            "searched_count": searched_count - attempt_searched_start,
+                            "raw_searched_count": raw_searched_count - attempt_raw_start,
+                            "search_page_count": search_page_count - attempt_pages_start,
+                            "stop_reason": attempt_stop_reason,
+                            "requested_second_page": attempt_requested_second_page,
+                            "first_page_classified_count": attempt_first_page_classified_count,
+                            "first_page_creation_count": attempt_first_page_creation_count,
+                            "first_page_creation_ratio": attempt_first_page_creation_ratio,
+                            "pages": attempt_page_summaries,
+                        }
                     )
                     )
 
 
-                    if page.page_index == 1:
-                        first_page_classified_count = page_classified_count
-                        first_page_creation_count = page_creation_count
-                        first_page_creation_ratio = page_ratio
-                        if pagination_max_pages <= 1:
-                            pagination_stop_reason = "max_pages_reached"
-                            break
-                        if not page.has_more or not page.next_cursor:
-                            pagination_stop_reason = "no_more_pages"
-                            break
-                        if page_classified_count <= 0:
-                            pagination_stop_reason = "no_classified_candidates"
-                            break
-                        if page_ratio is None or page_ratio < pagination_creation_threshold:
-                            pagination_stop_reason = "below_threshold"
-                            break
-                        requested_second_page = True
+                    if (
+                        attempt_index <= low_result_max_retries
+                        and display_count <= low_result_retry_threshold
+                    ):
+                        retry_triggered = True
                         continue
                         continue
-
-                    pagination_stop_reason = "max_pages_reached"
                     break
                     break
 
 
-                if not pagination_stop_reason:
-                    pagination_stop_reason = "pages_exhausted"
-
-                status = "done" if display_count >= display_limit else (
-                    "partial" if display_count else "failed"
-                )
+                status = "done" if display_count else "failed"
                 if status == "done":
                 if status == "done":
                     done += 1
                     done += 1
-                elif status == "partial":
-                    partial += 1
                 else:
                 else:
                     failed += 1
                     failed += 1
                 repo.update_acquisition_job(
                 repo.update_acquisition_job(
@@ -582,6 +664,13 @@ def run_batch(
                             "stop_reason": pagination_stop_reason,
                             "stop_reason": pagination_stop_reason,
                             "pages": page_summaries,
                             "pages": page_summaries,
                         },
                         },
+                        "low_result_retry": {
+                            "threshold": low_result_retry_threshold,
+                            "max_retries": low_result_max_retries if retry_enabled else 0,
+                            "triggered": retry_triggered,
+                            "attempt_count": len(retry_attempts),
+                            "attempts": retry_attempts,
+                        },
                         "errors": errors[-3:],
                         "errors": errors[-3:],
                     },
                     },
                 )
                 )
@@ -615,13 +704,7 @@ def run_batch(
                     },
                     },
                 )
                 )
 
 
-    run_status = (
-        "done"
-        if failed == 0 and partial == 0
-        else "partial"
-        if done > 0 or partial > 0
-        else "failed"
-    )
+    run_status = "done" if done > 0 else "failed"
     update_run = getattr(repo, "update_acquisition_run", None)
     update_run = getattr(repo, "update_acquisition_run", None)
     if update_run:
     if update_run:
         update_run(
         update_run(

+ 11 - 8
app/dependencies.py

@@ -4,11 +4,11 @@ from __future__ import annotations
 import os
 import os
 from typing import Iterator
 from typing import Iterator
 
 
-from fastapi import HTTPException
+from fastapi import Depends, HTTPException
 
 
 from acquisition.repositories.postgres import PostgresAcquisitionRepository
 from acquisition.repositories.postgres import PostgresAcquisitionRepository
 from core.config import CreationDbConfig
 from core.config import CreationDbConfig
-from core.db_session import transaction
+from core.db_session import pooled_transaction
 from decode_content.repositories.postgres import PostgresDecodeRepository
 from decode_content.repositories.postgres import PostgresDecodeRepository
 
 
 
 
@@ -20,14 +20,17 @@ def get_creation_db_config() -> CreationDbConfig:
     return CreationDbConfig.from_env(_env_file())
     return CreationDbConfig.from_env(_env_file())
 
 
 
 
-def get_acquisition_repository() -> Iterator[PostgresAcquisitionRepository]:
-    with transaction(get_creation_db_config()) as conn:
-        yield PostgresAcquisitionRepository(conn)
+def get_db_connection() -> Iterator[object]:
+    with pooled_transaction(get_creation_db_config()) as conn:
+        yield conn
 
 
 
 
-def get_decode_repository() -> Iterator[PostgresDecodeRepository]:
-    with transaction(get_creation_db_config()) as conn:
-        yield PostgresDecodeRepository(conn)
+def get_acquisition_repository(conn: object = Depends(get_db_connection)) -> PostgresAcquisitionRepository:
+    return PostgresAcquisitionRepository(conn)
+
+
+def get_decode_repository(conn: object = Depends(get_db_connection)) -> PostgresDecodeRepository:
+    return PostgresDecodeRepository(conn)
 
 
 
 
 def get_pipeline_repository():
 def get_pipeline_repository():

File diff ditekan karena terlalu besar
+ 0 - 8
app/frontend/dist/assets/index-Bx7KAD4Y.js


File diff ditekan karena terlalu besar
+ 0 - 0
app/frontend/dist/assets/index-C-saPy1c.css


File diff ditekan karena terlalu besar
+ 8 - 0
app/frontend/dist/assets/index-CxkG5OsZ.js


File diff ditekan karena terlalu besar
+ 0 - 0
app/frontend/dist/assets/index-QFmrwHTU.css


+ 3 - 3
app/frontend/dist/index.html

@@ -3,9 +3,9 @@
   <head>
   <head>
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>创作知识 · Query 正交 Demo</title>
-    <script type="module" crossorigin src="/app/assets/index-Bx7KAD4Y.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-QFmrwHTU.css">
+    <title>创作知识</title>
+    <script type="module" crossorigin src="/app/assets/index-CxkG5OsZ.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-C-saPy1c.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 1 - 1
app/frontend/index.html

@@ -3,7 +3,7 @@
   <head>
   <head>
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>创作知识 · Query 正交 Demo</title>
+    <title>创作知识</title>
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 34 - 1
app/frontend/package-lock.json

@@ -9,7 +9,8 @@
       "version": "0.1.0",
       "version": "0.1.0",
       "dependencies": {
       "dependencies": {
         "react": "^18.3.1",
         "react": "^18.3.1",
-        "react-dom": "^18.3.1"
+        "react-dom": "^18.3.1",
+        "react-window": "^1.8.11"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@vitejs/plugin-react": "^4.3.4",
         "@vitejs/plugin-react": "^4.3.4",
@@ -250,6 +251,15 @@
         "@babel/core": "^7.0.0-0"
         "@babel/core": "^7.0.0-0"
       }
       }
     },
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+      "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/template": {
     "node_modules/@babel/template": {
       "version": "7.29.7",
       "version": "7.29.7",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1436,6 +1446,12 @@
         "yallist": "^3.0.2"
         "yallist": "^3.0.2"
       }
       }
     },
     },
+    "node_modules/memoize-one": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
+      "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
+      "license": "MIT"
+    },
     "node_modules/ms": {
     "node_modules/ms": {
       "version": "2.1.3",
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1543,6 +1559,23 @@
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
     },
     },
+    "node_modules/react-window": {
+      "version": "1.8.11",
+      "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
+      "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.0.0",
+        "memoize-one": ">=3.1.1 <6"
+      },
+      "engines": {
+        "node": ">8.0.0"
+      },
+      "peerDependencies": {
+        "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/rollup": {
     "node_modules/rollup": {
       "version": "4.62.2",
       "version": "4.62.2",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",

+ 2 - 1
app/frontend/package.json

@@ -11,7 +11,8 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "react": "^18.3.1",
     "react": "^18.3.1",
-    "react-dom": "^18.3.1"
+    "react-dom": "^18.3.1",
+    "react-window": "^1.8.11"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@vitejs/plugin-react": "^4.3.4",
     "@vitejs/plugin-react": "^4.3.4",

+ 0 - 38
app/frontend/src/App.jsx

@@ -1,38 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import CreationDemo from './pages/CreationDemo.jsx'
-import CreationQueryDetail from './pages/CreationQueryDetail.jsx'
-import DecodeItemDetail from './pages/DecodeItemDetail.jsx'
-
-function parseRoute() {
-  const hash = window.location.hash || '#/'
-  if (hash.startsWith('#/query/')) {
-    const [, runId, queryId] = hash.match(/^#\/query\/([^/]+)\/([^/]+)/) || []
-    return { name: 'query', runId: decodeURIComponent(runId || ''), queryId: decodeURIComponent(queryId || '') }
-  }
-  if (hash.startsWith('#/decode-item/')) {
-    const [, itemId] = hash.match(/^#\/decode-item\/([^/]+)/) || []
-    return { name: 'decodeItem', itemId: decodeURIComponent(itemId || '') }
-  }
-  return { name: 'demo' }
-}
-
-export default function App() {
-  const [route, setRoute] = useState(parseRoute)
-  useEffect(() => {
-    const onHash = () => setRoute(parseRoute())
-    window.addEventListener('hashchange', onHash)
-    return () => window.removeEventListener('hashchange', onHash)
-  }, [])
-  const shellClass = route.name === 'decodeItem' ? 'decode-shell' : 'wrap'
-  return (
-    <div className={shellClass}>
-      {route.name === 'query' ? (
-        <CreationQueryDetail runId={route.runId} queryId={route.queryId} />
-      ) : route.name === 'decodeItem' ? (
-        <DecodeItemDetail itemId={route.itemId} />
-      ) : (
-        <CreationDemo />
-      )}
-    </div>
-  )
-}

+ 21 - 0
app/frontend/src/api/client.js

@@ -0,0 +1,21 @@
+export class ApiError extends Error {
+  constructor(message, { status = 0, payload = null } = {}) {
+    super(message)
+    this.name = 'ApiError'
+    this.status = status
+    this.payload = payload
+  }
+}
+
+export async function request(path, { signal } = {}) {
+  const response = await fetch(path, { signal })
+  const contentType = response.headers.get('content-type') || ''
+  const payload = contentType.includes('application/json')
+    ? await response.json().catch(() => null)
+    : await response.text().catch(() => '')
+  if (!response.ok) {
+    const message = payload?.detail || payload?.message || `请求失败:${response.status}`
+    throw new ApiError(message, { status: response.status, payload })
+  }
+  return payload
+}

+ 5 - 0
app/frontend/src/api/decode.js

@@ -0,0 +1,5 @@
+import { request } from './client.js'
+
+export function getDecodeItemDetail(itemId, { signal } = {}) {
+  return request(`/api/decode/items/${encodeURIComponent(itemId)}/detail`, { signal })
+}

+ 13 - 0
app/frontend/src/api/workbench.js

@@ -0,0 +1,13 @@
+import { request } from './client.js'
+
+export function getQueryPreview({ signal } = {}) {
+  return request('/api/query-generation/preview?per=0&batch_n=0', { signal })
+}
+
+export function getLatestBoard({ signal } = {}) {
+  return request('/api/query-generation/latest-singleton', { signal })
+}
+
+export function getLatestQueryDetail(queryId, { signal } = {}) {
+  return request(`/api/query-generation/latest/queries/${encodeURIComponent(queryId)}`, { signal })
+}

+ 25 - 0
app/frontend/src/app/App.jsx

@@ -0,0 +1,25 @@
+import { useEffect, useState } from 'react'
+import { parseRoute } from './routes.js'
+import QueryBoardPage from '../features/query-board/QueryBoardPage.jsx'
+import ItemDetailPage from '../features/item-detail/ItemDetailPage.jsx'
+
+export default function App() {
+  const [route, setRoute] = useState(parseRoute)
+  useEffect(() => {
+    const onHash = () => setRoute(parseRoute())
+    window.addEventListener('hashchange', onHash)
+    return () => window.removeEventListener('hashchange', onHash)
+  }, [])
+
+  if (route.name === 'item') {
+    return <ItemDetailPage itemId={route.itemId} />
+  }
+
+  return (
+    <div className="admin-shell">
+      <main className="admin-main admin-main-full">
+        <QueryBoardPage />
+      </main>
+    </div>
+  )
+}

+ 7 - 0
app/frontend/src/app/routes.js

@@ -0,0 +1,7 @@
+export function parseRoute(hash = window.location.hash || '#/') {
+  if (hash.startsWith('#/item/')) {
+    const [, itemId] = hash.match(/^#\/item\/([^/]+)/) || []
+    return { name: 'item', itemId: decodeURIComponent(itemId || '') }
+  }
+  return { name: 'demo' }
+}

+ 3 - 0
app/frontend/src/components/ErrorState.jsx

@@ -0,0 +1,3 @@
+export default function ErrorState({ children = '读取失败' }) {
+  return <div className="empty error-state">{children}</div>
+}

+ 3 - 0
app/frontend/src/components/LoadingState.jsx

@@ -0,0 +1,3 @@
+export default function LoadingState({ children = '加载中...' }) {
+  return <div className="empty">{children}</div>
+}

+ 95 - 0
app/frontend/src/features/decode/DecodeKnowledgeModal.jsx

@@ -0,0 +1,95 @@
+import { useEffect, useMemo, useState } from 'react'
+import { getDecodeItemDetail } from '../../api/decode.js'
+import ErrorState from '../../components/ErrorState.jsx'
+import LoadingState from '../../components/LoadingState.jsx'
+import {
+  KnowledgeCard,
+  PayloadModal,
+  Ribbon,
+  SourceDetails,
+} from './components.jsx'
+
+export default function DecodeKnowledgeModal({ itemId, embedded = true, onClose }) {
+  const [data, setData] = useState(null)
+  const [err, setErr] = useState('')
+  const [jsonPayload, setJsonPayload] = useState(null)
+  const [zoom, setZoom] = useState(null)
+
+  useEffect(() => {
+    setData(null)
+    setErr('')
+    if (!itemId) {
+      setErr('缺少 item_id')
+      return undefined
+    }
+    const controller = new AbortController()
+    getDecodeItemDetail(itemId, { signal: controller.signal })
+      .then(setData)
+      .catch((e) => {
+        if (e.name !== 'AbortError') setErr(e.message || '读取失败')
+      })
+    return () => controller.abort()
+  }, [itemId])
+
+  const scopesByParticle = useMemo(() => {
+    const map = new Map()
+    for (const scope of data?.scope_results || []) {
+      const key = scope.particle_id || ''
+      map.set(key, [...(map.get(key) || []), scope])
+    }
+    return map
+  }, [data])
+
+  const payloadByParticle = useMemo(() => {
+    const map = new Map()
+    for (const draft of data?.payload_drafts || []) {
+      if (draft.particle_id) map.set(draft.particle_id, draft)
+    }
+    return map
+  }, [data])
+
+  const pageClass = `legacy-decode-page${embedded ? ' embedded-decode-page' : ''}`
+
+  if (err) return <div className={pageClass}><ErrorState>{err}</ErrorState></div>
+  if (!data) return <div className={pageClass}><LoadingState /></div>
+
+  const particles = data.knowledge_particles || []
+  const readText = data.decode_result?.read_result?.text || ''
+
+  return (
+    <div className={pageClass}>
+      <div className="app">
+        <header className="top">
+          <h1>创作知识的解构</h1>
+          <div className="picker">
+            {embedded ? (
+              <button className="pill" type="button" onClick={onClose}>关闭弹窗</button>
+            ) : (
+              <a className="pill" href="#/">返回 Query 看板</a>
+            )}
+            <span className="pill on">{data.item?.title || '真实单例'}<span className="n">{particles.length}颗</span></span>
+          </div>
+        </header>
+        <div className="scroll">
+          <Ribbon />
+          <SourceDetails item={data.item} readText={readText} onZoom={setZoom} />
+          <div className="barhint">
+            这一帖拆出 <b>{particles.length}</b> 颗知识(how 工序表 / what 知识卡 / why 论点卡);每颗知识右上角可打开 payload。
+          </div>
+          {particles.map((particle, idx) => (
+            <KnowledgeCard
+              key={particle.id}
+              particle={particle}
+              idx={idx}
+              scopes={scopesByParticle.get(particle.id) || []}
+              payload={payloadByParticle.get(particle.id)}
+              onJson={setJsonPayload}
+            />
+          ))}
+        </div>
+      </div>
+      <PayloadModal payload={jsonPayload} onClose={() => setJsonPayload(null)} />
+      {zoom && <div className="lb" onClick={() => setZoom(null)}><img src={zoom} alt="" /></div>}
+    </div>
+  )
+}

+ 24 - 107
app/frontend/src/pages/DecodeItemDetail.jsx → app/frontend/src/features/decode/components.jsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from 'react'
+import React, { useState } from 'react'
 
 
 const SCOPES = [
 const SCOPES = [
   ['substance', '实质'],
   ['substance', '实质'],
@@ -24,10 +24,6 @@ function mediaUrl(asset) {
   return asset?.cdn_url || asset?.oss_url || asset?.source_url || ''
   return asset?.cdn_url || asset?.oss_url || asset?.source_url || ''
 }
 }
 
 
-function payloadTitle(payload) {
-  return payload?.title || payload?.source?.title || '未命名 payload'
-}
-
 function compactJson(value) {
 function compactJson(value) {
   return JSON.stringify(value || {}, null, 2)
   return JSON.stringify(value || {}, null, 2)
 }
 }
@@ -73,7 +69,7 @@ function KScopes({ scopes }) {
   )
   )
 }
 }
 
 
-function Ribbon() {
+export function Ribbon() {
   return (
   return (
     <div className="ribbon">
     <div className="ribbon">
       <div className="rcap">这套系统怎么「从帖子拆解知识」——从真实单例跑出的状态回看</div>
       <div className="rcap">这套系统怎么「从帖子拆解知识」——从真实单例跑出的状态回看</div>
@@ -93,7 +89,7 @@ function Ribbon() {
   )
   )
 }
 }
 
 
-function SourceDetails({ item, readText, onZoom }) {
+export function SourceDetails({ item, readText, onZoom, originalText = "" }) {
   const images = sourceImages(item)
   const images = sourceImages(item)
   const video = sourceVideo(item)
   const video = sourceVideo(item)
   return (
   return (
@@ -108,6 +104,7 @@ function SourceDetails({ item, readText, onZoom }) {
             {images.map((url, idx) => <img key={url} src={url} title={url} onClick={() => onZoom(url)} alt={`原帖图 ${idx + 1}`} />)}
             {images.map((url, idx) => <img key={url} src={url} title={url} onClick={() => onZoom(url)} alt={`原帖图 ${idx + 1}`} />)}
           </div>
           </div>
         )}
         )}
+        {originalText && <div className="src-original-text">② 原帖全文:{originalText}</div>}
         {readText && <div className="srcread">① 读懂后的内容:{readText}</div>}
         {readText && <div className="srcread">① 读懂后的内容:{readText}</div>}
       </div>
       </div>
     </details>
     </details>
@@ -136,21 +133,25 @@ function HowTable({ knowledge }) {
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          {steps.map((step, idx) => (
-            <tr className="step" key={step.id || idx}>
-              <td className="idx">{idx + 1}</td>
-              <td className="intent"><span className="in-txt">{step.input || '未写'}</span></td>
-              <td className="cstage">{step['创作阶段'] && <span className="cstage-chip">{step['创作阶段']}</span>}</td>
-              <td className="act">{step['动作'] && <span className="act-chip">{step['动作']}</span>}</td>
-              <td className="dir"><span className="dir-txt">{step.directive || '未写'}</span></td>
-              <td className="out"><span className="out-txt">{step.output || '未写'}</span></td>
-              {SCOPES.map(([key]) => (
-                <td key={key} className={`scope s-${key}`}>
-                  {scOf(step, key).map((scope, scopeIdx) => <ScopeChip key={scopeIdx} scope={scope} />)}
+          {steps.map((step, idx) => {
+            return (
+              <tr className="step" key={step.id || idx}>
+                <td className="idx">{idx + 1}</td>
+                <td className="intent">
+                  <span className="in-txt">{step.input || '未写'}</span>
                 </td>
                 </td>
-              ))}
-            </tr>
-          ))}
+                <td className="cstage">{step['创作阶段'] && <span className="cstage-chip">{step['创作阶段']}</span>}</td>
+                <td className="act">{step['动作'] && <span className="act-chip">{step['动作']}</span>}</td>
+                <td className="dir"><span className="dir-txt">{step.directive || '未写'}</span></td>
+                <td className="out"><span className="out-txt">{step.output || '未写'}</span></td>
+                {SCOPES.map(([key]) => (
+                  <td key={key} className={`scope s-${key}`}>
+                    {scOf(step, key).map((scope, scopeIdx) => <ScopeChip key={scopeIdx} scope={scope} />)}
+                  </td>
+                ))}
+              </tr>
+            )
+          })}
         </tbody>
         </tbody>
       </table>
       </table>
     </div>
     </div>
@@ -194,7 +195,7 @@ function WhyCard({ knowledge, scopes }) {
   )
   )
 }
 }
 
 
-function KnowledgeCard({ particle, idx, scopes, payload, onJson }) {
+export function KnowledgeCard({ particle, idx, scopes, payload, onJson }) {
   const knowledge = particle.content || {}
   const knowledge = particle.content || {}
   const type = particle.particle_type || knowledge.type || 'what'
   const type = particle.particle_type || knowledge.type || 'what'
   return (
   return (
@@ -216,7 +217,7 @@ function KnowledgeCard({ particle, idx, scopes, payload, onJson }) {
   )
   )
 }
 }
 
 
-function PayloadModal({ payload, onClose }) {
+export function PayloadModal({ payload, onClose }) {
   const [copied, setCopied] = useState(false)
   const [copied, setCopied] = useState(false)
   if (!payload) return null
   if (!payload) return null
   const copy = () => {
   const copy = () => {
@@ -238,87 +239,3 @@ function PayloadModal({ payload, onClose }) {
     </div>
     </div>
   )
   )
 }
 }
-
-export default function DecodeItemDetail({ itemId, embedded = false, onClose }) {
-  const [data, setData] = useState(null)
-  const [err, setErr] = useState('')
-  const [jsonPayload, setJsonPayload] = useState(null)
-  const [zoom, setZoom] = useState(null)
-
-  useEffect(() => {
-    setData(null)
-    setErr('')
-    if (!itemId) {
-      setErr('缺少 item_id')
-      return
-    }
-    fetch(`/api/decode/items/${encodeURIComponent(itemId)}/detail`)
-      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('decode 详情读取失败'))))
-      .then(setData)
-      .catch((e) => setErr(e.message || '读取失败'))
-  }, [itemId])
-
-  const scopesByParticle = useMemo(() => {
-    const map = new Map()
-    for (const scope of data?.scope_results || []) {
-      const key = scope.particle_id || ''
-      map.set(key, [...(map.get(key) || []), scope])
-    }
-    return map
-  }, [data])
-
-  const payloadByParticle = useMemo(() => {
-    const map = new Map()
-    for (const draft of data?.payload_drafts || []) {
-      if (draft.particle_id) map.set(draft.particle_id, draft)
-    }
-    return map
-  }, [data])
-
-  const pageClass = `legacy-decode-page${embedded ? ' embedded-decode-page' : ''}`
-
-  if (err) return <div className={pageClass}><div className="empty">{err}</div></div>
-  if (!data) return <div className={pageClass}><div className="empty">加载中...</div></div>
-
-  const particles = data.knowledge_particles || []
-  const readText = data.decode_result?.read_result?.text || ''
-  const pass = data.decode_result?.gate_result?.passed
-
-  return (
-    <div className={pageClass}>
-      <div className="app">
-        <header className="top">
-          <h1>创作知识的解构</h1>
-          <div className="picker">
-            {embedded ? (
-              <button className="pill" type="button" onClick={onClose}>关闭弹窗</button>
-            ) : (
-              <a className="pill" href="#/">返回 Query Demo</a>
-            )}
-            <span className="pill on">{data.item?.title || '真实单例'}<span className="n">{particles.length}颗</span></span>
-            <span className="lab">状态:{data.decode_result?.status || 'unknown'} · {pass ? '通过创作闸' : '未通过创作闸'}</span>
-          </div>
-        </header>
-        <div className="scroll">
-          <Ribbon />
-          <SourceDetails item={data.item} readText={readText} onZoom={setZoom} />
-          <div className="barhint">
-            这一帖拆出 <b>{particles.length}</b> 颗知识(how 工序表 / what 知识卡 / why 论点卡);每颗知识右上角可打开 payload。
-          </div>
-          {particles.map((particle, idx) => (
-            <KnowledgeCard
-              key={particle.id}
-              particle={particle}
-              idx={idx}
-              scopes={scopesByParticle.get(particle.id) || []}
-              payload={payloadByParticle.get(particle.id)}
-              onJson={setJsonPayload}
-            />
-          ))}
-        </div>
-      </div>
-      <PayloadModal payload={jsonPayload} onClose={() => setJsonPayload(null)} />
-      {zoom && <div className="lb" onClick={() => setZoom(null)}><img src={zoom} alt="" /></div>}
-    </div>
-  )
-}

+ 132 - 0
app/frontend/src/features/item-detail/ItemDetailPage.jsx

@@ -0,0 +1,132 @@
+import { useEffect, useMemo, useState } from 'react'
+import { getDecodeItemDetail } from '../../api/decode.js'
+import ErrorState from '../../components/ErrorState.jsx'
+import LoadingState from '../../components/LoadingState.jsx'
+import {
+  KnowledgeCard,
+  PayloadModal,
+  SourceDetails,
+} from '../decode/components.jsx'
+
+function statusText(data) {
+  const item = data?.item || {}
+  const result = data?.decode_result
+  const particles = data?.knowledge_particles || []
+  if (particles.length > 0) return `已提取 ${particles.length} 颗知识`
+  if (result?.status === 'decoded') return '已解构,无知识'
+  if (result?.status) return `解构状态:${result.status}`
+  if (item.classification?.is_creation_knowledge === false) return '粗筛非创作知识'
+  return '无知识'
+}
+
+export default function ItemDetailPage({ itemId }) {
+  const [data, setData] = useState(null)
+  const [err, setErr] = useState('')
+  const [jsonPayload, setJsonPayload] = useState(null)
+  const [zoom, setZoom] = useState(null)
+
+  useEffect(() => {
+    setData(null)
+    setErr('')
+    if (!itemId) {
+      setErr('缺少 item_id')
+      return undefined
+    }
+    const controller = new AbortController()
+    getDecodeItemDetail(itemId, { signal: controller.signal })
+      .then(setData)
+      .catch((e) => {
+        if (e.name !== 'AbortError') setErr(e.message || '读取失败')
+      })
+    return () => controller.abort()
+  }, [itemId])
+
+  const scopesByParticle = useMemo(() => {
+    const map = new Map()
+    for (const scope of data?.scope_results || []) {
+      const key = scope.particle_id || ''
+      map.set(key, [...(map.get(key) || []), scope])
+    }
+    return map
+  }, [data])
+
+  const payloadByParticle = useMemo(() => {
+    const map = new Map()
+    for (const draft of data?.payload_drafts || []) {
+      if (draft.particle_id) map.set(draft.particle_id, draft)
+    }
+    return map
+  }, [data])
+
+  if (err) return <ErrorState>{err}</ErrorState>
+  if (!data) return <LoadingState />
+
+  const particles = data.knowledge_particles || []
+  const readText = data.decode_result?.read_result?.text || ''
+  const originalText = data.item?.body_text || ''
+  const summaryText = data.item?.raw_summary || ''
+
+  return (
+    <div className="item-detail-page">
+      <div className="item-detail-top">
+        <a className="back" href="#/">返回 Query 看板</a>
+        <span className="item-detail-state">{statusText(data)}</span>
+      </div>
+      <div className="item-detail-layout">
+        <section className="item-post-pane">
+          <div className="pane-head">
+            <span>原帖</span>
+            <b>{data.item?.platform || 'unknown'}</b>
+          </div>
+          <SourceDetails item={data.item} readText="" originalText={originalText} onZoom={setZoom} />
+          {!originalText && (
+            <div className="item-body-copy item-missing-original">
+              未取到平台原文全文
+            </div>
+          )}
+          {summaryText && summaryText !== originalText && (
+            <details className="item-body-copy" open>
+              <summary>正文摘要</summary>
+              <div>{summaryText}</div>
+            </details>
+          )}
+          {readText && (
+            <details className="item-body-copy item-read-result">
+              <summary>读懂后的内容</summary>
+              <div>{readText}</div>
+            </details>
+          )}
+        </section>
+
+        <section className="item-knowledge-pane legacy-decode-page embedded-decode-page">
+          <div className="pane-head">
+            <span>提取知识</span>
+            <b>{particles.length} 颗</b>
+          </div>
+          {particles.length === 0 ? (
+            <div className="no-knowledge">无知识</div>
+          ) : (
+            particles.map((particle, idx) => (
+              <KnowledgeCard
+                key={particle.id}
+                particle={particle}
+                idx={idx}
+                scopes={scopesByParticle.get(particle.id) || []}
+                payload={payloadByParticle.get(particle.id)}
+                onJson={setJsonPayload}
+              />
+            ))
+          )}
+        </section>
+      </div>
+      <div className="legacy-decode-page embedded-decode-page item-payload-modal-scope">
+        <PayloadModal payload={jsonPayload} onClose={() => setJsonPayload(null)} />
+      </div>
+      {zoom && (
+        <div className="item-lightbox" onClick={() => setZoom(null)}>
+          <img src={zoom} alt="" />
+        </div>
+      )}
+    </div>
+  )
+}

+ 106 - 0
app/frontend/src/features/query-board/AxisColumn.jsx

@@ -0,0 +1,106 @@
+import { useState } from 'react'
+import { AXIS_ICON, AXIS_LABEL, axisRowStats, axisValues, hasFinalKnowledge, isQuerySearched, pct } from './model.js'
+
+export default function AxisColumn({ data, family, axis, latestByQuery }) {
+  const values = axisValues(data, family, axis)
+  const tree = data.axis_trees?.[axis] || []
+  const hasTree = tree.length > 0
+  const [collapsed, setCollapsed] = useState(() => new Set())
+  const familyItems = family.items || []
+  const searched = familyItems.filter((item) => isQuerySearched(latestByQuery.get(item.query))).length
+  const knowledge = familyItems.filter((item) => hasFinalKnowledge(latestByQuery.get(item.query))).length
+  const total = familyItems.length || 1
+  const toggle = (path) => {
+    setCollapsed((prev) => {
+      const next = new Set(prev)
+      if (next.has(path)) next.delete(path)
+      else next.add(path)
+      return next
+    })
+  }
+
+  const renderFlatRow = (value, index) => {
+    const stats = axisRowStats(familyItems, latestByQuery, axis, value)
+    const isMuted = stats.searched === 0 && index > 8
+    return (
+      <div className={`axv ${isMuted ? 'muted' : ''}`} key={value}>
+        <span className="tree-caret empty" />
+        <span className="tree-label">{value}</span>
+        <span className="tree-dots">
+          {stats.searched > 0 && <i className="dot blue" />}
+          {stats.knowledge > 0 && <i className="dot green" />}
+          {stats.total > 0 && <b>{stats.total}</b>}
+        </span>
+      </div>
+    )
+  }
+
+  const renderTreeNode = (node, level = 3) => {
+    const children = node.children || []
+    const childNames = children.map((child) => child.name)
+    const stats = axisRowStats(familyItems, latestByQuery, axis, [node.name, ...childNames])
+    const isCollapsed = collapsed.has(node.path)
+    const canExpand = level === 3 && children.length > 0
+    return (
+      <div className="tree-group" key={node.path}>
+        <button
+          className={`axv tree-node level-${level} ${stats.searched === 0 ? 'muted' : ''}`}
+          type="button"
+          onClick={() => canExpand && toggle(node.path)}
+        >
+          <span className={`tree-caret ${canExpand ? '' : 'empty'}`}>
+            {canExpand ? (isCollapsed ? '›' : '⌄') : ''}
+          </span>
+          <span className="tree-label">{node.name}</span>
+          <span className="tree-dots">
+            {stats.searched > 0 && <i className="dot blue" />}
+            {stats.knowledge > 0 && <i className="dot green" />}
+            {stats.total > 0 && <b>{stats.total}</b>}
+          </span>
+        </button>
+        {canExpand && !isCollapsed && children.map((child) => {
+          const childStats = axisRowStats(familyItems, latestByQuery, axis, child.name)
+          return (
+            <div className={`axv tree-node level-4 ${childStats.searched === 0 ? 'muted' : ''}`} key={child.path}>
+              <span className="tree-caret empty" />
+              <span className="tree-label">{child.name}</span>
+              <span className="tree-dots">
+                {childStats.searched > 0 && <i className="dot blue" />}
+                {childStats.knowledge > 0 && <i className="dot green" />}
+                {childStats.total > 0 && <b>{childStats.total}</b>}
+              </span>
+            </div>
+          )
+        })}
+      </div>
+    )
+  }
+
+  return (
+    <div className="axcol">
+      <div className="axhd">
+        <div className="axis-title">
+          <span className="axis-icon">{AXIS_ICON[axis] || axis.slice(0, 1)}</span>
+          <span>{AXIS_LABEL[axis] || axis}</span>
+        </div>
+        <div className="axis-metrics">
+          <div className="metric-pair">
+            <span className="metric-blue">搜索 {pct(searched, total)}%</span>
+            <span className="metric-green">知识 {pct(knowledge, total)}%</span>
+          </div>
+          <div className="mini-bars" aria-hidden="true">
+            <span style={{ width: `${Math.min(100, pct(searched, total))}%` }} />
+            <span style={{ width: `${Math.min(100, pct(knowledge, total))}%` }} />
+          </div>
+          <div className="metric-counts">
+            <span>{searched}/{familyItems.length}</span>
+            <span>{knowledge}/{familyItems.length}</span>
+          </div>
+        </div>
+      </div>
+      <div className="axlist">
+        {hasTree ? tree.map((node) => renderTreeNode(node)) : values.map(renderFlatRow)}
+      </div>
+    </div>
+  )
+}

+ 122 - 0
app/frontend/src/features/query-board/QueryBoardPage.jsx

@@ -0,0 +1,122 @@
+import { useEffect, useMemo, useState } from 'react'
+import ErrorState from '../../components/ErrorState.jsx'
+import LoadingState from '../../components/LoadingState.jsx'
+import DecodeKnowledgeModal from '../decode/DecodeKnowledgeModal.jsx'
+import AxisColumn from './AxisColumn.jsx'
+import QueryColumn from './QueryColumn.jsx'
+import SearchResultColumn from './SearchResultColumn.jsx'
+import { useLatestQueryDetail, useQueryBoard } from './useQueryBoard.js'
+import { isQuerySearched } from './model.js'
+
+export default function QueryBoardPage() {
+  const {
+    preview,
+    board,
+    latestByQuery,
+    loading,
+    error,
+    lastUpdatedAt,
+  } = useQueryBoard()
+  const [activeKey, setActiveKey] = useState('f1')
+  const [selectedQuery, setSelectedQuery] = useState(null)
+  const [decodeItemId, setDecodeItemId] = useState('')
+
+  const families = preview?.families || []
+  const family = families.find((row) => row.key === activeKey) || families[0] || { axes: [], items: [] }
+  const familyItems = family.items || []
+  const familyRunCount = familyItems.filter((item) => isQuerySearched(latestByQuery.get(item.query))).length
+  const totalRunCount = families.reduce((count, row) => {
+    return count + (row.items || []).filter((item) => isQuerySearched(latestByQuery.get(item.query))).length
+  }, 0)
+  const totalItemCount = families.reduce((sum, row) => sum + (row.items || []).length, 0)
+  const detailState = useLatestQueryDetail(selectedQuery)
+  const lastUpdatedText = useMemo(() => {
+    return lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString('zh-CN', { hour12: false }) : ''
+  }, [lastUpdatedAt])
+
+  useEffect(() => {
+    const firstKey = families[0]?.key
+    if (firstKey && !families.some((row) => row.key === activeKey)) {
+      setActiveKey(firstKey)
+    }
+  }, [families, activeKey])
+
+  useEffect(() => {
+    if (!familyItems.length) return
+    if (selectedQuery && familyItems.some((item) => item.query === selectedQuery.query)) return
+    const first = familyItems.find((item) => isQuerySearched(latestByQuery.get(item.query)))
+    if (first) {
+      const latest = latestByQuery.get(first.query)
+      setSelectedQuery({ query: first.query, query_id: latest.query_id })
+    } else {
+      setSelectedQuery(null)
+    }
+  }, [activeKey, board, familyItems, latestByQuery, selectedQuery])
+
+  if (error) return <ErrorState>{error}</ErrorState>
+  if (loading || !preview) return <LoadingState />
+
+  return (
+    <div className="dashboard-page">
+      <div className="dashboard-toolbar">
+        <div className="filter-chips">
+          <button className="filter-chip blue active" type="button"><span>✓</span>搜索过的</button>
+          <button className="filter-chip green active" type="button"><span>✓</span>有知识的</button>
+        </div>
+        <div className="family-switch">
+          {families.map((row) => (
+            <button
+              className={`fbtn ${row.key === family.key ? 'on' : ''}`}
+              key={row.key}
+              type="button"
+              onClick={() => setActiveKey(row.key)}
+            >
+              <span className="fbtn-pre">{row.axes?.[0] || row.key}</span>
+              <span className="fbtn-suf">{(row.axes || []).slice(1).join(' × ')}</span>
+            </button>
+          ))}
+        </div>
+        <span className="refresh-state">
+          {lastUpdatedText ? `上次刷新 ${lastUpdatedText}` : '等待后台写入数据'}
+        </span>
+      </div>
+
+      <div className="cdemo">
+        {(family.axes || []).map((axis) => (
+          <AxisColumn
+            key={axis}
+            axis={axis}
+            data={preview}
+            family={family}
+            latestByQuery={latestByQuery}
+          />
+        ))}
+        <QueryColumn
+          familyItems={familyItems}
+          latestByQuery={latestByQuery}
+          selectedQuery={selectedQuery}
+          onSelect={(queryItem, queryLatest) => {
+            setSelectedQuery({ query: queryItem.query, query_id: queryLatest.query_id })
+          }}
+          familyRunCount={familyRunCount}
+          totalRunCount={totalRunCount}
+          totalItemCount={totalItemCount}
+        />
+        <SearchResultColumn
+          selected={selectedQuery}
+          detail={detailState.detail}
+          loading={detailState.loading}
+          error={detailState.error}
+          onKnowledgeClick={setDecodeItemId}
+        />
+      </div>
+      {decodeItemId && (
+        <div className="decode-modal-mask" onClick={() => setDecodeItemId('')}>
+          <div className="decode-modal-panel" onClick={(event) => event.stopPropagation()}>
+            <DecodeKnowledgeModal itemId={decodeItemId} embedded onClose={() => setDecodeItemId('')} />
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 100 - 0
app/frontend/src/features/query-board/QueryColumn.jsx

@@ -0,0 +1,100 @@
+import { useEffect, useMemo, useState } from 'react'
+import { FixedSizeList } from 'react-window'
+import { coarseLabel, decodedLabel, pct, searchedLabel } from './model.js'
+
+function useListHeight() {
+  const getHeight = () => Math.max(360, window.innerHeight - 312)
+  const [height, setHeight] = useState(getHeight)
+  useEffect(() => {
+    const onResize = () => setHeight(getHeight())
+    window.addEventListener('resize', onResize)
+    return () => window.removeEventListener('resize', onResize)
+  }, [])
+  return height
+}
+
+function QueryRow({ item, latest, selected, onSelect, style }) {
+  const canSelect = Boolean(latest)
+  return (
+    <div style={style}>
+      <button
+        className={`qrow query-select-row ${item.keep === false ? 'drop' : 'keep'} ${selected ? 'selected' : ''}`}
+        disabled={!canSelect}
+        title={item.reason || ''}
+        type="button"
+        onClick={() => canSelect && onSelect?.(item, latest)}
+      >
+        <span className="qmark">{item.keep === false ? '✕' : '✓'}</span>
+        {typeof item.valid === 'number' && (
+          <span className={`qvalid ${item.valid >= 6 ? 'ok' : 'low'}`}>语义{item.valid}</span>
+        )}
+        <span className="qtext">{item.query}</span>
+        {latest ? (
+          <>
+            <span className="qcreation searched">{searchedLabel(latest)}</span>
+            <span className="qcreation knowledge">{coarseLabel(latest)}</span>
+            <span className="qcreation decoded">{decodedLabel(latest)}</span>
+          </>
+        ) : (
+          <span className="qdetail">未搜索</span>
+        )}
+      </button>
+    </div>
+  )
+}
+
+export default function QueryColumn({
+  familyItems,
+  latestByQuery,
+  selectedQuery,
+  onSelect,
+  familyRunCount,
+  totalRunCount,
+  totalItemCount,
+}) {
+  const height = useListHeight()
+  const itemData = useMemo(() => ({
+    familyItems,
+    latestByQuery,
+    selectedQuery,
+    onSelect,
+  }), [familyItems, latestByQuery, selectedQuery, onSelect])
+
+  return (
+    <div className="axcol qcol">
+      <div className="axhd qhd">
+        <div className="axis-title">
+          <span className="query-search-icon">⌕</span>
+          <span>Query 词</span>
+        </div>
+        <span className="qhd-metrics">
+          <span className="run-progress">执行进度 {pct(totalRunCount, totalItemCount)}%</span>
+          <span className="gn">{familyRunCount}/{familyItems.length}</span>
+        </span>
+      </div>
+      <FixedSizeList
+        className="axlist virtual-query-list"
+        height={height}
+        itemCount={familyItems.length}
+        itemData={itemData}
+        itemKey={(index, data) => `${data.familyItems[index]?.query}-${index}`}
+        itemSize={32}
+        width="100%"
+      >
+        {({ index, style, data }) => {
+          const item = data.familyItems[index]
+          const latest = data.latestByQuery.get(item.query)
+          return (
+            <QueryRow
+              item={item}
+              latest={latest}
+              selected={data.selectedQuery?.query_id === latest?.query_id}
+              onSelect={data.onSelect}
+              style={style}
+            />
+          )
+        }}
+      </FixedSizeList>
+    </div>
+  )
+}

+ 74 - 0
app/frontend/src/features/query-board/SearchResultColumn.jsx

@@ -0,0 +1,74 @@
+import { flatDetailItems, isFinalKnowledge, itemBrief, itemMediaUrl } from './model.js'
+
+export default function SearchResultColumn({ selected, detail, loading, error, onKnowledgeClick }) {
+  const items = [...flatDetailItems(detail)].sort((a, b) => {
+    const ak = isFinalKnowledge(a) ? 0 : 1
+    const bk = isFinalKnowledge(b) ? 0 : 1
+    if (ak !== bk) return ak - bk
+    return (a.platform || '').localeCompare(b.platform || '') || (a.title || '').localeCompare(b.title || '')
+  })
+  return (
+    <div className="axcol qresult-col">
+      <div className="axhd qresult-hd">
+        <div className="axis-title">
+          <span className="query-search-icon">帖</span>
+          <span>搜索结果{selected ? `(${items.length}贴)` : ''}</span>
+        </div>
+        <div className="qresult-sub">
+          {selected ? selected.query : '选择 Query 查看帖子'}
+        </div>
+      </div>
+      <div className="qresult-list">
+        {!selected && <div className="qresult-empty">点击左侧 Query 词后,这里展示本次搜到的帖子。</div>}
+        {selected && loading && <div className="qresult-empty">加载搜索结果...</div>}
+        {selected && error && <div className="qresult-empty error">{error}</div>}
+        {selected && !loading && !error && items.length === 0 && (
+          <div className="qresult-empty">这个 Query 暂无搜索结果。若后台还在运行,请等待数据写入。</div>
+        )}
+        {items.map((item) => {
+          const knowledge = isFinalKnowledge(item)
+          const cover = itemMediaUrl(item)
+          const confidence = item.classification?.confidence
+          return (
+            <article className={`result-mini ${knowledge ? 'knowledge' : ''}`} key={item.id}>
+              <button
+                className="result-mini-click"
+                type="button"
+                onClick={() => { window.location.hash = `/item/${item.id}` }}
+              >
+                {cover && <img src={cover} alt="" loading="lazy" />}
+                <div className="result-mini-main">
+                  <div className="result-mini-title">{item.title || '无标题'}</div>
+                  <div className="result-mini-tags">
+                    <span className={`platform-mini ${item.platform_tone}`}>{item.platform_label}</span>
+                    <span className={knowledge ? 'knowledge-mini yes' : 'knowledge-mini'}>{knowledge ? '创作知识' : '未形成知识'}</span>
+                    {typeof confidence === 'number' && <span className="score-mini">{confidence.toFixed(2)}</span>}
+                  </div>
+                  {itemBrief(item) && <div className="result-mini-text">{itemBrief(item)}</div>}
+                </div>
+              </button>
+              <div className="result-mini-actions">
+                <button
+                  className="mini-detail-btn detail"
+                  type="button"
+                  onClick={() => { window.location.hash = `/item/${item.id}` }}
+                >
+                  查看详情
+                </button>
+                {knowledge && (
+                  <button
+                    className="mini-detail-btn knowledge"
+                    type="button"
+                    onClick={() => onKnowledgeClick?.(item.id)}
+                  >
+                    查看创作知识
+                  </button>
+                )}
+              </div>
+            </article>
+          )
+        })}
+      </div>
+    </div>
+  )
+}

+ 155 - 0
app/frontend/src/features/query-board/model.js

@@ -0,0 +1,155 @@
+export const AXIS_LABEL = {
+  实质: '实质(3-4层)',
+  形式: '形式(3-4层)',
+}
+
+export const PLATFORM_LABEL = {
+  xiaohongshu: ['小红书', 'xhs'],
+  weixin: ['微信公众号', 'wx'],
+  douyin: ['抖音', 'dy'],
+}
+
+export const AXIS_ICON = {
+  实质: '实',
+  形式: '形',
+  动作: '动',
+  作用: '作',
+  类型: '类',
+  工具类型: '工',
+  知识状态: '知',
+  模态: '模',
+  业务阶段: '阶',
+  知识类型: '知',
+  '作用/感受/意图': '意',
+}
+
+export const LIVE_STATUSES = new Set(['pending', 'running'])
+
+export function statusText(status) {
+  return {
+    pending: '等待中',
+    running: '运行中',
+    done: '完成',
+    partial: '部分完成',
+    failed: '失败',
+    skipped: '跳过',
+  }[status] || status || '未开始'
+}
+
+export function querySummaryMap(summary) {
+  const out = new Map()
+  for (const row of summary?.queries || []) {
+    out.set(row.query_id, row)
+  }
+  return out
+}
+
+export function platformCounts(row) {
+  const platforms = row?.platforms || {}
+  return ['xiaohongshu', 'weixin', 'douyin'].map((key) => platforms[key]?.status || '未跑').join(' / ')
+}
+
+export function searchedLabel(row) {
+  return row ? `搜到 ${row.candidate_count || 0}` : ''
+}
+
+export function coarseLabel(row) {
+  return row ? `粗筛通过 ${row.creation_hit_count || 0}` : ''
+}
+
+export function decodedLabel(row) {
+  return row ? `解构完成 ${row.decoded_count || 0}` : ''
+}
+
+export function pct(part, total) {
+  if (!total) return 0
+  return Math.round((part / total) * 1000) / 10
+}
+
+export function uniqueAxisValues(items, axis) {
+  const seen = new Set()
+  const out = []
+  for (const item of items || []) {
+    const value = item.parts?.[axis]
+    if (value && !seen.has(value)) {
+      seen.add(value)
+      out.push(value)
+    }
+  }
+  return out
+}
+
+export function axisValues(data, family, axis) {
+  if (axis === '实质' || axis === '形式') {
+    return uniqueAxisValues(family.items, axis)
+  }
+  const key = axis === '作用/感受/意图' ? '目的池' : axis
+  return data.axis_values?.[key] || uniqueAxisValues(family.items, key)
+}
+
+export function axisRowStats(familyItems, latestByQuery, axis, value) {
+  const key = axis === '作用/感受/意图' ? '目的池' : axis
+  const values = Array.isArray(value) ? new Set(value) : new Set([value])
+  const related = familyItems.filter((item) => values.has(item.parts?.[key]))
+  const searched = related.filter((item) => isQuerySearched(latestByQuery.get(item.query))).length
+  const knowledge = related.filter((item) => hasFinalKnowledge(latestByQuery.get(item.query))).length
+  return { total: related.length, searched, knowledge }
+}
+
+export function singletonMaps(singleton) {
+  const byQueryText = new Map()
+  const decodedByQueryId = new Map()
+  for (const item of singleton?.decoded_items || []) {
+    const key = item.query_id || ''
+    decodedByQueryId.set(key, [...(decodedByQueryId.get(key) || []), item])
+  }
+  for (const row of singleton?.queries || []) {
+    byQueryText.set(row.query_text, {
+      ...row,
+      decoded_items: decodedByQueryId.get(row.query_id) || [],
+    })
+  }
+  return byQueryText
+}
+
+export function flatDetailItems(detail) {
+  const platforms = detail?.platforms || {}
+  return ['xiaohongshu', 'weixin', 'douyin'].flatMap((platform) => {
+    const [label, tone] = PLATFORM_LABEL[platform] || [platform, platform]
+    return (platforms[platform]?.items || []).map((item) => ({
+      ...item,
+      platform_label: label,
+      platform_tone: tone,
+    }))
+  })
+}
+
+export function itemMediaUrl(item) {
+  const media = item.media_assets || []
+  const firstImage = media.find((asset) => ['image', 'cover', 'frame'].includes(asset.media_type))
+  return firstImage?.cdn_url || firstImage?.oss_url || firstImage?.source_url || ''
+}
+
+export function itemBrief(item) {
+  return item.brief || item.raw_summary || item.classification?.reason || ''
+}
+
+export function hasFinalKnowledge(row) {
+  return (row?.decoded_count || 0) > 0 && (row?.payload_count || 0) > 0
+}
+
+export function isQuerySearched(row) {
+  return (row?.candidate_count || 0) > 0
+}
+
+export function isFinalKnowledge(item) {
+  const decoded = item.decode_summary
+  return item.classification?.is_creation_knowledge === true
+    && decoded?.decode_status === 'decoded'
+    && (decoded?.particle_count || 0) > 0
+    && (decoded?.payload_count || 0) > 0
+}
+
+export function isLiveRun(run) {
+  return LIVE_STATUSES.has(run?.status)
+}

+ 109 - 0
app/frontend/src/features/query-board/useQueryBoard.js

@@ -0,0 +1,109 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { getLatestBoard, getLatestQueryDetail, getQueryPreview } from '../../api/workbench.js'
+import { isLiveRun, singletonMaps } from './model.js'
+
+const BOARD_POLL_MS = 5 * 60 * 1000
+
+export function useQueryBoard() {
+  const [preview, setPreview] = useState(null)
+  const [board, setBoard] = useState(null)
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+  const [lastUpdatedAt, setLastUpdatedAt] = useState(null)
+
+  const refreshBoard = useCallback(({ signal } = {}) => {
+    return getLatestBoard({ signal }).then((payload) => {
+      setBoard(payload)
+      setLastUpdatedAt(new Date())
+      return payload
+    })
+  }, [])
+
+  useEffect(() => {
+    const controller = new AbortController()
+    setLoading(true)
+    setError('')
+    Promise.all([
+      getQueryPreview({ signal: controller.signal }),
+      getLatestBoard({ signal: controller.signal }).catch(() => null),
+    ])
+      .then(([previewPayload, boardPayload]) => {
+        setPreview(previewPayload)
+        setBoard(boardPayload)
+        setLastUpdatedAt(new Date())
+      })
+      .catch((e) => {
+        if (e.name !== 'AbortError') setError(e.message || '读取失败')
+      })
+      .finally(() => setLoading(false))
+    return () => controller.abort()
+  }, [])
+
+  useEffect(() => {
+    if (!isLiveRun(board?.run)) return undefined
+    let stopped = false
+    let timer = 0
+    let controller = null
+    const tick = () => {
+      controller = new AbortController()
+      refreshBoard({ signal: controller.signal })
+        .catch(() => {})
+        .finally(() => {
+          if (!stopped) timer = window.setTimeout(tick, BOARD_POLL_MS)
+        })
+    }
+    timer = window.setTimeout(tick, BOARD_POLL_MS)
+    return () => {
+      stopped = true
+      window.clearTimeout(timer)
+      controller?.abort()
+    }
+  }, [board?.run?.status, refreshBoard])
+
+  const latestByQuery = useMemo(() => singletonMaps(board), [board])
+
+  return {
+    preview,
+    board,
+    latestByQuery,
+    loading,
+    error,
+    lastUpdatedAt,
+    refreshBoard,
+    isPolling: isLiveRun(board?.run),
+  }
+}
+
+export function useLatestQueryDetail(selectedQuery) {
+  const [detail, setDetail] = useState(null)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState('')
+
+  const load = useCallback(({ signal, background = false } = {}) => {
+    if (!selectedQuery?.query_id) return Promise.resolve(null)
+    if (!background) setLoading(true)
+    setError('')
+    return getLatestQueryDetail(selectedQuery.query_id, { signal })
+      .then((payload) => {
+        setDetail(payload)
+        return payload
+      })
+      .catch((e) => {
+        if (e.name !== 'AbortError') setError(e.message || '搜索结果读取失败')
+        return null
+      })
+      .finally(() => {
+        if (!background) setLoading(false)
+      })
+  }, [selectedQuery?.query_id])
+
+  useEffect(() => {
+    setDetail(null)
+    if (!selectedQuery?.query_id) return undefined
+    const controller = new AbortController()
+    load({ signal: controller.signal })
+    return () => controller.abort()
+  }, [selectedQuery?.query_id, load])
+
+  return { detail, loading, error, reload: load }
+}

+ 1 - 1
app/frontend/src/main.jsx

@@ -1,6 +1,6 @@
 import React from 'react'
 import React from 'react'
 import { createRoot } from 'react-dom/client'
 import { createRoot } from 'react-dom/client'
-import App from './App.jsx'
+import App from './app/App.jsx'
 import './styles.css'
 import './styles.css'
 
 
 createRoot(document.getElementById('root')).render(<App />)
 createRoot(document.getElementById('root')).render(<App />)

+ 0 - 288
app/frontend/src/pages/CreationDemo.jsx

@@ -1,288 +0,0 @@
-import React, { useEffect, useMemo, useState } from 'react'
-
-const AXIS_LABEL = {
-  实质: '实质(3-4层)',
-  形式: '形式(3-4层)',
-}
-
-const PLATFORM_LABEL = {
-  xiaohongshu: ['小红书', 'xhs'],
-  weixin: ['微信公众号', 'wx'],
-  douyin: ['抖音', 'dy'],
-}
-
-function params() {
-  return new URLSearchParams(window.location.search)
-}
-
-function statusText(status) {
-  return {
-    pending: '等待中',
-    running: '运行中',
-    done: '完成',
-    partial: '部分完成',
-    failed: '失败',
-    skipped: '跳过',
-  }[status] || status || '未开始'
-}
-
-function querySummaryMap(summary) {
-  const out = new Map()
-  for (const row of summary?.queries || []) {
-    out.set(row.query_id, row)
-  }
-  return out
-}
-
-function platformCounts(row) {
-  const platforms = row?.platforms || {}
-  return ['xiaohongshu', 'weixin', 'douyin'].map((key) => platforms[key]?.status || '未跑').join(' / ')
-}
-
-function decodedLabel(row) {
-  if (!row) return ''
-  return `${row.decoded_count || 0}/${row.candidate_count || 0} 已拆`
-}
-
-function knowledgeLabel(row) {
-  if (!row) return ''
-  const hits = Math.max(row.creation_hit_count || 0, row.decoded_count || 0)
-  return `${hits}/${row.candidate_count || 0} 知识贴`
-}
-
-function uniqueAxisValues(items, axis) {
-  const seen = new Set()
-  const out = []
-  for (const item of items || []) {
-    const value = item.parts?.[axis]
-    if (value && !seen.has(value)) {
-      seen.add(value)
-      out.push(value)
-    }
-  }
-  return out
-}
-
-function axisValues(data, family, axis) {
-  if (axis === '实质' || axis === '形式') {
-    return uniqueAxisValues(family.items, axis)
-  }
-  const key = axis === '作用/感受/意图' ? '目的池' : axis
-  return data.axis_values?.[key] || uniqueAxisValues(family.items, key)
-}
-
-function AxisColumn({ data, family, axis }) {
-  const values = axisValues(data, family, axis)
-  return (
-    <div className="axcol">
-      <div className="axhd">
-        <span>{AXIS_LABEL[axis] || axis}</span>
-        <span className="gn">{values.length}</span>
-      </div>
-      <div className="axlist">
-        {values.map((value) => <div className="axv" key={value}>{value}</div>)}
-      </div>
-    </div>
-  )
-}
-
-function singletonMaps(singleton) {
-  const byQueryText = new Map()
-  const decodedByQueryId = new Map()
-  for (const item of singleton?.decoded_items || []) {
-    const key = item.query_id || ''
-    decodedByQueryId.set(key, [...(decodedByQueryId.get(key) || []), item])
-  }
-  for (const row of singleton?.queries || []) {
-    byQueryText.set(row.query_text, {
-      ...row,
-      decoded_items: decodedByQueryId.get(row.query_id) || [],
-    })
-  }
-  return byQueryText
-}
-
-function PreviewQueryRow({ item, index, latest }) {
-  return (
-    <div className={`qrow ${item.keep === false ? 'drop' : 'keep'}`} key={`${item.query}-${index}`} title={item.reason || ''}>
-      <span className="qmark">{item.keep === false ? '✕' : '✓'}</span>
-      {typeof item.valid === 'number' && (
-        <span className={`qvalid ${item.valid >= 6 ? 'ok' : 'low'}`}>语义{item.valid}</span>
-      )}
-      <span className="qtext">{item.query}</span>
-      {latest ? (
-        <>
-          <span className="qcreation done">{decodedLabel(latest)}</span>
-          <span className="qcreation knowledge">{knowledgeLabel(latest)}</span>
-          <a className="qdetail on" href={`#/query/latest/${encodeURIComponent(latest.query_id)}`}>查看详情</a>
-        </>
-      ) : (
-        <span className="qdetail">未搜索</span>
-      )}
-      {item.reason && <span className="qreason">{item.reason}</span>}
-    </div>
-  )
-}
-
-function QueryGenerationPreview() {
-  const [data, setData] = useState(null)
-  const [singleton, setSingleton] = useState(null)
-  const [activeKey, setActiveKey] = useState('f1')
-  const [err, setErr] = useState('')
-
-  useEffect(() => {
-    setErr('')
-    Promise.all([
-      fetch('/api/query-generation/preview?per=0&batch_n=0&dry=true')
-        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('query 生成预览读取失败')))),
-      fetch('/api/query-generation/latest-singleton')
-        .then((r) => (r.ok ? r.json() : null))
-        .catch(() => null),
-    ])
-      .then(([payload, latest]) => {
-        setData(payload)
-        setSingleton(latest)
-        const firstKey = payload.families?.[0]?.key
-        if (firstKey) setActiveKey(firstKey)
-      })
-      .catch((e) => setErr(e.message || '读取失败'))
-  }, [])
-
-  if (err) return <div className="empty">{err}</div>
-  if (!data) return <div className="empty">加载中...</div>
-
-  const families = data.families || []
-  const family = families.find((row) => row.key === activeKey) || families[0]
-  const kept = (family?.items || []).filter((item) => item.keep !== false).length
-  const latestByQuery = singletonMaps(singleton)
-
-  return (
-    <div>
-      <h1>创作知识 · Query 正交 Demo</h1>
-      <div className="sub">
-        只显示当前激活的 2 种正交方式:f1 / f2。生成逻辑来自正式 query builder;此页只预览,不写 DB、不发起搜索。
-      </div>
-
-      <div className="famcols two">
-        {families.map((row) => (
-          <button
-            className={`fbtn ${row.key === family.key ? 'on' : ''}`}
-            key={row.key}
-            type="button"
-            onClick={() => setActiveKey(row.key)}
-          >
-            <span className="fbtn-pre">{row.axes?.[0] || row.key}</span>
-            <span className="fbtn-suf">{(row.axes || []).slice(1).join(' × ')}</span>
-          </button>
-        ))}
-      </div>
-
-      <div className="cdemo">
-        {(family.axes || []).map((axis) => <AxisColumn key={axis} axis={axis} data={data} family={family} />)}
-        <div className="axcol qcol">
-          <div className="axhd qhd">
-            <span>Query 词</span>
-            <span className="qhd-metrics">
-              <span className="avgcreation pending">预览</span>
-              <span className="gn">留 {kept}/{family.items?.length || 0}</span>
-            </span>
-          </div>
-          <div className="axlist">
-            {(family.items || []).map((item, index) => {
-              const latest = latestByQuery.get(item.query)
-              return (
-                <PreviewQueryRow
-                  item={item}
-                  index={index}
-                  key={`${item.query}-${index}`}
-                  latest={latest}
-                />
-              )
-            })}
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}
-
-export default function CreationDemo() {
-  const [batch, setBatch] = useState(null)
-  const [summary, setSummary] = useState(null)
-  const [err, setErr] = useState('')
-  const search = params()
-  const batchId = search.get('batch_id') || ''
-  const runId = search.get('run_id') || ''
-  const summaryByQuery = useMemo(() => querySummaryMap(summary), [summary])
-
-  useEffect(() => {
-    setErr('')
-    setBatch(null)
-    setSummary(null)
-    if (!batchId) {
-      return
-    }
-    fetch(`/api/query-batches/${encodeURIComponent(batchId)}`)
-      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('query batch 读取失败'))))
-      .then(setBatch)
-      .catch((e) => setErr(e.message || '读取失败'))
-    if (runId) {
-      fetch(`/api/acquisition/runs/${encodeURIComponent(runId)}/summary`)
-        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('run summary 读取失败'))))
-        .then(setSummary)
-        .catch(() => setSummary(null))
-    }
-  }, [batchId, runId])
-
-  if (!batchId) {
-    return <QueryGenerationPreview />
-  }
-  if (err) return <div className="empty">{err}</div>
-  if (!batch) return <div className="empty">加载中...</div>
-
-  const queries = batch.queries || []
-  return (
-    <div>
-      <h1>创作知识采集工作台</h1>
-      <div className="sub">
-        {batch.batch?.name || 'Query Batch'} · {queries.length} 条 query
-        {summary && <span> · 候选 {summary.candidate_count} · 命中 {summary.creation_hit_count}</span>}
-      </div>
-
-      <div className="cdemo single">
-        <div className="axcol qcol wide">
-          <div className="axhd qhd">
-            <span>Query</span>
-            <span className="qhd-metrics">
-              {runId && <span className="avgcreation">{statusText(summary?.status)}</span>}
-              <span className="gn">{queries.length}</span>
-            </span>
-          </div>
-          <div className="axlist">
-            {queries.map((query) => {
-              const qsum = summaryByQuery.get(query.id)
-              const href = runId ? `#/query/${encodeURIComponent(runId)}/${encodeURIComponent(query.id)}` : '#/'
-              return (
-                <div className={`qrow ${query.keep === false ? 'drop' : 'keep'}`} key={query.id} title={query.filter_reason || ''}>
-                  <span className="qmark">{query.keep === false ? '✕' : '✓'}</span>
-                  <span className="qtext">{query.query_text}</span>
-                  {qsum && (
-                    <span className="qcreation done">
-                      {qsum.creation_hit_count}/{qsum.candidate_count} 命中
-                    </span>
-                  )}
-                  {runId ? (
-                    <a className="qdetail on" href={href}>查看素材<span>{platformCounts(qsum)}</span></a>
-                  ) : (
-                    <span className="qdetail">待运行</span>
-                  )}
-                  {query.filter_reason && <span className="qreason">{query.filter_reason}</span>}
-                </div>
-              )
-            })}
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 0 - 310
app/frontend/src/pages/CreationQueryDetail.jsx

@@ -1,310 +0,0 @@
-import React, { useEffect, useMemo, useState } from 'react'
-import DecodeItemDetail from './DecodeItemDetail.jsx'
-
-const PLATFORMS = [
-  ['xiaohongshu', '小红书', 'xhs'],
-  ['weixin', '微信公众号', 'wx'],
-  ['douyin', '抖音', 'dy'],
-]
-
-function shortText(text, n = 220) {
-  if (!text) return ''
-  return text.length > n ? text.slice(0, n) + '...' : text
-}
-
-function mediaUrl(asset) {
-  return asset?.cdn_url || asset?.oss_url || asset?.source_url || ''
-}
-
-function itemImages(item) {
-  return (item.media_assets || [])
-    .filter((asset) => ['image', 'cover', 'frame'].includes(asset.media_type))
-    .map(mediaUrl)
-    .filter(Boolean)
-}
-
-function itemVideo(item) {
-  const asset = (item.media_assets || []).find((row) => row.media_type === 'video')
-  return mediaUrl(asset)
-}
-
-function contentModeLabel(item) {
-  return {
-    image_post: '图文帖',
-    video_post: '视频帖',
-    article: '文章图文',
-    unsupported: '不支持',
-  }[item.content_mode] || item.content_type || '未识别'
-}
-
-function skipReasonText(item) {
-  const reason = item.metadata?.skip_reason || item.classification?.error_message || item.error_message || ''
-  return {
-    unsupported_content_mode: '暂不支持这种帖子类型,已跳过',
-    video_url_missing: '视频帖没有可处理的视频地址,已跳过',
-  }[reason] || reason
-}
-
-function clsLabel(cls, decoded) {
-  if (cls?.status === 'skipped') return ['none', '已跳过']
-  if (decoded) return ['yes', '已拆知识']
-  if (!cls || cls.is_creation_knowledge === null || cls.is_creation_knowledge === undefined) {
-    return ['none', '未粗筛']
-  }
-  return cls.is_creation_knowledge ? ['yes', '创作知识'] : ['no', '非创作知识']
-}
-
-function judgementText(item, decoded) {
-  if (item.metadata?.acquisition_match_status === 'existing') {
-    return `本次搜索重复命中,已复用历史解析,生成 ${decoded?.payload_count || 0} payload`
-  }
-  const skipText = skipReasonText(item)
-  if (skipText) return skipText
-  if (decoded) {
-    return `Decode 已通过,生成 ${decoded.payload_count || 0} payload`
-  }
-  if (item.classification?.reason) return item.classification.reason
-  if (item.classification?.is_creation_knowledge === false) return '粗筛判断为非创作知识'
-  return '本次未做粗筛判断'
-}
-
-function PlatformStatus({ platform }) {
-  const status = platform?.status || 'not_started'
-  const text = {
-    not_started: '未跑',
-    pending: '等待中',
-    running: '运行中',
-    done: '完成',
-    partial: '部分完成',
-    failed: '失败',
-  }[status] || status
-  return <span className={`status ${status}`}>{text}</span>
-}
-
-function Media({ item, onImageClick }) {
-  const video = itemVideo(item)
-  if (video) {
-    return <video controls playsInline src={video} />
-  }
-  const images = itemImages(item)
-  if (images.length > 1) {
-    return (
-      <div className="thumbstrip" aria-label="帖子图片">
-        {images.map((url, i) => (
-          <button className="thumbbtn" key={url} type="button" onClick={() => onImageClick?.(images, i, item.title)}>
-            <img className="gimg" src={url} alt={`第 ${i + 1} 张`} loading="lazy" />
-            <span>{i + 1}</span>
-          </button>
-        ))}
-      </div>
-    )
-  }
-  return images[0] ? (
-    <button className="coverbtn" type="button" onClick={() => onImageClick?.(images, 0, item.title)}>
-      <img className="cover" src={images[0]} alt="" loading="lazy" />
-    </button>
-  ) : null
-}
-
-function ResultCard({ item, tone, decoded, onImageClick, onDecodeClick }) {
-  const [clsTone, clsText] = clsLabel(item.classification, decoded)
-  const isExisting = item.metadata?.acquisition_match_status === 'existing'
-  const matchedCandidate = item.metadata?.matched_candidate || {}
-  const hasFullBody = item.body_text && item.body_text !== item.raw_summary
-  return (
-    <div className={`lane ${tone}`}>
-      <Media item={item} onImageClick={onImageClick} />
-      <div className="card-main">
-        <div className="t">{item.title || '无标题'}</div>
-        <span className={`cls ${clsTone}`}>{clsText}</span>
-      </div>
-      <div className="mode-row">
-        <span className={`mode-chip ${item.content_mode || 'unknown'}`}>{contentModeLabel(item)}</span>
-        {item.content_type && <span className="rawtype">原始:{item.content_type}</span>}
-      </div>
-      {isExisting && <div className="reuse-tag">重复命中 · 复用历史解析</div>}
-      {item.author_name && <div className="meta">{item.author_name}</div>}
-      <div className={`judge ${clsTone}`}>判断:{judgementText(item, decoded)}</div>
-      {decoded && (
-        <button className="src detail-link detail-button" type="button" onClick={() => onDecodeClick(item.id)}>
-          查看详情
-          <span>{decoded.payload_count || 0} payload</span>
-        </button>
-      )}
-      {item.raw_summary && (
-        <details className="fold">
-          <summary>正文摘要</summary>
-          <div className="bt">{shortText(item.raw_summary, 520)}</div>
-        </details>
-      )}
-      {hasFullBody && (
-        <details className="fold">
-          <summary>平台原文</summary>
-          <div className="bt">{item.body_text}</div>
-        </details>
-      )}
-      {isExisting && matchedCandidate.url && (
-        <a className="src reuse-src" href={matchedCandidate.url} target="_blank" rel="noreferrer">
-          本次搜索命中的原贴
-        </a>
-      )}
-      {item.canonical_url && <a className="src" href={item.canonical_url} target="_blank" rel="noreferrer">打开原链接</a>}
-    </div>
-  )
-}
-
-function PlatformGroup({ id, label, tone, data, decodedByItem, onImageClick, onDecodeClick }) {
-  const items = data?.items || []
-  const decodedFor = (item) => decodedByItem.get(item.id) || item.decode_summary
-  const creationItems = items.filter((item) => item.classification?.is_creation_knowledge === true || decodedFor(item))
-  const otherItems = items.filter((item) => item.classification?.is_creation_knowledge !== true && !decodedFor(item))
-  const renderRow = (rowItems, title, dim = false) => (
-    <div className="result-band">
-      <div className={`subhd ${dim ? 'dim' : ''}`}>
-        <span>{title}</span>
-        <span className="subn">{rowItems.length}</span>
-      </div>
-      {rowItems.length === 0 ? (
-        <div className="row-empty">{dim ? '暂无非创作知识或判断失败' : '暂无创作知识'}</div>
-      ) : (
-        <div className="grid result-row">
-          {rowItems.map((item) => (
-            <ResultCard
-              key={item.id}
-              item={item}
-              tone={tone}
-              decoded={decodedFor(item)}
-              onImageClick={onImageClick}
-              onDecodeClick={onDecodeClick}
-            />
-          ))}
-        </div>
-      )}
-    </div>
-  )
-  return (
-    <section className="pgroup">
-      <div className={`ghead ${tone}`}>
-        <span>{label}</span>
-        <PlatformStatus platform={data} />
-        <span className="gn">{items.length} 条</span>
-      </div>
-      {data?.error_message && <div className="perr">{data.error_message}</div>}
-      {items.length === 0 ? (
-        <div className="gnone">暂无可展示内容</div>
-      ) : (
-        <>
-          {renderRow(creationItems, '创作知识')}
-          {renderRow(otherItems, '非创作知识 / 判断失败', true)}
-        </>
-      )}
-    </section>
-  )
-}
-
-export default function CreationQueryDetail({ runId, queryId }) {
-  const [data, setData] = useState(null)
-  const [overview, setOverview] = useState(null)
-  const [err, setErr] = useState('')
-  const [lightbox, setLightbox] = useState(null)
-  const [decodeItemId, setDecodeItemId] = useState('')
-
-  const openImage = (images, index, title) => {
-    setLightbox({ images, index, title: title || '帖子图片' })
-  }
-
-  const moveImage = (delta) => {
-    setLightbox((prev) => {
-      if (!prev) return prev
-      const next = (prev.index + delta + prev.images.length) % prev.images.length
-      return { ...prev, index: next }
-    })
-  }
-
-  useEffect(() => {
-    setData(null)
-    setOverview(null)
-    setErr('')
-    if (!runId || !queryId) {
-      setErr('缺少 run_id 或 query_id')
-      return
-    }
-    const detailUrl = runId === 'latest'
-      ? `/api/query-generation/latest/queries/${encodeURIComponent(queryId)}`
-      : `/api/acquisition/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(queryId)}`
-    Promise.all([
-      fetch(detailUrl)
-        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('接口读取失败')))),
-      fetch('/api/query-generation/latest-singleton')
-        .then((r) => (r.ok ? r.json() : null))
-        .catch(() => null),
-    ])
-      .then(([detail, latest]) => {
-        setData(detail)
-        setOverview(latest)
-      })
-      .catch((e) => setErr(e.message || '读取失败'))
-  }, [runId, queryId])
-
-  const decodedByItem = useMemo(() => {
-    const map = new Map()
-    for (const row of overview?.decoded_items || []) {
-      if (row.query_id === queryId) map.set(row.item_id, row)
-    }
-    return map
-  }, [overview, queryId])
-
-  const queryStats = useMemo(() => {
-    return (overview?.queries || []).find((row) => row.query_id === queryId)
-  }, [overview, queryId])
-
-  return (
-    <div>
-      <div className="detail-top">
-        <a className="back" href="#/">返回 Query 列表</a>
-        {queryStats && (
-          <span className="meta">
-            {queryStats.decoded_count || 0}/{queryStats.candidate_count || 0} 已拆 · {Math.max(queryStats.creation_hit_count || 0, queryStats.decoded_count || 0)}/{queryStats.candidate_count || 0} 知识贴
-          </span>
-        )}
-      </div>
-      <h1 className="dq">{data?.query?.query_text || queryId}</h1>
-      {err && <div className="empty">{err}</div>}
-      {!data && !err && <div className="empty">加载中...</div>}
-      {data && PLATFORMS.map(([id, label, tone]) => (
-        <PlatformGroup
-          key={id}
-          id={id}
-          label={label}
-          tone={tone}
-          data={data.platforms?.[id]}
-          decodedByItem={decodedByItem}
-          onImageClick={openImage}
-          onDecodeClick={setDecodeItemId}
-        />
-      ))}
-      {decodeItemId && (
-        <div className="decode-modal-mask" onClick={() => setDecodeItemId('')}>
-          <div className="decode-modal-panel" onClick={(event) => event.stopPropagation()}>
-            <DecodeItemDetail itemId={decodeItemId} embedded onClose={() => setDecodeItemId('')} />
-          </div>
-        </div>
-      )}
-      {lightbox && (
-        <div className="lightbox" onClick={() => setLightbox(null)}>
-          <button className="lbx" type="button" onClick={() => setLightbox(null)}>×</button>
-          {lightbox.images.length > 1 && (
-            <button className="lbnav prev" type="button" onClick={(e) => { e.stopPropagation(); moveImage(-1) }}>‹</button>
-          )}
-          <figure className="lbfig" onClick={(e) => e.stopPropagation()}>
-            <img src={lightbox.images[lightbox.index]} alt="" />
-            <figcaption>{lightbox.title} · {lightbox.index + 1}/{lightbox.images.length}</figcaption>
-          </figure>
-          {lightbox.images.length > 1 && (
-            <button className="lbnav next" type="button" onClick={(e) => { e.stopPropagation(); moveImage(1) }}>›</button>
-          )}
-        </div>
-      )}
-    </div>
-  )
-}

+ 6 - 1349
app/frontend/src/styles.css

@@ -1,1349 +1,6 @@
-:root {
-  --bg: #f7f7f8;
-  --card: #fff;
-  --ink: #1a1a1f;
-  --muted: #8a8a94;
-  --line: #e6e6ea;
-  --accent: #5b5bd6;
-  --accent-soft: #ececfb;
-  --ok: #15a36a;
-  --bad: #d04646;
-}
-* { box-sizing: border-box; }
-body {
-  margin: 0;
-  background: var(--bg);
-  color: var(--ink);
-  font: 14px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
-}
-a { color: var(--accent); text-decoration: none; }
-a:hover { text-decoration: underline; }
-
-.wrap { max-width: 1180px; margin: 0 auto; padding: 22px 18px 60px; }
-h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
-.sub { color: var(--muted); font-size: 12.5px; margin-bottom: 18px; }
-
-/* 创作Demo:家族选择器左右两栏竖排,按尾缀分组 */
-.famcols { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; }
-.famcol { flex: 1 1 0; display: flex; flex-direction: column; gap: 6px; }
-.famcol-hd { font-size: 12px; font-weight: 700; color: var(--muted); padding: 0 2px 4px; }
-.fbtn { display: flex; justify-content: space-between; align-items: baseline; gap: 14px; text-align: left; padding: 9px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); font-size: 13px; color: var(--ink); cursor: pointer; }
-.fbtn:hover { border-color: var(--accent); }
-.fbtn.on { border-color: var(--accent); background: var(--accent); color: #fff; font-weight: 600; }
-.fbtn-pre { flex: 0 1 auto; }
-.fbtn-suf { flex: 0 0 auto; margin-left: auto; opacity: 0.7; }   /* 尾缀靠右、略淡 */
-.fbtn.on .fbtn-suf { opacity: 0.85; }
-
-/* 创作Query正交搜索结果:每条 query 一块 */
-.qblock { margin: 14px 0 22px; }
-.qbhd { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1px solid var(--line); }
-.qbq { font-size: 15px; font-weight: 700; color: var(--ink); }
-
-/* 页面切换顶栏 */
-.topnav { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
-.topnav a { padding: 8px 2px; color: var(--muted); font-size: 14px; border-bottom: 2px solid transparent; margin-bottom: -1px; }
-.topnav a.on { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
-
-/* 创作Query Demo:多列(轴列 + Query列) */
-.cdemo { display: flex; gap: 10px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
-.axcol { flex: 0 0 auto; width: 114px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); overflow: hidden; }
-.axcol.qcol { flex: 1 1 auto; min-width: 560px; }
-.axhd { font-size: 13px; font-weight: 700; padding: 9px 11px; border-bottom: 1px solid var(--line); background: #fafafb;
-  display: flex; align-items: center; justify-content: space-between; gap: 6px; }
-.axhd.qhd { color: var(--accent); }
-.axhd .gn { font-size: 11px; font-weight: 600; color: var(--muted); background: #f0f0f3; border-radius: 10px; padding: 1px 7px; }
-.qhd-metrics { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
-.avgcreation { font-size: 11px; font-weight: 700; border-radius: 10px; padding: 1px 8px; white-space: nowrap; }
-.avgcreation.done { color: var(--ok); background: #e6f6ee; }
-.avgcreation.pending { color: #9a6a00; background: #fff6df; }
-.axlist { max-height: 62vh; overflow: auto; }
-.axv { font-size: 12.5px; padding: 5px 11px; border-bottom: 1px solid #f3f3f5; color: #555; white-space: nowrap; }
-.axv.grp { font-weight: 700; color: #222; background: #fafafb; }
-.axv.child { padding-left: 26px; color: #666; }
-.axv.child::before { content: '·'; color: var(--muted); margin-right: 6px; }
-.qrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 7px; padding: 6px 11px; border-bottom: 1px solid #f3f3f5; font-size: 13px; }
-.qrow.keep .qmark { color: var(--ok); font-weight: 700; }
-.qrow.drop { color: var(--muted); }
-.qrow.drop .qmark { color: var(--bad); }
-.qrow.drop .qtext { text-decoration: line-through; }
-.qmark { flex: 0 0 auto; }
-.qvalid { flex: 0 0 auto; font-size: 11px; font-weight: 700; padding: 0 6px; border-radius: 9px; }
-.qvalid.ok { color: var(--ok); background: #e6f6ee; }
-.qvalid.low { color: var(--bad); background: #fbeaea; }
-.qtext { flex: 1 1 auto; white-space: normal; word-break: break-word; }   /* 长 query 换行显示,别截断 */
-.qreason { flex: 1 0 100%; margin-left: 20px; color: var(--muted); font-size: 11.5px; }   /* 理由独占一行、占满宽 */
-.qdetail { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line);
-  border-radius: 8px; padding: 2px 7px; font-size: 11.5px; color: var(--muted); background: #fff; }
-button.qdetail { font: inherit; cursor: pointer; }
-.qdetail.on { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
-.qdetail.active { color: #3f3fa8; border-color: #d9d9f4; background: #f4f4ff; font-weight: 700; }
-.qdetail.decode-on { color: var(--ok); border-color: #d8eadf; background: #e6f6ee; font-weight: 700; }
-.qdetail span { color: inherit; opacity: .8; }
-.qcreation { flex: 0 0 auto; font-size: 11.5px; font-weight: 700; border-radius: 9px; padding: 1px 7px; white-space: nowrap; }
-.qcreation.done { color: var(--ok); background: #e6f6ee; }
-.qcreation.pending { color: #9a6a00; background: #fff6df; }
-.qcreation.knowledge { color: #5b47bd; background: #f0edff; }
-.inline-results {
-  margin: 0 10px 10px 31px;
-  padding: 10px;
-  border: 1px solid #e8e8ee;
-  border-radius: 10px;
-  background: #fbfbfd;
-}
-.inline-results.loading,
-.inline-results.error,
-.inline-results.empty-inline {
-  color: var(--muted);
-  font-size: 12px;
-}
-.inline-results.error { color: var(--bad); background: #fff7f7; }
-.inline-platform + .inline-platform { margin-top: 10px; }
-.inline-platform-hd {
-  display: flex;
-  align-items: center;
-  gap: 7px;
-  margin-bottom: 8px;
-  font-size: 12px;
-  font-weight: 700;
-}
-.inline-platform-hd.xhs { color: #ff2442; }
-.inline-platform-hd.wx { color: var(--ok); }
-.inline-platform-hd.dy { color: #111; }
-.inline-platform-hd .gn {
-  font-size: 11px;
-  font-weight: 700;
-  color: var(--muted);
-  background: #f0f0f3;
-  border-radius: 10px;
-  padding: 1px 7px;
-}
-.inline-cards {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
-  gap: 8px;
-}
-.inline-card {
-  display: grid;
-  grid-template-columns: 74px minmax(0, 1fr);
-  gap: 10px;
-  align-items: start;
-  min-width: 0;
-  border: 1px solid var(--line);
-  border-radius: 9px;
-  background: #fff;
-  padding: 8px;
-}
-.inline-card.yes {
-  border-color: #cfeedd;
-  background: #fbfffd;
-}
-.inline-card.no {
-  border-color: #f0dddd;
-}
-.inline-thumb {
-  width: 74px;
-  height: 94px;
-  object-fit: cover;
-  object-position: top;
-  border-radius: 7px;
-  border: 1px solid var(--line);
-  background: #f0f0f3;
-}
-.empty-thumb {
-  display: grid;
-  place-items: center;
-  color: var(--muted);
-  font-size: 11px;
-}
-.inline-card-main { min-width: 0; }
-.inline-title {
-  font-size: 12.5px;
-  font-weight: 700;
-  line-height: 1.45;
-  color: var(--ink);
-  margin-bottom: 5px;
-}
-.inline-meta {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  gap: 5px;
-  color: var(--muted);
-  font-size: 11px;
-  margin-bottom: 5px;
-}
-.payload-chip {
-  color: var(--ok);
-  background: #e6f6ee;
-  border-radius: 9px;
-  padding: 1px 7px;
-  font-weight: 700;
-}
-.inline-reason {
-  color: #555;
-  font-size: 11.5px;
-  line-height: 1.5;
-  margin-bottom: 4px;
-}
-.inline-summary {
-  color: var(--muted);
-  font-size: 11.5px;
-  line-height: 1.55;
-}
-.inline-actions {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  margin-top: 7px;
-}
-
-/* 顶部主导航 */
-.nav { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
-.nav button {
-  border: 1px solid var(--line); background: var(--card); color: var(--ink);
-  padding: 7px 16px; border-radius: 9px; cursor: pointer; font-size: 13.5px;
-}
-.nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
-
-/* 筛选条 */
-.filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; }
-.filters select, .filters .seg {
-  border: 1px solid var(--line); background: var(--card); border-radius: 8px;
-  padding: 6px 10px; font-size: 13px; color: var(--ink);
-}
-.seg { display: inline-flex; padding: 0; overflow: hidden; }
-.seg button {
-  border: 0; background: transparent; padding: 6px 12px; cursor: pointer; font-size: 13px;
-  color: var(--muted); border-right: 1px solid var(--line);
-}
-.seg button:last-child { border-right: 0; }
-.seg button.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
-.count { color: var(--muted); font-size: 12.5px; margin-left: auto; }
-
-/* 表格(query 列表) */
-table { width: 100%; border-collapse: collapse; background: var(--card);
-  border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
-th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); font-size: 13px; vertical-align: top; }
-th { background: #fafafb; color: var(--muted); font-weight: 600; white-space: nowrap; }
-tr:last-child td { border-bottom: 0; }
-td.q { font-weight: 600; }
-.tag { display: inline-block; background: var(--accent-soft); color: var(--accent);
-  border-radius: 6px; padding: 1px 7px; font-size: 12px; margin: 1px 3px 1px 0; white-space: nowrap; }
-.tag.k { background: #f0f0f3; color: #555; }
-
-/* 多轴正交 query 的创作筛选层(留/排除) */
-.qdrop { opacity: 0.5; }
-.qdrop .q { text-decoration: line-through; }
-.fcell { white-space: nowrap; }
-.fbadge { font-size: 12px; font-weight: 700; padding: 1px 8px; border-radius: 10px; }
-.fbadge.keep { color: var(--ok); background: #e6f6ee; }
-.fbadge.drop { color: var(--bad); background: #fbeaea; }
-.freason { display: block; font-size: 11.5px; color: var(--muted); margin-top: 3px; max-width: 220px; white-space: normal; }
-/* 正交轴标签:横向拉开间距 */
-.axes { display: flex; flex-wrap: wrap; gap: 6px 18px; }
-.axes .tag { margin: 0; }
-
-/* 「查看人工定义的轴」幽灵按钮 */
-.btn.ghost { background: #fff; color: var(--accent); border: 1px solid var(--accent-soft); }
-.btn.ghost:hover { background: var(--accent-soft); text-decoration: none; }
-
-/* 人工定义轴弹窗 */
-.modal-mask { position: fixed; inset: 0; background: rgba(20, 20, 30, .38);
-  display: flex; align-items: center; justify-content: center; z-index: 50; padding: 24px; }
-.modal { background: #fff; border-radius: 14px; width: min(560px, 100%); max-height: 80vh;
-  overflow: auto; box-shadow: 0 18px 50px rgba(0, 0, 0, .25); }
-.modal-hd { display: flex; align-items: center; justify-content: space-between;
-  padding: 14px 18px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: #fff; }
-.modal-hd b { font-size: 14.5px; }
-.modal-hd .x { cursor: pointer; color: var(--muted); font-size: 15px; padding: 2px 6px; }
-.modal-bd { padding: 8px 18px 18px; }
-.modal-bd .note { color: var(--muted); font-size: 12px; margin: 8px 0 14px; }
-.axrow { padding: 10px 0; border-bottom: 1px solid var(--line); }
-.axrow:last-child { border-bottom: 0; }
-.axname { font-weight: 700; font-size: 13px; margin-bottom: 7px; }
-.axvals { display: flex; flex-wrap: wrap; gap: 6px; }
-.axsub { display: flex; flex-direction: column; gap: 7px; width: 100%; }
-.axsubrow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
-.axsubkey { font-size: 12px; color: var(--accent); font-weight: 600; min-width: 56px; }
-
-/* 判断提示词弹窗 */
-.modal.wide { width: min(840px, 100%); }
-.ptabs { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
-.ptabs button { border: 1px solid var(--line); background: var(--card); color: var(--ink);
-  padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 12.5px; }
-.ptabs button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
-.prompt { background: #0f1021; color: #e6e6ea; border-radius: 10px; padding: 14px 16px;
-  font: 12px/1.65 ui-monospace, Menlo, Consolas, monospace; white-space: pre-wrap;
-  word-break: break-word; max-height: 56vh; overflow: auto; margin: 0; }
-
-/* 搜索结果卡 */
-.cards { display: grid; grid-template-columns: 1fr; gap: 12px; }
-.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
-.card .head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
-.card .head .q { font-weight: 700; font-size: 14.5px; }
-.card .head .m { color: var(--muted); font-size: 12px; }
-.lanes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
-.lane { border: 1px solid var(--line); border-radius: 10px; padding: 10px; background: #fcfcfd; min-width: 0; }
-.lane .pl { font-size: 12px; font-weight: 700; margin-bottom: 8px; }
-.lane .pl.dy { color: #111; }
-.lane .pl.wx { color: #15a36a; }
-.lane video, .lane img.cover { width: 100%; max-height: 210px; object-fit: cover; border-radius: 8px; background: #000; display: block; }
-.lane .t { flex: 1 1 auto; font-size: 12.5px; margin: 0; line-height: 1.45; font-weight: 700; min-width: 0; }
-.card-main { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin: 8px 0 3px; }
-/* 创作知识 / 非创作知识 分类角标 */
-.cls { display: inline-block; font-size: 11px; font-weight: 700; border-radius: 6px;
-  padding: 2px 8px; margin: 0; white-space: nowrap; }
-.cls.yes { background: #e6f7ee; color: var(--ok); }
-.cls.no { background: #fbeaea; color: var(--bad); }
-.cls.none { background: #f0f0f3; color: var(--muted); font-weight: 600; }
-.reuse-tag {
-  align-self: flex-start;
-  border: 1px solid #d7cffb;
-  border-radius: 999px;
-  color: #6557d8;
-  font-size: 11.5px;
-  font-weight: 700;
-  padding: 3px 8px;
-  background: #f4f1ff;
-}
-.mode-row {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 6px;
-  margin: 5px 0 2px;
-}
-.mode-chip {
-  border-radius: 999px;
-  font-size: 11px;
-  font-weight: 700;
-  padding: 2px 7px;
-  color: #4b5563;
-  background: #eef0f4;
-}
-.mode-chip.image_post { color: #6557d8; background: #f1efff; }
-.mode-chip.video_post { color: #111827; background: #eceff3; }
-.mode-chip.article { color: var(--ok); background: #e6f6ee; }
-.mode-chip.unsupported { color: var(--bad); background: #fbeaea; }
-.rawtype {
-  color: var(--muted);
-  font-size: 11px;
-}
-/* 小红书/公众号完整图文:卡片内只放缩略条,点击进大图阅读 */
-.thumbstrip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: thin; }
-.thumbbtn, .coverbtn { border: 0; padding: 0; background: transparent; cursor: zoom-in; flex: 0 0 auto; position: relative; }
-.thumbbtn .gimg { width: 72px; height: 92px; object-fit: cover; object-position: top; border-radius: 7px; background: #f0f0f3; border: 1px solid var(--line); display: block; }
-.thumbbtn span { position: absolute; right: 4px; bottom: 4px; font-size: 10px; line-height: 1; color: #fff; background: rgba(0,0,0,.58); border-radius: 8px; padding: 2px 5px; }
-.coverbtn { width: 100%; display: block; }
-.lane .cover { cursor: zoom-in; }
-
-/* 长正文 / 知识点默认折叠,避免卡片一屏过高 */
-.fold { margin-top: 8px; border: 1px solid var(--line); border-radius: 8px; background: #fff; overflow: hidden; }
-.fold summary { cursor: pointer; list-style: none; padding: 6px 9px; font-size: 12px; font-weight: 700; color: var(--muted); display: flex; align-items: center; justify-content: space-between; }
-.fold summary::-webkit-details-marker { display: none; }
-.fold summary::after { content: '展开'; font-size: 11px; font-weight: 600; color: var(--muted); }
-.fold[open] summary::after { content: '收起'; }
-.knfold { border-color: var(--accent-soft); }
-.knfold summary { background: var(--accent-soft); color: var(--accent); }
-.knbody { padding: 9px 10px; font-size: 12px; line-height: 1.68; color: var(--ink); white-space: pre-wrap; max-height: 260px; overflow: auto; }
-
-/* 点击放大大图(小红书原帖被反爬封,直接看本地大图) */
-.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 60;
-  display: flex; align-items: center; justify-content: center; padding: 22px 72px; cursor: zoom-out; }
-.lbfig { margin: 0; max-width: 100%; max-height: 100%; display: flex; flex-direction: column; align-items: center; gap: 8px; }
-.lightbox img { max-width: calc(100vw - 150px); max-height: calc(100vh - 76px); object-fit: contain; border-radius: 6px; cursor: default; background: #fff; }
-.lbfig figcaption { color: #f4f4f7; font-size: 12px; max-width: min(860px, 90vw); text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.lightbox .lbx { position: fixed; top: 14px; right: 20px; color: #fff; font-size: 28px; cursor: pointer; border: 0; background: transparent; padding: 4px 8px; }
-.lbnav { position: fixed; top: 50%; transform: translateY(-50%); width: 42px; height: 54px; border: 0; border-radius: 10px; background: rgba(255,255,255,.14); color: #fff; font-size: 34px; cursor: pointer; }
-.lbnav:hover { background: rgba(255,255,255,.24); }
-.lbnav.prev { left: 18px; }
-.lbnav.next { right: 18px; }
-.lane .bt { font-size: 11.8px; color: #444; line-height: 1.58; padding: 0 10px 9px; margin: 0; max-height: 190px;
-  overflow: auto; white-space: pre-wrap; }
-.lane .meta { color: var(--muted); font-size: 11.5px; }
-.lane .fail { color: var(--bad); font-size: 12.5px; padding: 18px 4px; text-align: center; }
-.judge {
-  margin-top: 7px;
-  color: #555;
-  font-size: 11.8px;
-  line-height: 1.5;
-}
-.judge.yes { color: #0f7a50; }
-.judge.no { color: var(--bad); }
-.judge.none { color: var(--muted); }
-.detail-button {
-  border: 1px solid #d8eadf;
-  background: #e6f6ee;
-  color: var(--ok);
-  border-radius: 8px;
-  padding: 4px 10px;
-  cursor: pointer;
-  font: inherit;
-  font-weight: 700;
-}
-.detail-button span {
-  margin-left: 6px;
-  opacity: .78;
-  font-weight: 600;
-}
-.detail-button:hover {
-  background: #d9f1e6;
-}
-
-.decode-modal-mask {
-  position: fixed;
-  inset: 0;
-  z-index: 80;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 18px;
-  background: rgba(15, 16, 28, .52);
-}
-.decode-modal-panel {
-  width: min(1180px, calc(100vw - 36px));
-  height: min(88vh, 920px);
-  border-radius: 12px;
-  overflow: hidden;
-  box-shadow: 0 22px 70px rgba(0, 0, 0, .34);
-  background: #f5f5f7;
-}
-
-/* 分页 */
-.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 18px; }
-.pager button { border: 1px solid var(--line); background: var(--card); border-radius: 8px;
-  padding: 6px 12px; cursor: pointer; font-size: 13px; }
-.pager button:disabled { opacity: .4; cursor: default; }
-.pager .at { color: var(--muted); font-size: 12.5px; padding: 0 6px; }
-
-.empty { text-align: center; color: var(--muted); padding: 48px 0; font-size: 13.5px; }
-
-/* 每条 query 的「真实搜索结果」按钮 */
-.btn { display: inline-block; background: var(--accent); color: #fff; border-radius: 8px;
-  padding: 5px 11px; font-size: 12.5px; white-space: nowrap; }
-.btn:hover { text-decoration: none; opacity: .9; }
-.btn.off { background: #f1f1f4; color: var(--muted); cursor: default; }
-
-/* query 详情页(点进该 query 的多个视频) */
-.detail-top { margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
-.back { font-size: 13px; }
-.dq { font-size: 18px; margin: 10px 0 2px; }
-.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
-.result-band + .result-band { margin-top: 16px; }
-.result-row { align-items: start; }
-.row-empty { color: var(--muted); font-size: 12.5px; padding: 8px 0 12px; border-top: 1px dashed var(--line); }
-
-/* 渠道分区:抖音一组、微信公众号一组,标题色区分 */
-.pgroup { margin-top: 22px; }
-.ghead { display: flex; align-items: center; gap: 8px; font-size: 14.5px; font-weight: 700;
-  padding-bottom: 8px; margin-bottom: 14px; border-bottom: 2px solid var(--line); }
-.ghead.dy { color: #111; border-bottom-color: #111; }
-.ghead.wx { color: var(--ok); border-bottom-color: var(--ok); }
-.ghead.xhs { color: #ff2442; border-bottom-color: #ff2442; }
-.ghead .gn { font-size: 11.5px; font-weight: 600; color: var(--muted);
-  background: #f0f0f3; border-radius: 10px; padding: 1px 8px; }
-.gnone { color: var(--muted); font-size: 12.5px; padding: 2px 0 6px; }
-.perr { color: var(--bad); font-size: 12px; margin: -6px 0 10px; }
-.status { font-size: 11.5px; font-weight: 700; border-radius: 10px; padding: 1px 8px; background: #f0f0f3; color: var(--muted); }
-.status.done { background: #e6f6ee; color: var(--ok); }
-.status.partial, .status.running { background: #fff6df; color: #9a6a00; }
-.status.failed { background: #fbeaea; color: var(--bad); }
-.src { display: inline-block; margin-top: 8px; font-size: 12px; }
-.reuse-src { color: #6557d8; font-weight: 700; }
-.detail-link { margin-left: 8px; font-weight: 700; }
-/* 平台区内:创作知识 / 非创作知识 小标题(各自起一行) */
-.subhd { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700;
-  color: var(--ink); margin: 14px 0 8px; }
-.subhd::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
-.subhd.dim { color: var(--muted); }
-.subhd.dim::before { background: var(--bad); }
-.subn { font-size: 11px; font-weight: 700; color: var(--muted); background: #f0f0f3; border-radius: 10px; padding: 1px 7px; }
-/* 卡片左侧细色条,进一步区分渠道归属 */
-.lane.dy { border-left: 3px solid #111; }
-.lane.wx { border-left: 3px solid var(--ok); }
-.lane.xhs { border-left: 3px solid #ff2442; }
-
-/* decode 真实单例详情 */
-.source-panel {
-  display: grid;
-  grid-template-columns: minmax(0, 1.15fr) minmax(280px, .85fr);
-  gap: 18px;
-  align-items: start;
-  margin: 12px 0 18px;
-}
-.source-summary {
-  color: #3f3f46;
-  margin: 12px 0;
-  white-space: pre-wrap;
-  max-width: 72ch;
-}
-.source-media {
-  background: #fff;
-  border: 1px solid var(--line);
-  border-radius: 10px;
-  padding: 10px;
-  min-height: 180px;
-}
-.source-media video {
-  width: 100%;
-  max-height: 360px;
-  border-radius: 8px;
-  background: #000;
-}
-.decode-thumbs {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
-  gap: 8px;
-}
-.decode-thumbs img {
-  width: 100%;
-  aspect-ratio: 3 / 4;
-  object-fit: cover;
-  object-position: top;
-  border-radius: 8px;
-  border: 1px solid var(--line);
-  background: #f0f0f3;
-}
-.media-empty {
-  display: grid;
-  min-height: 160px;
-  place-items: center;
-  color: var(--muted);
-  background: #fafafb;
-  border-radius: 8px;
-}
-.decode-strip {
-  display: grid;
-  grid-template-columns: repeat(4, minmax(120px, 1fr));
-  gap: 10px;
-  margin-bottom: 20px;
-}
-.decode-strip > div {
-  background: #fff;
-  border: 1px solid var(--line);
-  border-radius: 10px;
-  padding: 10px 12px;
-}
-.decode-strip span {
-  display: block;
-  color: var(--muted);
-  font-size: 11.5px;
-  margin-bottom: 3px;
-}
-.decode-strip b { font-size: 18px; }
-.decode-section {
-  margin-top: 22px;
-}
-.decode-section h2 {
-  font-size: 16px;
-  margin: 0 0 10px;
-}
-.read-panel {
-  background: #fff;
-  border: 1px solid var(--line);
-  border-radius: 10px;
-  padding: 14px 16px;
-  color: #292932;
-  white-space: pre-wrap;
-  line-height: 1.72;
-  max-height: 360px;
-  overflow: auto;
-}
-.gate-panel {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-  align-items: center;
-  margin-top: 10px;
-  color: var(--muted);
-}
-.pill {
-  display: inline-flex;
-  align-items: center;
-  border-radius: 999px;
-  padding: 2px 9px;
-  font-size: 11.5px;
-  font-weight: 700;
-  background: #f0f0f3;
-  color: #555;
-}
-.pill.decoded,
-.pill.ingested {
-  background: #e6f6ee;
-  color: var(--ok);
-}
-.pill.rejected,
-.pill.failed {
-  background: #fbeaea;
-  color: var(--bad);
-}
-.pill.draft,
-.pill.pending {
-  background: #fff6df;
-  color: #9a6a00;
-}
-.knowledge-grid,
-.payload-grid {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
-  gap: 12px;
-}
-.knowledge-card,
-.payload-card {
-  background: #fff;
-  border: 1px solid var(--line);
-  border-radius: 10px;
-  padding: 13px 14px;
-  min-width: 0;
-}
-.knowledge-card.what { border-color: #cfe8ff; }
-.knowledge-card.how { border-color: #d8eadf; }
-.knowledge-card.why { border-color: #eadfcf; }
-.knowledge-head,
-.payload-head {
-  display: flex;
-  gap: 8px;
-  justify-content: space-between;
-  align-items: start;
-  margin-bottom: 8px;
-}
-.knowledge-head span {
-  flex: 0 0 auto;
-  color: var(--accent);
-  background: var(--accent-soft);
-  border-radius: 7px;
-  padding: 2px 7px;
-  font-size: 11.5px;
-  font-weight: 700;
-}
-.knowledge-head b,
-.payload-head b {
-  font-size: 14px;
-  line-height: 1.45;
-}
-.knowledge-meta,
-.payload-meta {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  color: var(--muted);
-  font-size: 11.5px;
-  margin-bottom: 9px;
-}
-.knowledge-purpose {
-  margin: 8px 0 10px;
-  color: #33343c;
-  white-space: pre-wrap;
-}
-.step-list,
-.what-body {
-  display: grid;
-  gap: 8px;
-  margin-top: 8px;
-}
-.step-row {
-  display: grid;
-  grid-template-columns: 24px minmax(0, 1fr);
-  gap: 9px;
-  padding: 9px 10px;
-  background: #fafafb;
-  border: 1px solid #eeeef1;
-  border-radius: 8px;
-}
-.step-no {
-  width: 22px;
-  height: 22px;
-  border-radius: 50%;
-  display: inline-grid;
-  place-items: center;
-  color: #fff;
-  background: var(--accent);
-  font-size: 12px;
-  font-weight: 700;
-}
-.step-row b {
-  display: inline-block;
-  min-width: 34px;
-  color: #555;
-  margin-right: 8px;
-}
-.what-row {
-  display: grid;
-  gap: 4px;
-  padding: 9px 10px;
-  background: #fafafb;
-  border: 1px solid #eeeef1;
-  border-radius: 8px;
-}
-.scope-row {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  margin-top: 10px;
-}
-.scope-chip {
-  border-radius: 999px;
-  border: 1px solid var(--line);
-  background: #fafafb;
-  color: #555;
-  padding: 2px 8px;
-  font-size: 11.5px;
-}
-.jsonbox,
-.payload-content {
-  margin: 0;
-  padding: 10px;
-  border-radius: 8px;
-  background: #14151f;
-  color: #f1f1f5;
-  white-space: pre-wrap;
-  word-break: break-word;
-  font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
-  max-height: 280px;
-  overflow: auto;
-}
-.payload-content {
-  margin-top: 8px;
-  background: #fbfbfd;
-  color: #2f3038;
-  border: 1px solid var(--line);
-}
-
-.decode-shell { min-height: 100vh; }
-
-/* 老版 Decode 知识详情页视觉系统:只作用于 decode-item 路由 */
-.legacy-decode-page {
-  height: 100vh;
-  min-height: 100vh;
-  overflow: hidden;
-  background: #f5f5f7;
-  color: #111827;
-  font: 13px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
-}
-.legacy-decode-page.embedded-decode-page {
-  height: 100%;
-  min-height: 0;
-  border-radius: 12px;
-}
-.legacy-decode-page.embedded-decode-page .app {
-  height: 100%;
-}
-.legacy-decode-page * { box-sizing: border-box; }
-.legacy-decode-page a { color: #2563eb; text-decoration: none; }
-.legacy-decode-page a:hover { text-decoration: underline; }
-.legacy-decode-page .app {
-  height: 100vh;
-  display: flex;
-  flex-direction: column;
-}
-.legacy-decode-page header.top {
-  flex: none;
-  padding: 11px 18px 9px;
-  background: #fff;
-  border-bottom: 1px solid #e5e7eb;
-}
-.legacy-decode-page header.top h1 {
-  margin: 0 0 7px;
-  font-size: 16px;
-  line-height: 1.35;
-  font-weight: 600;
-  letter-spacing: 0;
-}
-.legacy-decode-page .picker {
-  display: flex;
-  gap: 6px;
-  flex-wrap: wrap;
-  align-items: center;
-}
-.legacy-decode-page .picker .lab {
-  font-size: 11.5px;
-  color: #9ca3af;
-  margin-right: 2px;
-}
-.legacy-decode-page .pill {
-  display: inline-flex;
-  align-items: center;
-  font-size: 12px;
-  font-weight: 400;
-  line-height: 1.4;
-  padding: 4px 11px;
-  border-radius: 14px;
-  border: 1px solid #e5e7eb;
-  background: #fff;
-  color: #6b7280;
-  cursor: pointer;
-  font-family: inherit;
-}
-.legacy-decode-page .pill:hover {
-  background: #f3f4f6;
-  text-decoration: none;
-}
-.legacy-decode-page .pill.on {
-  background: #1e293b;
-  color: #fff;
-  border-color: #1e293b;
-  font-weight: 600;
-}
-.legacy-decode-page .pill .n {
-  font-size: 10px;
-  opacity: .7;
-  margin-left: 4px;
-}
-.legacy-decode-page .scroll {
-  flex: 1;
-  overflow: auto;
-  background: #f5f5f7;
-  padding-bottom: 40px;
-}
-.legacy-decode-page .empty {
-  color: #9ca3af;
-  padding: 50px;
-  text-align: center;
-}
-.legacy-decode-page details.src {
-  display: block;
-  margin: 10px 18px 0;
-  background: #fff;
-  border: 1px solid #e2e8f0;
-  border-radius: 6px;
-}
-.legacy-decode-page details.src summary {
-  padding: 8px 14px;
-  cursor: pointer;
-  font-size: 12px;
-  color: #475569;
-  list-style: none;
-}
-.legacy-decode-page details.src summary::-webkit-details-marker { display: none; }
-.legacy-decode-page details.src summary::before {
-  content: '▶ ';
-  color: #94a3b8;
-  font-size: 10px;
-}
-.legacy-decode-page details.src[open] summary::before { content: '▼ '; }
-.legacy-decode-page .srcbody {
-  padding: 4px 16px 12px;
-  font-size: 12px;
-}
-.legacy-decode-page .srcbody a {
-  color: #2563eb;
-  word-break: break-all;
-}
-.legacy-decode-page .srcimgs {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  margin-top: 8px;
-}
-.legacy-decode-page .srcimgs img {
-  width: 80px;
-  height: 80px;
-  object-fit: cover;
-  border-radius: 4px;
-  border: 1px solid #e2e8f0;
-  cursor: zoom-in;
-}
-.legacy-decode-page .srcvideo {
-  margin-top: 8px;
-}
-.legacy-decode-page .srcvideo video {
-  width: min(720px, 100%);
-  max-height: 360px;
-  background: #000;
-  border-radius: 5px;
-}
-.legacy-decode-page .srcread {
-  font-size: 12.5px;
-  color: #1f2937;
-  line-height: 1.6;
-  margin-top: 6px;
-  background: #fff;
-  border-left: 3px solid #0e7490;
-  padding: 6px 9px;
-}
-.legacy-decode-page .barhint {
-  margin: 8px 18px 0;
-  font-size: 11.5px;
-  color: #6b7280;
-}
-.legacy-decode-page .ribbon {
-  margin: 11px 18px 0;
-  background: #fff;
-  border: 1px solid #e2e8f0;
-  border-radius: 8px;
-  padding: 10px 14px;
-}
-.legacy-decode-page .rcap {
-  font-size: 11.5px;
-  color: #6b7280;
-  margin-bottom: 8px;
-}
-.legacy-decode-page .rsteps {
-  display: flex;
-  align-items: stretch;
-  flex-wrap: wrap;
-}
-.legacy-decode-page .rstep {
-  flex: 1;
-  min-width: 118px;
-  border: 1px solid #e2e8f0;
-  border-radius: 8px;
-  padding: 8px 9px;
-  cursor: pointer;
-  background: #f8fafc;
-}
-.legacy-decode-page .rstep:hover {
-  background: #ecfeff;
-  border-color: #99d5d0;
-}
-.legacy-decode-page .rstep.auto {
-  cursor: default;
-  background: #f1f5f9;
-  opacity: .9;
-}
-.legacy-decode-page .rstep.auto:hover {
-  background: #f1f5f9;
-  border-color: #e2e8f0;
-}
-.legacy-decode-page .rtop {
-  display: flex;
-  align-items: center;
-  gap: 7px;
-}
-.legacy-decode-page .rn {
-  width: 18px;
-  height: 18px;
-  border-radius: 50%;
-  background: #0e7490;
-  color: #fff;
-  font-size: 11px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex: none;
-}
-.legacy-decode-page .rstep.auto .rn { background: #94a3b8; }
-.legacy-decode-page .rlab {
-  font-size: 13.5px;
-  font-weight: 600;
-}
-.legacy-decode-page .rdesc {
-  font-size: 11.5px;
-  color: #6b7280;
-  margin: 5px 0 7px;
-  line-height: 1.5;
-}
-.legacy-decode-page .rbtn {
-  font-size: 11.5px;
-  color: #0e7490;
-}
-.legacy-decode-page .rstep.auto .rbtn { color: #94a3b8; }
-.legacy-decode-page .rarrow {
-  display: flex;
-  align-items: center;
-  color: #cbd5e1;
-  font-size: 18px;
-  padding: 0 4px;
-  flex: none;
-}
-.legacy-decode-page .lbtn {
-  padding: 3px 9px;
-  border: 1px solid #cbd5e1;
-  border-radius: 5px;
-  background: #fff;
-  cursor: pointer;
-  font-size: 11.5px;
-  color: #334155;
-  font-family: inherit;
-}
-.legacy-decode-page .lbtn:hover {
-  background: #f1f5f9;
-  text-decoration: none;
-}
-.legacy-decode-page .kcard {
-  margin: 12px 18px 0;
-  background: #fff;
-  border: 1px solid #e2e8f0;
-  border-radius: 8px;
-  overflow: hidden;
-}
-.legacy-decode-page .khead {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 9px 14px;
-  border-bottom: 1px solid #eef2f7;
-  flex-wrap: wrap;
-}
-.legacy-decode-page .kno {
-  font-size: 11px;
-  font-weight: 700;
-  color: #fff;
-  background: #475569;
-  border-radius: 4px;
-  padding: 1px 7px;
-}
-.legacy-decode-page .ktype {
-  font-size: 11px;
-  font-weight: 700;
-  border-radius: 4px;
-  padding: 1px 8px;
-}
-.legacy-decode-page .ty-how { background: #cffafe; color: #155e75; }
-.legacy-decode-page .ty-what { background: #fae8ff; color: #86198f; }
-.legacy-decode-page .ty-why { background: #fef9c3; color: #854d0e; }
-.legacy-decode-page .ktitle {
-  font-size: 14px;
-  font-weight: 600;
-}
-.legacy-decode-page .krole {
-  font-size: 10.5px;
-  padding: 1px 6px;
-  border-radius: 3px;
-  background: #f1f5f9;
-  color: #64748b;
-}
-.legacy-decode-page .kbiz {
-  font-size: 11px;
-  padding: 1px 7px;
-  border-radius: 3px;
-  background: #fef3c7;
-  color: #92400e;
-}
-.legacy-decode-page .kacts {
-  margin-left: auto;
-  display: flex;
-  gap: 6px;
-}
-.legacy-decode-page .kbody {
-  padding: 6px 14px 13px;
-}
-.legacy-decode-page .twrap {
-  overflow: auto;
-  border: 1px solid #e5e7eb;
-  border-radius: 6px;
-  margin-top: 8px;
-}
-.legacy-decode-page table.proc {
-  width: max-content;
-  min-width: 100%;
-  border-collapse: collapse;
-  font-size: 12px;
-  background: #fff;
-  border: 0;
-  border-radius: 0;
-}
-.legacy-decode-page table.proc th,
-.legacy-decode-page table.proc td {
-  border: 1px solid #e5e7eb;
-  padding: 6px 8px;
-  vertical-align: top;
-  line-height: 1.5;
-}
-.legacy-decode-page table.proc thead th {
-  font-weight: 600;
-  text-align: center;
-  font-size: 12px;
-  position: sticky;
-  top: 0;
-  z-index: 3;
-}
-.legacy-decode-page table.proc thead tr:nth-child(2) th { top: 30px; }
-.legacy-decode-page td.idx {
-  width: 30px;
-  text-align: center;
-  color: #6b7280;
-  background: #f8fafc;
-}
-.legacy-decode-page th.c-intent,
-.legacy-decode-page td.intent {
-  width: 210px;
-  min-width: 210px;
-  max-width: 210px;
-  white-space: pre-wrap;
-}
-.legacy-decode-page th.c-cstage,
-.legacy-decode-page td.cstage {
-  width: 54px;
-  text-align: center;
-  background: #ecfeff;
-}
-.legacy-decode-page th.c-act,
-.legacy-decode-page td.act {
-  min-width: 110px;
-  max-width: 140px;
-  word-break: break-word;
-  background: #ecfeff;
-}
-.legacy-decode-page th.c-dir,
-.legacy-decode-page td.dir {
-  min-width: 230px;
-  max-width: 330px;
-  white-space: pre-wrap;
-  word-break: break-word;
-  background: #ecfeff;
-}
-.legacy-decode-page th.c-out,
-.legacy-decode-page td.out {
-  min-width: 160px;
-  max-width: 240px;
-  white-space: pre-wrap;
-  background: #ecfeff;
-}
-.legacy-decode-page th.c-scope,
-.legacy-decode-page td.scope {
-  width: 92px;
-  min-width: 92px;
-  max-width: 92px;
-  word-break: break-word;
-}
-.legacy-decode-page th.g-frame { background: #0e7490; color: #fff; }
-.legacy-decode-page th.g-scope { background: #7c3aed; color: #fff; }
-.legacy-decode-page th.c-idx {
-  background: #475569;
-  color: #fff;
-  vertical-align: middle;
-}
-.legacy-decode-page thead tr:nth-child(2) th.c-intent,
-.legacy-decode-page thead tr:nth-child(2) th.c-cstage,
-.legacy-decode-page thead tr:nth-child(2) th.c-act,
-.legacy-decode-page thead tr:nth-child(2) th.c-dir,
-.legacy-decode-page thead tr:nth-child(2) th.c-out {
-  background: #0891b2;
-  color: #fff;
-}
-.legacy-decode-page thead tr:nth-child(2) th.s-substance { background: #dc2626; color: #fff; }
-.legacy-decode-page thead tr:nth-child(2) th.s-form { background: #2563eb; color: #fff; }
-.legacy-decode-page thead tr:nth-child(2) th.s-feeling { background: #db2777; color: #fff; }
-.legacy-decode-page thead tr:nth-child(2) th.s-effect { background: #16a34a; color: #fff; }
-.legacy-decode-page thead tr:nth-child(2) th.s-intent { background: #d97706; color: #fff; }
-.legacy-decode-page td.scope.s-substance { background: #fef2f2; }
-.legacy-decode-page td.scope.s-form { background: #eff6ff; }
-.legacy-decode-page td.scope.s-feeling { background: #fdf2f8; }
-.legacy-decode-page td.scope.s-effect { background: #f0fdf4; }
-.legacy-decode-page td.scope.s-intent { background: #fffbeb; }
-.legacy-decode-page tr.step > td { border-top: 1.5px solid #cbd5e1; }
-.legacy-decode-page .cstage-chip {
-  display: inline-block;
-  font-size: 11px;
-  padding: 1px 6px;
-  border-radius: 10px;
-  background: #ede9fe;
-  color: #5b21b6;
-}
-.legacy-decode-page .act-chip {
-  display: inline-block;
-  font-size: 11.5px;
-  padding: 1px 7px;
-  border-radius: 4px;
-  background: #fff7ed;
-  color: #9a3412;
-  border: 1px solid #fed7aa;
-}
-.legacy-decode-page .dir-txt {
-  font-size: 11.5px;
-  color: #33312c;
-  line-height: 1.65;
-}
-.legacy-decode-page .out-txt {
-  font-size: 11.5px;
-  color: #166534;
-}
-.legacy-decode-page .in-txt {
-  font-size: 11.5px;
-  color: #1e40af;
-  line-height: 1.6;
-}
-.legacy-decode-page .kindb {
-  font-size: 10.5px;
-  font-weight: 700;
-  padding: 1px 8px;
-  border-radius: 9px;
-  margin-right: 8px;
-  vertical-align: middle;
-}
-.legacy-decode-page .kind-子集 { background: #dbeafe; color: #1e40af; }
-.legacy-decode-page .kind-多维关系 { background: #dcfce7; color: #166534; }
-.legacy-decode-page .kind-序列 { background: #fef3c7; color: #92400e; }
-.legacy-decode-page .dimrule {
-  font-size: 10.5px;
-  padding: 1px 8px;
-  border-radius: 9px;
-  background: #ecfccb;
-  color: #3f6212;
-  margin-right: 8px;
-  vertical-align: middle;
-}
-.legacy-decode-page .def {
-  font-size: 13px;
-  background: #fdf4ff;
-  border-left: 3px solid #a21caf;
-  border-radius: 0 6px 6px 0;
-  padding: 8px 12px;
-  margin: 6px 0 10px;
-}
-.legacy-decode-page table.elem {
-  border-collapse: collapse;
-  width: 100%;
-  font-size: 12.5px;
-  background: #fff;
-  border: 0;
-}
-.legacy-decode-page table.elem td {
-  border: 1px solid #f0e6f5;
-  padding: 6px 10px;
-  text-align: left;
-  vertical-align: top;
-}
-.legacy-decode-page table.elem td.el {
-  font-weight: 600;
-  color: #86198f;
-  background: #fdf4ff;
-  width: 130px;
-}
-.legacy-decode-page .why-exp {
-  font-size: 12.5px;
-  line-height: 1.72;
-  color: #1f2937;
-  background: #fdf4ff;
-  border-left: 3px solid #a21caf;
-  border-radius: 0 6px 6px 0;
-  padding: 9px 13px;
-  margin: 6px 0;
-}
-.legacy-decode-page .kscopes {
-  margin-top: 9px;
-  display: flex;
-  gap: 5px;
-  flex-wrap: wrap;
-  align-items: center;
-}
-.legacy-decode-page .kscopes .lab {
-  font-size: 11px;
-  color: #9ca3af;
-}
-.legacy-decode-page .sv {
-  display: inline-block;
-  cursor: pointer;
-  font-size: 11.5px;
-  padding: 1px 5px;
-  border-radius: 3px;
-  border: 1px solid rgba(0,0,0,.08);
-  background: rgba(255,255,255,.7);
-  margin: 1px 0;
-}
-.legacy-decode-page .sv:hover { box-shadow: 0 0 0 1px #1e293b; }
-.legacy-decode-page .sv .k {
-  font-size: 11.5px;
-  opacity: .85;
-  font-weight: 700;
-  margin-right: 4px;
-}
-.legacy-decode-page table.proc th.c-scope { font-size: 13.5px; }
-.legacy-decode-page .sv .mk {
-  font-size: 9px;
-  padding: 0 3px;
-  border-radius: 2px;
-  margin-left: 3px;
-  vertical-align: top;
-}
-.legacy-decode-page .mk-new { background: #fde68a; color: #92400e; }
-.legacy-decode-page .mk-reuse { background: #bbf7d0; color: #166534; }
-.legacy-decode-page .sv.s-substance { background: #fef2f2; }
-.legacy-decode-page .sv.s-form { background: #eff6ff; }
-.legacy-decode-page .sv.s-feeling { background: #fdf2f8; }
-.legacy-decode-page .sv.s-effect { background: #f0fdf4; }
-.legacy-decode-page .sv.s-intent { background: #fffbeb; }
-.legacy-decode-page .modal {
-  position: fixed;
-  inset: 0;
-  background: rgba(0,0,0,.45);
-  z-index: 110;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 20px;
-  width: auto;
-  max-height: none;
-  overflow: visible;
-  border-radius: 0;
-  box-shadow: none;
-}
-.legacy-decode-page .mbox {
-  background: #fff;
-  width: 760px;
-  max-width: 94vw;
-  max-height: 86vh;
-  border-radius: 10px;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
-}
-.legacy-decode-page .mbox h2 {
-  margin: 0;
-  padding: 13px 18px;
-  background: #0f766e;
-  color: #fff;
-  font-size: 14px;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  letter-spacing: 0;
-}
-.legacy-decode-page .mbox h2 .x {
-  margin-left: auto;
-  cursor: pointer;
-  font-size: 18px;
-}
-.legacy-decode-page .mbox .body {
-  padding: 15px 18px;
-  overflow: auto;
-}
-.legacy-decode-page .mbox pre {
-  margin: 0;
-  font: 12.5px/1.7 ui-monospace, Menlo, monospace;
-  white-space: pre-wrap;
-  color: #1f2937;
-}
-.legacy-decode-page .copybtn {
-  margin-left: auto;
-  background: rgba(255,255,255,.12);
-  color: #fff;
-  border-color: rgba(255,255,255,.28);
-}
-.legacy-decode-page .copybtn:hover { background: rgba(255,255,255,.2); }
-.legacy-decode-page .lb {
-  position: fixed;
-  inset: 0;
-  background: rgba(0,0,0,.85);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 120;
-  cursor: zoom-out;
-}
-.legacy-decode-page .lb img {
-  max-width: 92vw;
-  max-height: 92vh;
-  border-radius: 6px;
-}
-
-@media (max-width: 720px) {
-  .lanes { grid-template-columns: 1fr; }
-  .source-panel { grid-template-columns: 1fr; }
-  .decode-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-  .knowledge-grid, .payload-grid { grid-template-columns: 1fr; }
-  .lightbox { padding: 18px 12px 48px; }
-  .lightbox img { max-width: 96vw; max-height: 82vh; }
-  .lbnav { bottom: 10px; top: auto; transform: none; }
-  .lbnav.prev { left: 26px; }
-  .lbnav.next { right: 26px; }
-  .legacy-decode-page .rsteps { display: block; }
-  .legacy-decode-page .rstep { margin-top: 7px; }
-  .legacy-decode-page .rarrow { display: none; }
-  .legacy-decode-page .khead { align-items: flex-start; }
-  .legacy-decode-page .kacts {
-    margin-left: 0;
-    width: 100%;
-  }
-}
+@import './styles/legacy.css';
+@import './styles/tokens.css';
+@import './styles/layout.css';
+@import './styles/query-board.css';
+@import './styles/decode.css';
+@import './styles/item-detail.css';

+ 3 - 0
app/frontend/src/styles/decode.css

@@ -0,0 +1,3 @@
+.embedded-decode-page {
+  height: 100%;
+}

+ 281 - 0
app/frontend/src/styles/item-detail.css

@@ -0,0 +1,281 @@
+.result-mini-click {
+  display: grid;
+  grid-column: 1 / -1;
+  grid-template-columns: 54px minmax(0, 1fr);
+  gap: 10px;
+  border: 0;
+  padding: 0;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+  font: inherit;
+  background: transparent;
+}
+
+.result-mini-click:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 3px;
+  border-radius: 6px;
+}
+
+.result-mini:hover {
+  background: #f7fbff;
+}
+
+.result-mini-actions {
+  grid-column: 2 / -1;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 8px;
+}
+
+.result-mini-actions .mini-detail-btn {
+  margin-top: 0;
+  cursor: pointer;
+}
+
+.mini-detail-btn.detail {
+  border-color: #d8dee9;
+  color: #1d4ed8;
+  background: #eef4ff;
+}
+
+.mini-detail-btn.knowledge {
+  border-color: #b7efd4;
+  color: #00875a;
+  background: #e8fff3;
+}
+
+.item-detail-page {
+  min-height: 100vh;
+  padding: 16px;
+  color: var(--ink);
+  background: var(--bg);
+}
+
+.item-detail-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.item-detail-state {
+  color: var(--ok);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.item-detail-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 35fr) minmax(0, 65fr);
+  gap: 14px;
+  align-items: start;
+}
+
+.item-post-pane,
+.item-knowledge-pane {
+  min-width: 0;
+  max-height: calc(100vh - 74px);
+  overflow: auto;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--card);
+}
+
+.pane-head {
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 14px;
+  border-bottom: 1px solid var(--line);
+  background: rgba(255, 255, 255, 0.96);
+}
+
+.pane-head span {
+  font-size: 15px;
+  font-weight: 800;
+}
+
+.pane-head b {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.item-post-pane .src {
+  margin: 12px;
+}
+
+.item-post-pane .src summary {
+  overflow-wrap: anywhere;
+  line-height: 1.5;
+}
+
+.item-post-pane .srcbody {
+  min-width: 0;
+}
+
+.item-post-pane .srcbody a {
+  display: block;
+  overflow-wrap: anywhere;
+  word-break: break-word;
+}
+
+.item-post-pane .srcimgs {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
+  gap: 8px;
+  margin-top: 10px;
+}
+
+.item-post-pane .srcimgs img {
+  width: 100%;
+  max-height: min(42vh, 360px);
+  object-fit: contain;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  background: #f7f8fa;
+  cursor: zoom-in;
+}
+
+.item-post-pane .srcvideo {
+  margin-top: 10px;
+}
+
+.item-post-pane .srcvideo video {
+  display: block;
+  width: 100%;
+  max-height: min(52vh, 520px);
+  object-fit: contain;
+  border-radius: 6px;
+  background: #000;
+}
+
+.item-post-pane .src-original-text {
+  margin-top: 12px;
+  white-space: pre-wrap;
+  color: #1f2937;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.item-body-copy {
+  margin: 12px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #fbfcfe;
+}
+
+.item-body-copy summary {
+  cursor: pointer;
+  padding: 10px 12px;
+  color: #4e5969;
+  font-weight: 700;
+}
+
+.item-body-copy div {
+  white-space: pre-wrap;
+  padding: 0 12px 12px;
+  color: #1f2937;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.item-read-result {
+  background: #f8fafc;
+}
+
+.item-read-result summary {
+  color: #0f766e;
+}
+
+.item-missing-original {
+  padding: 12px;
+  color: #86909c;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.item-knowledge-pane {
+  padding-bottom: 12px;
+}
+
+.item-knowledge-pane .kcard {
+  margin: 12px;
+}
+
+.item-knowledge-pane.legacy-decode-page {
+  height: auto;
+  min-height: 0;
+  overflow: auto;
+  background: var(--card);
+  color: #111827;
+}
+
+.item-knowledge-pane.legacy-decode-page .pane-head {
+  font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
+}
+
+.item-knowledge-pane.legacy-decode-page .kcard {
+  width: auto;
+  margin: 12px;
+}
+
+.item-knowledge-pane.legacy-decode-page .twrap {
+  margin: 0;
+}
+
+.item-payload-modal-scope {
+  height: 0;
+  min-height: 0;
+  overflow: visible;
+}
+
+.item-detail-page .item-lightbox {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  display: grid;
+  align-items: center;
+  justify-content: center;
+  place-items: center;
+  padding: 24px;
+  background: rgba(0, 0, 0, 0.85);
+  cursor: zoom-out;
+}
+
+.item-detail-page .item-lightbox img {
+  display: block;
+  max-width: 94vw;
+  max-height: 94vh;
+  width: auto;
+  height: auto;
+  object-fit: contain;
+  border-radius: 6px;
+  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
+}
+
+.no-knowledge {
+  display: grid;
+  min-height: 260px;
+  place-items: center;
+  color: var(--muted);
+  font-size: 16px;
+  font-weight: 800;
+}
+
+@media (max-width: 1020px) {
+  .item-detail-layout {
+    grid-template-columns: 1fr;
+  }
+
+  .item-post-pane,
+  .item-knowledge-pane {
+    max-height: none;
+  }
+}

+ 14 - 0
app/frontend/src/styles/layout.css

@@ -0,0 +1,14 @@
+.admin-shell {
+  min-height: 100vh;
+  background: var(--bg);
+}
+
+.admin-main {
+  min-width: 0;
+  padding: 10px 16px 24px;
+  background: var(--bg);
+}
+
+.admin-main-full {
+  min-height: 100vh;
+}

+ 1453 - 0
app/frontend/src/styles/legacy.css

@@ -0,0 +1,1453 @@
+:root {
+  --bg: #f7f7f8;
+  --card: #fff;
+  --ink: #1a1a1f;
+  --muted: #8a8a94;
+  --line: #e6e6ea;
+  --accent: #5b5bd6;
+  --accent-soft: #ececfb;
+  --ok: #15a36a;
+  --bad: #d04646;
+}
+* { box-sizing: border-box; }
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--ink);
+  font: 14px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+}
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+.wrap { max-width: 1180px; margin: 0 auto; padding: 22px 18px 60px; }
+h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
+.sub { color: var(--muted); font-size: 12.5px; margin-bottom: 18px; }
+
+.fbtn { display: flex; justify-content: space-between; align-items: baseline; gap: 14px; text-align: left; padding: 9px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); font-size: 13px; color: var(--ink); cursor: pointer; }
+.fbtn:hover { border-color: var(--accent); }
+.fbtn.on { border-color: var(--accent); background: var(--accent); color: #fff; font-weight: 600; }
+.fbtn-pre { flex: 0 1 auto; }
+.fbtn-suf { flex: 0 0 auto; margin-left: auto; opacity: 0.7; }   /* 尾缀靠右、略淡 */
+.fbtn.on .fbtn-suf { opacity: 0.85; }
+
+/* 创作Query Demo:多列(轴列 + Query列) */
+.cdemo { display: flex; gap: 10px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
+.axcol { flex: 0 0 auto; width: 114px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); overflow: hidden; }
+.axcol.qcol { flex: 1 1 auto; min-width: 560px; }
+.axhd { font-size: 13px; font-weight: 700; padding: 9px 11px; border-bottom: 1px solid var(--line); background: #fafafb;
+  display: flex; align-items: center; justify-content: space-between; gap: 6px; }
+.axhd.qhd { color: var(--accent); }
+.axhd .gn { font-size: 11px; font-weight: 600; color: var(--muted); background: #f0f0f3; border-radius: 10px; padding: 1px 7px; }
+.qhd-metrics { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+.avgcreation { font-size: 11px; font-weight: 700; border-radius: 10px; padding: 1px 8px; white-space: nowrap; }
+.avgcreation.done { color: var(--ok); background: #e6f6ee; }
+.avgcreation.pending { color: #9a6a00; background: #fff6df; }
+.axlist { max-height: 62vh; overflow: auto; }
+.axv { font-size: 12.5px; padding: 5px 11px; border-bottom: 1px solid #f3f3f5; color: #555; white-space: nowrap; }
+.axv.grp { font-weight: 700; color: #222; background: #fafafb; }
+.axv.child { padding-left: 26px; color: #666; }
+.axv.child::before { content: '·'; color: var(--muted); margin-right: 6px; }
+.qrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 7px; padding: 6px 11px; border-bottom: 1px solid #f3f3f5; font-size: 13px; }
+.qrow.keep .qmark { color: var(--ok); font-weight: 700; }
+.qrow.drop { color: var(--muted); }
+.qrow.drop .qmark { color: var(--bad); }
+.qrow.drop .qtext { text-decoration: line-through; }
+.qmark { flex: 0 0 auto; }
+.qvalid { flex: 0 0 auto; font-size: 11px; font-weight: 700; padding: 0 6px; border-radius: 9px; }
+.qvalid.ok { color: var(--ok); background: #e6f6ee; }
+.qvalid.low { color: var(--bad); background: #fbeaea; }
+.qtext { flex: 1 1 auto; white-space: normal; word-break: break-word; }   /* 长 query 换行显示,别截断 */
+.qreason { flex: 1 0 100%; margin-left: 20px; color: var(--muted); font-size: 11.5px; }   /* 理由独占一行、占满宽 */
+.qdetail { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line);
+  border-radius: 8px; padding: 2px 7px; font-size: 11.5px; color: var(--muted); background: #fff; }
+button.qdetail { font: inherit; cursor: pointer; }
+.qdetail.on { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
+.qdetail.active { color: #3f3fa8; border-color: #d9d9f4; background: #f4f4ff; font-weight: 700; }
+.qdetail.decode-on { color: var(--ok); border-color: #d8eadf; background: #e6f6ee; font-weight: 700; }
+.qdetail span { color: inherit; opacity: .8; }
+.qcreation { flex: 0 0 auto; font-size: 11.5px; font-weight: 700; border-radius: 9px; padding: 1px 7px; white-space: nowrap; }
+.qcreation.searched { color: #53606d; background: #eef2f6; }
+.qcreation.done { color: var(--ok); background: #e6f6ee; }
+.qcreation.pending { color: #9a6a00; background: #fff6df; }
+.qcreation.knowledge { color: #5b47bd; background: #f0edff; }
+.qcreation.decoded { color: var(--ok); background: #e6f6ee; }
+.tag { display: inline-block; background: var(--accent-soft); color: var(--accent);
+  border-radius: 6px; padding: 1px 7px; font-size: 12px; margin: 1px 3px 1px 0; white-space: nowrap; }
+.tag.k { background: #f0f0f3; color: #555; }
+
+.decode-modal-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 80;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 18px;
+  background: rgba(15, 16, 28, .52);
+}
+.decode-modal-panel {
+  width: min(1180px, calc(100vw - 36px));
+  height: min(88vh, 920px);
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 22px 70px rgba(0, 0, 0, .34);
+  background: #f5f5f7;
+}
+
+.empty { text-align: center; color: var(--muted); padding: 48px 0; font-size: 13.5px; }
+
+.pill {
+  display: inline-flex;
+  align-items: center;
+  border-radius: 999px;
+  padding: 2px 9px;
+  font-size: 11.5px;
+  font-weight: 700;
+  background: #f0f0f3;
+  color: #555;
+}
+.pill.decoded,
+.pill.ingested {
+  background: #e6f6ee;
+  color: var(--ok);
+}
+.pill.rejected,
+.pill.failed {
+  background: #fbeaea;
+  color: var(--bad);
+}
+.pill.draft,
+.pill.pending {
+  background: #fff6df;
+  color: #9a6a00;
+}
+
+/* 老版 Decode 知识详情页视觉系统:用于知识弹窗和帖子详情页右侧 */
+.legacy-decode-page {
+  height: 100vh;
+  min-height: 100vh;
+  overflow: hidden;
+  background: #f5f5f7;
+  color: #111827;
+  font: 13px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
+}
+.legacy-decode-page.embedded-decode-page {
+  height: 100%;
+  min-height: 0;
+  border-radius: 12px;
+}
+.legacy-decode-page.embedded-decode-page .app {
+  height: 100%;
+}
+.legacy-decode-page * { box-sizing: border-box; }
+.legacy-decode-page a { color: #2563eb; text-decoration: none; }
+.legacy-decode-page a:hover { text-decoration: underline; }
+.legacy-decode-page .app {
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+.legacy-decode-page header.top {
+  flex: none;
+  padding: 11px 18px 9px;
+  background: #fff;
+  border-bottom: 1px solid #e5e7eb;
+}
+.legacy-decode-page header.top h1 {
+  margin: 0 0 7px;
+  font-size: 16px;
+  line-height: 1.35;
+  font-weight: 600;
+  letter-spacing: 0;
+}
+.legacy-decode-page .picker {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+.legacy-decode-page .picker .lab {
+  font-size: 11.5px;
+  color: #9ca3af;
+  margin-right: 2px;
+}
+.legacy-decode-page .pill {
+  display: inline-flex;
+  align-items: center;
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 1.4;
+  padding: 4px 11px;
+  border-radius: 14px;
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #6b7280;
+  cursor: pointer;
+  font-family: inherit;
+}
+.legacy-decode-page .pill:hover {
+  background: #f3f4f6;
+  text-decoration: none;
+}
+.legacy-decode-page .pill.on {
+  background: #1e293b;
+  color: #fff;
+  border-color: #1e293b;
+  font-weight: 600;
+}
+.legacy-decode-page .pill .n {
+  font-size: 10px;
+  opacity: .7;
+  margin-left: 4px;
+}
+.legacy-decode-page .scroll {
+  flex: 1;
+  overflow: auto;
+  background: #f5f5f7;
+  padding-bottom: 40px;
+}
+.legacy-decode-page .empty {
+  color: #9ca3af;
+  padding: 50px;
+  text-align: center;
+}
+.legacy-decode-page details.src {
+  display: block;
+  margin: 10px 18px 0;
+  background: #fff;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+}
+.legacy-decode-page details.src summary {
+  padding: 8px 14px;
+  cursor: pointer;
+  font-size: 12px;
+  color: #475569;
+  list-style: none;
+}
+.legacy-decode-page details.src summary::-webkit-details-marker { display: none; }
+.legacy-decode-page details.src summary::before {
+  content: '▶ ';
+  color: #94a3b8;
+  font-size: 10px;
+}
+.legacy-decode-page details.src[open] summary::before { content: '▼ '; }
+.legacy-decode-page .srcbody {
+  padding: 4px 16px 12px;
+  font-size: 12px;
+}
+.legacy-decode-page .srcbody a {
+  color: #2563eb;
+  word-break: break-all;
+}
+.legacy-decode-page .srcimgs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-top: 8px;
+}
+.legacy-decode-page .srcimgs img {
+  width: 80px;
+  height: 80px;
+  object-fit: cover;
+  border-radius: 4px;
+  border: 1px solid #e2e8f0;
+  cursor: zoom-in;
+}
+.legacy-decode-page .srcvideo {
+  margin-top: 8px;
+}
+.legacy-decode-page .srcvideo video {
+  width: min(720px, 100%);
+  max-height: 360px;
+  background: #000;
+  border-radius: 5px;
+}
+.legacy-decode-page .srcread {
+  font-size: 12.5px;
+  color: #1f2937;
+  line-height: 1.6;
+  margin-top: 6px;
+  background: #fff;
+  border-left: 3px solid #0e7490;
+  padding: 6px 9px;
+}
+.legacy-decode-page .barhint {
+  margin: 8px 18px 0;
+  font-size: 11.5px;
+  color: #6b7280;
+}
+.legacy-decode-page .ribbon {
+  margin: 11px 18px 0;
+  background: #fff;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 10px 14px;
+}
+.legacy-decode-page .rcap {
+  font-size: 11.5px;
+  color: #6b7280;
+  margin-bottom: 8px;
+}
+.legacy-decode-page .rsteps {
+  display: flex;
+  align-items: stretch;
+  flex-wrap: wrap;
+}
+.legacy-decode-page .rstep {
+  flex: 1;
+  min-width: 118px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 8px 9px;
+  cursor: pointer;
+  background: #f8fafc;
+}
+.legacy-decode-page .rstep:hover {
+  background: #ecfeff;
+  border-color: #99d5d0;
+}
+.legacy-decode-page .rstep.auto {
+  cursor: default;
+  background: #f1f5f9;
+  opacity: .9;
+}
+.legacy-decode-page .rstep.auto:hover {
+  background: #f1f5f9;
+  border-color: #e2e8f0;
+}
+.legacy-decode-page .rtop {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+}
+.legacy-decode-page .rn {
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background: #0e7490;
+  color: #fff;
+  font-size: 11px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: none;
+}
+.legacy-decode-page .rstep.auto .rn { background: #94a3b8; }
+.legacy-decode-page .rlab {
+  font-size: 13.5px;
+  font-weight: 600;
+}
+.legacy-decode-page .rdesc {
+  font-size: 11.5px;
+  color: #6b7280;
+  margin: 5px 0 7px;
+  line-height: 1.5;
+}
+.legacy-decode-page .rbtn {
+  font-size: 11.5px;
+  color: #0e7490;
+}
+.legacy-decode-page .rstep.auto .rbtn { color: #94a3b8; }
+.legacy-decode-page .rarrow {
+  display: flex;
+  align-items: center;
+  color: #cbd5e1;
+  font-size: 18px;
+  padding: 0 4px;
+  flex: none;
+}
+.legacy-decode-page .lbtn {
+  padding: 3px 9px;
+  border: 1px solid #cbd5e1;
+  border-radius: 5px;
+  background: #fff;
+  cursor: pointer;
+  font-size: 11.5px;
+  color: #334155;
+  font-family: inherit;
+}
+.legacy-decode-page .lbtn:hover {
+  background: #f1f5f9;
+  text-decoration: none;
+}
+.legacy-decode-page .kcard {
+  margin: 12px 18px 0;
+  background: #fff;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  overflow: hidden;
+}
+.legacy-decode-page .khead {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 9px 14px;
+  border-bottom: 1px solid #eef2f7;
+  flex-wrap: wrap;
+}
+.legacy-decode-page .kno {
+  font-size: 11px;
+  font-weight: 700;
+  color: #fff;
+  background: #475569;
+  border-radius: 4px;
+  padding: 1px 7px;
+}
+.legacy-decode-page .ktype {
+  font-size: 11px;
+  font-weight: 700;
+  border-radius: 4px;
+  padding: 1px 8px;
+}
+.legacy-decode-page .ty-how { background: #cffafe; color: #155e75; }
+.legacy-decode-page .ty-what { background: #fae8ff; color: #86198f; }
+.legacy-decode-page .ty-why { background: #fef9c3; color: #854d0e; }
+.legacy-decode-page .ktitle {
+  font-size: 14px;
+  font-weight: 600;
+}
+.legacy-decode-page .krole {
+  font-size: 10.5px;
+  padding: 1px 6px;
+  border-radius: 3px;
+  background: #f1f5f9;
+  color: #64748b;
+}
+.legacy-decode-page .kbiz {
+  font-size: 11px;
+  padding: 1px 7px;
+  border-radius: 3px;
+  background: #fef3c7;
+  color: #92400e;
+}
+.legacy-decode-page .kacts {
+  margin-left: auto;
+  display: flex;
+  gap: 6px;
+}
+.legacy-decode-page .kbody {
+  padding: 6px 14px 13px;
+}
+.legacy-decode-page .twrap {
+  overflow: auto;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  margin-top: 8px;
+}
+.legacy-decode-page table.proc {
+  width: max-content;
+  min-width: 100%;
+  border-collapse: collapse;
+  font-size: 12px;
+  background: #fff;
+  border: 0;
+  border-radius: 0;
+}
+.legacy-decode-page table.proc th,
+.legacy-decode-page table.proc td {
+  border: 1px solid #e5e7eb;
+  padding: 6px 8px;
+  vertical-align: top;
+  line-height: 1.5;
+}
+.legacy-decode-page table.proc thead th {
+  font-weight: 600;
+  text-align: center;
+  font-size: 12px;
+  position: sticky;
+  top: 0;
+  z-index: 3;
+}
+.legacy-decode-page table.proc thead tr:nth-child(2) th { top: 30px; }
+.legacy-decode-page td.idx {
+  width: 30px;
+  text-align: center;
+  color: #6b7280;
+  background: #f8fafc;
+}
+.legacy-decode-page th.c-intent,
+.legacy-decode-page td.intent {
+  width: 210px;
+  min-width: 210px;
+  max-width: 210px;
+  white-space: pre-wrap;
+}
+.legacy-decode-page th.c-cstage,
+.legacy-decode-page td.cstage {
+  width: 54px;
+  text-align: center;
+  background: #ecfeff;
+}
+.legacy-decode-page th.c-act,
+.legacy-decode-page td.act {
+  min-width: 110px;
+  max-width: 140px;
+  word-break: break-word;
+  background: #ecfeff;
+}
+.legacy-decode-page th.c-dir,
+.legacy-decode-page td.dir {
+  min-width: 230px;
+  max-width: 330px;
+  white-space: pre-wrap;
+  word-break: break-word;
+  background: #ecfeff;
+}
+.legacy-decode-page th.c-out,
+.legacy-decode-page td.out {
+  min-width: 160px;
+  max-width: 240px;
+  white-space: pre-wrap;
+  background: #ecfeff;
+}
+.legacy-decode-page th.c-scope,
+.legacy-decode-page td.scope {
+  width: 92px;
+  min-width: 92px;
+  max-width: 92px;
+  word-break: break-word;
+}
+.legacy-decode-page th.g-frame { background: #0e7490; color: #fff; }
+.legacy-decode-page th.g-scope { background: #7c3aed; color: #fff; }
+.legacy-decode-page th.c-idx {
+  background: #475569;
+  color: #fff;
+  vertical-align: middle;
+}
+.legacy-decode-page thead tr:nth-child(2) th.c-intent,
+.legacy-decode-page thead tr:nth-child(2) th.c-cstage,
+.legacy-decode-page thead tr:nth-child(2) th.c-act,
+.legacy-decode-page thead tr:nth-child(2) th.c-dir,
+.legacy-decode-page thead tr:nth-child(2) th.c-out {
+  background: #0891b2;
+  color: #fff;
+}
+.legacy-decode-page thead tr:nth-child(2) th.s-substance { background: #dc2626; color: #fff; }
+.legacy-decode-page thead tr:nth-child(2) th.s-form { background: #2563eb; color: #fff; }
+.legacy-decode-page thead tr:nth-child(2) th.s-feeling { background: #db2777; color: #fff; }
+.legacy-decode-page thead tr:nth-child(2) th.s-effect { background: #16a34a; color: #fff; }
+.legacy-decode-page thead tr:nth-child(2) th.s-intent { background: #d97706; color: #fff; }
+.legacy-decode-page td.scope.s-substance { background: #fef2f2; }
+.legacy-decode-page td.scope.s-form { background: #eff6ff; }
+.legacy-decode-page td.scope.s-feeling { background: #fdf2f8; }
+.legacy-decode-page td.scope.s-effect { background: #f0fdf4; }
+.legacy-decode-page td.scope.s-intent { background: #fffbeb; }
+.legacy-decode-page tr.step > td { border-top: 1.5px solid #cbd5e1; }
+.legacy-decode-page .cstage-chip {
+  display: inline-block;
+  font-size: 11px;
+  padding: 1px 6px;
+  border-radius: 10px;
+  background: #ede9fe;
+  color: #5b21b6;
+}
+.legacy-decode-page .act-chip {
+  display: inline-block;
+  font-size: 11.5px;
+  padding: 1px 7px;
+  border-radius: 4px;
+  background: #fff7ed;
+  color: #9a3412;
+  border: 1px solid #fed7aa;
+}
+.legacy-decode-page .dir-txt {
+  font-size: 11.5px;
+  color: #33312c;
+  line-height: 1.65;
+}
+.legacy-decode-page .out-txt {
+  font-size: 11.5px;
+  color: #166534;
+}
+.legacy-decode-page .in-txt {
+  font-size: 11.5px;
+  color: #1e40af;
+  line-height: 1.6;
+}
+.legacy-decode-page .kindb {
+  font-size: 10.5px;
+  font-weight: 700;
+  padding: 1px 8px;
+  border-radius: 9px;
+  margin-right: 8px;
+  vertical-align: middle;
+}
+.legacy-decode-page .kind-子集 { background: #dbeafe; color: #1e40af; }
+.legacy-decode-page .kind-多维关系 { background: #dcfce7; color: #166534; }
+.legacy-decode-page .kind-序列 { background: #fef3c7; color: #92400e; }
+.legacy-decode-page .dimrule {
+  font-size: 10.5px;
+  padding: 1px 8px;
+  border-radius: 9px;
+  background: #ecfccb;
+  color: #3f6212;
+  margin-right: 8px;
+  vertical-align: middle;
+}
+.legacy-decode-page .def {
+  font-size: 13px;
+  background: #fdf4ff;
+  border-left: 3px solid #a21caf;
+  border-radius: 0 6px 6px 0;
+  padding: 8px 12px;
+  margin: 6px 0 10px;
+}
+.legacy-decode-page table.elem {
+  border-collapse: collapse;
+  width: 100%;
+  font-size: 12.5px;
+  background: #fff;
+  border: 0;
+}
+.legacy-decode-page table.elem td {
+  border: 1px solid #f0e6f5;
+  padding: 6px 10px;
+  text-align: left;
+  vertical-align: top;
+}
+.legacy-decode-page table.elem td.el {
+  font-weight: 600;
+  color: #86198f;
+  background: #fdf4ff;
+  width: 130px;
+}
+.legacy-decode-page .why-exp {
+  font-size: 12.5px;
+  line-height: 1.72;
+  color: #1f2937;
+  background: #fdf4ff;
+  border-left: 3px solid #a21caf;
+  border-radius: 0 6px 6px 0;
+  padding: 9px 13px;
+  margin: 6px 0;
+}
+.legacy-decode-page .kscopes {
+  margin-top: 9px;
+  display: flex;
+  gap: 5px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+.legacy-decode-page .kscopes .lab {
+  font-size: 11px;
+  color: #9ca3af;
+}
+.legacy-decode-page .sv {
+  display: inline-block;
+  cursor: pointer;
+  font-size: 11.5px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  border: 1px solid rgba(0,0,0,.08);
+  background: rgba(255,255,255,.7);
+  margin: 1px 0;
+}
+.legacy-decode-page .sv:hover { box-shadow: 0 0 0 1px #1e293b; }
+.legacy-decode-page .sv .k {
+  font-size: 11.5px;
+  opacity: .85;
+  font-weight: 700;
+  margin-right: 4px;
+}
+.legacy-decode-page table.proc th.c-scope { font-size: 13.5px; }
+.legacy-decode-page .sv .mk {
+  font-size: 9px;
+  padding: 0 3px;
+  border-radius: 2px;
+  margin-left: 3px;
+  vertical-align: top;
+}
+.legacy-decode-page .mk-new { background: #fde68a; color: #92400e; }
+.legacy-decode-page .mk-reuse { background: #bbf7d0; color: #166534; }
+.legacy-decode-page .sv.s-substance { background: #fef2f2; }
+.legacy-decode-page .sv.s-form { background: #eff6ff; }
+.legacy-decode-page .sv.s-feeling { background: #fdf2f8; }
+.legacy-decode-page .sv.s-effect { background: #f0fdf4; }
+.legacy-decode-page .sv.s-intent { background: #fffbeb; }
+.legacy-decode-page .modal {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,.45);
+  z-index: 110;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20px;
+  width: auto;
+  max-height: none;
+  overflow: visible;
+  border-radius: 0;
+  box-shadow: none;
+}
+.legacy-decode-page .mbox {
+  background: #fff;
+  width: 760px;
+  max-width: 94vw;
+  max-height: 86vh;
+  border-radius: 10px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+.legacy-decode-page .mbox h2 {
+  margin: 0;
+  padding: 13px 18px;
+  background: #0f766e;
+  color: #fff;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  letter-spacing: 0;
+}
+.legacy-decode-page .mbox h2 .x {
+  margin-left: auto;
+  cursor: pointer;
+  font-size: 18px;
+}
+.legacy-decode-page .mbox .body {
+  padding: 15px 18px;
+  overflow: auto;
+}
+.legacy-decode-page .mbox pre {
+  margin: 0;
+  font: 12.5px/1.7 ui-monospace, Menlo, monospace;
+  white-space: pre-wrap;
+  color: #1f2937;
+}
+.legacy-decode-page .copybtn {
+  margin-left: auto;
+  background: rgba(255,255,255,.12);
+  color: #fff;
+  border-color: rgba(255,255,255,.28);
+}
+.legacy-decode-page .copybtn:hover { background: rgba(255,255,255,.2); }
+.legacy-decode-page .lb {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,.85);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 120;
+  cursor: zoom-out;
+}
+.legacy-decode-page .lb img {
+  max-width: 92vw;
+  max-height: 92vh;
+  border-radius: 6px;
+}
+
+@media (max-width: 720px) {
+  .lanes { grid-template-columns: 1fr; }
+  .source-panel { grid-template-columns: 1fr; }
+  .decode-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .knowledge-grid, .payload-grid { grid-template-columns: 1fr; }
+  .lightbox { padding: 18px 12px 48px; }
+  .lightbox img { max-width: 96vw; max-height: 82vh; }
+  .lbnav { bottom: 10px; top: auto; transform: none; }
+  .lbnav.prev { left: 26px; }
+  .lbnav.next { right: 26px; }
+  .legacy-decode-page .rsteps { display: block; }
+  .legacy-decode-page .rstep { margin-top: 7px; }
+  .legacy-decode-page .rarrow { display: none; }
+  .legacy-decode-page .khead { align-items: flex-start; }
+  .legacy-decode-page .kacts {
+    margin-left: 0;
+    width: 100%;
+  }
+}
+
+/* 正式后台看板视觉壳:参考 AIGC 管理台的 Arco 风格 */
+:root {
+  --bg: #f2f3f5;
+  --card: #fff;
+  --ink: #1d2129;
+  --muted: #86909c;
+  --line: #e5e8ef;
+  --line-strong: #d8dee9;
+  --accent: #1677ff;
+  --accent-soft: #e8f3ff;
+  --ok: #00a870;
+  --ok-soft: #e8fff3;
+  --bad: #f53f3f;
+  --sidebar: #fff;
+}
+
+body {
+  background: var(--bg);
+  color: var(--ink);
+  font: 14px/1.5 Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+  letter-spacing: 0;
+}
+
+h1 {
+  font-size: 18px;
+  line-height: 1.35;
+  letter-spacing: 0;
+}
+
+.admin-shell {
+  min-height: 100vh;
+  background: var(--bg);
+}
+
+.admin-main {
+  min-width: 0;
+  padding: 10px 16px 24px;
+  background: var(--bg);
+}
+
+.admin-main-full {
+  min-height: 100vh;
+}
+
+.dashboard-page {
+  min-width: 980px;
+}
+
+.dashboard-toolbar {
+  min-height: 72px;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 20px 14px;
+}
+
+.filter-chips,
+.family-switch {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.filter-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  height: 32px;
+  padding: 0 11px;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  background: #fff;
+  color: #4e5969;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.filter-chip span {
+  width: 14px;
+  height: 14px;
+  display: inline-grid;
+  place-items: center;
+  border-radius: 3px;
+  font-size: 10px;
+  color: #fff;
+}
+
+.filter-chip.blue {
+  color: #165dff;
+  border-color: #94bfff;
+  background: #e8f3ff;
+}
+
+.filter-chip.blue span {
+  background: #165dff;
+}
+
+.filter-chip.green {
+  color: #00875a;
+  border-color: #7be0b2;
+  background: #e8fff3;
+}
+
+.filter-chip.green span {
+  background: #00a870;
+}
+
+.family-switch {
+  min-height: 32px;
+  margin-left: 8px;
+  padding-left: 12px;
+  border-left: 1px solid var(--line);
+}
+
+.clear-filter {
+  margin-left: auto;
+  border: 0;
+  background: transparent;
+  color: #4e5969;
+  font-size: 12px;
+}
+
+.clear-filter:hover {
+  color: var(--accent);
+}
+
+.dashboard-page .fbtn {
+  align-items: center;
+  height: 32px;
+  max-width: 280px;
+  padding: 0 10px;
+  border-radius: 6px;
+  border-color: #d8dee9;
+  color: #4e5969;
+  background: #fff;
+  font-size: 12px;
+}
+
+.dashboard-page .fbtn.on {
+  border-color: #94bfff;
+  background: #e8f3ff;
+  color: #165dff;
+}
+
+.dashboard-page .fbtn-pre {
+  font-weight: 700;
+}
+
+.dashboard-page .fbtn-suf {
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  opacity: .86;
+}
+
+.dashboard-page .cdemo {
+  display: grid;
+  grid-template-columns: 280px 138px 168px 170px 504px minmax(480px, 1fr);
+  gap: 0;
+  min-height: calc(100vh - 210px);
+  padding: 0;
+  margin: 0;
+  overflow: auto;
+  border: 1px solid var(--line-strong);
+  border-radius: 8px;
+  background: #fff;
+}
+
+.dashboard-page .cdemo.single {
+  display: block;
+  min-height: auto;
+}
+
+.dashboard-page .axcol {
+  width: auto;
+  min-width: 0;
+  border: 0;
+  border-right: 1px solid var(--line-strong);
+  border-radius: 0;
+  background: #fff;
+}
+
+.dashboard-page .axcol:first-child {
+  border-radius: 8px 0 0 8px;
+}
+
+.dashboard-page .axcol.qcol {
+  width: auto;
+  min-width: 0;
+  max-width: none;
+  border-right: 1px solid var(--line-strong);
+}
+
+.dashboard-page .axcol.qresult-col {
+  width: auto;
+  min-width: 0;
+  max-width: none;
+  border-right: 0;
+}
+
+.dashboard-page .axcol.qcol.wide {
+  width: 100%;
+  max-width: none;
+}
+
+.dashboard-page .axhd {
+  min-height: 96px;
+  display: block;
+  padding: 12px 12px 9px;
+  border-bottom: 1px solid var(--line);
+  background: #fff;
+}
+
+.dashboard-page .qhd {
+  color: var(--ink);
+}
+
+.axis-title {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  min-width: 0;
+  color: #1d2129;
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.axis-icon,
+.query-search-icon {
+  width: 26px;
+  height: 26px;
+  display: inline-grid;
+  place-items: center;
+  flex: 0 0 auto;
+  border-radius: 6px;
+  background: #eef3ff;
+  color: #165dff;
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.axis-metrics {
+  margin-top: 9px;
+}
+
+.metric-pair,
+.metric-counts {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  font-size: 10px;
+  font-weight: 700;
+}
+
+.metric-blue {
+  color: #165dff;
+}
+
+.metric-green {
+  color: #00a870;
+}
+
+.mini-bars {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+  margin: 4px 0 3px;
+}
+
+.mini-bars span {
+  height: 3px;
+  min-width: 8px;
+  border-radius: 999px;
+}
+
+.mini-bars span:first-child {
+  background: #165dff;
+}
+
+.mini-bars span:last-child {
+  background: #00a870;
+}
+
+.metric-counts {
+  color: #86909c;
+  font-weight: 500;
+}
+
+.dashboard-page .qhd {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.dashboard-page .qhd-metrics {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 4px;
+  white-space: nowrap;
+}
+
+.run-progress {
+  color: #4e5969;
+  font-size: 10px;
+  font-weight: 500;
+}
+
+.dashboard-page .axhd .gn {
+  border-radius: 0;
+  padding: 0;
+  background: transparent;
+  color: #4e5969;
+  font-size: 11px;
+}
+
+.dashboard-page .axlist {
+  max-height: calc(100vh - 312px);
+  overflow: auto;
+  padding: 9px 0;
+}
+
+.dashboard-page .cdemo.single .axlist {
+  max-height: calc(100vh - 260px);
+}
+
+.dashboard-page .axv {
+  min-height: 32px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 0 12px 0 18px;
+  border-bottom: 0;
+  color: #1d2129;
+  font-size: 12.5px;
+  line-height: 1.35;
+  white-space: nowrap;
+}
+
+.dashboard-page button.axv {
+  width: 100%;
+  border: 0;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+  font-family: inherit;
+}
+
+.dashboard-page button.axv:hover {
+  background: #f7f9fc;
+}
+
+.dashboard-page .axv.level-4 {
+  padding-left: 34px;
+}
+
+.dashboard-page .axv.muted {
+  color: #1d2129;
+}
+
+.tree-caret {
+  width: 10px;
+  color: #a9b4c4;
+  font-size: 13px;
+  flex: 0 0 10px;
+}
+
+.tree-caret.empty {
+  color: transparent;
+  padding: 0;
+}
+
+.tree-label {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.tree-dots {
+  margin-left: auto;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  min-width: 32px;
+  justify-content: flex-end;
+}
+
+.dot {
+  width: 5px;
+  height: 5px;
+  border-radius: 50%;
+  display: inline-block;
+}
+
+.dot.blue {
+  background: #165dff;
+}
+
+.dot.green {
+  background: #00a870;
+}
+
+.tree-dots b {
+  color: #00a870;
+  font-size: 9px;
+  font-weight: 700;
+}
+
+.dashboard-page .qrow {
+  position: relative;
+  min-height: 41px;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 7px;
+  padding: 8px 13px;
+  border-bottom: 0;
+  color: #1d2129;
+  font-size: 13px;
+}
+
+.dashboard-page button.qrow {
+  width: 100%;
+  border: 0;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+
+.dashboard-page button.qrow:disabled {
+  cursor: default;
+}
+
+.dashboard-page .qrow:hover {
+  background: #f7f8fa;
+}
+
+.dashboard-page .query-select-row.selected {
+  background: #f2f7ff;
+  box-shadow: inset 3px 0 0 #165dff;
+}
+
+.dashboard-page .qmark {
+  color: #00a870;
+  font-size: 14px;
+  font-weight: 800;
+}
+
+.dashboard-page .qvalid,
+.dashboard-page .qcreation,
+.dashboard-page .qdetail,
+.dashboard-page .qreason {
+  flex: 0 0 auto;
+}
+
+.dashboard-page .qvalid {
+  justify-self: start;
+  padding: 1px 7px;
+  border-radius: 999px;
+  font-size: 11px;
+}
+
+.dashboard-page .qvalid.ok {
+  color: #00875a;
+  background: #e8fff3;
+}
+
+.dashboard-page .qtext {
+  flex: 1 1 150px;
+  min-width: 0;
+  color: #1d2129;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.dashboard-page .qcreation,
+.dashboard-page .qdetail {
+  border-radius: 999px;
+  padding: 2px 8px;
+  border: 0;
+  font-size: 11px;
+}
+
+.dashboard-page .qcreation.searched {
+  color: #4e5969;
+  background: #eef2f7;
+}
+
+.dashboard-page .qcreation.knowledge {
+  color: #165dff;
+  background: #e8f3ff;
+}
+
+.dashboard-page .qcreation.decoded {
+  color: #00875a;
+  background: #e8fff3;
+}
+
+.dashboard-page .qdetail {
+  color: #165dff;
+  background: #f0f4ff;
+  font-weight: 600;
+}
+
+.dashboard-page .qdetail.on {
+  color: #165dff;
+  background: #f0f4ff;
+}
+
+.dashboard-page .qreason {
+  flex-basis: 100%;
+  margin: -4px 0 0;
+  padding-left: 26px;
+  color: #86909c;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
+.qresult-hd {
+  display: block;
+}
+
+.qresult-sub {
+  margin-top: 10px;
+  color: #4e5969;
+  font-size: 12px;
+  line-height: 1.45;
+  max-height: 36px;
+  overflow: hidden;
+}
+
+.qresult-list {
+  height: calc(100vh - 312px);
+  overflow: auto;
+  padding: 0;
+  background: #fff;
+}
+
+.qresult-empty {
+  color: #86909c;
+  font-size: 12px;
+  line-height: 1.6;
+  padding: 18px 16px;
+}
+
+.qresult-empty.error {
+  color: #f53f3f;
+}
+
+.result-mini {
+  display: grid;
+  grid-template-columns: 54px minmax(0, 1fr);
+  gap: 10px;
+  min-height: 96px;
+  padding: 13px 14px;
+  border-bottom: 1px solid #edf0f5;
+  background: #fff;
+}
+
+.result-mini.knowledge {
+  background: #fbfffd;
+  box-shadow: inset 3px 0 0 #00a870;
+}
+
+.result-mini img {
+  width: 54px;
+  height: 72px;
+  object-fit: cover;
+  object-position: top;
+  border-radius: 6px;
+  border: 1px solid #e5e8ef;
+  background: #f2f3f5;
+}
+
+.result-mini-main {
+  min-width: 0;
+}
+
+.result-mini-title {
+  color: #1d2129;
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 1.45;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.result-mini-tags {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 5px;
+  margin-top: 7px;
+}
+
+.platform-mini,
+.knowledge-mini,
+.score-mini {
+  display: inline-flex;
+  align-items: center;
+  min-height: 19px;
+  border-radius: 4px;
+  padding: 1px 6px;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.platform-mini.xhs {
+  color: #f53f3f;
+  background: #fff0f0;
+  border: 1px solid #ffd6d6;
+}
+
+.platform-mini.wx {
+  color: #00875a;
+  background: #e8fff3;
+  border: 1px solid #b7efd4;
+}
+
+.platform-mini.dy {
+  color: #1d2129;
+  background: #eef2f7;
+  border: 1px solid #d8dee9;
+}
+
+.knowledge-mini {
+  color: #86909c;
+  background: #f2f3f5;
+}
+
+.knowledge-mini.yes {
+  color: #00875a;
+  background: #e8fff3;
+}
+
+.score-mini {
+  margin-left: auto;
+  color: #00a870;
+  background: transparent;
+  padding-right: 0;
+}
+
+.result-mini-text {
+  margin-top: 7px;
+  color: #86909c;
+  font-size: 12px;
+  line-height: 1.5;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.mini-detail-btn {
+  margin-top: 8px;
+  height: 24px;
+  border: 1px solid #b7efd4;
+  border-radius: 5px;
+  background: #e8fff3;
+  color: #00875a;
+  font-size: 12px;
+  font-weight: 700;
+  padding: 0 8px;
+}
+
+.run-title-row {
+  min-height: 72px;
+  display: flex;
+  align-items: center;
+  padding: 15px 18px 8px;
+}
+
+.dashboard-page .sub {
+  margin: 5px 0 0;
+  color: #86909c;
+}
+
+@media (max-width: 1100px) {
+  .dashboard-page {
+    min-width: 860px;
+  }
+  .dashboard-page .cdemo {
+    grid-template-columns: 250px 120px 150px 150px 468px minmax(360px, 1fr);
+  }
+}

+ 29 - 0
app/frontend/src/styles/query-board.css

@@ -0,0 +1,29 @@
+.refresh-state {
+  margin-left: auto;
+  color: #4e5969;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.virtual-query-list {
+  padding: 0;
+}
+
+.virtual-query-list .qrow {
+  min-height: 32px;
+  padding-top: 0;
+  padding-bottom: 0;
+  flex-wrap: nowrap;
+  gap: 6px;
+  overflow: hidden;
+}
+
+.dashboard-page .query-select-row {
+  height: 100%;
+}
+
+.virtual-query-list .qtext {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}

+ 17 - 0
app/frontend/src/styles/tokens.css

@@ -0,0 +1,17 @@
+:root {
+  --bg: #f2f3f5;
+  --card: #fff;
+  --ink: #1d2129;
+  --muted: #86909c;
+  --line: #e5e8ef;
+  --line-strong: #d8dee9;
+  --accent: #1677ff;
+  --accent-soft: #e8f3ff;
+  --ok: #00a870;
+  --ok-soft: #e8fff3;
+  --bad: #f53f3f;
+}
+
+.error-state {
+  color: var(--bad);
+}

+ 10 - 2
app/routes/decode.py

@@ -40,9 +40,13 @@ def decode_item_detail(
     try:
     try:
         item = acquisition_repo.get_candidate_item(item_id)
         item = acquisition_repo.get_candidate_item(item_id)
         media = acquisition_repo.list_media_assets_for_item(item_id)
         media = acquisition_repo.list_media_assets_for_item(item_id)
+    except Exception as exc:
+        raise HTTPException(status_code=404, detail=f"candidate item not found: {exc}") from exc
+
+    try:
         result = decode_repo.get_decode_result_for_item(item_id)
         result = decode_repo.get_decode_result_for_item(item_id)
     except Exception as exc:
     except Exception as exc:
-        raise HTTPException(status_code=404, detail=f"decode detail not found: {exc}") from exc
+        result = None
 
 
     list_particles = getattr(decode_repo, "list_knowledge_particles", None)
     list_particles = getattr(decode_repo, "list_knowledge_particles", None)
     list_scopes = getattr(decode_repo, "list_scope_results", None)
     list_scopes = getattr(decode_repo, "list_scope_results", None)
@@ -61,7 +65,11 @@ def decode_item_detail(
     ).model_dump(mode="json")
     ).model_dump(mode="json")
     return {
     return {
         "item": item_payload,
         "item": item_payload,
-        "decode_result": DecodeResultSchema.model_validate(result).model_dump(mode="json"),
+        "decode_result": (
+            DecodeResultSchema.model_validate(result).model_dump(mode="json")
+            if result is not None
+            else None
+        ),
         "knowledge_particles": [
         "knowledge_particles": [
             KnowledgeParticleSchema.model_validate(row).model_dump(mode="json")
             KnowledgeParticleSchema.model_validate(row).model_dump(mode="json")
             for row in (list_particles(item_id=item_id) if list_particles else [])
             for row in (list_particles(item_id=item_id) if list_particles else [])

+ 68 - 12
app/routes/query_generation.py

@@ -8,7 +8,8 @@ from fastapi import APIRouter, Depends, Query
 
 
 from acquisition.queries.builder import QueryBuildOptions, TREES, build_creation_query_batch
 from acquisition.queries.builder import QueryBuildOptions, TREES, build_creation_query_batch
 from app.dependencies import _env_file, get_acquisition_repository
 from app.dependencies import _env_file, get_acquisition_repository
-from app.routes.acquisition import _normalize_query_detail
+from app.routes.acquisition import _model_dump
+from app.schemas import QuerySchema
 from core.config import Settings
 from core.config import Settings
 
 
 router = APIRouter(prefix="/api/query-generation", tags=["query-generation"])
 router = APIRouter(prefix="/api/query-generation", tags=["query-generation"])
@@ -29,11 +30,72 @@ def _summary(generated: dict[str, Any]) -> dict[str, Any]:
     }
     }
 
 
 
 
+def _normalize_light_query_detail(detail: dict[str, Any]) -> dict[str, Any]:
+    media_by_item = {media["item_id"]: media for media in detail.get("media_assets") or []}
+    classification_by_item = {row["item_id"]: row for row in detail.get("classifications") or []}
+    decode_by_item = {row["item_id"]: row for row in detail.get("decode_summaries") or []}
+    platforms: dict[str, dict[str, Any]] = {}
+    for raw_job in detail.get("jobs") or []:
+        job = _model_dump(raw_job)
+        platform = job.get("platform")
+        if not platform:
+            continue
+        platforms.setdefault(
+            platform,
+            {
+                "platform": platform,
+                "status": job.get("status") or "pending",
+                "attempt_count": job.get("attempt_count"),
+                "display_limit": job.get("display_limit"),
+                "search_limit": job.get("search_limit"),
+                "error_message": job.get("error_message"),
+                "items": [],
+            },
+        )
+
+    items: list[dict[str, Any]] = []
+    for row in detail.get("items") or []:
+        item = _model_dump(row)
+        item_id = item["id"]
+        classification = classification_by_item.get(item_id)
+        payload = {
+            "id": item_id,
+            "platform": item.get("platform"),
+            "title": item.get("title"),
+            "raw_summary": item.get("raw_summary"),
+            "status": item.get("status"),
+            "content_mode": item.get("content_mode"),
+            "metadata": item.get("metadata") or {},
+            "classification": _model_dump(classification) if classification else None,
+            "decode_summary": _model_dump(decode_by_item[item_id]) if item_id in decode_by_item else None,
+            "media_assets": [],
+        }
+        if item_id in media_by_item:
+            payload["media_assets"] = [_model_dump(media_by_item[item_id])]
+        items.append(payload)
+        platform = payload["platform"]
+        group = platforms.setdefault(
+            platform,
+            {"platform": platform, "status": "done", "items": []},
+        )
+        if group.get("status") in {None, "pending"}:
+            group["status"] = "done"
+        group["items"].append(payload)
+
+    return {
+        "query": QuerySchema.model_validate(detail["query"]).model_dump(mode="json"),
+        "run": _model_dump(detail["run"]) if detail.get("run") else None,
+        "jobs": [_model_dump(job) for job in detail.get("jobs") or []],
+        "items": items,
+        "platforms": platforms,
+    }
+
+
 @router.get("/preview")
 @router.get("/preview")
 def query_generation_preview(
 def query_generation_preview(
     per: int = Query(default=0, ge=0, le=10000),
     per: int = Query(default=0, ge=0, le=10000),
     batch_n: int = Query(default=0, ge=0, le=1000),
     batch_n: int = Query(default=0, ge=0, le=1000),
-    dry: bool = Query(default=True),
+    enable_query_filter: bool = Query(default=False),
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Preview the currently active formal query families without writing DB rows."""
     """Preview the currently active formal query families without writing DB rows."""
 
 
@@ -44,7 +106,7 @@ def query_generation_preview(
         options=QueryBuildOptions(
         options=QueryBuildOptions(
             per=per,
             per=per,
             batch_n=batch_n,
             batch_n=batch_n,
-            dry=dry,
+            enable_query_filter=enable_query_filter,
             active_family_keys=("f1", "f2"),
             active_family_keys=("f1", "f2"),
         ),
         ),
     )
     )
@@ -69,13 +131,7 @@ def latest_query_detail(
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Return search material for one query in the latest real query board batch."""
     """Return search material for one query in the latest real query board batch."""
 
 
-    overview_getter = getattr(repo, "get_latest_singleton_overview", None)
-    detail_getter = getattr(repo, "get_query_detail_for_batch", None)
-    if overview_getter is None or detail_getter is None:
-        return {"query": None, "jobs": [], "items": [], "platforms": {}}
-    overview = overview_getter()
-    batch = overview.get("batch")
-    batch_id = batch.get("id") if isinstance(batch, dict) else getattr(batch, "id", None)
-    if batch_id is None:
+    detail_getter = getattr(repo, "get_latest_query_result_list", None)
+    if detail_getter is None:
         return {"query": None, "jobs": [], "items": [], "platforms": {}}
         return {"query": None, "jobs": [], "items": [], "platforms": {}}
-    return _normalize_query_detail(detail_getter(batch_id=batch_id, query_id=query_id))
+    return _normalize_light_query_detail(detail_getter(query_id))

+ 2 - 0
app/schemas.py

@@ -98,6 +98,8 @@ class AcquisitionRunSummarySchema(ApiSchema):
     job_count: int = 0
     job_count: int = 0
     candidate_count: int = 0
     candidate_count: int = 0
     creation_hit_count: int = 0
     creation_hit_count: int = 0
+    decoded_count: int = 0
+    payload_count: int = 0
     queries: list[dict[str, Any]] = Field(default_factory=list)
     queries: list[dict[str, Any]] = Field(default_factory=list)
     started_at: datetime | None = None
     started_at: datetime | None = None
     finished_at: datetime | None = None
     finished_at: datetime | None = None

+ 4 - 0
core/config.py

@@ -79,6 +79,8 @@ class CreationDbConfig:
     schema: str = DEFAULT_PG_SCHEMA
     schema: str = DEFAULT_PG_SCHEMA
     timeout: int = 10
     timeout: int = 10
     application_name: str = "creation-knowledge"
     application_name: str = "creation-knowledge"
+    pool_min: int = 1
+    pool_max: int = 10
 
 
     @classmethod
     @classmethod
     def from_env(cls, env_file: str | Path = ".env") -> "CreationDbConfig":
     def from_env(cls, env_file: str | Path = ".env") -> "CreationDbConfig":
@@ -94,6 +96,8 @@ class CreationDbConfig:
                 "creation_knowledge_prod",
                 "creation_knowledge_prod",
             ),
             ),
             schema=env_value("CK_DB_SCHEMA", file_env, DEFAULT_PG_SCHEMA),
             schema=env_value("CK_DB_SCHEMA", file_env, DEFAULT_PG_SCHEMA),
+            pool_min=int(env_value("CK_DB_POOL_MIN", file_env, "1")),
+            pool_max=int(env_value("CK_DB_POOL_MAX", file_env, "10")),
         )
         )
 
 
 
 

+ 61 - 0
core/db_session.py

@@ -2,15 +2,21 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 from contextlib import contextmanager
 from contextlib import contextmanager
+from threading import Lock
 from typing import Any, Iterator
 from typing import Any, Iterator
 
 
 import psycopg2
 import psycopg2
 import psycopg2.extras
 import psycopg2.extras
+from psycopg2.pool import ThreadedConnectionPool
 
 
 from core.config import CreationDbConfig
 from core.config import CreationDbConfig
 
 
 psycopg2.extras.register_uuid()
 psycopg2.extras.register_uuid()
 
 
+_pool: ThreadedConnectionPool | None = None
+_pool_key: tuple[Any, ...] | None = None
+_pool_lock = Lock()
+
 
 
 def connect(config: CreationDbConfig) -> Any:
 def connect(config: CreationDbConfig) -> Any:
     """Open a PostgreSQL connection scoped to the formal state schema."""
     """Open a PostgreSQL connection scoped to the formal state schema."""
@@ -26,6 +32,46 @@ def connect(config: CreationDbConfig) -> Any:
     )
     )
 
 
 
 
+def _connection_kwargs(config: CreationDbConfig) -> dict[str, Any]:
+    return {
+        "host": config.host,
+        "port": config.port,
+        "user": config.user,
+        "password": config.password,
+        "dbname": config.database,
+        "connect_timeout": config.timeout,
+        "application_name": config.application_name,
+        "options": f"-c search_path={config.schema},public",
+    }
+
+
+def get_pool(config: CreationDbConfig) -> ThreadedConnectionPool:
+    """Return a process-local pool for the current creation DB config."""
+    global _pool, _pool_key
+    key = (
+        config.host,
+        config.port,
+        config.user,
+        config.database,
+        config.schema,
+        config.application_name,
+        config.pool_min,
+        config.pool_max,
+    )
+    with _pool_lock:
+        if _pool is not None and _pool_key == key:
+            return _pool
+        if _pool is not None:
+            _pool.closeall()
+        _pool = ThreadedConnectionPool(
+            minconn=max(1, config.pool_min),
+            maxconn=max(config.pool_min, config.pool_max),
+            **_connection_kwargs(config),
+        )
+        _pool_key = key
+        return _pool
+
+
 @contextmanager
 @contextmanager
 def transaction(config: CreationDbConfig) -> Iterator[Any]:
 def transaction(config: CreationDbConfig) -> Iterator[Any]:
     """Yield a connection and commit or roll back around the caller's work."""
     """Yield a connection and commit or roll back around the caller's work."""
@@ -40,6 +86,21 @@ def transaction(config: CreationDbConfig) -> Iterator[Any]:
         conn.close()
         conn.close()
 
 
 
 
+@contextmanager
+def pooled_transaction(config: CreationDbConfig) -> Iterator[Any]:
+    """Yield a pooled connection and return it to the pool after the request."""
+    pool = get_pool(config)
+    conn = pool.getconn()
+    try:
+        yield conn
+        conn.commit()
+    except Exception:
+        conn.rollback()
+        raise
+    finally:
+        pool.putconn(conn, close=bool(getattr(conn, "closed", False)))
+
+
 def fetch_all(
 def fetch_all(
     config: CreationDbConfig,
     config: CreationDbConfig,
     sql: str,
     sql: str,

+ 11 - 0
decode_content/repositories/postgres.py

@@ -130,6 +130,17 @@ class PostgresDecodeRepository:
         )
         )
         return DecodeResult.model_validate(row)
         return DecodeResult.model_validate(row)
 
 
+    def mark_running_decode_jobs_failed(self, item_id: UUID, error_message: str) -> None:
+        with self.conn.cursor() as cur:
+            cur.execute(
+                """
+                UPDATE decode_jobs
+                SET status = 'failed', finished_at = now(), error_message = %s
+                WHERE item_id = %s AND status = 'running'
+                """,
+                (error_message, item_id),
+            )
+
     def save_knowledge_particle(
     def save_knowledge_particle(
         self,
         self,
         *,
         *,

+ 1 - 1
pipeline/acquisition_runner.py

@@ -42,7 +42,7 @@ def run_acquisition_stage(
         if pipeline_repo and job and job.id:
         if pipeline_repo and job and job.id:
             job = pipeline_repo.mark_job_status(
             job = pipeline_repo.mark_job_status(
                 job.id,
                 job.id,
-                status="done" if result.failed == 0 else "partial",
+                status="done" if result.done > 0 else "failed",
                 metadata=result.__dict__,
                 metadata=result.__dict__,
             )
             )
         return AcquisitionStageResult(pipeline_job=job, acquisition=result)
         return AcquisitionStageResult(pipeline_job=job, acquisition=result)

+ 32 - 2
pipeline/decode_runner.py

@@ -1,11 +1,12 @@
 """Pipeline adapter for decoding creation candidate items."""
 """Pipeline adapter for decoding creation candidate items."""
 from __future__ import annotations
 from __future__ import annotations
 
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Protocol
 from typing import Protocol
 from uuid import UUID
 from uuid import UUID
 
 
 from acquisition.domain import CandidateItem, MediaAsset
 from acquisition.domain import CandidateItem, MediaAsset
+from core.text_limits import ERROR_MESSAGE_MAX_CHARS, clip_text
 from decode_content.readers.service import post_from_candidate_item
 from decode_content.readers.service import post_from_candidate_item
 from decode_content.service import DecodeService, DecodeWorkflowOutput
 from decode_content.service import DecodeService, DecodeWorkflowOutput
 from pipeline.dedupe import dedupe_candidate_items, should_decode_item
 from pipeline.dedupe import dedupe_candidate_items, should_decode_item
@@ -31,6 +32,32 @@ class DecodeBatchResult:
     skipped: int
     skipped: int
     failed: int
     failed: int
     outputs: list[DecodeWorkflowOutput]
     outputs: list[DecodeWorkflowOutput]
+    failures: list[dict[str, str]] = field(default_factory=list)
+
+
+def _record_failure(decode_service: DecodeService, item: CandidateItem, exc: Exception) -> dict[str, str]:
+    message = clip_text(str(exc) or exc.__class__.__name__, ERROR_MESSAGE_MAX_CHARS)
+    failure = {
+        "item_id": str(item.id),
+        "platform": item.platform,
+        "title": item.title or "",
+        "error": message,
+    }
+    repo = getattr(decode_service, "repository", None)
+    if repo is not None and item.id is not None:
+        mark_jobs = getattr(repo, "mark_running_decode_jobs_failed", None)
+        if mark_jobs is not None:
+            mark_jobs(item.id, message)
+        save_result = getattr(repo, "save_decode_result", None)
+        if save_result is not None:
+            save_result(
+                item_id=item.id,
+                read_result={"is_empty": True, "text": "", "metadata": {"decode_error": message}},
+                gate_result={"passed": False, "reason": "decode_failed", "details": {"error": message}},
+                framing_result={"error": message},
+                status="failed",
+            )
+    return failure
 
 
 
 
 def run_decode_stage(
 def run_decode_stage(
@@ -43,6 +70,7 @@ def run_decode_stage(
 ) -> DecodeBatchResult:
 ) -> DecodeBatchResult:
     items = dedupe_candidate_items(candidate_repo.list_creation_candidate_items(run_id=run_id, limit=limit))
     items = dedupe_candidate_items(candidate_repo.list_creation_candidate_items(run_id=run_id, limit=limit))
     outputs: list[DecodeWorkflowOutput] = []
     outputs: list[DecodeWorkflowOutput] = []
+    failures: list[dict[str, str]] = []
     decoded = skipped = failed = 0
     decoded = skipped = failed = 0
     for item in items:
     for item in items:
         if item.id is None:
         if item.id is None:
@@ -57,12 +85,14 @@ def run_decode_stage(
             post = post_from_candidate_item(item, media)
             post = post_from_candidate_item(item, media)
             outputs.append(decode_service.decode_post(item_id=item.id, post=post))
             outputs.append(decode_service.decode_post(item_id=item.id, post=post))
             decoded += 1
             decoded += 1
-        except Exception:
+        except Exception as exc:
             failed += 1
             failed += 1
+            failures.append(_record_failure(decode_service, item, exc))
     return DecodeBatchResult(
     return DecodeBatchResult(
         total=len(items),
         total=len(items),
         decoded=decoded,
         decoded=decoded,
         skipped=skipped,
         skipped=skipped,
         failed=failed,
         failed=failed,
         outputs=outputs,
         outputs=outputs,
+        failures=failures,
     )
     )

+ 84 - 34
prompts/classify_imgtext.txt

@@ -1,42 +1,92 @@
-你在判断一篇图文帖(小红书 / 微信公众号,含标题、正文、图片)是不是【图文/视频内容创作】的可迁移知识。请把图片也看完(知识常在图里)再判断
+你在判断一篇图文帖(小红书 / 微信公众号,含标题、正文、图片)是不是【图文/视频内容创作】的可迁移知识。请同时看标题、正文和图片,知识可能在图里
 
 
-<什么算创作知识>
-创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构或清单。判断必须同时过两条轴,缺一不算:
+<判定标准>
+创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构、清单或高质量案例。
 
 
-【轴A·产出物是不是内容】
-这条知识帮你创作出来的成品,必须是图文 / 视频 / 文章 / 脚本 / 剧本 / 小说 / 播客 / 课程 / 解说等可被阅读、观看、收听或使用的**内容作品**。题材不限,卡的是产出物。
-- 算:产出"关于某题材的内容"——游戏实况/解说视频、历史视频脚本、知识科普文章、电商带货视频 idea、小说结构。
-- 不算:产出"题材本身"——游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身。
+必须同时满足两条轴:
+
+【轴A·产出物是内容】
+这条知识帮助创作出来的成品,必须是图文、视频、文章、脚本、剧本、小说、播客、课程、解说等内容作品。
+题材不限,关键看产出物是不是"内容"。
+
+算:
+- 如何写短视频脚本
+- 如何构思图文选题
+- 如何设计标题、封面、开头、结尾
+- 如何组织故事、观点、节奏、冲突、反差、笑点
+- 如何把历史、游戏、电商、生活经验等题材做成视频、图文或文章
+
+不算:
+- 教游戏玩法、关卡设计、产品开发、电商选品、运营动作、行业分析、做菜本身
+- 这些是在做题材本身,不是在做"关于题材的内容"
 
 
 【轴B·是创作不是制作】
 【轴B·是创作不是制作】
-它教的是"怎么构思 / 选题 / 写 / 结构 / 呈现 / 判断"(创作决策),不是"用某 App/AI/软件把成品做出来"(制作/工具操作)。
-- 算:选题/构思方法、脚本结构、标题公式、封面设计判断、叙事与呈现、人物冲突、节奏设计、表达方式、账号定位、数据复盘。
-- 不算:打开某 App/小程序/AI → 输入/粘贴 → 生成/扩写 → 设参数 → 导出/保存;排版、秀米、剪辑、调色、导出等编辑器操作。
-- 特别强调:即使用 AI/App 生成的是文案、图片或视频,只要核心是"让工具替你产出成品",就算制作,不算创作;除非它真的在教如何拆解创作需求、建立可迁移的判断和方法。
-
-【两轴 AND】轴A、轴B 都过才算创作知识;任一不过就判非创作。
-</什么算创作知识>
-
-<一票否决·命中任一即判非创作(is_empty=true)>
-整篇主体只要落在下面任一类,就判**非创作**,别拔高、别勉强:
-1. 【题材本身 / 越界对象】教的是做那个东西本身,而不是做关于它的内容:游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身等。
-   —— 同样讲游戏:做游戏 = 不算;做游戏视频/解说脚本 = 算。同样讲电商:做选品运营 = 不算;做电商带货视频 idea = 算。
-2. 【制作 / 工具操作】用 AI / App / 小程序 / 软件 / 提示词 / 参数把内容生成或做出来(文生视频、AI 写文案/出图、提示词框架、参数设置),以及排版 / 秀米 / 剪辑 / 调色 / 导出等编辑器操作。
-   —— 这是"怎么把成品做出来(制作 / 执行)",不是"怎么构思出更好的内容(创作)"。
-3. 【应试 / 学术写作】申论、高考/中考作文、作文或申论范文、人物/作文**素材库**、答题模板、解题套路、考研/学术论文、公文 / 讲话稿 / 材料写作。
-   —— 目标是"拿分数 / 合规范",不是"创作内容作品"。即便都在"写",也不算;除非它明确抽象出可迁移到内容创作的叙事、结构、表达方法。
-4. 【学科知识 / 评论 / 作品本身】只是在讲某学科知识(历史 / 政治 / 外交 / 经济本身)、输出某个观点 / 感悟 / 人生道理、一篇时政评论或一份具体作品、一份纯素材 / 范文合集。
-   —— 这是"内容 / 作品本身",不是"教你怎么创作内容的方法"。例如"一段历史事实"不算;"怎么把历史事实讲成一期视频/文章"算。
+它教的是构思、选题、写作、结构、表达、呈现、判断、复盘等创作决策。
+不是用某个 App、AI、小程序、软件,把内容生成、排版、剪辑、导出。
+
+算:
+- 选题方法
+- 脚本结构
+- 标题公式
+- 封面判断
+- 叙事方式
+- 人物冲突
+- 节奏设计
+- 笑点设计
+- 反差设计
+- 表达方式
+- 账号定位
+- 内容数据复盘
+
+不算:
+- 打开工具 -> 输入/粘贴 -> 生成/扩写 -> 设参数 -> 导出
+- 剪辑、调色、排版、导出、软件操作
+- 让 AI 或 App 直接替你产出成品,且没有创作判断方法
+</判定标准>
+
+<案例型创作知识>
+案例型创作知识也可以算。
+
+如果帖子给的是完整脚本、完整范例、成品案例、拆解样例,但能从中看出可迁移的创作结构、叙事套路、段落功能、表达方法、笑点设计、反差设计、镜头组织或内容组织方法,可以判为创作知识候选。
+
+例:如果一个脚本通过日常预期与异常设定、身份、规则或结果之间的反差制造笑点,并体现出可复用的设定、递进、反转或回扣方法,可以作为"反差感搞笑脚本"的案例型创作知识。
+
+但不能把所有成品都拔高成方法。
+如果只是可复制文案、脚本范文、素材包、标题合集、金句合集,且抽不出可迁移结构、方法或判断依据,仍判非创作。
+
+粗筛阶段的原则:
+- 如果它明显是纯素材、纯范文、具体作品,判非创作。
+- 如果它像案例,但确实能看出可迁移结构,可以先判创作知识候选,交后续 Decode 深筛。
+</案例型创作知识>
+
+<一票否决>
+命中以下任一类,判非创作知识:
+
+1. 【题材本身】
+教的是做某个东西本身,而不是做关于它的内容。
+例如游戏玩法、关卡设计、产品开发、电商选品、运营动作、行业分析、做菜本身。
+
+2. 【制作/工具操作】
+核心是用 AI、App、小程序、软件生成、排版、剪辑、调色、导出内容。
+除非它明确教的是创作判断、需求拆解、结构设计,而不是工具执行。
+
+3. 【应试/学术/公文】
+申论、高考作文、中考作文、作文素材、答题模板、论文、公文、讲话稿等,以拿分、合规范为目标的写作。
+
+4. 【学科知识/观点本身】
+只是在讲历史、政治、经济、外交、人生道理、情绪感悟、时事评论、个人观点本身。
+除非它明确教"如何把这些题材创作为内容作品"。
+
+5. 【纯素材/纯范文/具体作品】
+只是提供现成脚本、标题、金句、素材、范文、案例合集,且无法抽出可迁移创作结构。
 </一票否决>
 </一票否决>
 
 
-<判定>
-- 先复述"这帖到底在教什么",再按轴A/轴B判断:教的是"怎么创作内容作品、怎么组织表达、怎么让内容被理解/吸引/看完/记住/传播"的可迁移方法 → 算(is_empty=false)。
-- "做题材本身 / 应试达标 / 用工具把成品做出来 / 某学科知识或观点本身 / 纯素材范文 / 具体作品" → 不算(is_empty=true)。
-- 范围内但拿不准有没有方法 → 可以算(false),交后续再筛;但"在不在范围内"要果断,明显越界就判非创作(true)。
-- 拿不准、模棱两可、半创作半制作时,除非核心知识明确落在创作决策上,否则倾向判非创作(true)。
-- 若 is_empty=false,把帖子里教的**具体创作知识点**忠实提炼出来(分条、不编造、不拔高)。
+<输出要求>
+先判断这帖到底在教什么,再给结论。
 
 
 只输出一个 JSON 对象:
 只输出一个 JSON 对象:
-{"is_empty": true/false,
- "reason": "判断理由:说明它教了哪种内容创作方法,或属于应试/制作/学科/作品本身的哪一类;保留必要证据,不要为了简短而省略关键判断依据",
- "knowledge": "is_empty=false 时:帖子里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}
+{
+  "is_empty": true/false,
+  "reason": "判断理由。说明它为什么是创作知识,或为什么只是题材本身/制作工具/应试写作/作品本身/纯素材。若是案例型知识,要说明可迁移结构证据。",
+  "knowledge": "is_empty=false 时,提炼帖子中的具体创作知识点;is_empty=true 时,输出空字符串"
+}

+ 84 - 34
prompts/classify_video.txt

@@ -1,42 +1,92 @@
-你在判断一条短视频是不是【图文/视频内容创作】的可迁移知识。请看完整段视频(听口播、看画面与字幕)再判断
+你在判断一条短视频是不是【图文/视频内容创作】的可迁移知识。请看完整段视频,听口播、看画面与字幕;知识可能在标题、正文、字幕、口播或画面里
 
 
-<什么算创作知识>
-创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构或清单。判断必须同时过两条轴,缺一不算:
+<判定标准>
+创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构、清单或高质量案例。
 
 
-【轴A·产出物是不是内容】
-这条知识帮你创作出来的成品,必须是短视频 / 图文 / 文章 / 脚本 / 剧本 / 小说 / 播客 / 课程 / 解说等可被阅读、观看、收听或使用的**内容作品**。题材不限,卡的是产出物。
-- 算:产出"关于某题材的内容"——游戏实况/解说视频、历史视频脚本、知识科普视频、电商带货视频 idea、小说结构。
-- 不算:产出"题材本身"——游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身。
+必须同时满足两条轴:
+
+【轴A·产出物是内容】
+这条知识帮助创作出来的成品,必须是图文、视频、文章、脚本、剧本、小说、播客、课程、解说等内容作品。
+题材不限,关键看产出物是不是"内容"。
+
+算:
+- 如何写短视频脚本
+- 如何构思图文选题
+- 如何设计标题、封面、开头、结尾
+- 如何组织故事、观点、节奏、冲突、反差、笑点
+- 如何把历史、游戏、电商、生活经验等题材做成视频、图文或文章
+
+不算:
+- 教游戏玩法、关卡设计、产品开发、电商选品、运营动作、行业分析、做菜本身
+- 这些是在做题材本身,不是在做"关于题材的内容"
 
 
 【轴B·是创作不是制作】
 【轴B·是创作不是制作】
-它教的是"怎么构思 / 选题 / 写 / 结构 / 呈现 / 判断"(创作决策),不是"用某 App/AI/软件把成品做出来"(制作/工具操作)。
-- 算:选题/构思方法、脚本结构、标题公式、封面设计判断、叙事与呈现、人物冲突、节奏设计、表达方式、账号定位、数据复盘。
-- 不算:打开某 App/小程序/AI → 输入/粘贴 → 生成/扩写 → 设参数 → 导出/保存;剪辑、调色、导出、排版等编辑器操作。
-- 特别强调:即使用 AI/App 生成的是文案、图片或视频,只要核心是"让工具替你产出成品",就算制作,不算创作;除非它真的在教如何拆解创作需求、建立可迁移的判断和方法。
-
-【两轴 AND】轴A、轴B 都过才算创作知识;任一不过就判非创作。
-</什么算创作知识>
-
-<一票否决·命中任一即判非创作(is_empty=true)>
-整条视频主体只要落在下面任一类,就判**非创作**,别拔高、别勉强:
-1. 【题材本身 / 越界对象】教的是做那个东西本身,而不是做关于它的内容:游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身等。
-   —— 同样讲游戏:做游戏 = 不算;做游戏视频/解说脚本 = 算。同样讲电商:做选品运营 = 不算;做电商带货视频 idea = 算。
-2. 【制作 / 工具操作】用 AI / App / 小程序 / 软件 / 提示词 / 参数把内容生成或做出来(文生视频、AI 写文案/出图、提示词框架、参数设置),以及剪辑 / 调色 / 导出 / 排版等编辑器操作。
-   —— 这是"怎么把成品做出来(制作 / 执行)",不是"怎么构思出更好的内容(创作)"。
-3. 【一个人讲观点 / 道理 / 感悟 = 作品本身】对着镜头讲人生观 / 价值观 / 人生感悟 / 某个道理 / 某个话题(如"人生的意义""如何看待 XX""我最有帮助的改变")。
-   —— 这是在表达观点 / 内容本身,不是教别人怎么创作内容。不要把"一个人在表达观点"拔高成"教你做观点输出 / 口播内容";但如果它明确在教观点类内容的选题、结构、表达、节奏或判断标准,则算创作知识。
-4. 【应试 / 学术写作】申论、高考/中考作文、作文或申论范文、人物/作文素材、答题模板、解题套路、考研/学术论文、公文 / 讲话稿。目标是拿分数 / 合规范,不是创作内容作品;除非它明确抽象出可迁移到内容创作的叙事、结构、表达方法。
-5. 【学科知识 / 评论 / 作品本身】只是在讲某学科知识(历史 / 政治 / 外交本身)、一篇时政评论、纯叙事讲故事 / 抒情励志 / 个人经历分享、纯素材合集。是内容 / 作品本身,不是创作方法。例如"一段历史事实"不算;"怎么把历史事实讲成一期视频/文章"算。
+它教的是构思、选题、写作、结构、表达、呈现、判断、复盘等创作决策。
+不是用某个 App、AI、小程序、软件,把内容生成、排版、剪辑、导出。
+
+算:
+- 选题方法
+- 脚本结构
+- 标题公式
+- 封面判断
+- 叙事方式
+- 人物冲突
+- 节奏设计
+- 笑点设计
+- 反差设计
+- 表达方式
+- 账号定位
+- 内容数据复盘
+
+不算:
+- 打开工具 -> 输入/粘贴 -> 生成/扩写 -> 设参数 -> 导出
+- 剪辑、调色、排版、导出、软件操作
+- 让 AI 或 App 直接替你产出成品,且没有创作判断方法
+</判定标准>
+
+<案例型创作知识>
+案例型创作知识也可以算。
+
+如果视频给的是完整脚本、完整范例、成品案例、拆解样例,但能从中看出可迁移的创作结构、叙事套路、段落功能、表达方法、笑点设计、反差设计、镜头组织或内容组织方法,可以判为创作知识候选。
+
+例:如果一个脚本通过日常预期与异常设定、身份、规则或结果之间的反差制造笑点,并体现出可复用的设定、递进、反转或回扣方法,可以作为"反差感搞笑脚本"的案例型创作知识。
+
+但不能把所有成品都拔高成方法。
+如果只是可复制文案、脚本范文、素材包、标题合集、金句合集,且抽不出可迁移结构、方法或判断依据,仍判非创作。
+
+粗筛阶段的原则:
+- 如果它明显是纯素材、纯范文、具体作品,判非创作。
+- 如果它像案例,但确实能看出可迁移结构,可以先判创作知识候选,交后续 Decode 深筛。
+</案例型创作知识>
+
+<一票否决>
+命中以下任一类,判非创作知识:
+
+1. 【题材本身】
+教的是做某个东西本身,而不是做关于它的内容。
+例如游戏玩法、关卡设计、产品开发、电商选品、运营动作、行业分析、做菜本身。
+
+2. 【制作/工具操作】
+核心是用 AI、App、小程序、软件生成、排版、剪辑、调色、导出内容。
+除非它明确教的是创作判断、需求拆解、结构设计,而不是工具执行。
+
+3. 【应试/学术/公文】
+申论、高考作文、中考作文、作文素材、答题模板、论文、公文、讲话稿等,以拿分、合规范为目标的写作。
+
+4. 【学科知识/观点本身】
+只是在讲历史、政治、经济、外交、人生道理、情绪感悟、时事评论、个人观点本身。
+除非它明确教"如何把这些题材创作为内容作品"。
+
+5. 【纯素材/纯范文/具体作品】
+只是提供现成脚本、标题、金句、素材、范文、案例合集,且无法抽出可迁移创作结构。
 </一票否决>
 </一票否决>
 
 
-<判定>
-- 先复述"这条视频到底在教什么",再按轴A/轴B判断:教的是"怎么创作内容作品、怎么组织表达、怎么让内容被理解/吸引/看完/记住/传播"的可迁移方法 → 算(is_empty=false)。
-- "做题材本身 / 讲观点或道理本身 / 应试达标 / 用工具把成品做出来 / 某学科知识或作品本身 / 纯素材合集" → 不算(is_empty=true)。
-- 范围内但拿不准有没有方法 → 可以算(false),交后续再筛;但"在不在范围内"要果断,明显越界就判非创作(true)。
-- 拿不准、模棱两可、半创作半制作时,除非核心知识明确落在创作决策上,否则倾向判非创作(true)。
-- 若 is_empty=false,把视频里教的**具体创作知识点**忠实提炼出来(分条、不编造、不拔高)。
+<输出要求>
+先判断这条视频到底在教什么,再给结论。
 
 
 只输出一个 JSON 对象:
 只输出一个 JSON 对象:
-{"is_empty": true/false,
- "reason": "判断理由:说明它教了哪种内容创作方法,或属于讲观点/应试/制作/学科/作品本身的哪一类;保留必要证据,不要为了简短而省略关键判断依据",
- "knowledge": "is_empty=false 时:视频里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}
+{
+  "is_empty": true/false,
+  "reason": "判断理由。说明它为什么是创作知识,或为什么只是题材本身/制作工具/应试写作/作品本身/纯素材。若是案例型知识,要说明可迁移结构证据。",
+  "knowledge": "is_empty=false 时,提炼视频中的具体创作知识点;is_empty=true 时,输出空字符串"
+}

+ 7 - 3
prompts/extract.txt

@@ -18,9 +18,13 @@
 </什么算创作知识>
 </什么算创作知识>
 
 
 <工作要点>
 <工作要点>
-- 知识常在图片/视频里,正文常常只是话题串(如 "#短剧编剧# #写作#")。为什么强调:只读正文会系统性漏掉真正的知识,所以必须看图、看视频。
+- 正文、图片、视频都可能承载创作知识,不同平台重点不同:
+  - 公众号文章通常以正文为主,图片可能只是配图、装饰、二维码、关注引导或 UI 动效。
+  - 小红书图文可能正文和图片都重要。
+  - 视频内容以口播、字幕、画面共同判断。
+- 本步的首要目标是:忠实读懂原帖里真正讲到的创作方法、清单、原理和判断标准。不要为了填图片卡片而把正文知识硬挂到图片上,也不要把装饰图、封面图、账号主页截图当成知识来源。
 - 以原始素材为准,忠实转述,不编造、不补全、不替作者总结它没说的结论;有的尽量提全。
 - 以原始素材为准,忠实转述,不编造、不补全、不替作者总结它没说的结论;有的尽量提全。
-- **按卡片归因**:每张图/帧旁都标了【卡片N】。除了 text(综合所有卡片把创作知识讲清楚,主产物),还要填 cards——每张**有知识**的卡片一条 {{"index": N, "content": "这张卡上的知识要点"}},N 就是【卡片N】里的号;某张卡没有知识就不列它。为什么:后续要据此把每条知识溯源到具体卡片。
+- `text` 是主产物:把整帖中可迁移的创作知识完整讲清楚。`cards` 只是辅助:只有当某张图片/卡片**本身明确承载了创作知识**时,才写入 cards;如果知识来自正文,就只写进 text,不必写 cards。`from_video` 只有在真实看到了视频内容并能确认视频里讲了这些知识时才写;如果正文里只是出现 video 链接或占位符,不要写 from_video
 - **is_empty(整条流程的总闸)**:判 `true` 后,后面的拆颗 / 归类 / 入库**全部不走**。两种情况标 `true`:
 - **is_empty(整条流程的总闸)**:判 `true` 后,后面的拆颗 / 归类 / 入库**全部不走**。两种情况标 `true`:
   ① **纯作品/纯展示/纯无关**——通篇看完确实没有任何可迁移的创作方法/原理/清单。
   ① **纯作品/纯展示/纯无关**——通篇看完确实没有任何可迁移的创作方法/原理/清单。
   ② **整帖不在范围内(越界要果断判 true,别放行)**——整帖产出物不是图文/视频内容(如游戏玩法/关卡设计、产品/运营/选品、做菜本身),**或**整帖是制作/工具操作(如"用某 AI/App 几步做出一段视频"的打开→点按钮→导出流程)。这类即便看着像"教程/步骤",也按轴A/轴B 判定为越界。
   ② **整帖不在范围内(越界要果断判 true,别放行)**——整帖产出物不是图文/视频内容(如游戏玩法/关卡设计、产品/运营/选品、做菜本身),**或**整帖是制作/工具操作(如"用某 AI/App 几步做出一段视频"的打开→点按钮→导出流程)。这类即便看着像"教程/步骤",也按轴A/轴B 判定为越界。
@@ -50,6 +54,6 @@
 <输出>
 <输出>
 只输出一个 JSON 对象:
 只输出一个 JSON 对象:
 {{"text": "把创作知识完整忠实讲清楚;没有就空字符串",
 {{"text": "把创作知识完整忠实讲清楚;没有就空字符串",
-  "cards": [{{"index": 卡片号, "content": "这张卡的知识要点"}}],
+  "cards": [{{"index": 卡片号, "content": "这张卡片本身讲到的知识要点"}}],
   "from_image": "", "from_video": "", "is_empty": false}}
   "from_image": "", "from_video": "", "is_empty": false}}
 </输出>
 </输出>

+ 1 - 0
prompts/extract_video.txt

@@ -8,6 +8,7 @@
 - 视频是口播 + 画面 + 字幕,请**同时听口播、看画面与字幕**——口播里往往是核心知识,别只看画面。
 - 视频是口播 + 画面 + 字幕,请**同时听口播、看画面与字幕**——口播里往往是核心知识,别只看画面。
 - 以视频实际内容为准,忠实提炼,原文没有的不要编造、不要拔高。
 - 以视频实际内容为准,忠实提炼,原文没有的不要编造、不要拔高。
 - **按视频时间顺序分段**,每段给时间戳和这段讲到的创作知识;一段对应一个相对完整的知识点/小主题。
 - **按视频时间顺序分段**,每段给时间戳和这段讲到的创作知识;一段对应一个相对完整的知识点/小主题。
+- 只有你能明确判断某个知识点出现在这一段,才生成该 segment。如果全片只是展示、没有明确讲创作方法,就不要为了凑 segment 编造知识。时间戳必须来自视频内容判断;不能确定时间范围时,不要生成这一段。
 - 每段标 What/Why/How(这段讲的是"是什么/有哪些"、"为什么有效"、"怎么做"),有哪个填哪个,没有的填 null。
 - 每段标 What/Why/How(这段讲的是"是什么/有哪些"、"为什么有效"、"怎么做"),有哪个填哪个,没有的填 null。
 </工作要点>
 </工作要点>
 
 

+ 50 - 10
prompts/gate_admit.txt

@@ -1,13 +1,53 @@
-你在判断:一篇帖子「读懂后的内容」,是不是【图文/视频内容创作】的可迁移知识。用户消息就是这帖读懂后的内容。
+你在判断:一篇帖子"读懂后的内容",是不是【图文/视频内容创作】的可迁移知识。
+用户消息就是这帖读懂后的内容。
 
 
-两条轴**都过**,才算"创作知识"(in_scope=true):
-- 轴A·产出物是内容:它教你创作出来的成品,是 图文 / 视频 / 脚本 / 剧本 / 小说 这类【内容】(题材不限:美食/游戏/健身都行)。
-- 轴B·是创作不是制作:它教"怎么构思/选题/写/结构/呈现"(创作),不是"用某 App/AI、打开→输入→生成→导出 把成品做出来"(制作/工具操作)。
+<判定标准>
+两条轴都过,才算创作知识:
 
 
-**题材不限,只看产出物 + 层面**——同样涉及游戏/电商也可能是创作:
-- ✅ 算:『如何创作**游戏实况/解说视频**』『如何构思**电商带货视频**的 idea』『如何构思**小说**结构』——产出是视频/小说**内容**、教的是创作。
-- ❌ 不算:『如何**设计游戏玩法/关卡**』『如何**做电商选品/运营**』——产出是游戏/生意**本身**,不是"关于它的内容"。
-一句话:**做题材本身→不算;做"关于题材的内容"→算。**
+【轴A·产出物是内容】
+它帮助创作出来的成品,是图文、视频、文章、脚本、剧本、小说、播客、课程、解说等内容作品。
+题材不限,关键看产出物是不是内容。
 
 
-先用一句话复述"这帖到底在教什么",再对照两轴判定。
-输出 JSON:{"复述":"<一句话>","轴A":true/false,"轴B":true/false,"in_scope":true/false,"category":"创作/制作/越界","理由":"<为什么>"}
+【轴B·是创作不是制作】
+它教的是构思、选题、写作、结构、呈现、表达、判断、复盘等创作决策。
+不是用某个 App、AI、软件执行生成、排版、剪辑、导出。
+
+算:
+- 如何创作短视频脚本
+- 如何构思图文选题
+- 如何设计标题、开头、封面、结尾
+- 如何组织故事、观点、节奏、冲突、反差、笑点
+- 如何把某个题材做成视频、图文、文章或脚本
+
+不算:
+- 如何做游戏玩法、产品开发、电商选品、运营动作、行业分析
+- 如何操作工具生成内容、排版、剪辑、导出
+- 只是在表达观点、讲知识、讲故事、给素材或给成品
+</判定标准>
+
+<案例型创作知识>
+案例型内容可以算,但必须严格。
+
+如果读懂内容里给的是完整脚本、完整范例、成品案例或拆解样例,只要能从中忠实抽出可迁移的创作结构、叙事套路、段落功能、表达方法、笑点设计、反差设计、镜头组织或内容组织方法,可以判为 in_scope=true。
+
+例:如果一个脚本通过日常预期与异常设定、身份、规则或结果之间的反差制造笑点,并体现出可复用的设定、递进、反转或回扣方法,可以作为"反差感搞笑脚本"的案例型创作知识。
+
+但不要把任何成品都拔高成方法。
+如果它只是可复制文案、脚本范文、素材包、标题合集、金句合集,且抽不出可迁移结构、方法或判断依据,判 in_scope=false。
+
+判断案例型知识时,重点问:
+1. 去掉具体人物、行业、话题后,剩下的结构还能不能迁移到另一个选题?
+2. 它是否体现了创作层面的设计,而不只是给了一个成品?
+3. 能否进一步拆成 What / How / Why 的知识颗粒?
+</案例型创作知识>
+
+先用一句话复述"这帖到底在教什么",再对照两条轴判断。
+输出 JSON:
+{
+  "复述": "<一句话>",
+  "轴A": true/false,
+  "轴B": true/false,
+  "in_scope": true/false,
+  "category": "创作/案例型创作/制作/越界/应试/观点本身/纯素材",
+  "理由": "<为什么;如果是案例型创作,必须说明可迁移结构证据>"
+}

+ 36 - 7
prompts/gate_refute.txt

@@ -1,11 +1,40 @@
-你在审查(带着怀疑核验,但只认事实、命中才算):一篇帖子「读懂后的内容」,是不是其实【不该进创作知识库】。用户消息就是这帖读懂后的内容。
+你在审查(带着怀疑核验,但只认事实、命中才算):一篇帖子"读懂后的内容",是不是其实【不该进创作知识库】。
+用户消息就是这帖读懂后的内容。
 
 
-属于以下任一 → out_of_scope=true(该排除):
-- 制作/工具操作:核心是"用某 App/AI/软件,打开→输入/粘贴→生成/扩写→设参数→导出/保存"把成品产出来。**哪怕它教你"怎么给 AI 下指令 / 设身份 / 给字数要求",只要本质是让工具替你产出,就算制作。**
-- 越界题材本身:教的是"做那个东西本身"——游戏玩法/关卡设计、产品开发、电商/运营/选品、行业分析等(不是"做关于它的内容")。
+<排除规则>
+以下情况判 out_of_scope=true:
 
 
-⚠️ **别把题材当越界(最容易误伤)**:题材是游戏/电商/任何都没关系——『如何创作**游戏视频** / 构思**电商带货视频** idea / 构思**小说**结构』是**创作**(产出是视频/小说内容),**不要排除**;只有"做**游戏玩法** / 做**电商选品运营**本身"(产出是游戏/生意、不是关于它的内容)才排除。判据就一句:**做题材本身→排除;做"关于题材的内容"→收。**
+1. 制作/工具操作:
+核心是用 AI、App、小程序、软件生成、扩写、排版、剪辑、调色、导出。
 
 
-逐条对照上面的越界判据**如实核验**:确实命中就判 out_of_scope=true;一条都不命中,就如实判 out_of_scope=false——不要硬凑越界理由。
+2. 越界题材本身:
+教的是做题材本身,例如游戏玩法、关卡设计、产品开发、电商选品、运营、行业分析,而不是做关于它的内容。
+
+3. 应试/学术/公文:
+申论、作文、答题模板、论文、公文、讲话稿等,以拿分、合规范为目标。
+
+4. 观点/知识/故事本身:
+只是在讲某个知识、观点、故事、感悟、评论、经历,没有教内容创作方法。
+
+5. 纯素材/纯范文/具体作品:
+只提供现成脚本、文案、标题、金句、素材、范文,无法抽出可迁移创作结构。
+</排除规则>
+
+<不要误排除>
+题材是游戏、电商、历史、生活、职场、任何领域都没关系。关键是看它是在做题材本身,还是做"关于题材的内容"。
+
+案例型内容不要直接按"具体作品"排除。
+如果读懂内容虽然给的是完整脚本、完整范例或成品案例,但能从中忠实抽出可迁移的创作结构、叙事套路、段落功能、表达方法、笑点设计、反差设计、镜头组织或内容组织方法,就不要判 out_of_scope=true。
+
+但如果它只是可复制文案、脚本范文、素材包、标题合集、金句合集,抽不出可迁移结构、方法或判断依据,就应判 out_of_scope=true。
+</不要误排除>
+
+逐条对照上面的排除规则如实核验:确实命中就判 out_of_scope=true;一条都不命中,就判 out_of_scope=false。
 先用一句话复述"这帖在教什么",再判。
 先用一句话复述"这帖在教什么",再判。
-输出 JSON:{"复述":"<一句话>","out_of_scope":true/false,"category":"创作/制作/越界","理由":"<越界点;或为何确实没有>"}
+输出 JSON:
+{
+  "复述": "<一句话>",
+  "out_of_scope": true/false,
+  "category": "创作/案例型创作/制作/越界/应试/观点本身/纯素材",
+  "理由": "<越界点;或为何确实没有;如果是案例型内容,要说明是否能抽出可迁移结构>"
+}

+ 38 - 7
prompts/gate_tiebreak.txt

@@ -1,11 +1,42 @@
-两个判断者对"这帖是否属于【图文/视频内容创作知识】"产生了分歧,你来裁决。用户消息就是这帖读懂后的内容。
+两个判断者对"这帖是否属于【图文/视频内容创作知识】"产生了分歧,你来裁决。
+用户消息就是这帖读懂后的内容。
 
 
-标准(两轴**都过**才算创作 in_scope=true):
-- 轴A:产出物是 图文 / 视频 / 脚本 / 剧本 / 小说 这类内容;
-- 轴B:教创作(构思/选题/写/结构/呈现),不是"用工具产出成品"、也不是"做题材本身"(游戏/产品/运营/选品/行业分析)。
+<裁决标准>
+两条轴都过,才算创作知识(in_scope=true):
+- 轴A:产出物是图文、视频、文章、脚本、剧本、小说、播客、课程、解说等内容作品。
+- 轴B:教创作决策,例如构思、选题、写作、结构、呈现、表达、判断、复盘;不是工具生成、排版、剪辑、导出,也不是做题材本身。
 
 
-**题材不限,看产出物+层面、不看题材词**:『创作游戏视频 / 构思电商带货视频 idea / 构思小说结构』= 创作(收);『设计游戏玩法 / 做电商选品运营』= 越界(排除)。
+题材不限,看产出物和层面:
+- 创作游戏视频、构思电商带货视频 idea、写历史解说脚本 = 创作。
+- 设计游戏玩法、做电商选品运营、讲行业分析本身 = 越界。
+</裁决标准>
+
+<案例型创作知识>
+案例型内容可以算,但必须能抽出可迁移结构。
+
+如果读懂内容给的是完整脚本、完整范例、成品案例或拆解样例,并能从中忠实抽出可迁移的创作结构、叙事套路、段落功能、表达方法、笑点设计、反差设计、镜头组织或内容组织方法,可以判 in_scope=true。
+
+例:如果一个脚本通过日常预期与异常设定、身份、规则或结果之间的反差制造笑点,并体现出可复用的设定、递进、反转或回扣方法,可以作为"反差感搞笑脚本"的案例型创作知识。
+
+如果只是可复制文案、脚本范文、素材包、标题合集、金句合集,且抽不出可迁移结构、方法或判断依据,判 in_scope=false。
+</案例型创作知识>
+
+<排除规则>
+以下情况判 in_scope=false:
+- 制作/工具操作:用 AI、App、小程序、软件生成、扩写、排版、剪辑、调色、导出。
+- 越界题材本身:做游戏玩法、产品开发、电商选品、运营、行业分析等题材本身。
+- 应试/学术/公文:申论、作文、答题模板、论文、公文、讲话稿等。
+- 观点/知识/故事本身:只是在讲知识、观点、故事、感悟、评论、经历,没有教内容创作方法。
+- 纯素材/纯范文/具体作品:只提供现成脚本、文案、标题、金句、素材、范文,无法抽出可迁移创作结构。
+</排除规则>
+
+拿不准、模棱两可、半创作半制作时,除非核心知识明确落在创作决策或可迁移案例结构上,否则判 in_scope=false。
 
 
-**重要:拿不准、模棱两可、半创作半制作 → 一律判 in_scope=false(边界倾向排除)。**
 先用一句话复述"这帖在教什么",再裁决。
 先用一句话复述"这帖在教什么",再裁决。
-输出 JSON:{"复述":"<一句话>","in_scope":true/false,"category":"创作/制作/越界","理由":"<为什么>"}
+输出 JSON:
+{
+  "复述": "<一句话>",
+  "in_scope": true/false,
+  "category": "创作/案例型创作/制作/越界/应试/观点本身/纯素材",
+  "理由": "<为什么;如果是案例型创作,必须说明可迁移结构证据>"
+}

+ 2 - 2
scripts/build_creation_demo.py

@@ -33,7 +33,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
     parser.add_argument("--per", type=int, default=0, help="Max queries per family; 0 means all combinations")
     parser.add_argument("--per", type=int, default=0, help="Max queries per family; 0 means all combinations")
     parser.add_argument("--batch-n", type=int, default=0, help="Max tree nodes per axis; 0 means all L3/L4 nodes")
     parser.add_argument("--batch-n", type=int, default=0, help="Max tree nodes per axis; 0 means all L3/L4 nodes")
     parser.add_argument("--seed", type=int, default=7)
     parser.add_argument("--seed", type=int, default=7)
-    parser.add_argument("--dry", action="store_true", help="Skip LLM query filtering")
+    parser.add_argument("--enable-query-filter", action="store_true", help="Run the optional LLM query pre-filter")
     parser.add_argument(
     parser.add_argument(
         "--family",
         "--family",
         action="append",
         action="append",
@@ -74,7 +74,7 @@ def main(argv: list[str] | None = None) -> int:
             per=args.per,
             per=args.per,
             batch_n=args.batch_n,
             batch_n=args.batch_n,
             seed=args.seed,
             seed=args.seed,
-            dry=args.dry,
+            enable_query_filter=args.enable_query_filter,
             active_family_keys=tuple(args.families) if args.families else ("f1", "f2"),
             active_family_keys=tuple(args.families) if args.families else ("f1", "f2"),
         ),
         ),
     )
     )

+ 122 - 0
scripts/run_creation_pipeline.py

@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Run formal acquisition, decode every coarse-hit item, and build payload drafts."""
+from __future__ import annotations
+
+import argparse
+import json
+from dataclasses import asdict, is_dataclass
+from uuid import UUID
+
+from acquisition.repositories.postgres import PostgresAcquisitionRepository
+from acquisition.runner import DEFAULT_PLATFORMS, run_batch
+from core.config import CreationDbConfig, Settings
+from core.db_session import transaction
+from decode_content.repositories.postgres import PostgresDecodeRepository
+from decode_content.service import DecodeService
+from pipeline.decode_runner import run_decode_stage
+
+
+def _model_dump(value):
+    if hasattr(value, "model_dump"):
+        return value.model_dump(mode="json")
+    if is_dataclass(value):
+        return asdict(value)
+    if isinstance(value, dict):
+        return value
+    return dict(value)
+
+
+def _dry_ingest_payloads(repo: PostgresDecodeRepository, outputs: list) -> list[dict]:
+    records: list[dict] = []
+    for output in outputs:
+        for draft in output.payload_drafts:
+            if draft.id is None:
+                continue
+            repo.mark_payload_draft_ingested(draft.id)
+            record = repo.save_ingest_record(
+                payload_draft_id=draft.id,
+                target_system="dry-run",
+                target_id=str(draft.id),
+                status="ingested",
+                response_payload={
+                    "dry_run": True,
+                    "note": "payload generated by formal creation pipeline; external ingest API not called",
+                    "payload": draft.payload,
+                },
+            )
+            records.append(_model_dump(record))
+    return records
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--batch-id", required=True, help="Formal query batch UUID")
+    parser.add_argument(
+        "--platform",
+        action="append",
+        choices=DEFAULT_PLATFORMS,
+        help="Platform to run. Repeat to run multiple platforms. Default: all.",
+    )
+    parser.add_argument("--search-limit", type=int, default=10)
+    parser.add_argument("--display-limit", type=int, default=5)
+    parser.add_argument("--decode-limit", type=int, default=100)
+    parser.add_argument("--run-key")
+    parser.add_argument("--env-file", default=".env")
+    parser.add_argument("--no-resume", action="store_true")
+    parser.add_argument("--no-skip-done", action="store_true")
+    parser.add_argument("--no-dry-ingest-record", action="store_true")
+    return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = parse_args(argv)
+    settings = Settings.from_env(args.env_file)
+    db_config = CreationDbConfig.from_env(args.env_file)
+    platforms = tuple(args.platform or DEFAULT_PLATFORMS)
+    batch_id = UUID(args.batch_id)
+
+    with transaction(db_config) as conn:
+        acquisition_repo = PostgresAcquisitionRepository(conn)
+        acquisition = run_batch(
+            acquisition_repo,
+            batch_id=batch_id,
+            settings=settings,
+            platforms=platforms,
+            search_limit=args.search_limit,
+            display_limit=args.display_limit,
+            classify=True,
+            resume=not args.no_resume,
+            skip_done=not args.no_skip_done,
+            run_key=args.run_key,
+        )
+
+    with transaction(db_config) as conn:
+        acquisition_repo = PostgresAcquisitionRepository(conn)
+        decode_repo = PostgresDecodeRepository(conn)
+        decode_service = DecodeService(settings=settings, repository=decode_repo)
+        decode = run_decode_stage(
+            candidate_repo=acquisition_repo,
+            decode_service=decode_service,
+            run_id=acquisition.run_id,
+            limit=args.decode_limit,
+        )
+        ingest_records = [] if args.no_dry_ingest_record else _dry_ingest_payloads(decode_repo, decode.outputs)
+
+    print(json.dumps(
+        {
+            "batch_id": str(batch_id),
+            "run_id": str(acquisition.run_id),
+            "platforms": list(platforms),
+            "acquisition": _model_dump(acquisition),
+            "decode": _model_dump(decode),
+            "dry_ingest_records": ingest_records,
+        },
+        ensure_ascii=False,
+        default=str,
+        indent=2,
+    ))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 4 - 4
scripts/run_creation_singleton.py

@@ -37,9 +37,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
     parser.add_argument("--batch-n", type=int, default=30)
     parser.add_argument("--batch-n", type=int, default=30)
     parser.add_argument("--seed", type=int, default=7)
     parser.add_argument("--seed", type=int, default=7)
     parser.add_argument(
     parser.add_argument(
-        "--dry-query-filter",
+        "--enable-query-filter",
         action="store_true",
         action="store_true",
-        help="Skip only the query-filter LLM; search/classify/decode remain real.",
+        help="Run the optional query-filter LLM before search.",
     )
     )
     parser.add_argument(
     parser.add_argument(
         "--platform",
         "--platform",
@@ -49,7 +49,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
     )
     )
     parser.add_argument("--search-limit", type=int, default=1)
     parser.add_argument("--search-limit", type=int, default=1)
     parser.add_argument("--display-limit", type=int, default=1)
     parser.add_argument("--display-limit", type=int, default=1)
-    parser.add_argument("--decode-limit", type=int, default=1)
+    parser.add_argument("--decode-limit", type=int, default=100)
     parser.add_argument("--name", default="")
     parser.add_argument("--name", default="")
     parser.add_argument("--frontend-base", default="http://127.0.0.1:5180/app/")
     parser.add_argument("--frontend-base", default="http://127.0.0.1:5180/app/")
     return parser.parse_args(argv)
     return parser.parse_args(argv)
@@ -109,7 +109,7 @@ def main(argv: list[str] | None = None) -> int:
             per=args.per,
             per=args.per,
             batch_n=args.batch_n,
             batch_n=args.batch_n,
             seed=args.seed,
             seed=args.seed,
-            dry=args.dry_query_filter,
+            enable_query_filter=args.enable_query_filter,
             active_family_keys=("f1", "f2"),
             active_family_keys=("f1", "f2"),
         ),
         ),
     )
     )

+ 112 - 4
tests/test_acquisition_runner.py

@@ -330,6 +330,38 @@ class PagedAdapter:
         return detail
         return detail
 
 
 
 
+class RetryPagedAdapter:
+    platform = "douyin"
+
+    def __init__(
+        self,
+        page_batches: list[list[PlatformSearchPage]],
+        details: dict[str, PlatformItem | Exception],
+    ) -> None:
+        self.page_batches = page_batches
+        self.details = details
+        self.search_calls = 0
+        self.pages_requested = 0
+        self.detail_calls = 0
+        self.limiter_ids: list[int] = []
+
+    def search_pages(self, query, *, settings, limit, rate_limiter, max_pages=2):
+        self.limiter_ids.append(id(rate_limiter))
+        batch_index = min(self.search_calls, len(self.page_batches) - 1)
+        self.search_calls += 1
+        for page in self.page_batches[batch_index][:max_pages]:
+            self.pages_requested += 1
+            yield page
+
+    def fetch_detail(self, candidate, *, settings, rate_limiter):
+        self.limiter_ids.append(id(rate_limiter))
+        self.detail_calls += 1
+        detail = self.details[candidate.source_id]
+        if isinstance(detail, Exception):
+            raise detail
+        return detail
+
+
 def _detail(
 def _detail(
     source_id: str,
     source_id: str,
     *,
     *,
@@ -605,6 +637,7 @@ def test_run_batch_requests_second_page_when_first_page_creation_ratio_reaches_t
         media_stabilizer=lambda **kwargs: [],
         media_stabilizer=lambda **kwargs: [],
         classifier=_classification_by_title({"a", "c"}),
         classifier=_classification_by_title({"a", "c"}),
         rate_limiter_factory=lambda platform: object(),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
     )
 
 
     pagination = repo.updated_jobs[-1].metadata["pagination"]
     pagination = repo.updated_jobs[-1].metadata["pagination"]
@@ -613,6 +646,7 @@ def test_run_batch_requests_second_page_when_first_page_creation_ratio_reaches_t
     assert pagination["first_page_classified_count"] == 2
     assert pagination["first_page_classified_count"] == 2
     assert pagination["first_page_creation_count"] == 1
     assert pagination["first_page_creation_count"] == 1
     assert pagination["first_page_creation_ratio"] == 0.5
     assert pagination["first_page_creation_ratio"] == 0.5
+    assert pagination["creation_threshold"] == 0.4
     assert pagination["requested_second_page"] is True
     assert pagination["requested_second_page"] is True
     assert pagination["stop_reason"] == "max_pages_reached"
     assert pagination["stop_reason"] == "max_pages_reached"
     assert len(pagination["pages"]) == 2
     assert len(pagination["pages"]) == 2
@@ -639,6 +673,7 @@ def test_run_batch_does_not_request_second_page_below_threshold():
         media_stabilizer=lambda **kwargs: [],
         media_stabilizer=lambda **kwargs: [],
         classifier=_classification_by_title(set()),
         classifier=_classification_by_title(set()),
         rate_limiter_factory=lambda platform: object(),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
     )
 
 
     pagination = repo.updated_jobs[-1].metadata["pagination"]
     pagination = repo.updated_jobs[-1].metadata["pagination"]
@@ -669,6 +704,7 @@ def test_run_batch_does_not_request_second_page_when_no_classified_candidates():
         media_stabilizer=lambda **kwargs: [],
         media_stabilizer=lambda **kwargs: [],
         classifier=_classification_by_title({"c"}),
         classifier=_classification_by_title({"c"}),
         rate_limiter_factory=lambda platform: object(),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
     )
 
 
     pagination = repo.updated_jobs[-1].metadata["pagination"]
     pagination = repo.updated_jobs[-1].metadata["pagination"]
@@ -716,6 +752,7 @@ def test_run_batch_excludes_duplicate_and_skipped_items_from_pagination_denomina
         media_stabilizer=lambda **kwargs: [],
         media_stabilizer=lambda **kwargs: [],
         classifier=_classification_by_title({"good", "next"}),
         classifier=_classification_by_title({"good", "next"}),
         rate_limiter_factory=lambda platform: object(),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
     )
 
 
     pagination = repo.updated_jobs[-1].metadata["pagination"]
     pagination = repo.updated_jobs[-1].metadata["pagination"]
@@ -748,6 +785,7 @@ def test_run_batch_threshold_is_configurable():
         classifier=_classification_by_title({"a"}),
         classifier=_classification_by_title({"a"}),
         rate_limiter_factory=lambda platform: object(),
         rate_limiter_factory=lambda platform: object(),
         pagination_creation_threshold=0.75,
         pagination_creation_threshold=0.75,
+        low_result_max_retries=0,
     )
     )
 
 
     pagination = repo.updated_jobs[-1].metadata["pagination"]
     pagination = repo.updated_jobs[-1].metadata["pagination"]
@@ -773,7 +811,7 @@ def test_run_batch_skip_done_does_not_call_platform():
     assert repo.updated_jobs == []
     assert repo.updated_jobs == []
 
 
 
 
-def test_run_batch_marks_partial_when_some_details_fail():
+def test_run_batch_marks_done_when_some_details_fail_but_an_item_displays():
     repo = FakeRepo()
     repo = FakeRepo()
     adapter = PartialAdapter()
     adapter = PartialAdapter()
 
 
@@ -795,14 +833,84 @@ def test_run_batch_marks_partial_when_some_details_fail():
         rate_limiter_factory=lambda platform: object(),
         rate_limiter_factory=lambda platform: object(),
     )
     )
 
 
-    assert result.partial == 1
-    assert result.done == 0
+    assert result.partial == 0
+    assert result.done == 1
     assert result.failed == 0
     assert result.failed == 0
-    assert repo.updated_jobs[-1].status == "partial"
+    assert repo.updated_jobs[-1].status == "done"
     assert repo.updated_jobs[-1].metadata["display_count"] == 1
     assert repo.updated_jobs[-1].metadata["display_count"] == 1
     assert repo.updated_jobs[-1].metadata["errors"] == ["detail boom"]
     assert repo.updated_jobs[-1].metadata["errors"] == ["detail boom"]
 
 
 
 
+def test_run_batch_retries_low_result_platform_once_and_merges_unique_results():
+    repo = FakeRepo()
+    adapter = RetryPagedAdapter(
+        page_batches=[
+            [_paged_search_page(1, ["a", "b", "dup"])],
+            [_paged_search_page(1, ["dup", "c", "d", "e", "f", "g"])],
+        ],
+        details={source_id: _detail(source_id) for source_id in ["a", "b", "dup", "c", "d", "e", "f", "g"]},
+    )
+    limiter = object()
+
+    result = run_batch(
+        repo,
+        batch_id=repo.batch_id,
+        settings=_settings(),
+        platforms=("douyin",),
+        search_limit=10,
+        display_limit=5,
+        adapter_factory=lambda platform: adapter,
+        media_stabilizer=lambda **kwargs: [],
+        classifier=_classification_by_title(set()),
+        rate_limiter_factory=lambda platform: limiter,
+    )
+
+    metadata = repo.updated_jobs[-1].metadata
+    assert result.done == 1
+    assert result.partial == 0
+    assert repo.updated_jobs[-1].status == "done"
+    assert adapter.search_calls == 2
+    assert adapter.detail_calls == 8
+    assert len(repo.items) == 8
+    assert metadata["display_count"] == 8
+    assert metadata["searched_count"] == 8
+    assert metadata["low_result_retry"]["triggered"] is True
+    assert metadata["low_result_retry"]["attempt_count"] == 2
+    assert metadata["low_result_retry"]["attempts"][0]["display_count"] == 3
+    assert metadata["low_result_retry"]["attempts"][1]["display_count"] == 5
+    assert {item.platform_item_id for item in repo.items} == {"a", "b", "dup", "c", "d", "e", "f", "g"}
+    assert set(adapter.limiter_ids) == {id(limiter)}
+
+
+def test_run_batch_does_not_retry_when_first_attempt_is_above_low_result_threshold():
+    repo = FakeRepo()
+    adapter = RetryPagedAdapter(
+        page_batches=[
+            [_paged_search_page(1, ["a", "b", "c", "d", "e", "f"])],
+            [_paged_search_page(1, ["g"])],
+        ],
+        details={source_id: _detail(source_id) for source_id in ["a", "b", "c", "d", "e", "f", "g"]},
+    )
+
+    run_batch(
+        repo,
+        batch_id=repo.batch_id,
+        settings=_settings(),
+        platforms=("douyin",),
+        search_limit=10,
+        display_limit=5,
+        adapter_factory=lambda platform: adapter,
+        media_stabilizer=lambda **kwargs: [],
+        classifier=_classification_by_title(set()),
+        rate_limiter_factory=lambda platform: object(),
+    )
+
+    metadata = repo.updated_jobs[-1].metadata
+    assert adapter.search_calls == 1
+    assert metadata["display_count"] == 6
+    assert metadata["low_result_retry"]["triggered"] is False
+
+
 def test_run_batch_marks_failed_when_search_fails_before_any_item():
 def test_run_batch_marks_failed_when_search_fails_before_any_item():
     repo = FakeRepo()
     repo = FakeRepo()
     adapter = FailingSearchAdapter()
     adapter = FailingSearchAdapter()

+ 72 - 0
tests/test_app_api.py

@@ -133,6 +133,70 @@ class FakeAcquisitionRepo:
             ],
             ],
         }
         }
 
 
+    def get_latest_query_result_list(self, query_id):
+        assert query_id == self.query_id
+        return {
+            "query": {
+                "id": query_id,
+                "batch_id": self.batch_id,
+                "query_text": "短视频脚本 开头 怎么写",
+                "axes": {},
+                "keep": True,
+                "filter_reason": None,
+                "status": "ready",
+                "sort_order": 0,
+            },
+            "run": {
+                "id": self.run_id,
+                "batch_id": self.batch_id,
+                "run_key": "run-1",
+                "status": "running",
+            },
+            "jobs": [{"id": uuid4(), "platform": "xiaohongshu", "status": "done"}],
+            "items": [
+                {
+                    "id": self.item_id,
+                    "platform": "xiaohongshu",
+                    "title": "脚本开头",
+                    "raw_summary": "先定受众",
+                    "status": "candidate",
+                    "content_mode": "image_post",
+                    "metadata": {},
+                }
+            ],
+            "media_assets": [
+                {
+                    "id": self.media_id,
+                    "item_id": self.item_id,
+                    "media_type": "image",
+                    "source_url": "https://origin.test/1.jpg",
+                    "oss_url": "https://oss.test/1.jpg",
+                    "cdn_url": "https://cdn.test/1.jpg",
+                    "position": 1,
+                    "status": "done",
+                }
+            ],
+            "classifications": [
+                {
+                    "id": self.classification_id,
+                    "item_id": self.item_id,
+                    "is_creation_knowledge": True,
+                    "label": "creation",
+                    "confidence": 0.98,
+                    "status": "done",
+                    "error_message": None,
+                }
+            ],
+            "decode_summaries": [
+                {
+                    "item_id": self.item_id,
+                    "decode_status": "decoded",
+                    "particle_count": 1,
+                    "payload_count": 1,
+                }
+            ],
+        }
+
     def get_candidate_item(self, item_id):
     def get_candidate_item(self, item_id):
         assert item_id == self.item_id
         assert item_id == self.item_id
         return self.get_query_detail(run_id=self.run_id, query_id=self.query_id)["items"][0]
         return self.get_query_detail(run_id=self.run_id, query_id=self.query_id)["items"][0]
@@ -247,6 +311,14 @@ def test_app_health_and_formal_acquisition_routes():
         assert item["body_text"] == "先定受众,再写开头钩子"
         assert item["body_text"] == "先定受众,再写开头钩子"
         assert item["media_assets"][0]["cdn_url"] == "https://cdn.test/1.jpg"
         assert item["media_assets"][0]["cdn_url"] == "https://cdn.test/1.jpg"
         assert item["classification"]["is_creation_knowledge"] is True
         assert item["classification"]["is_creation_knowledge"] is True
+
+        latest = client.get(f"/api/query-generation/latest/queries/{repo.query_id}").json()
+        lightweight_item = latest["platforms"]["xiaohongshu"]["items"][0]
+        assert "body_text" not in lightweight_item
+        assert "source_payload" not in lightweight_item
+        assert lightweight_item["raw_summary"] == "先定受众"
+        assert len(lightweight_item["media_assets"]) == 1
+        assert lightweight_item["decode_summary"]["particle_count"] == 1
     finally:
     finally:
         app.dependency_overrides.clear()
         app.dependency_overrides.clear()
 
 

+ 4 - 0
tests/test_formal_state_models.py

@@ -35,6 +35,8 @@ def test_creation_db_config_reads_ck_db_without_open_aigc(tmp_path):
                 "CK_DB_USER=ck_app",
                 "CK_DB_USER=ck_app",
                 "CK_DB_PASSWORD=secret",
                 "CK_DB_PASSWORD=secret",
                 "CK_DB_SCHEMA=creation_knowledge",
                 "CK_DB_SCHEMA=creation_knowledge",
+                "CK_DB_POOL_MIN=2",
+                "CK_DB_POOL_MAX=8",
             ]
             ]
         ),
         ),
         encoding="utf-8",
         encoding="utf-8",
@@ -47,6 +49,8 @@ def test_creation_db_config_reads_ck_db_without_open_aigc(tmp_path):
     assert cfg.user == "ck_app"
     assert cfg.user == "ck_app"
     assert cfg.database == "creation_knowledge_prod"
     assert cfg.database == "creation_knowledge_prod"
     assert cfg.schema == "creation_knowledge"
     assert cfg.schema == "creation_knowledge"
+    assert cfg.pool_min == 2
+    assert cfg.pool_max == 8
 
 
 
 
 def test_decode_content_models_are_contract_shaped():
 def test_decode_content_models_are_contract_shaped():

+ 30 - 0
tests/test_platform_adapters.py

@@ -144,6 +144,36 @@ def test_weixin_adapter_hashes_url_and_reads_detail(monkeypatch):
     assert item.content_mode == "article"
     assert item.content_mode == "article"
 
 
 
 
+def test_weixin_adapter_filters_duplicate_and_decorative_gif_images(monkeypatch):
+    settings = SimpleNamespace()
+
+    def fake_detail(url, *, settings, rate_limiter):
+        return "公众号正文", [
+            "https://mmbiz.qpic.cn/mmbiz_png/a/640?wx_fmt=png",
+            "https://mmbiz.qpic.cn/mmbiz_png/a/640?wx_fmt=png",
+            "https://mmbiz.qpic.cn/mmbiz_png/b/640?wx_fmt=png",
+            "https://mmbiz.qpic.cn/mmbiz_gif/c/640?wx_fmt=gif",
+            "https://mmbiz.qpic.cn/mmbiz_gif/d/640",
+        ]
+
+    monkeypatch.setattr(weixin_module, "fetch_weixin_detail", fake_detail)
+
+    candidate = PlatformCandidate(
+        rank=1,
+        platform="weixin",
+        source_id="wx1",
+        url="https://mp.weixin.qq.com/s/abc",
+        title="公众号选题",
+        cover_url="https://img.test/cover.jpg",
+    )
+    item = WeixinAdapter().fetch_detail(candidate, settings=settings, rate_limiter=None)
+
+    assert item.image_urls == [
+        "https://mmbiz.qpic.cn/mmbiz_png/a/640?wx_fmt=png",
+        "https://mmbiz.qpic.cn/mmbiz_png/b/640?wx_fmt=png",
+    ]
+
+
 def test_weixin_adapter_search_pages_uses_next_cursor(monkeypatch):
 def test_weixin_adapter_search_pages_uses_next_cursor(monkeypatch):
     settings = SimpleNamespace()
     settings = SimpleNamespace()
     cursors: list[str] = []
     cursors: list[str] = []

+ 63 - 0
tests/test_postgres_repository_contract.py

@@ -98,6 +98,8 @@ def test_postgres_repository_lists_only_creation_candidates_for_decode():
     assert "JOIN acquisition_jobs aj ON aj.id = ci.job_id" in sql
     assert "JOIN acquisition_jobs aj ON aj.id = ci.job_id" in sql
     assert "JOIN item_classifications ic ON ic.item_id = ci.id" in sql
     assert "JOIN item_classifications ic ON ic.item_id = ci.id" in sql
     assert "ic.is_creation_knowledge IS TRUE" in sql
     assert "ic.is_creation_knowledge IS TRUE" in sql
+    assert "NOT EXISTS" in sql
+    assert "FROM decode_results dr" in sql
     assert "LIMIT %s" in sql
     assert "LIMIT %s" in sql
     assert params == (run_id, 20)
     assert params == (run_id, 20)
     assert items[0].id == item_id
     assert items[0].id == item_id
@@ -229,6 +231,67 @@ def test_postgres_query_detail_loads_media_and_classifications_only_when_items_e
     assert detail["decode_summaries"][0]["payload_count"] == 2
     assert detail["decode_summaries"][0]["payload_count"] == 2
 
 
 
 
+def test_postgres_latest_query_result_list_uses_lightweight_projection():
+    batch_id = uuid4()
+    run_id = uuid4()
+    query_id = uuid4()
+    item_id = uuid4()
+    repo = RecordingPostgresRepo()
+    repo.one_results.append(
+        {"id": query_id, "batch_id": batch_id, "query_text": "q", "status": "ready"}
+    )
+    repo.one_or_none_results.append({"id": run_id, "batch_id": batch_id, "status": "running"})
+    repo.all_results.extend(
+        [
+            [{"id": uuid4(), "run_id": run_id, "query_id": query_id, "platform": "weixin"}],
+            [
+                {
+                    "id": item_id,
+                    "query_id": query_id,
+                    "job_id": uuid4(),
+                    "platform": "weixin",
+                    "title": "标题",
+                    "raw_summary": "摘要",
+                    "status": "candidate",
+                    "content_mode": "article",
+                    "metadata": {},
+                }
+            ],
+            [{"id": uuid4(), "item_id": item_id, "media_type": "image", "status": "done"}],
+            [
+                {
+                    "id": uuid4(),
+                    "item_id": item_id,
+                    "is_creation_knowledge": True,
+                    "label": "creation",
+                    "confidence": 0.9,
+                    "status": "done",
+                    "error_message": None,
+                }
+            ],
+            [{"item_id": item_id, "decode_status": "decoded", "particle_count": 1, "payload_count": 1}],
+        ]
+    )
+
+    detail = repo.get_latest_query_result_list(query_id)
+
+    item_sql = repo.all_calls[1][0]
+    media_sql = repo.all_calls[2][0]
+    classification_sql = repo.all_calls[3][0]
+    assert "SELECT ci.*" not in item_sql
+    assert "source_payload" not in item_sql
+    assert "body_text" not in item_sql
+    assert "LEFT(ci.raw_summary, 700) AS raw_summary" in item_sql
+    assert "SELECT DISTINCT ON (item_id)" in media_sql
+    assert "media_type IN ('cover', 'image', 'frame')" in media_sql
+    assert "SELECT DISTINCT ON (item_id)" in classification_sql
+    assert "reason" not in classification_sql
+    assert detail["run"]["id"] == run_id
+    assert detail["items"][0]["raw_summary"] == "摘要"
+    assert detail["media_assets"][0]["item_id"] == item_id
+    assert detail["decode_summaries"][0]["particle_count"] == 1
+
+
 def test_postgres_attach_existing_candidate_updates_query_job_and_metadata():
 def test_postgres_attach_existing_candidate_updates_query_job_and_metadata():
     item_id = uuid4()
     item_id = uuid4()
     job_id = uuid4()
     job_id = uuid4()

+ 57 - 5
tests/test_query_builder.py

@@ -6,6 +6,7 @@ from uuid import uuid4
 import pytest
 import pytest
 
 
 from acquisition.domain import Query, QueryBatch
 from acquisition.domain import Query, QueryBatch
+from acquisition.queries import builder as query_builder
 from acquisition.queries import filter as query_filter
 from acquisition.queries import filter as query_filter
 from acquisition.queries.builder import (
 from acquisition.queries.builder import (
     QueryBuildOptions,
     QueryBuildOptions,
@@ -80,7 +81,7 @@ def _expected_l3_l4_names(source_type: str) -> set[str]:
 def test_build_creation_query_batch_defaults_to_first_two_families():
 def test_build_creation_query_batch_defaults_to_first_two_families():
     generated = build_creation_query_batch(
     generated = build_creation_query_batch(
         _settings(),
         _settings(),
-        options=QueryBuildOptions(per=2, batch_n=4, dry=True),
+        options=QueryBuildOptions(per=2, batch_n=4),
     )
     )
 
 
     assert [family["key"] for family in generated["families"]] == ["f1", "f2"]
     assert [family["key"] for family in generated["families"]] == ["f1", "f2"]
@@ -91,7 +92,7 @@ def test_build_creation_query_batch_defaults_to_first_two_families():
 def test_build_creation_query_batch_uses_all_l3_l4_substance_and_form_nodes():
 def test_build_creation_query_batch_uses_all_l3_l4_substance_and_form_nodes():
     generated = build_creation_query_batch(
     generated = build_creation_query_batch(
         _settings(),
         _settings(),
-        options=QueryBuildOptions(per=2, batch_n=999, dry=True),
+        options=QueryBuildOptions(per=2, batch_n=999),
     )
     )
 
 
     assert set(generated["axis_values"]["实质"]) == _expected_l3_l4_names("实质")
     assert set(generated["axis_values"]["实质"]) == _expected_l3_l4_names("实质")
@@ -102,10 +103,27 @@ def test_build_creation_query_batch_uses_all_l3_l4_substance_and_form_nodes():
     assert {"配乐", "语音"} <= set(generated["axis_values"]["形式"])
     assert {"配乐", "语音"} <= set(generated["axis_values"]["形式"])
 
 
 
 
+def test_build_creation_query_batch_exposes_substance_and_form_axis_trees():
+    generated = build_creation_query_batch(
+        _settings(),
+        options=QueryBuildOptions(per=2, batch_n=999),
+    )
+
+    for axis in ("实质", "形式"):
+        tree = generated["axis_trees"][axis]
+        assert tree
+        assert all(node["level"] == 3 for node in tree)
+        assert all(child["level"] == 4 for node in tree for child in node["children"])
+        tree_names = {node["name"] for node in tree} | {
+            child["name"] for node in tree for child in node["children"]
+        }
+        assert tree_names <= set(generated["axis_values"][axis])
+
+
 def test_build_creation_query_batch_expands_active_families_as_cartesian_products():
 def test_build_creation_query_batch_expands_active_families_as_cartesian_products():
     generated = build_creation_query_batch(
     generated = build_creation_query_batch(
         _settings(),
         _settings(),
-        options=QueryBuildOptions(per=0, batch_n=0, dry=True),
+        options=QueryBuildOptions(per=0, batch_n=0),
     )
     )
 
 
     by_key = {family["key"]: family for family in generated["families"]}
     by_key = {family["key"]: family for family in generated["families"]}
@@ -146,7 +164,6 @@ def test_build_creation_query_batch_can_explicitly_enable_reserved_families():
         options=QueryBuildOptions(
         options=QueryBuildOptions(
             per=1,
             per=1,
             batch_n=4,
             batch_n=4,
-            dry=True,
             active_family_keys=all_keys,
             active_family_keys=all_keys,
         ),
         ),
     )
     )
@@ -160,12 +177,47 @@ def test_build_creation_query_batch_rejects_unknown_family_key():
         build_creation_query_batch(
         build_creation_query_batch(
             _settings(),
             _settings(),
             options=QueryBuildOptions(
             options=QueryBuildOptions(
-                dry=True,
                 active_family_keys=("f1", "no_such_family"),
                 active_family_keys=("f1", "no_such_family"),
             ),
             ),
         )
         )
 
 
 
 
+def test_build_creation_query_batch_keeps_all_queries_without_default_llm_filter(monkeypatch):
+    def fail_filter(*args, **kwargs):
+        raise AssertionError("query filter should be disabled by default")
+
+    monkeypatch.setattr(query_builder, "filter_queries", fail_filter)
+
+    generated = build_creation_query_batch(
+        _settings(),
+        options=QueryBuildOptions(per=3, batch_n=4),
+    )
+
+    assert generated["metadata"]["query_filter_enabled"] is False
+    assert all(item["keep"] is True for family in generated["families"] for item in family["items"])
+    assert all(item["reason"] == "" for family in generated["families"] for item in family["items"])
+
+
+def test_build_creation_query_batch_can_enable_llm_filter(monkeypatch):
+    def fake_filter(queries, settings):
+        return [
+            {"keep": i != 1, "valid": 9 if i != 1 else 5, "relevant": i != 1, "reason": f"r{i}"}
+            for i, _ in enumerate(queries)
+        ]
+
+    monkeypatch.setattr(query_builder, "filter_queries", fake_filter)
+
+    generated = build_creation_query_batch(
+        _settings(),
+        options=QueryBuildOptions(per=3, batch_n=4, enable_query_filter=True),
+    )
+    items = generated["families"][0]["items"]
+
+    assert generated["metadata"]["query_filter_enabled"] is True
+    assert [item["keep"] for item in items] == [True, False, True]
+    assert [item["reason"] for item in items] == ["r0", "r1", "r2"]
+
+
 def test_persist_query_batch_writes_formal_batch_and_query_contract():
 def test_persist_query_batch_writes_formal_batch_and_query_contract():
     repo = FakeRepo()
     repo = FakeRepo()
     generated = {
     generated = {

+ 6 - 6
创作知识提取-skill/extraction/phase1-frame.md

@@ -69,11 +69,11 @@
 - `input` 输入(这步**吃什么**:首步=工序总输入;后步**必须实指前步产出物原话**,如 `← s1 的〈赛道圣经句清单〉`,**禁空泛"←s1产出"**——指不出就说明不依赖前步,见 A2节 闸①)
 - `input` 输入(这步**吃什么**:首步=工序总输入;后步**必须实指前步产出物原话**,如 `← s1 的〈赛道圣经句清单〉`,**禁空泛"←s1产出"**——指不出就说明不依赖前步,见 A2节 闸①)
 - `directive` 方法(怎么做,含判断标准;**原帖示例必须原文保留** `例:『…』`;不编原文没有的例子)
 - `directive` 方法(怎么做,含判断标准;**原帖示例必须原文保留** `例:『…』`;不编原文没有的例子)
 - `output` 产出(这步**吐什么**;会成为下一步的 `input`)
 - `output` 产出(这步**吐什么**;会成为下一步的 `input`)
-- `出处`(图N / 视频时间)
+- `出处`:可选。只在原帖里有明确来源时填写;不确定就留空数组 `[]`,不要猜。
 
 
 > **为什么用 输入→方法→产出 而不是"目的"**:旧的"目的"和产出重复("找X"≈产出"X"),是冗余。改成显式的 input 让"后步吃前步"(闸①)变成结构可验证,也让 AI 真能把状态一步步串下去。整条工序的总目标只在颗级 `purpose` 说一次,不在每步重复。
 > **为什么用 输入→方法→产出 而不是"目的"**:旧的"目的"和产出重复("找X"≈产出"X"),是冗余。改成显式的 input 让"后步吃前步"(闸①)变成结构可验证,也让 AI 真能把状态一步步串下去。整条工序的总目标只在颗级 `purpose` 说一次,不在每步重复。
 
 
-> **每颗都带 `出处`(溯源用)**:How 颗的 `出处` 在每个 step 上(这步来自哪些卡);What/Why 颗在颗级给 `出处`(这颗来自哪些卡,如 ["图3","卡片2"])。出处填卡片号(图N=第N张图,卡片N=视频第N段),让前端能点回原图/原视频段
+> **出处不是知识质量的核心字段**:拆颗时优先保证知识真实来自原帖、What / How / Why 类型正确、内容忠实完整、决策落点清楚。出处只作为可选证据字段:来自正文可写 `["正文"]` / `["公众号正文"]`;来自明确图片卡片可写 `["图1"]` / `["卡片2"]`;来自真实视频片段且有时间可写 `["00:12-00:18"]`;不能确定来源就写 `[]`。禁止为了满足格式把正文知识硬挂到图N / 卡片N;没有时间段时禁止泛写“视频”;禁止把模型推理、补充知识、常识推断写成原帖出处
 
 
 ### What(是什么)— role 主 或 组件
 ### What(是什么)— role 主 或 组件
 `概要`(**唯一必填**,一句话这是什么,做锚点)+ `kind`(**必填**:恰好取 `子集` / `多维关系` / `序列` 之一,**勿写变体**如"多维度关系")+ `维度拆分规则`(**仅子集填**:这组选项的**单一划分轴**短词,如「冲突范围」;**套不出单一轴就填 `类型枚举`**;多维关系/序列填 null)+ `body`:**1..N 个扁平条目**,每条 `{item_name, item_desc, 作用域:[]}`(无小节、无形式标)。无 steps。+ 颗级 `出处`。
 `概要`(**唯一必填**,一句话这是什么,做锚点)+ `kind`(**必填**:恰好取 `子集` / `多维关系` / `序列` 之一,**勿写变体**如"多维度关系")+ `维度拆分规则`(**仅子集填**:这组选项的**单一划分轴**短词,如「冲突范围」;**套不出单一轴就填 `类型枚举`**;多维关系/序列填 null)+ `body`:**1..N 个扁平条目**,每条 `{item_name, item_desc, 作用域:[]}`(无小节、无形式标)。无 steps。+ 颗级 `出处`。
@@ -160,14 +160,14 @@
       "title": "<框架名,从本帖抽>", "purpose": "<一句话目标>", "业务阶段": ["<灵感/选题/脚本,可多选>"],
       "title": "<框架名,从本帖抽>", "purpose": "<一句话目标>", "业务阶段": ["<灵感/选题/脚本,可多选>"],
       "steps": [
       "steps": [
         {"id":"s1","input":"<这步吃什么:首步=工序总输入;后步实指前步产出物原话,如 ← s1 的〈赛道圣经句清单〉>","directive":"<方法,含判断标准,原帖示例用 例:『…』 原文保留>","output":"<这步吐什么,会成下一步 input>",
         {"id":"s1","input":"<这步吃什么:首步=工序总输入;后步实指前步产出物原话,如 ← s1 的〈赛道圣经句清单〉>","directive":"<方法,含判断标准,原帖示例用 例:『…』 原文保留>","output":"<这步吐什么,会成下一步 input>",
-         "出处":["<图N/视频时间>"],"创作阶段":"<定向/构思/结构/成文/打磨>","动作":"<这步的具体手法,从内容抽>","作用域":[]}
+         "出处":["<可选;明确才填,如 正文 / 图3 / 00:12-00:18;不确定填 []>"],"创作阶段":"<定向/构思/结构/成文/打磨>","动作":"<这步的具体手法,从内容抽>","作用域":[]}
       ],
       ],
       "dropped": ["<剔除项 — 理由>"]
       "dropped": ["<剔除项 — 理由>"]
     },
     },
     {
     {
       "id": "k2", "type": "what", "role": "组件", "parent": {"how_id":"k1","step":2},
       "id": "k2", "type": "what", "role": "组件", "parent": {"how_id":"k1","step":2},
       "title": "<某 how 步骤产出的、可独立复用的清单/构成名>", "业务阶段": ["<…>"],
       "title": "<某 how 步骤产出的、可独立复用的清单/构成名>", "业务阶段": ["<…>"],
-      "出处": ["<这颗来自哪些卡片,如 图3 / 卡片2>"],
+      "出处": ["<可选;明确才填,如 正文 / 图3 / 00:12-00:18;不确定填 []>"],
       "概要": "<一句话这是什么(必填)>", "kind": "<子集 / 多维关系 / 序列(必填)>",
       "概要": "<一句话这是什么(必填)>", "kind": "<子集 / 多维关系 / 序列(必填)>",
       "维度拆分规则": "<仅子集:单一划分轴短词(命名轴、不是结果),如 冲突范围;无单一轴填 类型枚举;多维关系/序列填 null>",
       "维度拆分规则": "<仅子集:单一划分轴短词(命名轴、不是结果),如 冲突范围;无单一轴填 类型枚举;多维关系/序列填 null>",
       "body": [
       "body": [
@@ -178,7 +178,7 @@
     },
     },
     {
     {
       "id": "k3", "type": "why", "role": "主", "parent": null,
       "id": "k3", "type": "why", "role": "主", "parent": null,
-      "title": "<原理名>", "业务阶段": ["<…>"], "出处": ["<这颗来自哪些卡片>"],
+      "title": "<原理名>", "业务阶段": ["<…>"], "出处": ["<可选;明确才填,如 正文 / 图3 / 00:12-00:18;不确定填 []>"],
       "阐述": "<一段:这条原理是什么+为什么成立。忠实整合原帖几张卡原话——可缝接,不可改实质/补/润色/转祈使>",
       "阐述": "<一段:这条原理是什么+为什么成立。忠实整合原帖几张卡原话——可缝接,不可改实质/补/润色/转祈使>",
       "作用域": []
       "作用域": []
     }
     }
@@ -190,7 +190,7 @@
 - 颗数合理(how 主颗 + 组件颗 + orphan,可混);每颗有 type/role/parent。
 - 颗数合理(how 主颗 + 组件颗 + orphan,可混);每颗有 type/role/parent。
 - **每颗 how 都过了 A2节 两道闸**:步骤真有序(后步吃前步)+ 最后一步 output = purpose 收尾名词。并列招式已改 What;半截工序已补收尾或已降 purpose。
 - **每颗 how 都过了 A2节 两道闸**:步骤真有序(后步吃前步)+ 最后一步 output = purpose 收尾名词。并列招式已改 What;半截工序已补收尾或已降 purpose。
 - **决策落点都落到了**:how 每步有 `input`(首步=工序输入,后步指向前步产出);what `kind` 已填、子集型 `维度拆分规则` 已填且每条 `item_desc` 含「何时/为何选」(原帖没说才只写是什么);why `阐述` 已忠实整合(无改实质/补/润色/转祈使)。
 - **决策落点都落到了**:how 每步有 `input`(首步=工序输入,后步指向前步产出);what `kind` 已填、子集型 `维度拆分规则` 已填且每条 `item_desc` 含「何时/为何选」(原帖没说才只写是什么);why `阐述` 已忠实整合(无改实质/补/润色/转祈使)。
-- how 颗:每步 input/directive/output/出处 + 创作阶段 + 动作;what 颗:概要 + kind + 维度拆分规则(子集) + body 条目(item_name/item_desc);why 颗:title + 阐述(忠实整合"是什么+为什么")。
+- how 颗:每步 input/directive/output + 创作阶段 + 动作,出处有明确证据才填;what 颗:概要 + kind + 维度拆分规则(子集) + body 条目(item_name/item_desc),出处可空;why 颗:title + 阐述(忠实整合"是什么+为什么"),出处可空
 - 组件颗有 parent cross-ref;**每颗业务阶段非空(≥1,∈灵感/选题/脚本)**;贴不上三值的非内容创作颗已 drop。
 - 组件颗有 parent cross-ref;**每颗业务阶段非空(≥1,∈灵感/选题/脚本)**;贴不上三值的非内容创作颗已 drop。
 - 纯软件操作流程 / 非内容创作题材已整颗 drop(D节)。
 - 纯软件操作流程 / 非内容创作题材已整颗 drop(D节)。
 - 所有颗的 `作用域` 留空(含 how 的每步),③ 来填。
 - 所有颗的 `作用域` 留空(含 how 的每步),③ 来填。

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini