Преглед на файлове

feat: formalize creation pipeline workbench

SamLee преди 2 седмици
родител
ревизия
44200efaaf
променени са 67 файла, в които са добавени 4292 реда и са изтрити 2361 реда
  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_SCHEMA=creation_knowledge
 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;
 # 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
 
 import hashlib
+from urllib.parse import parse_qs, urlparse
 from typing import Any, Iterable
 
 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]
 
 
+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:
     platform = "weixin"
 
@@ -116,6 +158,6 @@ class WeixinAdapter:
             title=candidate.title,
             author=candidate.author,
             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,
         )

+ 41 - 8
acquisition/queries/builder.py

@@ -26,7 +26,7 @@ class QueryBuildOptions:
     per: int = 0
     batch_n: int = 0
     seed: int = 7
-    dry: bool = False
+    enable_query_filter: bool = False
     active_family_keys: tuple[str, ...] = DEFAULT_ACTIVE_FAMILY_KEYS
 
 
@@ -74,6 +74,38 @@ def _nodes_at_depth(
     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]:
     if limit <= 0 or limit >= len(values):
         return values
@@ -196,11 +228,15 @@ def build_creation_query_batch(
             "动作": ACTIONS,
             "作用": f6_zy,
         },
+        "axis_trees": {
+            "实质": _axis_tree(idx, "实质"),
+            "形式": _axis_tree(idx, "形式"),
+        },
         "metadata": {
             "seed": opts.seed,
             "per": opts.per,
             "batch_n": opts.batch_n,
-            "dry": opts.dry,
+            "query_filter_enabled": opts.enable_query_filter,
             "active_family_keys": list(opts.active_family_keys),
             "query_filter_prompt_version": prompt_version(),
         },
@@ -227,12 +263,9 @@ def build_creation_query_batch(
             if opts.per > 0 and len(items) >= opts.per:
                 break
         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):
             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_latest_query_result_list(self, query_id: UUID) -> dict[str, Any]:
+        ...
+
     def list_creation_candidate_items(
         self,
         *,

+ 211 - 13
acquisition/repositories/postgres.py

@@ -505,12 +505,25 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT ci.id)::int AS candidate_count,
                 COUNT(DISTINCT ic.id) FILTER (
                     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
             LEFT JOIN acquisition_jobs aj ON aj.run_id = ar.id
             LEFT JOIN queries q ON q.id = aj.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
             WHERE ar.id = %s
             GROUP BY ar.id
             """,
@@ -526,6 +539,16 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT ic.id) FILTER (
                     WHERE ic.is_creation_knowledge IS TRUE
                 )::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(
                     aj.platform,
                     jsonb_build_object(
@@ -540,6 +563,9 @@ class PostgresAcquisitionRepository:
             JOIN acquisition_jobs aj ON aj.query_id = q.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
             WHERE aj.run_id = %s
             GROUP BY q.id, q.query_text, q.sort_order
             ORDER BY q.sort_order, q.query_text
@@ -593,8 +619,10 @@ class PostgresAcquisitionRepository:
                 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
@@ -661,8 +689,10 @@ class PostgresAcquisitionRepository:
                 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
@@ -679,6 +709,112 @@ class PostgresAcquisitionRepository:
             "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]:
         batch = self._one_or_none(
             """
@@ -711,7 +847,9 @@ class PostgresAcquisitionRepository:
                     q.metadata->>'family_key' AS family_key,
                     q.sort_order,
                     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
                 WHERE q.batch_id = %s
                 ORDER BY q.sort_order, q.created_at
@@ -720,8 +858,48 @@ class PostgresAcquisitionRepository:
             )
             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(
-            """
+            latest_queries_cte
+            + """
             SELECT
                 q.id AS query_id,
                 q.query_text,
@@ -731,40 +909,50 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT ic.id) FILTER (
                     WHERE ic.is_creation_knowledge IS TRUE
                 )::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_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 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 q.batch_id = %s
             GROUP BY q.id, q.query_text, q.metadata, q.sort_order
             ORDER BY q.sort_order, q.created_at
             """,
-            (batch["id"],),
+            (),
         )
         decoded_items = self._all(
-            """
+            latest_queries_cte
+            + """
             SELECT
                 ci.id AS item_id,
                 ci.query_id,
                 ci.title,
                 ci.platform,
                 dr.status AS decode_status,
+                COUNT(DISTINCT kp.id)::int AS particle_count,
                 COUNT(DISTINCT pd.id)::int AS payload_count
             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_runs ar ON ar.id = aj.run_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
-            WHERE ar.batch_id = %s
             GROUP BY ci.id, ci.query_id, ci.title, ci.platform, dr.status, dr.created_at
             ORDER BY dr.created_at DESC
             """,
