Sfoglia il codice sorgente

feat(data): add formal item identity and text contracts

SamLee 2 settimane fa
parent
commit
3043058355

+ 10 - 0
.env.example

@@ -11,6 +11,16 @@ CK_DB_NAME=creation_knowledge_prod
 CK_DB_USER=ck_app
 CK_DB_USER=ck_app
 CK_DB_PASSWORD=
 CK_DB_PASSWORD=
 CK_DB_SCHEMA=creation_knowledge
 CK_DB_SCHEMA=creation_knowledge
+CK_DB_SSH_TUNNEL=
+
+# 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.
+CK_DB_ADMIN_SSH_USER=root
+CK_DB_ADMIN_SSH_HOST=
+CK_DB_ADMIN_SSH_KEY=
+CK_DB_ADMIN_PG_SYSTEM_USER=postgres
+CK_DB_ADMIN_PSQL="runuser -u postgres -- psql -d creation_knowledge_prod -v ON_ERROR_STOP=1"
+CK_DB_MIGRATION_APPLY="ssh -i <key> -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new root@<host> \"runuser -u postgres -- psql -d creation_knowledge_prod -v ON_ERROR_STOP=1\" < path/to/migration.sql"
 
 
 # -----------------------------------------------------------------------------
 # -----------------------------------------------------------------------------
 # Upstream Open AIGC / category-tree database
 # Upstream Open AIGC / category-tree database

+ 26 - 2
acquisition/classification/coarse.py

@@ -15,6 +15,7 @@ from acquisition.classify import (
 )
 )
 from core.config import Settings
 from core.config import Settings
 from core.prompts import load_prompt
 from core.prompts import load_prompt
+from core.text_limits import CLASSIFY_BODY_MAX_CHARS, clip_text
 
 
 ROOT = Path(__file__).resolve().parents[2]
 ROOT = Path(__file__).resolve().parents[2]
 
 
@@ -50,7 +51,7 @@ def classify_imgtext(payload: dict[str, Any], settings: Settings) -> tuple:
             "text": (
             "text": (
                 f"平台:{payload.get('platform')}\n"
                 f"平台:{payload.get('platform')}\n"
                 f"标题:{payload.get('title', '')}\n"
                 f"标题:{payload.get('title', '')}\n"
-                f"正文:{(payload.get('body_text') or '')[:1500]}\n"
+                f"正文:{clip_text(payload.get('body_text') or '', CLASSIFY_BODY_MAX_CHARS)}\n"
                 "(下附帖子图片,请一并看完)"
                 "(下附帖子图片,请一并看完)"
             ),
             ),
         }
         }
@@ -76,13 +77,36 @@ def classify_video(payload: dict[str, Any], settings: Settings) -> tuple:
 def coarse_classify_item(
 def coarse_classify_item(
     *,
     *,
     platform: str,
     platform: str,
+    content_mode: str | None = None,
     title: str = "",
     title: str = "",
     body_text: str = "",
     body_text: str = "",
     image_urls: list[str] | None = None,
     image_urls: list[str] | None = None,
     video_url: str = "",
     video_url: str = "",
     settings: Settings,
     settings: Settings,
 ) -> ClassificationResult:
 ) -> ClassificationResult:
