242 lines
14 KiB
Python
242 lines
14 KiB
Python
"""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"]
|