-            (batch["id"],),
+            (),
         )
         return {
             "batch": batch,
@@ -785,6 +973,11 @@ class PostgresAcquisitionRepository:
                 SELECT ci.* FROM candidate_items ci
                 JOIN item_classifications ic ON ic.item_id = ci.id
                 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
                 LIMIT %s
                 """,
@@ -797,6 +990,11 @@ class PostgresAcquisitionRepository:
                 JOIN acquisition_jobs aj ON aj.id = ci.job_id
                 JOIN item_classifications ic ON ic.item_id = ci.id
                 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
                 LIMIT %s
                 """,

+ 174 - 91
acquisition/runner.py

@@ -24,8 +24,10 @@ from core.text_limits import (
 
 DEFAULT_PLATFORMS = ("xiaohongshu", "weixin", "douyin")
 PAGINATION_STRATEGY = "creation_ratio_two_page_v1"
-PAGINATION_CREATION_THRESHOLD = 0.5
+PAGINATION_CREATION_THRESHOLD = 0.4
 PAGINATION_MAX_PAGES = 2
+LOW_RESULT_RETRY_THRESHOLD = 5
+LOW_RESULT_MAX_RETRIES = 1
 
 AdapterFactory = Callable[[str], PlatformAdapter]
 Classifier = Callable[..., ClassificationResult]
@@ -158,6 +160,7 @@ def _record_candidate(
     media_stabilizer: MediaStabilizer,
     classifier: Classifier,
     classify: bool,
+    attempt_index: int = 1,
 ) -> RecordCandidateResult:
     source_payload = _source_payload(candidate, detail)
     platform_item_id = detail.source_id or candidate.source_id or None
@@ -171,6 +174,7 @@ def _record_candidate(
         "candidate_rank": candidate.rank,
         "acquisition_match_status": "created",
         "content_mode": content_mode,
+        "retry_attempt_index": attempt_index,
         **_candidate_page_metadata(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(
     repo: AcquisitionRepository,
     *,
@@ -292,6 +307,7 @@ def _attach_existing_candidate(
     query: Query,
     candidate: PlatformCandidate,
     unique_key: str,
+    attempt_index: int = 1,
 ) -> bool:
     get_by_key = getattr(repo, "get_candidate_item_by_unique_key", None)
     attach = getattr(repo, "attach_existing_candidate_item", None)
@@ -309,6 +325,7 @@ def _attach_existing_candidate(
             "matched_unique_key": unique_key,
             "matched_candidate": candidate.model_dump(),
             "candidate_rank": candidate.rank,
+            "retry_attempt_index": attempt_index,
             **_candidate_page_metadata(candidate),
             **({"search_provider": _candidate_provider(candidate)} if _candidate_provider(candidate) else {}),
         },
@@ -382,6 +399,8 @@ def run_batch(
     rate_limiter_factory: RateLimiterFactory | None = None,
     pagination_creation_threshold: float = PAGINATION_CREATION_THRESHOLD,
     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:
     """Run query x platform acquisition and write formal cloud-state rows."""
     queries = repo.list_queries_for_batch(batch_id, keep=True)
@@ -399,6 +418,10 @@ def run_batch(
                 pagination_creation_threshold,
                 pagination_max_pages,
             ),
+            "low_result_retry": {
+                "threshold": low_result_retry_threshold,
+                "max_retries": low_result_max_retries,
+            },
         },
     )
     if run.id is None:
@@ -453,68 +476,100 @@ def run_batch(
                         else PlatformRateLimiter(platform)
                     )
                     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
-                            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,
                             "raw_count": page.raw_count,
                             "candidate_count": len(page.candidates),
+                            "unique_candidate_count": unique_candidate_count,
                             "classified_count": page_classified_count,
                             "creation_count": page_creation_count,
                             "creation_ratio": page_ratio,
@@ -522,40 +577,67 @@ def run_batch(
                             "next_cursor": page.next_cursor,
                             "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
-
-                    pagination_stop_reason = "max_pages_reached"
                     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":
                     done += 1
-                elif status == "partial":
-                    partial += 1
                 else:
                     failed += 1
                 repo.update_acquisition_job(
@@ -582,6 +664,13 @@ def run_batch(
                             "stop_reason": pagination_stop_reason,
                             "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:],
                     },
                 )
@@ -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)
     if update_run:
         update_run(

+ 11 - 8
app/dependencies.py

@@ -4,11 +4,11 @@ from __future__ import annotations
 import os
 from typing import Iterator
 
-from fastapi import HTTPException
+from fastapi import Depends, HTTPException
 
 from acquisition.repositories.postgres import PostgresAcquisitionRepository
 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
 
 
@@ -20,14 +20,17 @@ def get_creation_db_config() -> CreationDbConfig:
     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():

Файловите разлики са ограничени, защото са твърде много
+ 0 - 8
app/frontend/dist/assets/index-Bx7KAD4Y.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
app/frontend/dist/assets/index-C-saPy1c.css


Файловите разлики са ограничени, защото са твърде много
+ 8 - 0
app/frontend/dist/assets/index-CxkG5OsZ.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
app/frontend/dist/assets/index-QFmrwHTU.css


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

@@ -3,9 +3,9 @@
   <head>
     <meta charset="UTF-8" />
     <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>
   <body>
     <div id="root"></div>

+ 1 - 1
app/frontend/index.html

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

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

@@ -9,7 +9,8 @@
       "version": "0.1.0",
       "dependencies": {
         "react": "^18.3.1",
-        "react-dom": "^18.3.1"
+        "react-dom": "^18.3.1",
+        "react-window": "^1.8.11"
       },
       "devDependencies": {
         "@vitejs/plugin-react": "^4.3.4",
@@ -250,6 +251,15 @@
         "@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": {
       "version": "7.29.7",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1436,6 +1446,12 @@
         "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": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1543,6 +1559,23 @@
         "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": {
       "version": "4.62.2",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",

+ 2 - 1
app/frontend/package.json

@@ -11,7 +11,8 @@
   },
   "dependencies": {
     "react": "^18.3.1",
-    "react-dom": "^18.3.1"
+    "react-dom": "^18.3.1",
+    "react-window": "^1.8.11"
   },
   "devDependencies": {
     "@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 = [
   ['substance', '实质'],
@@ -24,10 +24,6 @@ function mediaUrl(asset) {
   return asset?.cdn_url || asset?.oss_url || asset?.source_url || ''
 }
 
-function payloadTitle(payload) {
-  return payload?.title || payload?.source?.title || '未命名 payload'
-}
-
 function compactJson(value) {
   return JSON.stringify(value || {}, null, 2)
 }
@@ -73,7 +69,7 @@ function KScopes({ scopes }) {
   )
 }
 
-function Ribbon() {
+export function Ribbon() {
   return (
     <div className="ribbon">
       <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 video = sourceVideo(item)
   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}`} />)}
           </div>
         )}
+        {originalText && <div className="src-original-text">② 原帖全文:{originalText}</div>}
         {readText && <div className="srcread">① 读懂后的内容:{readText}</div>}
       </div>
     </details>
@@ -136,21 +133,25 @@ function HowTable({ knowledge }) {
           </tr>
         </thead>
         <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>
-              ))}
-            </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>
       </table>
     </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 type = particle.particle_type || knowledge.type || 'what'
   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)
   if (!payload) return null
   const copy = () => {
@@ -238,87 +239,3 @@ function PayloadModal({ payload, onClose }) {
     </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 { createRoot } from 'react-dom/client'
-import App from './App.jsx'
+import App from './app/App.jsx'
 import './styles.css'
 
 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:
         item = acquisition_repo.get_candidate_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)
     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_scopes = getattr(decode_repo, "list_scope_results", None)
@@ -61,7 +65,11 @@ def decode_item_detail(
     ).model_dump(mode="json")
     return {
         "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": [
             KnowledgeParticleSchema.model_validate(row).model_dump(mode="json")
             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 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
 
 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")
 def query_generation_preview(
     per: int = Query(default=0, ge=0, le=10000),
     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]:
     """Preview the currently active formal query families without writing DB rows."""
 
@@ -44,7 +106,7 @@ def query_generation_preview(
         options=QueryBuildOptions(
             per=per,
             batch_n=batch_n,
-            dry=dry,
+            enable_query_filter=enable_query_filter,
             active_family_keys=("f1", "f2"),
         ),
     )
@@ -69,13 +131,7 @@ def latest_query_detail(
 ) -> dict[str, Any]:
     """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 _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
     candidate_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)
     started_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
     timeout: int = 10
     application_name: str = "creation-knowledge"
+    pool_min: int = 1
+    pool_max: int = 10
 
     @classmethod
     def from_env(cls, env_file: str | Path = ".env") -> "CreationDbConfig":
@@ -94,6 +96,8 @@ class CreationDbConfig:
                 "creation_knowledge_prod",
             ),
             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 contextlib import contextmanager
+from threading import Lock
 from typing import Any, Iterator
 
 import psycopg2
 import psycopg2.extras
+from psycopg2.pool import ThreadedConnectionPool
 
 from core.config import CreationDbConfig
 
 psycopg2.extras.register_uuid()
 
+_pool: ThreadedConnectionPool | None = None
+_pool_key: tuple[Any, ...] | None = None
+_pool_lock = Lock()
+
 
 def connect(config: CreationDbConfig) -> Any:
     """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
 def transaction(config: CreationDbConfig) -> Iterator[Any]:
     """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()
 
 
+@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(
     config: CreationDbConfig,
     sql: str,

+ 11 - 0
decode_content/repositories/postgres.py

@@ -130,6 +130,17 @@ class PostgresDecodeRepository:
         )
         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(
         self,
         *,

+ 1 - 1
pipeline/acquisition_runner.py

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

+ 32 - 2
pipeline/decode_runner.py

@@ -1,11 +1,12 @@
 """Pipeline adapter for decoding creation candidate items."""
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Protocol
 from uuid import UUID
 
 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.service import DecodeService, DecodeWorkflowOutput
 from pipeline.dedupe import dedupe_candidate_items, should_decode_item
@@ -31,6 +32,32 @@ class DecodeBatchResult:
     skipped: int
     failed: int
     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(
@@ -43,6 +70,7 @@ def run_decode_stage(
 ) -> DecodeBatchResult:
     items = dedupe_candidate_items(candidate_repo.list_creation_candidate_items(run_id=run_id, limit=limit))
     outputs: list[DecodeWorkflowOutput] = []
+    failures: list[dict[str, str]] = []
     decoded = skipped = failed = 0
     for item in items:
         if item.id is None:
@@ -57,12 +85,14 @@ def run_decode_stage(
             post = post_from_candidate_item(item, media)
             outputs.append(decode_service.decode_post(item_id=item.id, post=post))
             decoded += 1
-        except Exception:
+        except Exception as exc:
             failed += 1
+            failures.append(_record_failure(decode_service, item, exc))
     return DecodeBatchResult(
         total=len(items),
         decoded=decoded,
         skipped=skipped,
         failed=failed,
         outputs=outputs,
+        failures=failures,
     )

+ 84 - 34
prompts/classify_imgtext.txt

@@ -1,42 +1,92 @@
-你在判断一篇图文帖(小红书 / 微信公众号,含标题、正文、图片)是不是【图文/视频内容创作】的可迁移知识。请把图片也看完(知识常在图里)再判断
+你在判断一篇图文帖(小红书 / 微信公众号,含标题、正文、图片)是不是【图文/视频内容创作】的可迁移知识。请同时看标题、正文和图片,知识可能在图里
 
-<什么算创作知识>
-创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构或清单。判断必须同时过两条轴,缺一不算:
+<判定标准>
+创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构、清单或高质量案例。
 
-【轴A·产出物是不是内容】
-这条知识帮你创作出来的成品,必须是图文 / 视频 / 文章 / 脚本 / 剧本 / 小说 / 播客 / 课程 / 解说等可被阅读、观看、收听或使用的**内容作品**。题材不限,卡的是产出物。
-- 算:产出"关于某题材的内容"——游戏实况/解说视频、历史视频脚本、知识科普文章、电商带货视频 idea、小说结构。
-- 不算:产出"题材本身"——游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身。
+必须同时满足两条轴:
+
+【轴A·产出物是内容】
+这条知识帮助创作出来的成品,必须是图文、视频、文章、脚本、剧本、小说、播客、课程、解说等内容作品。
+题材不限,关键看产出物是不是"内容"。
+
+算:
+- 如何写短视频脚本
+- 如何构思图文选题
+- 如何设计标题、封面、开头、结尾
+- 如何组织故事、观点、节奏、冲突、反差、笑点
+- 如何把历史、游戏、电商、生活经验等题材做成视频、图文或文章
+
+不算:
+- 教游戏玩法、关卡设计、产品开发、电商选品、运营动作、行业分析、做菜本身
+- 这些是在做题材本身,不是在做"关于题材的内容"
 
 【轴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 对象:
-{"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·是创作不是制作】
-它教的是"怎么构思 / 选题 / 写 / 结构 / 呈现 / 判断"(创作决策),不是"用某 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 对象:
-{"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`:
   ① **纯作品/纯展示/纯无关**——通篇看完确实没有任何可迁移的创作方法/原理/清单。
   ② **整帖不在范围内(越界要果断判 true,别放行)**——整帖产出物不是图文/视频内容(如游戏玩法/关卡设计、产品/运营/选品、做菜本身),**或**整帖是制作/工具操作(如"用某 AI/App 几步做出一段视频"的打开→点按钮→导出流程)。这类即便看着像"教程/步骤",也按轴A/轴B 判定为越界。
@@ -50,6 +54,6 @@
 <输出>
 只输出一个 JSON 对象:
 {{"text": "把创作知识完整忠实讲清楚;没有就空字符串",
-  "cards": [{{"index": 卡片号, "content": "这张卡的知识要点"}}],
+  "cards": [{{"index": 卡片号, "content": "这张卡片本身讲到的知识要点"}}],
   "from_image": "", "from_video": "", "is_empty": false}}
 </输出>

+ 1 - 0
prompts/extract_video.txt

@@ -8,6 +8,7 @@
 - 视频是口播 + 画面 + 字幕,请**同时听口播、看画面与字幕**——口播里往往是核心知识,别只看画面。
 - 以视频实际内容为准,忠实提炼,原文没有的不要编造、不要拔高。
 - **按视频时间顺序分段**,每段给时间戳和这段讲到的创作知识;一段对应一个相对完整的知识点/小主题。
+- 只有你能明确判断某个知识点出现在这一段,才生成该 segment。如果全片只是展示、没有明确讲创作方法,就不要为了凑 segment 编造知识。时间戳必须来自视频内容判断;不能确定时间范围时,不要生成这一段。
 - 每段标 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("--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("--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(
         "--family",
         action="append",
@@ -74,7 +74,7 @@ def main(argv: list[str] | None = None) -> int:
             per=args.per,
             batch_n=args.batch_n,
             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"),
         ),
     )

+ 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("--seed", type=int, default=7)
     parser.add_argument(
-        "--dry-query-filter",
+        "--enable-query-filter",
         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(
         "--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("--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("--frontend-base", default="http://127.0.0.1:5180/app/")
     return parser.parse_args(argv)
@@ -109,7 +109,7 @@ def main(argv: list[str] | None = None) -> int:
             per=args.per,
             batch_n=args.batch_n,
             seed=args.seed,
-            dry=args.dry_query_filter,
+            enable_query_filter=args.enable_query_filter,
             active_family_keys=("f1", "f2"),
         ),
     )

+ 112 - 4
tests/test_acquisition_runner.py

@@ -330,6 +330,38 @@ class PagedAdapter:
         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(
     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: [],
         classifier=_classification_by_title({"a", "c"}),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
 
     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_creation_count"] == 1
     assert pagination["first_page_creation_ratio"] == 0.5
+    assert pagination["creation_threshold"] == 0.4
     assert pagination["requested_second_page"] is True
     assert pagination["stop_reason"] == "max_pages_reached"
     assert len(pagination["pages"]) == 2
@@ -639,6 +673,7 @@ def test_run_batch_does_not_request_second_page_below_threshold():
         media_stabilizer=lambda **kwargs: [],
         classifier=_classification_by_title(set()),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
 
     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: [],
         classifier=_classification_by_title({"c"}),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
 
     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: [],
         classifier=_classification_by_title({"good", "next"}),
         rate_limiter_factory=lambda platform: object(),
+        low_result_max_retries=0,
     )
 
     pagination = repo.updated_jobs[-1].metadata["pagination"]
@@ -748,6 +785,7 @@ def test_run_batch_threshold_is_configurable():
         classifier=_classification_by_title({"a"}),
         rate_limiter_factory=lambda platform: object(),
         pagination_creation_threshold=0.75,
+        low_result_max_retries=0,
     )
 
     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 == []
 
 
-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()
     adapter = PartialAdapter()
 
@@ -795,14 +833,84 @@ def test_run_batch_marks_partial_when_some_details_fail():
         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 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["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():
     repo = FakeRepo()
     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):
         assert item_id == self.item_id
         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["media_assets"][0]["cdn_url"] == "https://cdn.test/1.jpg"
         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:
         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_PASSWORD=secret",
                 "CK_DB_SCHEMA=creation_knowledge",
+                "CK_DB_POOL_MIN=2",
+                "CK_DB_POOL_MAX=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.database == "creation_knowledge_prod"
     assert cfg.schema == "creation_knowledge"
+    assert cfg.pool_min == 2
+    assert cfg.pool_max == 8
 
 
 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"
 
 
+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):
     settings = SimpleNamespace()
     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 item_classifications ic ON ic.item_id = ci.id" 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 params == (run_id, 20)
     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
 
 
+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():
     item_id = uuid4()
     job_id = uuid4()

+ 57 - 5
tests/test_query_builder.py

@@ -6,6 +6,7 @@ from uuid import uuid4
 import pytest
 
 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.builder import (
     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():
     generated = build_creation_query_batch(
         _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"]
@@ -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():
     generated = build_creation_query_batch(
         _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("实质")
@@ -102,10 +103,27 @@ def test_build_creation_query_batch_uses_all_l3_l4_substance_and_form_nodes():
     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():
     generated = build_creation_query_batch(
         _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"]}
@@ -146,7 +164,6 @@ def test_build_creation_query_batch_can_explicitly_enable_reserved_families():
         options=QueryBuildOptions(
             per=1,
             batch_n=4,
-            dry=True,
             active_family_keys=all_keys,
         ),
     )
@@ -160,12 +177,47 @@ def test_build_creation_query_batch_rejects_unknown_family_key():
         build_creation_query_batch(
             _settings(),
             options=QueryBuildOptions(
-                dry=True,
                 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():
     repo = FakeRepo()
     generated = {

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

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

Някои файлове не бяха показани, защото твърде много файлове са промени