-    if platform == "douyin" or video_url:
+    if content_mode == "unsupported":
+        return ClassificationResult(
+            is_creation_knowledge=None,
+            label="unsupported_content_mode",
+            confidence=None,
+            reason="内容模态暂不支持,跳过粗筛",
+            prompt_version="content_mode_guard",
+            result_payload={"content_mode": content_mode},
+            status="skipped",
+            error_message="unsupported_content_mode",
+        )
+    if content_mode == "video_post" and not video_url:
+        return ClassificationResult(
+            is_creation_knowledge=None,
+            label="video_missing",
+            confidence=None,
+            reason="视频帖缺少可处理的视频地址,跳过粗筛",
+            prompt_version="content_mode_guard",
+            result_payload={"content_mode": content_mode, "video_url_missing": True},
+            status="skipped",
+            error_message="video_url_missing",
+        )
+    if content_mode == "video_post" or (content_mode is None and video_url):
         version = prompt_version("classify_video")
         version = prompt_version("classify_video")
         is_creation, reason, knowledge, points = classify_video(
         is_creation, reason, knowledge, points = classify_video(
             {
             {

+ 17 - 5
acquisition/classify.py

@@ -25,6 +25,12 @@ import httpx
 
 
 from core.config import Settings, load_env_file
 from core.config import Settings, load_env_file
 from core.prompts import load_prompt
 from core.prompts import load_prompt
+from core.text_limits import (
+    CLASSIFY_BODY_MAX_CHARS,
+    ERROR_MESSAGE_MAX_CHARS,
+    REASON_MAX_CHARS,
+    clip_text,
+)
 
 
 ROOT = Path(__file__).resolve().parent.parent
 ROOT = Path(__file__).resolve().parent.parent
 PLATFORMS = ["xiaohongshu", "weixin", "douyin"]   # 默认重判全部;可传平台名覆盖
 PLATFORMS = ["xiaohongshu", "weixin", "douyin"]   # 默认重判全部;可传平台名覆盖
@@ -108,8 +114,8 @@ def _compress(mp4: Path) -> Path:
 def _parse_judge_content(content: str) -> tuple:
 def _parse_judge_content(content: str) -> tuple:
     d = json.loads(content)
     d = json.loads(content)
     if bool(d.get("is_empty")):
     if bool(d.get("is_empty")):
-        return 0, str(d.get("reason", ""))[:60], "", ""
-    return 1, str(d.get("reason", ""))[:60], str(d.get("knowledge", "") or ""), ""
+        return 0, clip_text(d.get("reason", ""), REASON_MAX_CHARS), "", ""
+    return 1, clip_text(d.get("reason", ""), REASON_MAX_CHARS), str(d.get("knowledge", "") or ""), ""
 
 
 
 
 def _env_first(env: dict, *keys: str) -> str:
 def _env_first(env: dict, *keys: str) -> str:
@@ -284,7 +290,7 @@ def _judge(messages: list, settings: Settings, timeout: float) -> tuple:
                 if resp.status_code in (401, 403, 404):
                 if resp.status_code in (401, 403, 404):
                     break
                     break
             except Exception as exc:
             except Exception as exc:
-                last = f"{name} {str(exc)[:50]}"
+                last = f"{name} {clip_text(exc, ERROR_MESSAGE_MAX_CHARS)}"
             if "http " not in last or any(f"http {code}" in last for code in RETRYABLE_STATUS_CODES):
             if "http " not in last or any(f"http {code}" in last for code in RETRYABLE_STATUS_CODES):
                 time.sleep(2 * (attempt + 1))
                 time.sleep(2 * (attempt + 1))
             else:
             else:
@@ -297,7 +303,7 @@ def _judge(messages: list, settings: Settings, timeout: float) -> tuple:
 def classify_imgtext(p: dict, settings: Settings) -> tuple:
 def classify_imgtext(p: dict, settings: Settings) -> tuple:
     """图文:标题+正文+全部图,用收紧的 classify_imgtext.txt 判 is_empty 并提取知识点。"""
     """图文:标题+正文+全部图,用收紧的 classify_imgtext.txt 判 is_empty 并提取知识点。"""
     user = [{"type": "text", "text": f"平台:{p.get('platform')}\n标题:{p.get('title', '')}\n"
     user = [{"type": "text", "text": f"平台:{p.get('platform')}\n标题:{p.get('title', '')}\n"
-             f"正文:{(p.get('body_text') or '')[:1500]}\n(下附帖子图片,请一并看完)"}]
+             f"正文:{clip_text(p.get('body_text') or '', CLASSIFY_BODY_MAX_CHARS)}\n(下附帖子图片,请一并看完)"}]
     for im in (p.get("images") or [])[:MAX_CARDS]:
     for im in (p.get("images") or [])[:MAX_CARDS]:
         if _is_http_url(im):
         if _is_http_url(im):
             user.append({"type": "image_url", "image_url": {"url": im}})
             user.append({"type": "image_url", "image_url": {"url": im}})
@@ -331,8 +337,14 @@ def classify_video(p: dict, settings: Settings) -> tuple:
                     use.unlink()
                     use.unlink()
                 except Exception:
                 except Exception:
                     pass
                     pass
+    user_text = (
+        "判断这条视频是不是创作知识。\n"
+        f"平台:{p.get('platform', '')}\n"
+        f"标题:{p.get('title', '')}\n"
+        f"正文/文案:{clip_text(p.get('body_text') or '', CLASSIFY_BODY_MAX_CHARS)}"
+    )
     messages = [{"role": "system", "content": load_prompt("classify_video")},
     messages = [{"role": "system", "content": load_prompt("classify_video")},
-                {"role": "user", "content": [{"type": "text", "text": "判断这条视频是不是创作知识。"},
+                {"role": "user", "content": [{"type": "text", "text": user_text},
                                              {"type": "video_url", "video_url": {"url": media}}]}]
                                              {"type": "video_url", "video_url": {"url": media}}]}]
     return _judge(messages, settings, timeout=300)
     return _judge(messages, settings, timeout=300)
 
 

+ 108 - 0
acquisition/content_mode.py

@@ -0,0 +1,108 @@
+"""Business-level content mode helpers for acquisition."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Literal
+
+
+ContentMode = Literal["image_post", "video_post", "article", "unsupported"]
+
+IMAGE_POST: ContentMode = "image_post"
+VIDEO_POST: ContentMode = "video_post"
+ARTICLE: ContentMode = "article"
+UNSUPPORTED: ContentMode = "unsupported"
+
+
+@dataclass(frozen=True)
+class ContentModeGuard:
+    can_process: bool
+    reason: str = ""
+    label: str = ""
+    metadata: dict[str, Any] | None = None
+
+
+def _has_any(values: list[str] | None) -> bool:
+    return any(bool(value) for value in values or [])
+
+
+def _raw_type(raw: dict[str, Any] | None) -> str:
+    if not isinstance(raw, dict):
+        return ""
+    candidates = [
+        raw.get("type"),
+        raw.get("content_type"),
+        raw.get("note_type"),
+        (raw.get("note_card") or {}).get("type") if isinstance(raw.get("note_card"), dict) else None,
+        raw.get("aweme_type"),
+    ]
+    for value in candidates:
+        if value is not None and str(value).strip():
+            return str(value).strip().lower()
+    return ""
+
+
+def infer_content_mode(
+    *,
+    platform: str,
+    content_type: str | None = None,
+    body_text: str | None = None,
+    image_urls: list[str] | None = None,
+    video_urls: list[str] | None = None,
+    raw: dict[str, Any] | None = None,
+) -> ContentMode:
+    """Infer the business mode without overwriting the platform raw type."""
+
+    platform_key = (platform or "").lower()
+    raw_kind = _raw_type(raw)
+    raw_content_type = (content_type or "").strip().lower()
+    has_image = _has_any(image_urls)
+    has_video = _has_any(video_urls)
+    has_text = bool((body_text or "").strip())
+
+    if platform_key == "weixin":
+        return ARTICLE
+    if has_video:
+        return VIDEO_POST
+    if has_image or has_text:
+        return IMAGE_POST
+    if raw_kind in {"normal", "note", "image", "image_post", "图文"}:
+        return IMAGE_POST
+    if raw_content_type in {"normal", "note", "image", "image_post", "图文"}:
+        return IMAGE_POST
+    return UNSUPPORTED
+
+
+def guard_content_for_processing(
+    *,
+    content_mode: str | None,
+    video_urls: list[str] | None = None,
+) -> ContentModeGuard:
+    """Return whether this item should enter media/classify/decode processing."""
+
+    if content_mode == UNSUPPORTED:
+        return ContentModeGuard(
+            can_process=False,
+            reason="unsupported_content_mode",
+            label="unsupported_content_mode",
+            metadata={"skip_reason": "unsupported_content_mode"},
+        )
+    if content_mode == VIDEO_POST and not _has_any(video_urls):
+        return ContentModeGuard(
+            can_process=False,
+            reason="video_url_missing",
+            label="video_missing",
+            metadata={"skip_reason": "video_url_missing", "video_url_missing": True},
+        )
+    return ContentModeGuard(can_process=True)
+
+
+__all__ = [
+    "ARTICLE",
+    "ContentMode",
+    "ContentModeGuard",
+    "IMAGE_POST",
+    "UNSUPPORTED",
+    "VIDEO_POST",
+    "guard_content_for_processing",
+    "infer_content_mode",
+]

+ 4 - 0
acquisition/domain.py

@@ -14,6 +14,7 @@ from pydantic import BaseModel, ConfigDict, Field
 
 
 JsonDict = dict[str, Any]
 JsonDict = dict[str, Any]
 Platform = Literal["xiaohongshu", "weixin", "douyin"]
 Platform = Literal["xiaohongshu", "weixin", "douyin"]
+ContentMode = Literal["image_post", "video_post", "article", "unsupported"]
 AcquisitionStatus = Literal[
 AcquisitionStatus = Literal[
     "draft",
     "draft",
     "pending",
     "pending",
@@ -95,11 +96,14 @@ class CandidateItem(DomainModel):
     query_id: UUID | None = None
     query_id: UUID | None = None
     platform: str
     platform: str
     platform_item_id: str | None = None
     platform_item_id: str | None = None
+    unique_key: str | None = None
     canonical_url: str | None = None
     canonical_url: str | None = None
     content_type: str | None = None
     content_type: str | None = None
+    content_mode: ContentMode | None = None
     title: str | None = None
     title: str | None = None
     author_name: str | None = None
     author_name: str | None = None
     published_at: datetime | None = None
     published_at: datetime | None = None
+    body_text: str | None = None
     raw_summary: str | None = None
     raw_summary: str | None = None
     status: str = "candidate"
     status: str = "candidate"
     source_payload: JsonDict = Field(default_factory=dict)
     source_payload: JsonDict = Field(default_factory=dict)

+ 19 - 0
acquisition/repositories/base.py

@@ -107,10 +107,13 @@ class AcquisitionRepository(Protocol):
         job_id: UUID | None = None,
         job_id: UUID | None = None,
         query_id: UUID | None = None,
         query_id: UUID | None = None,
         platform_item_id: str | None = None,
         platform_item_id: str | None = None,
+        unique_key: str | None = None,
         canonical_url: str | None = None,
         canonical_url: str | None = None,
         content_type: str | None = None,
         content_type: str | None = None,
+        content_mode: str | None = None,
         title: str | None = None,
         title: str | None = None,
         author_name: str | None = None,
         author_name: str | None = None,
+        body_text: str | None = None,
         raw_summary: str | None = None,
         raw_summary: str | None = None,
         status: str = "candidate",
         status: str = "candidate",
         source_payload: dict[str, Any] | None = None,
         source_payload: dict[str, Any] | None = None,
@@ -119,6 +122,19 @@ class AcquisitionRepository(Protocol):
     ) -> CandidateItem:
     ) -> CandidateItem:
         ...
         ...
 
 
+    def get_candidate_item_by_unique_key(self, unique_key: str) -> CandidateItem | None:
+        ...
+
+    def attach_existing_candidate_item(
+        self,
+        item_id: UUID,
+        *,
+        job_id: UUID,
+        query_id: UUID,
+        metadata: dict[str, Any] | None = None,
+    ) -> CandidateItem:
+        ...
+
     def add_media_asset(
     def add_media_asset(
         self,
         self,
         *,
         *,
@@ -156,6 +172,9 @@ class AcquisitionRepository(Protocol):
     def get_query_detail(self, *, run_id: UUID, query_id: UUID) -> dict[str, Any]:
     def get_query_detail(self, *, run_id: UUID, query_id: UUID) -> dict[str, Any]:
         ...
         ...
 
 
+    def get_query_detail_for_batch(self, *, batch_id: UUID, query_id: UUID) -> dict[str, Any]:
+        ...
+
     def list_creation_candidate_items(
     def list_creation_candidate_items(
         self,
         self,
         *,
         *,

+ 171 - 19
acquisition/repositories/postgres.py

@@ -264,10 +264,13 @@ class PostgresAcquisitionRepository:
         job_id: UUID | None = None,
         job_id: UUID | None = None,
         query_id: UUID | None = None,
         query_id: UUID | None = None,
         platform_item_id: str | None = None,
         platform_item_id: str | None = None,
+        unique_key: str | None = None,
         canonical_url: str | None = None,
         canonical_url: str | None = None,
         content_type: str | None = None,
         content_type: str | None = None,
+        content_mode: str | None = None,
         title: str | None = None,
         title: str | None = None,
         author_name: str | None = None,
         author_name: str | None = None,
+        body_text: str | None = None,
         raw_summary: str | None = None,
         raw_summary: str | None = None,
         status: str = "candidate",
         status: str = "candidate",
         source_payload: dict[str, Any] | None = None,
         source_payload: dict[str, Any] | None = None,
@@ -275,31 +278,46 @@ class PostgresAcquisitionRepository:
         error_message: str | None = None,
         error_message: str | None = None,
     ) -> CandidateItem:
     ) -> CandidateItem:
         existing_id = None
         existing_id = None
-        if platform_item_id:
+        if unique_key:
             row = self._one_or_none(
             row = self._one_or_none(
                 """
                 """
                 SELECT id FROM candidate_items
                 SELECT id FROM candidate_items
-                WHERE platform = %s AND platform_item_id = %s
+                WHERE unique_key = %s
                 ORDER BY created_at DESC
                 ORDER BY created_at DESC
                 LIMIT 1
                 LIMIT 1
                 """,
                 """,
-                (platform, platform_item_id),
+                (unique_key,),
             )
             )
             existing_id = row["id"] if row else None
             existing_id = row["id"] if row else None
 
 
+        if platform_item_id:
+            if existing_id is None:
+                row = self._one_or_none(
+                    """
+                    SELECT id FROM candidate_items
+                    WHERE platform = %s AND platform_item_id = %s
+                    ORDER BY created_at DESC
+                    LIMIT 1
+                    """,
+                    (platform, platform_item_id),
+                )
+                existing_id = row["id"] if row else None
+
         if existing_id:
         if existing_id:
             row = self._one(
             row = self._one(
                 """
                 """
                 UPDATE candidate_items SET
                 UPDATE candidate_items SET
                     job_id = %s,
                     job_id = %s,
                     query_id = %s,
                     query_id = %s,
+                    unique_key = COALESCE(%s, unique_key),
                     canonical_url = %s,
                     canonical_url = %s,
                     content_type = %s,
                     content_type = %s,
+                    content_mode = %s,
                     title = %s,
                     title = %s,
                     author_name = %s,
                     author_name = %s,
+                    body_text = %s,
                     raw_summary = %s,
                     raw_summary = %s,
                     status = %s,
                     status = %s,
-                    source_payload = %s,
                     metadata = %s,
                     metadata = %s,
                     error_message = %s
                     error_message = %s
                 WHERE id = %s
                 WHERE id = %s
@@ -308,13 +326,15 @@ class PostgresAcquisitionRepository:
                 (
                 (
                     job_id,
                     job_id,
                     query_id,
                     query_id,
+                    unique_key,
                     canonical_url,
                     canonical_url,
                     content_type,
                     content_type,
+                    content_mode,
                     title,
                     title,
                     author_name,
                     author_name,
+                    body_text,
                     raw_summary,
                     raw_summary,
                     status,
                     status,
-                    Json(source_payload or {}),
                     Json(metadata or {}),
                     Json(metadata or {}),
                     error_message,
                     error_message,
                     existing_id,
                     existing_id,
@@ -324,11 +344,11 @@ class PostgresAcquisitionRepository:
             row = self._one(
             row = self._one(
                 """
                 """
                 INSERT INTO candidate_items(
                 INSERT INTO candidate_items(
-                    job_id, query_id, platform, platform_item_id, canonical_url,
-                    content_type, title, author_name, raw_summary, status,
+                    job_id, query_id, platform, platform_item_id, unique_key, canonical_url,
+                    content_type, content_mode, title, author_name, body_text, raw_summary, status,
                     source_payload, metadata, error_message
                     source_payload, metadata, error_message
                 )
                 )
-                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                 RETURNING *
                 RETURNING *
                 """,
                 """,
                 (
                 (
@@ -336,10 +356,13 @@ class PostgresAcquisitionRepository:
                     query_id,
                     query_id,
                     platform,
                     platform,
                     platform_item_id,
                     platform_item_id,
+                    unique_key,
                     canonical_url,
                     canonical_url,
                     content_type,
                     content_type,
+                    content_mode,
                     title,
                     title,
                     author_name,
                     author_name,
+                    body_text,
                     raw_summary,
                     raw_summary,
                     status,
                     status,
                     Json(source_payload or {}),
                     Json(source_payload or {}),
@@ -355,6 +378,44 @@ class PostgresAcquisitionRepository:
             row = cur.fetchone()
             row = cur.fetchone()
             return dict(row) if row else None
             return dict(row) if row else None
 
 
+    def get_candidate_item_by_unique_key(self, unique_key: str) -> CandidateItem | None:
+        row = self._one_or_none(
+            """
+            SELECT * FROM candidate_items
+            WHERE unique_key = %s
+            ORDER BY created_at DESC
+            LIMIT 1
+            """,
+            (unique_key,),
+        )
+        return CandidateItem.model_validate(row) if row else None
+
+    def attach_existing_candidate_item(
+        self,
+        item_id: UUID,
+        *,
+        job_id: UUID,
+        query_id: UUID,
+        metadata: dict[str, Any] | None = None,
+    ) -> CandidateItem:
+        row = self._one(
+            """
+            UPDATE candidate_items SET
+                job_id = %s,
+                query_id = %s,
+                metadata = metadata || %s
+            WHERE id = %s
+            RETURNING *
+            """,
+            (
+                job_id,
+                query_id,
+                Json(metadata or {}),
+                item_id,
+            ),
+        )
+        return CandidateItem.model_validate(row)
+
     def add_media_asset(
     def add_media_asset(
         self,
         self,
         *,
         *,
@@ -509,6 +570,7 @@ class PostgresAcquisitionRepository:
         item_ids = [row["id"] for row in items]
         item_ids = [row["id"] for row in items]
         media: list[dict[str, Any]] = []
         media: list[dict[str, Any]] = []
         classifications: list[dict[str, Any]] = []
         classifications: list[dict[str, Any]] = []
+        decode_summaries: list[dict[str, Any]] = []
         if item_ids:
         if item_ids:
             media = self._all(
             media = self._all(
                 """
                 """
@@ -526,23 +588,107 @@ class PostgresAcquisitionRepository:
                 """,
                 """,
                 (item_ids,),
                 (item_ids,),
             )
             )
+            decode_summaries = self._all(
+                """
+                SELECT DISTINCT ON (dr.item_id)
+                    dr.item_id,
+                    dr.status AS decode_status,
+                    COUNT(DISTINCT pd.id)::int AS payload_count
+                FROM decode_results dr
+                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 {
         return {
             "query": query,
             "query": query,
             "jobs": jobs,
             "jobs": jobs,
             "items": items,
             "items": items,
             "media_assets": media,
             "media_assets": media,
             "classifications": classifications,
             "classifications": classifications,
+            "decode_summaries": decode_summaries,
+        }
+
+    def get_query_detail_for_batch(self, *, batch_id: UUID, query_id: UUID) -> dict[str, Any]:
+        query = self._one(
+            "SELECT * FROM queries WHERE id = %s AND batch_id = %s",
+            (query_id, batch_id),
+        )
+        jobs = self._all(
+            """
+            SELECT aj.* FROM acquisition_jobs aj
+            JOIN acquisition_runs ar ON ar.id = aj.run_id
+            WHERE ar.batch_id = %s AND aj.query_id = %s
+            ORDER BY aj.created_at, aj.platform
+            """,
+            (batch_id, query_id),
+        )
+        items = self._all(
+            """
+            SELECT ci.* FROM candidate_items ci
+            JOIN acquisition_jobs aj ON aj.id = ci.job_id
+            JOIN acquisition_runs ar ON ar.id = aj.run_id
+            WHERE ar.batch_id = %s AND ci.query_id = %s
+            ORDER BY ci.platform, ci.created_at
+            """,
+            (batch_id, 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 * FROM media_assets
+                WHERE item_id = ANY(%s)
+                ORDER BY item_id, position
+                """,
+                (item_ids,),
+            )
+            classifications = self._all(
+                """
+                SELECT * FROM item_classifications
+                WHERE item_id = ANY(%s)
+                ORDER BY 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 pd.id)::int AS payload_count
+                FROM decode_results dr
+                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,
+            "jobs": jobs,
+            "items": items,
+            "media_assets": media,
+            "classifications": classifications,
+            "decode_summaries": decode_summaries,
         }
         }
 
 
     def get_latest_singleton_overview(self) -> dict[str, Any]:
     def get_latest_singleton_overview(self) -> dict[str, Any]:
         batch = self._one_or_none(
         batch = self._one_or_none(
             """
             """
-            SELECT * FROM query_batches
-            WHERE generation_method = %s
-            ORDER BY created_at DESC
+            SELECT qb.* FROM query_batches qb
+            JOIN acquisition_runs ar ON ar.batch_id = qb.id
+            GROUP BY qb.id
+            ORDER BY MAX(ar.created_at) DESC
             LIMIT 1
             LIMIT 1
             """,
             """,
-            ("creation_singleton_v1",),
+            (),
         )
         )
         if batch is None:
         if batch is None:
             return {"batch": None, "run": None, "queries": [], "decoded_items": []}
             return {"batch": None, "run": None, "queries": [], "decoded_items": []}
@@ -550,11 +696,11 @@ class PostgresAcquisitionRepository:
         run = self._one_or_none(
         run = self._one_or_none(
             """
             """
             SELECT * FROM acquisition_runs
             SELECT * FROM acquisition_runs
-            WHERE batch_id = %s AND run_key LIKE %s
+            WHERE batch_id = %s
             ORDER BY created_at DESC
             ORDER BY created_at DESC
             LIMIT 1
             LIMIT 1
             """,
             """,
-            (batch["id"], "singleton-acquisition:%"),
+            (batch["id"],),
         )
         )
         if run is None:
         if run is None:
             queries = self._all(
             queries = self._all(
@@ -584,16 +730,21 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT ci.id)::int AS candidate_count,
                 COUNT(DISTINCT ci.id)::int AS candidate_count,
                 COUNT(DISTINCT ic.id) FILTER (
                 COUNT(DISTINCT ic.id) FILTER (
                     WHERE ic.is_creation_knowledge IS TRUE
                     WHERE ic.is_creation_knowledge IS TRUE
-                )::int AS creation_hit_count
+                )::int AS creation_hit_count,
+                COUNT(DISTINCT dr.id)::int AS decoded_count,
+                COUNT(DISTINCT pd.id)::int AS payload_count
             FROM queries q
             FROM queries q
-            LEFT JOIN acquisition_jobs aj ON aj.query_id = q.id AND aj.run_id = %s
+            LEFT JOIN acquisition_jobs aj ON aj.query_id = q.id
+            LEFT JOIN acquisition_runs ar ON ar.id = aj.run_id AND ar.batch_id = q.batch_id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN candidate_items ci ON ci.job_id = aj.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
             LEFT JOIN item_classifications ic ON ic.item_id = ci.id
+            LEFT JOIN decode_results dr ON dr.item_id = ci.id
+            LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
             WHERE q.batch_id = %s
             WHERE q.batch_id = %s
             GROUP BY q.id, q.query_text, q.metadata, q.sort_order
             GROUP BY q.id, q.query_text, q.metadata, q.sort_order
             ORDER BY q.sort_order, q.created_at
             ORDER BY q.sort_order, q.created_at
             """,
             """,
-            (run["id"], batch["id"]),
+            (batch["id"],),
         )
         )
         decoded_items = self._all(
         decoded_items = self._all(
             """
             """
@@ -606,13 +757,14 @@ class PostgresAcquisitionRepository:
                 COUNT(DISTINCT pd.id)::int AS payload_count
                 COUNT(DISTINCT pd.id)::int AS payload_count
             FROM candidate_items ci
             FROM candidate_items ci
             JOIN acquisition_jobs aj ON aj.id = ci.job_id
             JOIN acquisition_jobs aj ON aj.id = ci.job_id
+            JOIN acquisition_runs ar ON ar.id = aj.run_id
             JOIN decode_results dr ON dr.item_id = ci.id
             JOIN decode_results dr ON dr.item_id = ci.id
             LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
             LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
-            WHERE aj.run_id = %s
+            WHERE ar.batch_id = %s
             GROUP BY ci.id, ci.query_id, ci.title, ci.platform, dr.status, dr.created_at
             GROUP BY ci.id, ci.query_id, ci.title, ci.platform, dr.status, dr.created_at
             ORDER BY dr.created_at DESC
             ORDER BY dr.created_at DESC
             """,
             """,
-            (run["id"],),
+            (batch["id"],),
         )
         )
         return {
         return {
             "batch": batch,
             "batch": batch,

+ 56 - 0
acquisition/unique_key.py

@@ -0,0 +1,56 @@
+"""Stable business identity helpers for acquired candidate items."""
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+
+def _first(params: dict[str, list[str]], key: str) -> str:
+    values = params.get(key) or []
+    return values[0].strip() if values and values[0] else ""
+
+
+def parse_weixin_unique_key(url: str | None) -> str | None:
+    """Build the Weixin article key from URL params when all parts exist."""
+    if not url:
+        return None
+    params = parse_qs(urlparse(url).query, keep_blank_values=False)
+    biz = _first(params, "__biz")
+    mid = _first(params, "mid")
+    idx = _first(params, "idx")
+    sn = _first(params, "sn")
+    if not all([biz, mid, idx, sn]):
+        return None
+    return f"wx:{biz}:{mid}:{idx}:{sn}"
+
+
+def _raw_nested(raw_payload: dict[str, Any] | None, path: tuple[str, ...]) -> str:
+    current: Any = raw_payload or {}
+    for key in path:
+        if not isinstance(current, dict):
+            return ""
+        current = current.get(key)
+    return str(current).strip() if current else ""
+
+
+def build_unique_key(
+    platform: str,
+    *,
+    platform_item_id: str | None = None,
+    url: str | None = None,
+    raw_payload: dict[str, Any] | None = None,
+) -> str | None:
+    """Return the formal candidate item unique_key, or None if unknown."""
+    item_id = (platform_item_id or "").strip()
+    if platform == "xiaohongshu":
+        item_id = item_id or _raw_nested(raw_payload, ("candidate", "source_id"))
+        item_id = item_id or _raw_nested(raw_payload, ("detail", "data", "data", "channel_content_id"))
+        return f"xhs:{item_id}" if item_id else None
+    if platform == "douyin":
+        item_id = item_id or _raw_nested(raw_payload, ("candidate", "source_id"))
+        item_id = item_id or _raw_nested(raw_payload, ("candidate", "raw", "id"))
+        item_id = item_id or _raw_nested(raw_payload, ("detail", "data", "data", "channel_content_id"))
+        return f"dy:{item_id}" if item_id else None
+    if platform == "weixin":
+        return parse_weixin_unique_key(url)
+    return None

+ 7 - 0
app/schemas.py

@@ -66,18 +66,25 @@ class ItemClassificationSchema(ApiSchema):
     confidence: float | None = None
     confidence: float | None = None
     reason: str | None = None
     reason: str | None = None
     status: str
     status: str
+    error_message: str | None = None
 
 
 
 
 class CandidateItemSchema(ApiSchema):
 class CandidateItemSchema(ApiSchema):
     id: UUID
     id: UUID
     platform: str
     platform: str
     platform_item_id: str | None = None
     platform_item_id: str | None = None
+    unique_key: str | None = None
     canonical_url: str | None = None
     canonical_url: str | None = None
     content_type: str | None = None
     content_type: str | None = None
+    content_mode: str | None = None
     title: str | None = None
     title: str | None = None
     author_name: str | None = None
     author_name: str | None = None
+    body_text: str | None = None
     raw_summary: str | None = None
     raw_summary: str | None = None
     status: str
     status: str
+    metadata: dict[str, Any] = Field(default_factory=dict)
+    error_message: str | None = None
+    decode_summary: dict[str, Any] | None = None
     media_assets: list[MediaAssetSchema] = Field(default_factory=list)
     media_assets: list[MediaAssetSchema] = Field(default_factory=list)
     classification: ItemClassificationSchema | None = None
     classification: ItemClassificationSchema | None = None
 
 

+ 3 - 1
core/jsonio.py

@@ -4,6 +4,8 @@ from __future__ import annotations
 import json
 import json
 from typing import Any
 from typing import Any
 
 
+from core.text_limits import JSON_ERROR_SNIPPET_MAX_CHARS, clip_text
+
 
 
 def extract_json_object(text: str) -> dict:
 def extract_json_object(text: str) -> dict:
     """取第一个 { 到最后一个 } 的切片解析为 dict。
     """取第一个 { 到最后一个 } 的切片解析为 dict。
@@ -12,7 +14,7 @@ def extract_json_object(text: str) -> dict:
     """
     """
     start, end = text.find("{"), text.rfind("}")
     start, end = text.find("{"), text.rfind("}")
     if start == -1 or end == -1 or end < start:
     if start == -1 or end == -1 or end < start:
-        raise ValueError(f"no json object in output: {text[:120]!r}")
+        raise ValueError(f"no json object in output: {clip_text(text, JSON_ERROR_SNIPPET_MAX_CHARS)!r}")
     return json.loads(text[start : end + 1])
     return json.loads(text[start : end + 1])
 
 
 
 

+ 2 - 0
core/models.py

@@ -28,10 +28,12 @@ class Post(BaseModel):
 
 
     id: str  # xhs_<content_id>,复用为 ingest source.id
     id: str  # xhs_<content_id>,复用为 ingest source.id
     platform: str = "xiaohongshu"
     platform: str = "xiaohongshu"
+    provider: str = ""
     url: str
     url: str
     content_id: str
     content_id: str
     title: str = ""
     title: str = ""
     content_type: str = ""  # video / 图文
     content_type: str = ""  # video / 图文
+    content_mode: str = ""  # image_post / video_post / article / unsupported
     body_text: str = ""
     body_text: str = ""
     topic_list: list[str] = Field(default_factory=list)
     topic_list: list[str] = Field(default_factory=list)
     image_urls: list[str] = Field(default_factory=list)
     image_urls: list[str] = Field(default_factory=list)

+ 24 - 0
core/text_limits.py

@@ -0,0 +1,24 @@
+"""Shared text length limits for the formal creation-knowledge pipeline."""
+from __future__ import annotations
+
+from typing import Any
+
+BODY_TEXT_MAX_CHARS = 50_000
+CLASSIFY_BODY_MAX_CHARS = 50_000
+RAW_SUMMARY_MAX_CHARS = 5_000
+REASON_MAX_CHARS = 10_000
+DIRECTIVE_CONTEXT_MAX_CHARS = 10_000
+QUERY_FILTER_REASON_MAX_CHARS = 5_000
+ERROR_MESSAGE_MAX_CHARS = 5_000
+TITLE_FALLBACK_MAX_CHARS = 1_000
+JSON_ERROR_SNIPPET_MAX_CHARS = 5_000
+
+
+def clip_text(value: Any, limit: int) -> str:
+    """Return text capped at a shared business limit."""
+    if value is None:
+        return ""
+    text = str(value)
+    if limit <= 0:
+        return ""
+    return text[:limit]

+ 54 - 0
db/migrations/002_candidate_items_unique_key.sql

@@ -0,0 +1,54 @@
+-- 002: add formal candidate item unique_key.
+-- Applied to database: creation_knowledge_prod
+
+ALTER TABLE creation_knowledge.candidate_items
+ADD COLUMN IF NOT EXISTS unique_key text;
+
+WITH extracted AS (
+    SELECT
+        id,
+        platform,
+        COALESCE(
+            NULLIF(platform_item_id, ''),
+            NULLIF(source_payload #>> '{candidate,source_id}', ''),
+            NULLIF(source_payload #>> '{detail,data,data,channel_content_id}', ''),
+            NULLIF(substring(canonical_url from '/explore/([^/?#]+)'), '')
+        ) AS xhs_id,
+        COALESCE(
+            NULLIF(platform_item_id, ''),
+            NULLIF(source_payload #>> '{candidate,source_id}', ''),
+            NULLIF(source_payload #>> '{candidate,raw,id}', ''),
+            NULLIF(source_payload #>> '{detail,data,data,channel_content_id}', ''),
+            NULLIF(substring(canonical_url from '/video/([^/?#]+)'), '')
+        ) AS dy_id,
+        substring(canonical_url from '[?&]__biz=([^&#]+)') AS wx_biz,
+        substring(canonical_url from '[?&]mid=([^&#]+)') AS wx_mid,
+        substring(canonical_url from '[?&]idx=([^&#]+)') AS wx_idx,
+        substring(canonical_url from '[?&]sn=([^&#]+)') AS wx_sn
+    FROM creation_knowledge.candidate_items
+    WHERE unique_key IS NULL
+)
+UPDATE creation_knowledge.candidate_items ci
+SET unique_key = CASE
+    WHEN e.platform = 'xiaohongshu' AND e.xhs_id IS NOT NULL THEN 'xhs:' || e.xhs_id
+    WHEN e.platform = 'douyin' AND e.dy_id IS NOT NULL THEN 'dy:' || e.dy_id
+    WHEN e.platform = 'weixin'
+        AND e.wx_biz IS NOT NULL
+        AND e.wx_mid IS NOT NULL
+        AND e.wx_idx IS NOT NULL
+        AND e.wx_sn IS NOT NULL
+        THEN 'wx:' || e.wx_biz || ':' || e.wx_mid || ':' || e.wx_idx || ':' || e.wx_sn
+    ELSE NULL
+END
+FROM extracted e
+WHERE ci.id = e.id;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_candidate_items_unique_key
+ON creation_knowledge.candidate_items(unique_key)
+WHERE unique_key IS NOT NULL;
+
+INSERT INTO creation_knowledge.schema_migrations(version, description)
+VALUES ('002_candidate_items_unique_key', 'Add candidate_items.unique_key and backfill existing candidate identities')
+ON CONFLICT (version) DO UPDATE
+SET description = EXCLUDED.description,
+    applied_at = now();

+ 29 - 0
db/migrations/003_candidate_items_content_mode.sql

@@ -0,0 +1,29 @@
+-- 003: add formal candidate item content_mode.
+-- Applied to database: creation_knowledge_prod
+
+ALTER TABLE creation_knowledge.candidate_items
+ADD COLUMN IF NOT EXISTS content_mode text;
+
+UPDATE creation_knowledge.candidate_items ci
+SET content_mode = CASE
+    WHEN ci.platform = 'weixin' THEN 'article'
+    WHEN EXISTS (
+        SELECT 1 FROM creation_knowledge.media_assets ma
+        WHERE ma.item_id = ci.id AND ma.media_type = 'video'
+    ) THEN 'video_post'
+    WHEN EXISTS (
+        SELECT 1 FROM creation_knowledge.media_assets ma
+        WHERE ma.item_id = ci.id AND ma.media_type IN ('image', 'cover', 'frame')
+    ) OR NULLIF(ci.raw_summary, '') IS NOT NULL THEN 'image_post'
+    ELSE 'unsupported'
+END
+WHERE ci.content_mode IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_candidate_items_content_mode
+ON creation_knowledge.candidate_items(content_mode);
+
+INSERT INTO creation_knowledge.schema_migrations(version, description)
+VALUES ('003_candidate_items_content_mode', 'Add candidate_items.content_mode and backfill business media modes')
+ON CONFLICT (version) DO UPDATE
+SET description = EXCLUDED.description,
+    applied_at = now();

+ 15 - 0
db/migrations/004_candidate_items_body_text.sql

@@ -0,0 +1,15 @@
+-- 004: add full body text storage for formal candidate items.
+
+ALTER TABLE creation_knowledge.candidate_items
+ADD COLUMN IF NOT EXISTS body_text text;
+
+UPDATE creation_knowledge.candidate_items
+SET body_text = LEFT(NULLIF(source_payload #>> '{detail,data,data,body_text}', ''), 50000)
+WHERE body_text IS NULL
+  AND NULLIF(source_payload #>> '{detail,data,data,body_text}', '') IS NOT NULL;
+
+INSERT INTO creation_knowledge.schema_migrations(version, description)
+VALUES ('004_candidate_items_body_text', 'Add candidate_items.body_text for full post text')
+ON CONFLICT (version) DO UPDATE SET
+    description = EXCLUDED.description,
+    applied_at = now();

+ 2 - 1
decode_content/gates.py

@@ -6,6 +6,7 @@ from typing import Any, Callable
 
 
 from core.llm import chat_json as default_chat_json
 from core.llm import chat_json as default_chat_json
 from core.prompts import load_prompt
 from core.prompts import load_prompt
+from core.text_limits import DIRECTIVE_CONTEXT_MAX_CHARS, clip_text
 from decode_content.models import GateResult
 from decode_content.models import GateResult
 
 
 
 
@@ -82,7 +83,7 @@ def how_gate(knowledge: dict[str, Any], *, chat_json_fn: ChatJsonFn = default_ch
             "steps": [
             "steps": [
                 {
                 {
                     "input": step.get("input"),
                     "input": step.get("input"),
-                    "方法": (step.get("directive") or "")[:300],
+                    "方法": clip_text(step.get("directive") or "", DIRECTIVE_CONTEXT_MAX_CHARS),
                     "产出": step.get("output"),
                     "产出": step.get("output"),
                 }
                 }
                 for step in knowledge.get("steps", [])
                 for step in knowledge.get("steps", [])

+ 46 - 10
decode_content/readers/service.py

@@ -1,8 +1,9 @@
 """Unified post reader for image-text and video decode inputs."""
 """Unified post reader for image-text and video decode inputs."""
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Protocol
+from typing import Any, Protocol
 
 
+from acquisition.content_mode import infer_content_mode
 from acquisition.domain import CandidateItem, MediaAsset
 from acquisition.domain import CandidateItem, MediaAsset
 from core.config import Settings
 from core.config import Settings
 from core.models import Card, ExtractedContent, Post
 from core.models import Card, ExtractedContent, Post
@@ -21,6 +22,31 @@ class VideoReader(Protocol):
         ...
         ...
 
 
 
 
+class UnsupportedPostModeError(ValueError):
+    pass
+
+
+def _nested_text(payload: dict, *path: str) -> str:
+    current: Any = payload
+    for key in path:
+        if not isinstance(current, dict):
+            return ""
+        current = current.get(key)
+    return current if isinstance(current, str) else ""
+
+
+def _body_text_from_payload(source_payload: dict) -> str:
+    return (
+        _nested_text(source_payload, "body_text")
+        or _nested_text(source_payload, "detail", "body_text")
+        or _nested_text(source_payload, "detail", "data", "data", "body_text")
+        or _nested_text(source_payload, "candidate", "body_text")
+        or _nested_text(source_payload, "candidate", "raw", "body_text")
+        or _nested_text(source_payload, "desc")
+        or _nested_text(source_payload, "content")
+    )
+
+
 def _media_url(asset: MediaAsset) -> str | None:
 def _media_url(asset: MediaAsset) -> str | None:
     return asset.cdn_url or asset.oss_url or asset.source_url
     return asset.cdn_url or asset.oss_url or asset.source_url
 
 
@@ -51,21 +77,25 @@ def post_from_candidate_item(item: CandidateItem, media_assets: list[MediaAsset]
                 )
                 )
             )
             )
     source_payload = item.source_payload or {}
     source_payload = item.source_payload or {}
+    body_text = item.body_text or _body_text_from_payload(source_payload) or item.raw_summary or ""
     content_id = item.platform_item_id or (str(item.id) if item.id else "")
     content_id = item.platform_item_id or (str(item.id) if item.id else "")
+    content_mode = item.content_mode or infer_content_mode(
+        platform=item.platform,
+        content_type=item.content_type or "",
+        body_text=body_text,
+        image_urls=image_urls,
+        video_urls=video_urls,
+        raw=source_payload if isinstance(source_payload, dict) else {},
+    )
     return Post(
     return Post(
         id=f"{item.platform}_{content_id}" if content_id and not content_id.startswith(f"{item.platform}_") else content_id,
         id=f"{item.platform}_{content_id}" if content_id and not content_id.startswith(f"{item.platform}_") else content_id,
         platform=item.platform,
         platform=item.platform,
         url=item.canonical_url or "",
         url=item.canonical_url or "",
         content_id=content_id,
         content_id=content_id,
         title=item.title or "",
         title=item.title or "",
-        content_type=item.content_type or ("video" if video_urls else "图文"),
-        body_text=(
-            item.raw_summary
-            or source_payload.get("body_text")
-            or source_payload.get("desc")
-            or source_payload.get("content")
-            or ""
-        ),
+        content_type=item.content_type or ("video" if content_mode == "video_post" else "图文"),
+        content_mode=content_mode,
+        body_text=body_text,
         topic_list=source_payload.get("topic_list") or source_payload.get("topics") or [],
         topic_list=source_payload.get("topic_list") or source_payload.get("topics") or [],
         image_urls=image_urls,
         image_urls=image_urls,
         video_urls=video_urls,
         video_urls=video_urls,
@@ -133,7 +163,12 @@ def read_post(
     image_reader: ImageReader | None = None,
     image_reader: ImageReader | None = None,
     video_reader: VideoReader | None = None,
     video_reader: VideoReader | None = None,
 ) -> ReadResult:
 ) -> ReadResult:
-    if post.video_urls:
+    mode = post.content_mode or ("video_post" if post.video_urls else "image_post")
+    if mode == "unsupported":
+        raise UnsupportedPostModeError("unsupported_content_mode")
+    if mode == "video_post":
+        if not post.video_urls:
+            raise UnsupportedPostModeError("video_url_missing")
         if video_reader is not None:
         if video_reader is not None:
             extracted = video_reader(post)
             extracted = video_reader(post)
         else:
         else:
@@ -165,6 +200,7 @@ def read_item(
 
 
 __all__ = [
 __all__ = [
     "ImageReader",
     "ImageReader",
+    "UnsupportedPostModeError",
     "VideoReader",
     "VideoReader",
     "post_from_candidate_item",
     "post_from_candidate_item",
     "read_item",
     "read_item",

+ 2 - 1
decode_content/scoping.py

@@ -7,6 +7,7 @@ from typing import Any, Protocol
 
 
 from core.llm import chat_json as default_chat_json
 from core.llm import chat_json as default_chat_json
 from core.prompts import load_prompt
 from core.prompts import load_prompt
+from core.text_limits import DIRECTIVE_CONTEXT_MAX_CHARS, clip_text
 from decode_content.contracts import REUSE_THRESHOLD, SCOPE_TYPE_CN, SkillContract, load_contract
 from decode_content.contracts import REUSE_THRESHOLD, SCOPE_TYPE_CN, SkillContract, load_contract
 from decode_content.gates import ChatJsonFn
 from decode_content.gates import ChatJsonFn
 
 
@@ -31,7 +32,7 @@ def slim_knowledges(knowledges: list[dict[str, Any]]) -> list[dict[str, Any]]:
                 {
                 {
                     "id": step.get("id"),
                     "id": step.get("id"),
                     "input": step.get("input"),
                     "input": step.get("input"),
-                    "directive": (step.get("directive") or "")[:500],
+                    "directive": clip_text(step.get("directive") or "", DIRECTIVE_CONTEXT_MAX_CHARS),
                     "output": step.get("output"),
                     "output": step.get("output"),
                 }
                 }
                 for step in knowledge.get("steps", [])
                 for step in knowledge.get("steps", [])

+ 6 - 1
pipeline/acquisition_runner.py

@@ -8,6 +8,7 @@ from uuid import UUID
 from acquisition.repositories.base import AcquisitionRepository
 from acquisition.repositories.base import AcquisitionRepository
 from acquisition.runner import RunBatchResult, run_batch
 from acquisition.runner import RunBatchResult, run_batch
 from core.config import Settings
 from core.config import Settings
+from core.text_limits import ERROR_MESSAGE_MAX_CHARS, clip_text
 from pipeline.models import PipelineJob, PipelineRun
 from pipeline.models import PipelineJob, PipelineRun
 from pipeline.repository import PipelineRepository
 from pipeline.repository import PipelineRepository
 
 
@@ -47,5 +48,9 @@ def run_acquisition_stage(
         return AcquisitionStageResult(pipeline_job=job, acquisition=result)
         return AcquisitionStageResult(pipeline_job=job, acquisition=result)
     except Exception as exc:
     except Exception as exc:
         if pipeline_repo and job and job.id:
         if pipeline_repo and job and job.id:
-            pipeline_repo.mark_job_status(job.id, status="failed", error_message=str(exc)[:300])
+            pipeline_repo.mark_job_status(
+                job.id,
+                status="failed",
+                error_message=clip_text(exc, ERROR_MESSAGE_MAX_CHARS),
+            )
         raise
         raise

+ 4 - 0
pipeline/dedupe.py

@@ -15,6 +15,8 @@ class DedupeDecision:
 
 
 
 
 def item_dedupe_key(item: CandidateItem) -> str:
 def item_dedupe_key(item: CandidateItem) -> str:
+    if item.unique_key:
+        return f"unique_key:{item.unique_key}"
     if item.platform_item_id:
     if item.platform_item_id:
         return f"{item.platform}:id:{item.platform_item_id}"
         return f"{item.platform}:id:{item.platform_item_id}"
     if item.canonical_url:
     if item.canonical_url:
@@ -43,4 +45,6 @@ def should_decode_item(
     decoded = decoded_item_ids or set()
     decoded = decoded_item_ids or set()
     if str(item.id) in decoded or key in decoded:
     if str(item.id) in decoded or key in decoded:
         return DedupeDecision(keep=False, key=key, reason="already_decoded")
         return DedupeDecision(keep=False, key=key, reason="already_decoded")
+    if item.content_mode == "unsupported":
+        return DedupeDecision(keep=False, key=key, reason="unsupported_content_mode")
     return DedupeDecision(keep=True, key=key)
     return DedupeDecision(keep=True, key=key)

+ 1 - 1
prompts/classify_imgtext.txt

@@ -38,5 +38,5 @@
 
 
 只输出一个 JSON 对象:
 只输出一个 JSON 对象:
 {"is_empty": true/false,
 {"is_empty": true/false,
- "reason": "一句话理由:是「教了哪种内容创作方法」,还是「属于应试/制作/学科/作品本身的哪一类」(≤30字)",
+ "reason": "判断理由:说明它教了哪种内容创作方法,或属于应试/制作/学科/作品本身的哪一类;保留必要证据,不要为了简短而省略关键判断依据",
  "knowledge": "is_empty=false 时:帖子里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}
  "knowledge": "is_empty=false 时:帖子里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}

+ 1 - 1
prompts/classify_video.txt

@@ -38,5 +38,5 @@
 
 
 只输出一个 JSON 对象:
 只输出一个 JSON 对象:
 {"is_empty": true/false,
 {"is_empty": true/false,
- "reason": "一句话理由:是「教了哪种内容创作方法」,还是「属于讲观点/应试/制作/学科/作品本身的哪一类」(≤30字)",
+ "reason": "判断理由:说明它教了哪种内容创作方法,或属于讲观点/应试/制作/学科/作品本身的哪一类;保留必要证据,不要为了简短而省略关键判断依据",
  "knowledge": "is_empty=false 时:视频里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}
  "knowledge": "is_empty=false 时:视频里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}

+ 15 - 0
tests/test_app_api.py

@@ -96,10 +96,13 @@ class FakeAcquisitionRepo:
                     "id": self.item_id,
                     "id": self.item_id,
                     "platform": "xiaohongshu",
                     "platform": "xiaohongshu",
                     "platform_item_id": "xhs1",
                     "platform_item_id": "xhs1",
+                    "unique_key": "xhs:xhs1",
                     "canonical_url": "https://xhs.test/1",
                     "canonical_url": "https://xhs.test/1",
                     "content_type": "图文",
                     "content_type": "图文",
+                    "content_mode": "image_post",
                     "title": "脚本开头",
                     "title": "脚本开头",
                     "author_name": "作者",
                     "author_name": "作者",
+                    "body_text": "先定受众,再写开头钩子",
                     "raw_summary": "先定受众",
                     "raw_summary": "先定受众",
                     "status": "candidate",
                     "status": "candidate",
                 }
                 }
@@ -125,6 +128,7 @@ class FakeAcquisitionRepo:
                     "confidence": 0.98,
                     "confidence": 0.98,
                     "reason": "可复用",
                     "reason": "可复用",
                     "status": "done",
                     "status": "done",
+                    "error_message": None,
                 }
                 }
             ],
             ],
         }
         }
@@ -239,6 +243,8 @@ def test_app_health_and_formal_acquisition_routes():
 
 
         detail = client.get(f"/api/acquisition/runs/{repo.run_id}/queries/{repo.query_id}").json()
         detail = client.get(f"/api/acquisition/runs/{repo.run_id}/queries/{repo.query_id}").json()
         item = detail["platforms"]["xiaohongshu"]["items"][0]
         item = detail["platforms"]["xiaohongshu"]["items"][0]
+        assert item["content_mode"] == "image_post"
+        assert item["body_text"] == "先定受众,再写开头钩子"
         assert item["media_assets"][0]["cdn_url"] == "https://cdn.test/1.jpg"
         assert item["media_assets"][0]["cdn_url"] == "https://cdn.test/1.jpg"
         assert item["classification"]["is_creation_knowledge"] is True
         assert item["classification"]["is_creation_knowledge"] is True
     finally:
     finally:
@@ -255,6 +261,15 @@ def test_query_generation_preview_defaults_to_first_two_families():
     assert data["metadata"]["active_family_keys"] == ["f1", "f2"]
     assert data["metadata"]["active_family_keys"] == ["f1", "f2"]
 
 
 
 
+def test_query_generation_preview_can_return_full_cartesian_combinations():
+    client = TestClient(app)
+    data = client.get("/api/query-generation/preview?per=0&batch_n=1").json()
+
+    assert data["summary"]["family_keys"] == ["f1", "f2"]
+    assert data["summary"]["query_count"] == 36
+    assert [len(family["items"]) for family in data["families"]] == [18, 18]
+
+
 def test_formal_app_does_not_expose_legacy_static_mounts():
 def test_formal_app_does_not_expose_legacy_static_mounts():
     paths = {route.path for route in app.routes if hasattr(route, "path")}
     paths = {route.path for route in app.routes if hasattr(route, "path")}
     assert "/data" not in paths
     assert "/data" not in paths

+ 96 - 0
tests/test_classification_coarse.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import acquisition.classification.coarse as coarse
 import acquisition.classification.coarse as coarse
 import acquisition.classify as legacy_classify
 import acquisition.classify as legacy_classify
 from core.config import PgConfig, Settings
 from core.config import PgConfig, Settings
+from core.text_limits import CLASSIFY_BODY_MAX_CHARS
 
 
 
 
 def _settings() -> Settings:
 def _settings() -> Settings:
@@ -46,6 +47,30 @@ def test_formal_classify_imgtext_passes_http_image_urls(monkeypatch):
     assert {"type": "image_url", "image_url": {"url": "https://cdn.test/a.jpg"}} in user_content
     assert {"type": "image_url", "image_url": {"url": "https://cdn.test/a.jpg"}} in user_content
 
 
 
 
+def test_formal_classify_imgtext_uses_wide_body_limit(monkeypatch):
+    captured = {}
+
+    def fake_judge(messages, settings, timeout):
+        captured["messages"] = messages
+        return 1, "ok", "长正文方法", ""
+
+    monkeypatch.setattr(coarse, "_judge", fake_judge)
+    long_body = "甲" * (CLASSIFY_BODY_MAX_CHARS + 7)
+
+    coarse.classify_imgtext(
+        {
+            "platform": "xiaohongshu",
+            "title": "长文",
+            "body_text": long_body,
+            "images": [],
+        },
+        _settings(),
+    )
+
+    user_text = captured["messages"][1]["content"][0]["text"]
+    assert user_text.count("甲") == CLASSIFY_BODY_MAX_CHARS
+
+
 def test_legacy_classify_imgtext_also_passes_http_image_urls(monkeypatch):
 def test_legacy_classify_imgtext_also_passes_http_image_urls(monkeypatch):
     captured = {}
     captured = {}
 
 
@@ -70,6 +95,31 @@ def test_legacy_classify_imgtext_also_passes_http_image_urls(monkeypatch):
     assert {"type": "image_url", "image_url": {"url": "https://cdn.test/w.jpg"}} in user_content
     assert {"type": "image_url", "image_url": {"url": "https://cdn.test/w.jpg"}} in user_content
 
 
 
 
+def test_legacy_classify_video_includes_title_and_body_text(monkeypatch):
+    captured = {}
+
+    def fake_judge(messages, settings, timeout):
+        captured["messages"] = messages
+        return 1, "ok", "视频方法", ""
+
+    monkeypatch.setattr(legacy_classify, "_judge", fake_judge)
+
+    legacy_classify.classify_video(
+        {
+            "platform": "douyin",
+            "title": "视频标题",
+            "body_text": "视频文案",
+            "video": "https://cdn.test/video.mp4",
+        },
+        _settings(),
+    )
+
+    user_text = captured["messages"][1]["content"][0]["text"]
+    assert "平台:douyin" in user_text
+    assert "标题:视频标题" in user_text
+    assert "正文/文案:视频文案" in user_text
+
+
 def test_coarse_classify_item_maps_judge_result(monkeypatch):
 def test_coarse_classify_item_maps_judge_result(monkeypatch):
     monkeypatch.setattr(
     monkeypatch.setattr(
         coarse,
         coarse,
@@ -88,3 +138,49 @@ def test_coarse_classify_item_maps_judge_result(monkeypatch):
     assert result.is_creation_knowledge is False
     assert result.is_creation_knowledge is False
     assert result.label == "not_creation"
     assert result.label == "not_creation"
     assert result.status == "classified"
     assert result.status == "classified"
+
+
+def test_coarse_classify_item_routes_douyin_image_post_to_imgtext(monkeypatch):
+    called = {}
+
+    def fake_imgtext(payload, settings):
+        called["imgtext"] = payload
+        return 1, "是创作知识", "图片方法", ""
+
+    def fake_video(payload, settings):
+        called["video"] = payload
+        return 1, "不应调用", "", ""
+
+    monkeypatch.setattr(coarse, "classify_imgtext", fake_imgtext)
+    monkeypatch.setattr(coarse, "classify_video", fake_video)
+
+    result = coarse.coarse_classify_item(
+        platform="douyin",
+        content_mode="image_post",
+        title="图片帖",
+        body_text="图片正文",
+        image_urls=["https://cdn.test/dy.jpg"],
+        settings=_settings(),
+    )
+
+    assert result.is_creation_knowledge is True
+    assert "imgtext" in called
+    assert "video" not in called
+
+
+def test_coarse_classify_item_skips_unsupported_and_missing_video():
+    unsupported = coarse.coarse_classify_item(
+        platform="douyin",
+        content_mode="unsupported",
+        settings=_settings(),
+    )
+    missing_video = coarse.coarse_classify_item(
+        platform="douyin",
+        content_mode="video_post",
+        settings=_settings(),
+    )
+
+    assert unsupported.status == "skipped"
+    assert unsupported.label == "unsupported_content_mode"
+    assert missing_video.status == "skipped"
+    assert missing_video.label == "video_missing"

+ 39 - 1
tests/test_db_migration_contract.py

@@ -4,12 +4,20 @@ from pathlib import Path
 
 
 
 
 MIGRATION = Path("db/migrations/001_creation_knowledge_schema.sql")
 MIGRATION = Path("db/migrations/001_creation_knowledge_schema.sql")
+MIGRATIONS_DIR = Path("db/migrations")
 
 
 
 
 def _migration_sql() -> str:
 def _migration_sql() -> str:
     return MIGRATION.read_text(encoding="utf-8")
     return MIGRATION.read_text(encoding="utf-8")
 
 
 
 
+def _all_migration_sql() -> str:
+    return "\n".join(
+        path.read_text(encoding="utf-8")
+        for path in sorted(MIGRATIONS_DIR.glob("*.sql"))
+    )
+
+
 def test_creation_knowledge_migration_declares_formal_business_tables():
 def test_creation_knowledge_migration_declares_formal_business_tables():
     sql = _migration_sql()
     sql = _migration_sql()
     expected_tables = [
     expected_tables = [
@@ -49,7 +57,7 @@ def test_migration_is_replayable_and_not_sqlite_shaped():
 
 
 
 
 def test_migration_keeps_state_rows_traceable_and_resumable():
 def test_migration_keeps_state_rows_traceable_and_resumable():
-    sql = _migration_sql()
+    sql = _all_migration_sql()
 
 
     for table in [
     for table in [
         "acquisition_runs",
         "acquisition_runs",
@@ -70,3 +78,33 @@ def test_migration_keeps_state_rows_traceable_and_resumable():
     assert "response_payload jsonb NOT NULL DEFAULT '{}'::jsonb" in sql
     assert "response_payload jsonb NOT NULL DEFAULT '{}'::jsonb" in sql
     assert "UNIQUE (run_id, query_id, platform)" in sql
     assert "UNIQUE (run_id, query_id, platform)" in sql
     assert "idx_candidate_items_platform_item" in sql
     assert "idx_candidate_items_platform_item" in sql
+
+
+def test_candidate_items_unique_key_migration_is_declared():
+    sql = _all_migration_sql()
+
+    assert "ADD COLUMN IF NOT EXISTS unique_key text" in sql
+    assert "idx_candidate_items_unique_key" in sql
+    assert "WHERE unique_key IS NOT NULL" in sql
+    assert "'002_candidate_items_unique_key'" in sql
+
+
+def test_candidate_items_content_mode_migration_is_declared():
+    sql = _all_migration_sql()
+
+    assert "ADD COLUMN IF NOT EXISTS content_mode text" in sql
+    assert "idx_candidate_items_content_mode" in sql
+    assert "video_post" in sql
+    assert "image_post" in sql
+    assert "article" in sql
+    assert "unsupported" in sql
+    assert "'003_candidate_items_content_mode'" in sql
+
+
+def test_candidate_items_body_text_migration_is_declared():
+    sql = _all_migration_sql()
+
+    assert "ADD COLUMN IF NOT EXISTS body_text text" in sql
+    assert "source_payload #>> '{detail,data,data,body_text}'" in sql
+    assert "LEFT(NULLIF(source_payload #>> '{detail,data,data,body_text}', ''), 50000)" in sql
+    assert "'004_candidate_items_body_text'" in sql

+ 74 - 2
tests/test_decode_readers_service.py

@@ -4,7 +4,12 @@ from uuid import uuid4
 
 
 from acquisition.domain import CandidateItem, MediaAsset
 from acquisition.domain import CandidateItem, MediaAsset
 from core.models import Card, CardExtract, ExtractedContent, Post
 from core.models import Card, CardExtract, ExtractedContent, Post
-from decode_content.readers.service import post_from_candidate_item, read_item, read_post
+from decode_content.readers.service import (
+    UnsupportedPostModeError,
+    post_from_candidate_item,
+    read_item,
+    read_post,
+)
 
 
 
 
 def test_post_from_candidate_item_prefers_cdn_then_oss_then_source():
 def test_post_from_candidate_item_prefers_cdn_then_oss_then_source():
@@ -13,7 +18,9 @@ def test_post_from_candidate_item_prefers_cdn_then_oss_then_source():
         id=item_id,
         id=item_id,
         platform="xiaohongshu",
         platform="xiaohongshu",
         platform_item_id="abc",
         platform_item_id="abc",
+        unique_key="xhs:abc",
         canonical_url="https://xhs/item/abc",
         canonical_url="https://xhs/item/abc",
+        content_mode="image_post",
         title="脚本结构",
         title="脚本结构",
         author_name="sam",
         author_name="sam",
         raw_summary="正文",
         raw_summary="正文",
@@ -27,12 +34,47 @@ def test_post_from_candidate_item_prefers_cdn_then_oss_then_source():
     post = post_from_candidate_item(item, media)
     post = post_from_candidate_item(item, media)
 
 
     assert post.id == "xiaohongshu_abc"
     assert post.id == "xiaohongshu_abc"
+    assert post.content_id == "abc"
+    assert post.content_mode == "image_post"
     assert post.image_urls == ["oss0", "cdn1"]
     assert post.image_urls == ["oss0", "cdn1"]
     assert [card.index for card in post.cards] == [1, 2]
     assert [card.index for card in post.cards] == [1, 2]
     assert post.body_text == "正文"
     assert post.body_text == "正文"
     assert post.topic_list == ["脚本"]
     assert post.topic_list == ["脚本"]
 
 
 
 
+def test_post_from_candidate_item_prefers_full_body_text_over_summary():
+    item_id = uuid4()
+    item = CandidateItem(
+        id=item_id,
+        platform="weixin",
+        platform_item_id="wx1",
+        content_mode="article",
+        title="长文",
+        body_text="完整正文",
+        raw_summary="摘要",
+    )
+
+    post = post_from_candidate_item(item, [])
+
+    assert post.body_text == "完整正文"
+
+
+def test_post_from_candidate_item_reads_legacy_nested_body_before_summary():
+    item_id = uuid4()
+    item = CandidateItem(
+        id=item_id,
+        platform="xiaohongshu",
+        platform_item_id="x1",
+        content_mode="image_post",
+        raw_summary="摘要",
+        source_payload={"detail": {"data": {"data": {"body_text": "旧 raw 正文"}}}},
+    )
+
+    post = post_from_candidate_item(item, [])
+
+    assert post.body_text == "旧 raw 正文"
+
+
 def test_read_post_imgtext_merges_text_cards_and_media():
 def test_read_post_imgtext_merges_text_cards_and_media():
     post = Post(
     post = Post(
         id="xhs_1",
         id="xhs_1",
@@ -60,7 +102,13 @@ def test_read_post_imgtext_merges_text_cards_and_media():
 
 
 def test_read_item_routes_video_to_video_reader():
 def test_read_item_routes_video_to_video_reader():
     item_id = uuid4()
     item_id = uuid4()
-    item = CandidateItem(id=item_id, platform="douyin", platform_item_id="v1", title="视频")
+    item = CandidateItem(
+        id=item_id,
+        platform="douyin",
+        platform_item_id="v1",
+        content_mode="video_post",
+        title="视频",
+    )
     media = [MediaAsset(item_id=item_id, media_type="video", cdn_url="https://cdn/video.mp4", position=1)]
     media = [MediaAsset(item_id=item_id, media_type="video", cdn_url="https://cdn/video.mp4", position=1)]
 
 
     def video_reader(post: Post) -> ExtractedContent:
     def video_reader(post: Post) -> ExtractedContent:
@@ -73,3 +121,27 @@ def test_read_item_routes_video_to_video_reader():
     assert result.media["type"] == "video"
     assert result.media["type"] == "video"
     assert result.cards[0].kind == "segment"
     assert result.cards[0].kind == "segment"
     assert result.cards[0].content == "片段知识"
     assert result.cards[0].content == "片段知识"
+
+
+def test_read_post_rejects_unsupported_and_missing_video():
+    unsupported = Post(
+        id="dy_x",
+        platform="douyin",
+        url="u",
+        content_id="x",
+        content_mode="unsupported",
+    )
+    missing_video = Post(
+        id="dy_v",
+        platform="douyin",
+        url="u",
+        content_id="v",
+        content_mode="video_post",
+    )
+
+    import pytest
+
+    with pytest.raises(UnsupportedPostModeError, match="unsupported_content_mode"):
+        read_post(unsupported)
+    with pytest.raises(UnsupportedPostModeError, match="video_url_missing"):
+        read_post(missing_video)

+ 23 - 5
tests/test_pipeline_formal.py

@@ -39,14 +39,32 @@ class FakePipelineRepo:
         return None
         return None
 
 
 
 
-def test_dedupe_candidate_items_prefers_platform_item_id_then_url():
-    first = CandidateItem(id=uuid4(), platform="xiaohongshu", platform_item_id="x1", canonical_url="https://a")
-    dup = CandidateItem(id=uuid4(), platform="xiaohongshu", platform_item_id="x1", canonical_url="https://b")
+def test_dedupe_candidate_items_prefers_unique_key_then_platform_item_id_then_url():
+    first = CandidateItem(
+        id=uuid4(),
+        platform="xiaohongshu",
+        platform_item_id="x1",
+        unique_key="xhs:x1",
+        canonical_url="https://a",
+    )
+    dup = CandidateItem(
+        id=uuid4(),
+        platform="xiaohongshu",
+        platform_item_id="x1",
+        unique_key="xhs:x1",
+        canonical_url="https://b",
+    )
+    platform_id_only = CandidateItem(id=uuid4(), platform="douyin", platform_item_id="dy1")
     url_only = CandidateItem(id=uuid4(), platform="weixin", canonical_url="HTTPS://MP.TEST/A")
     url_only = CandidateItem(id=uuid4(), platform="weixin", canonical_url="HTTPS://MP.TEST/A")
 
 
-    assert item_dedupe_key(first) == "xiaohongshu:id:x1"
+    assert item_dedupe_key(first) == "unique_key:xhs:x1"
+    assert item_dedupe_key(platform_id_only) == "douyin:id:dy1"
     assert item_dedupe_key(url_only) == "url:https://mp.test/a"
     assert item_dedupe_key(url_only) == "url:https://mp.test/a"
-    assert dedupe_candidate_items([first, dup, url_only]) == [first, url_only]
+    assert dedupe_candidate_items([first, dup, platform_id_only, url_only]) == [
+        first,
+        platform_id_only,
+        url_only,
+    ]
     assert should_decode_item(first, decoded_item_ids={str(first.id)}).keep is False
     assert should_decode_item(first, decoded_item_ids={str(first.id)}).keep is False
 
 
 
 

+ 108 - 1
tests/test_postgres_repository_contract.py

@@ -9,14 +9,20 @@ class RecordingPostgresRepo(PostgresAcquisitionRepository):
     def __init__(self):
     def __init__(self):
         super().__init__(conn=None)
         super().__init__(conn=None)
         self.one_calls = []
         self.one_calls = []
+        self.one_or_none_calls = []
         self.all_calls = []
         self.all_calls = []
         self.one_results = []
         self.one_results = []
+        self.one_or_none_results = []
         self.all_results = []
         self.all_results = []
 
 
     def _one(self, sql, params):
     def _one(self, sql, params):
         self.one_calls.append((sql, params))
         self.one_calls.append((sql, params))
         return self.one_results.pop(0)
         return self.one_results.pop(0)
 
 
+    def _one_or_none(self, sql, params):
+        self.one_or_none_calls.append((sql, params))
+        return self.one_or_none_results.pop(0)
+
     def _all(self, sql, params):
     def _all(self, sql, params):
         self.all_calls.append((sql, params))
         self.all_calls.append((sql, params))
         return self.all_results.pop(0)
         return self.all_results.pop(0)
@@ -79,6 +85,7 @@ def test_postgres_repository_lists_only_creation_candidates_for_decode():
                 "id": item_id,
                 "id": item_id,
                 "platform": "xiaohongshu",
                 "platform": "xiaohongshu",
                 "platform_item_id": "x1",
                 "platform_item_id": "x1",
+                "unique_key": "xhs:x1",
                 "canonical_url": "https://xhs.test/1",
                 "canonical_url": "https://xhs.test/1",
                 "status": "candidate",
                 "status": "candidate",
             }
             }
@@ -97,6 +104,63 @@ def test_postgres_repository_lists_only_creation_candidates_for_decode():
     assert items[0].platform == "xiaohongshu"
     assert items[0].platform == "xiaohongshu"
 
 
 
 
+def test_postgres_upsert_candidate_item_prefers_unique_key_for_existing_rows():
+    item_id = uuid4()
+    repo = RecordingPostgresRepo()
+    repo.one_or_none_results.append({"id": item_id})
+    repo.one_results.append(
+        {
+            "id": item_id,
+            "job_id": None,
+            "query_id": None,
+            "platform": "xiaohongshu",
+            "platform_item_id": "x1",
+            "unique_key": "xhs:x1",
+            "canonical_url": "https://xhs.test/1",
+            "content_type": "图文",
+            "content_mode": "image_post",
+            "title": "更新标题",
+            "author_name": None,
+            "published_at": None,
+            "body_text": "完整正文",
+            "raw_summary": None,
+            "status": "candidate",
+            "source_payload": {},
+            "metadata": {},
+            "error_message": None,
+            "created_at": None,
+            "updated_at": None,
+        }
+    )
+
+    item = repo.upsert_candidate_item(
+        platform="xiaohongshu",
+        platform_item_id="x1",
+        unique_key="xhs:x1",
+        canonical_url="https://xhs.test/1",
+        content_type="图文",
+        content_mode="image_post",
+        title="更新标题",
+        body_text="完整正文",
+    )
+
+    lookup_sql, lookup_params = repo.one_or_none_calls[0]
+    update_sql, update_params = repo.one_calls[0]
+    assert "WHERE unique_key = %s" in lookup_sql
+    assert lookup_params == ("xhs:x1",)
+    assert "source_payload =" not in update_sql
+    assert "unique_key = COALESCE(%s, unique_key)" in update_sql
+    assert "content_mode = %s" in update_sql
+    assert "body_text = %s" in update_sql
+    assert "INSERT INTO candidate_items" not in update_sql
+    assert update_params[2] == "xhs:x1"
+    assert update_params[5] == "image_post"
+    assert update_params[8] == "完整正文"
+    assert item.unique_key == "xhs:x1"
+    assert item.content_mode == "image_post"
+    assert item.body_text == "完整正文"
+
+
 def test_postgres_query_detail_loads_media_and_classifications_only_when_items_exist():
 def test_postgres_query_detail_loads_media_and_classifications_only_when_items_exist():
     run_id = uuid4()
     run_id = uuid4()
     query_id = uuid4()
     query_id = uuid4()
@@ -109,13 +173,56 @@ def test_postgres_query_detail_loads_media_and_classifications_only_when_items_e
             [{"id": item_id, "platform": "weixin", "query_id": query_id, "status": "candidate"}],
             [{"id": item_id, "platform": "weixin", "query_id": query_id, "status": "candidate"}],
             [{"id": uuid4(), "item_id": item_id, "media_type": "image", "status": "done"}],
             [{"id": uuid4(), "item_id": item_id, "media_type": "image", "status": "done"}],
             [{"id": uuid4(), "item_id": item_id, "status": "classified"}],
             [{"id": uuid4(), "item_id": item_id, "status": "classified"}],
+            [{"item_id": item_id, "decode_status": "decoded", "payload_count": 2}],
         ]
         ]
     )
     )
 
 
     detail = repo.get_query_detail(run_id=run_id, query_id=query_id)
     detail = repo.get_query_detail(run_id=run_id, query_id=query_id)
 
 
-    assert len(repo.all_calls) == 4
+    assert len(repo.all_calls) == 5
     assert "SELECT * FROM media_assets" in repo.all_calls[2][0]
     assert "SELECT * FROM media_assets" in repo.all_calls[2][0]
     assert "SELECT * FROM item_classifications" in repo.all_calls[3][0]
     assert "SELECT * FROM item_classifications" in repo.all_calls[3][0]
+    assert "FROM decode_results dr" in repo.all_calls[4][0]
     assert detail["items"][0]["id"] == item_id
     assert detail["items"][0]["id"] == item_id
     assert detail["media_assets"][0]["item_id"] == item_id
     assert detail["media_assets"][0]["item_id"] == item_id
+    assert detail["decode_summaries"][0]["payload_count"] == 2
+
+
+def test_postgres_attach_existing_candidate_updates_query_job_and_metadata():
+    item_id = uuid4()
+    job_id = uuid4()
+    query_id = uuid4()
+    repo = RecordingPostgresRepo()
+    repo.one_results.append(
+        {
+            "id": item_id,
+            "job_id": job_id,
+            "query_id": query_id,
+            "platform": "weixin",
+            "platform_item_id": "wx1",
+            "unique_key": "wx:key",
+            "canonical_url": "https://mp.weixin.qq.com/s?...",
+            "content_mode": "article",
+            "body_text": None,
+            "status": "candidate",
+            "source_payload": {},
+            "metadata": {"acquisition_match_status": "existing"},
+            "created_at": None,
+            "updated_at": None,
+        }
+    )
+
+    item = repo.attach_existing_candidate_item(
+        item_id,
+        job_id=job_id,
+        query_id=query_id,
+        metadata={"acquisition_match_status": "existing"},
+    )
+
+    sql, params = repo.one_calls[0]
+    assert "UPDATE candidate_items SET" in sql
+    assert "metadata = metadata || %s" in sql
+    assert params[0] == job_id
+    assert params[1] == query_id
+    assert params[3] == item_id
+    assert item.metadata["acquisition_match_status"] == "existing"

+ 44 - 0
tests/test_unique_key.py

@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from acquisition.unique_key import build_unique_key, parse_weixin_unique_key
+
+
+def test_build_unique_key_for_xiaohongshu_and_douyin():
+    assert (
+        build_unique_key("xiaohongshu", platform_item_id="685a477d00000000120211af")
+        == "xhs:685a477d00000000120211af"
+    )
+    assert (
+        build_unique_key("douyin", platform_item_id="7612592616106388138")
+        == "dy:7612592616106388138"
+    )
+
+
+def test_build_unique_key_reads_raw_payload_fallbacks():
+    assert (
+        build_unique_key("xiaohongshu", raw_payload={"candidate": {"source_id": "xhs-1"}})
+        == "xhs:xhs-1"
+    )
+    assert (
+        build_unique_key("douyin", raw_payload={"candidate": {"raw": {"id": "dy-1"}}})
+        == "dy:dy-1"
+    )
+
+
+def test_parse_weixin_unique_key_from_article_url():
+    url = (
+        "https://mp.weixin.qq.com/s?__biz=MzUxMDg4NDY1Ng=="
+        "&mid=2247484898&idx=2&sn=5069e65919f414b96b7aec53671dc1b5"
+        "&chksm=abc#rd"
+    )
+
+    assert (
+        parse_weixin_unique_key(url)
+        == "wx:MzUxMDg4NDY1Ng==:2247484898:2:5069e65919f414b96b7aec53671dc1b5"
+    )
+    assert build_unique_key("weixin", url=url) == parse_weixin_unique_key(url)
+
+
+def test_weixin_unique_key_is_none_when_url_lacks_article_parts():
+    assert parse_weixin_unique_key("https://mp.weixin.qq.com/s/a") is None
+    assert build_unique_key("weixin", platform_item_id="old-hash", url="https://mp.weixin.qq.com/s/a") is None