assistant/backend/prospecting/people.py

242 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Source-bound candidate identities and explicit delivery targets.
A demand excerpt is not a person. Only observed author profile links (or a
source-bound profile verification) establish a stable grouping key. Nicknames,
internal account fields and neighbouring comments never merge people.
"""
from __future__ import annotations
import re
from urllib.parse import urlsplit
def _profile_key(value: object) -> str | None:
if not isinstance(value, str):
return None
try:
parts = urlsplit(value)
if (parts.scheme != "https" or parts.hostname not in {"www.xiaohongshu.com", "xiaohongshu.com"}
or parts.username is not None or parts.password is not None or parts.port not in {None, 443}):
return None
except ValueError:
return None
match = re.fullmatch(r"/user/profile/([a-fA-F0-9]{24})/?", parts.path)
return "xhs:" + match.group(1).lower() if match else None
def required_fields(task: dict) -> set[str]:
inputs = task.get("inputs") or {}
fields = set(inputs.get("required_result_fields") or []) & {"xiaohongshu_id", "explicit_demand", "region", "csv"}
# A supplied geographic filter is a business requirement, whereas an absent
# filter is unknown/optional, never an implicit nationwide match.
if inputs.get("region"):
fields.add("region")
if inputs.get("intent") == "export":
fields.add("csv")
return fields
def _badge(status: str, label: str, detail: str = "", evidence: list[dict] | None = None) -> dict:
return {"status": status, "label": label, "detail": detail, "evidence": evidence or []}
def region_quote_supports(region: str | None, quote: str) -> bool:
"""Conservative literal grounding; model context cannot supply the place."""
if not region or not quote:
return False
# Avoid inventing administrative equivalence. Common suffixes can be
# omitted in an actual utterance (杭州市/杭州), but unrelated districts are
# not inferred to belong to the requested city.
location = re.sub(r"[市省]$", "", region.strip())
if not location or location not in quote:
return False
return not re.search(r"(?:不在|不是|不去|不考虑|非)\s*" + re.escape(location), quote)
def own_region_quote(content: str, quote: str) -> bool:
# Selecting just “杭州” from “IP属地:杭州” must not bypass the IP ban.
# Remove the metadata span before checking the purported person's quote.
own_content = re.sub(r"IP\s*(?:属地|地址)\s*[::]?[^\n,。;;]*", "", content, flags=re.I)
return bool(quote and quote in own_content and not re.search(r"IP\s*(?:属地|地址)", quote, re.I))
def assess_person(task: dict, group: dict, *, sources_by_id: dict | None = None) -> dict:
"""Read-only, source-bound eligibility; unknown optional fields stay optional."""
artifacts = task.get("artifacts") or {}
assessed = artifacts.get("analysis_goal_version")
assessment_current = assessed is None or assessed == (task.get("inputs") or {}).get("_goal_version", 1)
sources = sources_by_id if sources_by_id is not None else {
row.get("source_id"): row for row in artifacts.get("sources") or [] if isinstance(row, dict)}
fields = required_fields(task)
own = []
for row in group["evidence"]:
sid = row.get("source_id") or row.get("id")
source = sources.get(sid, {})
quote = row.get("quote") or ""
if quote and quote in (source.get("content") or ""):
own.append((row, source, {"source_id": sid, "quote": quote}))
explicit = [evidence for row, _, evidence in own if row.get("seeking_service") == "yes"]
not_seeking = [evidence for row, _, evidence in own if row.get("seeking_service") == "no"]
if explicit:
demand = _badge("explicit", "本人明确表达需求", "本人原话表达寻找服务,不代表已确认客户或成交。", explicit)
elif not_seeking:
demand = _badge("not_seeking", "未表达找服务需求", "本人原话未表达正在寻找服务;是否符合目标取决于本次条件。", not_seeking)
elif own:
demand = _badge("related", "相关需求信号", "保留相关信号,明确委托意图仍未知。", [item[2] for item in own])
else:
demand = _badge("unknown", "语境不足", "当前记录缺少可回指本人原话的明确需求判断。")
identity_status = group["identity_status"]
identity = _badge(identity_status, "无法区分身份" if identity_status == "unresolved" else "来源身份可区分",
"按实际保存的作者主页入口关联,同名不合并。")
account = _badge("obtained" if group["account_verified"] else "unknown",
"已取得小红书号" if group["account_verified"] else "账号号未获取",
"仅接受与来源作者匹配的主页可见小红书号;主页入口和内部ID不能代填。")
requested_region = (task.get("inputs") or {}).get("region")
region_evidence: dict[str, list[dict]] = {"yes": [], "no": []}
for row in group["evidence"]:
sid = row.get("source_id") or row.get("id")
quote = row.get("region_quote") or ""
source = sources.get(sid, {})
# Historic or hand-edited rows must meet the same own-source rule as
# newly validated analysis. Never read source.ip_location or context.
scope_matches = (not row.get("region_scope") or row.get("region_scope") == requested_region)
if (requested_region and scope_matches and own_region_quote(source.get("content") or "", quote)
and row.get("region_match") in region_evidence):
if row["region_match"] == "yes" and not region_quote_supports(requested_region, quote):
continue
region_evidence[row["region_match"]].append({"source_id": sid, "quote": quote})
if region_evidence["yes"] and region_evidence["no"]:
region = _badge("conflicting", "地区依据有冲突", "同人存在不同地点表达,尚不能确认本次需求地区。",
region_evidence["yes"] + region_evidence["no"])
elif region_evidence["yes"]:
region = _badge("matched", "需求原话支持", f"本人原话支持本次地区:{requested_region}。", region_evidence["yes"])
elif region_evidence["no"]:
region = _badge("mismatched", "地区不匹配", f"本人原话与本次地区{requested_region}不符。", region_evidence["no"])
else:
region = _badge("unknown", "相关信息不足", "尚无本人服务/项目地区原话。" if requested_region else "本次未限定地区,不增加地区必填条件。")
actions = [row for row in artifacts.get("action_ledger") or [] if isinstance(row, dict)
and row.get("source_id") in group["source_ids"]]
# Unknown/failed sends remain prominent, even if an earlier follow succeeded.
unresolved = [row for row in actions if row.get("action") == "message" and row.get("status") in {"failed", "unknown", "executing", "prepared"}]
if unresolved:
latest = unresolved[-1]
outreach = _badge("failed" if latest["status"] == "failed" else "unknown",
"发送失败" if latest["status"] == "failed" else "结果未知", "以实际动作账本为准,未知发送不能自动重发。")
elif any(row.get("action") == "message" and row.get("status") == "success" for row in actions):
outreach = _badge("sent", "已发送", "实际账本记录发送成功,尚无回复依据。")
elif any(row.get("action") == "follow" and row.get("status") in {"success", "skipped_already_followed"} for row in actions):
outreach = _badge("followed", "已关注", "实际账本记录已关注。")
elif any(row.get("status") in {"unknown", "executing", "prepared"} for row in actions):
outreach = _badge("unknown", "结果未知", "动作账本尚无确定结果。")
else:
outreach = _badge("not_executed", "未执行", "没有实际成功的跟进动作记录。")
missing = []
for key, label, met in (
("identity", "来源身份无法区分", identity_status != "unresolved"),
("xiaohongshu_id", "缺少已归属的小红书号", account["status"] == "obtained"),
("explicit_demand", "缺少本人明确需求原话", demand["status"] == "explicit"),
("region", "缺少匹配地区的本人需求原话", region["status"] == "matched"),
):
if (key == "identity" or key in fields) and not met:
missing.append({"key": key, "label": label, "required": True})
if not assessment_current:
missing.append({"key": "assessment_current", "label": "旧标准判断尚未按当前目标重算", "required": True})
eligible = not missing
return {"person_key": group["person_key"], "eligible": eligible, "assessment_current": assessment_current,
"group": "unresolved" if identity_status == "unresolved" else "eligible" if eligible else "supplemental",
"badges": {"demand": demand, "identity": identity, "account": account, "region": region, "outreach": outreach},
"missing": missing}
def group_candidates(task: dict) -> dict:
artifacts = task.get("artifacts") or {}
sources = {row.get("source_id"): row for row in artifacts.get("sources") or []
if isinstance(row, dict) and row.get("source_id")}
profiles = {row.get("source_id"): row for row in artifacts.get("profiles") or []
if isinstance(row, dict) and row.get("source_id")}
groups: dict[str, dict] = {}
seen_sources: set[str] = set()
signals = 0
for index, lead in enumerate(artifacts.get("leads") or []):
if not isinstance(lead, dict):
continue
# Only kept demand signals participate, never excluded/unclear rows or
# arbitrary profile records that have no associated kept source.
if lead.get("decision") not in {None, "keep"}:
continue
sid = lead.get("source_id") or lead.get("id")
if not isinstance(sid, str) or not sid:
sid = f"unidentified-source-{index}"
if sid in seen_sources:
continue
seen_sources.add(sid)
signals += 1
source = sources.get(sid, {})
profile = profiles.get(sid, {})
source_key = _profile_key(source.get("author_profile_url"))
# A plain lead.profile_url, lead.author_profile_url or account_id is
# never enough: the author link must belong to saved source evidence.
profile_key = _profile_key(profile.get("profile_url"))
verified = bool(profile_key and profile.get("observed_at")
and profile.get("evidence") == "matched_source_author_link_and_visible_profile_header"
and (source_key is None or profile_key == source_key))
key = source_key or (profile_key if verified else None)
identity_status = "profile_verified" if verified else "source_link" if key else "unresolved"
account_verified = bool(verified and isinstance(profile.get("xiaohongshu_id"), str)
and profile["xiaohongshu_id"].strip())
person_key = key or "unresolved:" + sid
group = groups.get(person_key)
if group is None:
group = {"person_key": person_key, "source_ids": [], "representative": dict(lead),
"evidence": [], "identity_status": identity_status,
"account_verified": account_verified}
groups[person_key] = group
group["source_ids"].append(sid)
group["evidence"].append(dict(lead))
if identity_status == "profile_verified":
group["identity_status"] = identity_status
group["account_verified"] = group["account_verified"] or account_verified
result = [{**group, **assess_person(task, group, sources_by_id=sources)} for group in groups.values()]
return {"groups": result, "counts": {
"candidates": sum(group["identity_status"] != "unresolved" for group in result),
"signals": signals,
"unresolved_sources": sum(len(group["source_ids"]) for group in result if group["identity_status"] == "unresolved"),
"verified_accounts": sum(group["account_verified"] for group in result),
}}
def target_progress(task: dict) -> dict:
inputs = task.get("inputs") or {}
assessed = (task.get("artifacts") or {}).get("analysis_goal_version")
assessment_current = assessed is None or assessed == inputs.get("_goal_version", 1)
explicit = inputs.get("target_candidates")
target = explicit if type(explicit) is int and explicit > 0 and inputs.get("intent", "discover") == "discover" else None
grouped = group_candidates(task)
count = sum(group["eligible"] for group in grouped["groups"]) if assessment_current else 0
scope = inputs.get("discovery_scope", "candidate_count")
note = "按符合本次必要条件且身份可区分的去重人数统计"
if target is not None:
scope = "candidate_count"
elif inputs.get("max_notes") is not None:
scope, note = "route_pass", "按用户明确阅读范围完成本轮"
elif inputs.get("subject_urls"):
scope, note = "route_pass", "按用户指定页面完成本轮"
elif scope == "route_pass":
note = "按当前关键词先做一轮,路线无新增时交付阶段结果"
elif inputs.get("intent", "discover") != "discover":
scope, note = "route_pass", "按本次指定任务范围完成"
return {"scope": scope, "scope_note": note, "target": target, "current": count,
"known_identity": grouped["counts"]["candidates"],
"supplemental": sum(group["group"] == "supplemental" for group in grouped["groups"]),
"assessment_current": assessment_current,
"remaining": max(0, target - count) if target is not None else None,
"reached": target is not None and count >= target}
def candidate_count(task: dict) -> int:
return target_progress(task)["current"]
def target_reached(task: dict) -> bool:
return target_progress(task)["reached"]