118 lines
5.2 KiB
Python
118 lines
5.2 KiB
Python
"""商家条件与逐来源分析合同。"""
|
|
|
|
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)
|