"""商家条件与逐来源分析合同。""" from __future__ import annotations from typing import Literal from urllib.parse import urlsplit from pydantic import BaseModel, ConfigDict, Field, field_validator KNOWLEDGE_ID = "xiaohongshu_prospecting" BUSINESS_ID = "prospecting" START_URL = "https://www.xiaohongshu.com/explore" ALLOWED_DOMAINS = ["www.xiaohongshu.com", "xiaohongshu.com"] TARGET_QUESTION = "这次先整理多少位候选?" class ProspectingBrief(BaseModel): model_config = ConfigDict(extra="forbid") product: str = Field(default="", max_length=300) target_customer: str = Field(default="", max_length=300) keywords: list[str] = Field(default_factory=list) region: str | None = Field(default=None, max_length=100) # None means no application-imposed quota. A positive number is an explicit # scope chosen by the user, not a default business/runtime budget. max_notes: int | None = Field(default=None, ge=1) max_comments_per_note: int | None = Field(default=None, ge=0) # Desired deliverable, counted by a source-bound person identity. This is # distinct from how many notes or comments may be read to find that person. target_candidates: int | None = Field(default=None, ge=1, strict=True) # Program-grounded deliverable requirements, never guessed from the model's # interpretation. Missing optional account/demand fields do not add a gate. required_result_fields: list[Literal["xiaohongshu_id", "explicit_demand", "region", "csv"]] = Field(default_factory=list) discovery_scope: Literal["candidate_count", "route_pass"] = "candidate_count" use_web_search: bool = False user_notes: str = Field(default="", max_length=2000) intent: Literal["discover", "research", "inspect", "export", "follow", "message"] = "discover" subject_urls: list[str] = Field(default_factory=list) action_scope: str = Field(default="", max_length=2000) message_text: str = Field(default="", max_length=2000) @field_validator("product", "target_customer") @classmethod def trim_text(cls, value: str) -> str: return value.strip() @field_validator("region") @classmethod def normalize_region(cls, value: str | None) -> str | None: return value.strip() or None if value else None @field_validator("keywords") @classmethod def normalize_keywords(cls, values: list[str]) -> list[str]: result = list(dict.fromkeys(value.strip() for value in values if value.strip())) if any(len(value) > 80 for value in result): raise ValueError("每个搜索词最多 80 字") return result @field_validator("subject_urls") @classmethod def validate_subject_urls(cls, values: list[str]) -> list[str]: clean = list(dict.fromkeys(value.strip() for value in values if value.strip())) for url in clean: parts = urlsplit(url) if parts.scheme != "https" or parts.hostname not in {"www.xiaohongshu.com", "xiaohongshu.com", "xhslink.com", "www.xhslink.com"} or parts.username or parts.password: raise ValueError("请提供实际小红书 HTTPS 页面或分享链接") return clean def missing(self) -> list[str]: if self.intent in {"follow", "message"}: missing = [] if self.action_scope or self.subject_urls else ["要操作的对象范围"] if self.intent == "message" and not self.message_text: missing.append("私信内容") return missing if self.intent == "export": return [] # Existing sources can be exported without a business profile. missing = [] if self.subject_urls or self.keywords or self.target_customer or self.user_notes else ["想完成的任务"] # An explicitly bounded read/page task already has an endpoint. Legacy # default note limits are removed by the orchestrator before this check. if (self.intent == "discover" and self.target_candidates is None and self.discovery_scope == "candidate_count" and self.max_notes is None and not self.subject_urls): missing.append(TARGET_QUESTION) return missing class IntentPlan(BaseModel): """A model interpretation, never an authorization to send or follow.""" model_config = ConfigDict(extra="forbid") brief: ProspectingBrief summary: str = Field(min_length=1, max_length=1000) stages: list[str] = Field(default_factory=list) missing: list[str] = Field(default_factory=list) assumptions: list[str] = Field(default_factory=list) reuse_existing_sources: bool = False class SourceDecision(BaseModel): model_config = ConfigDict(extra="forbid") source_id: str decision: Literal["keep", "exclude", "unclear"] quote: str = Field(max_length=1500) reason: str = Field(min_length=1, max_length=1000) demand_stage: Literal["unknown", "planning", "in_progress", "completed"] = "unknown" seeking_service: Literal["yes", "no", "unknown"] = "unknown" region_match: Literal["yes", "no", "unknown"] = "unknown" region_quote: str = Field(default="", max_length=500) class AnalysisOutput(BaseModel): model_config = ConfigDict(extra="forbid") results: list[SourceDecision] summary: str = Field(default="", max_length=2000)