"""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"]