assistant/backend/prospecting/contracts.py

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)