"""Read-only checks for a user's explicit finite discussion inspection. An upper reading limit does not establish a minimum. Model summaries, source text and a successful ``done`` are not evidence of requested discussion work. """ from __future__ import annotations import hashlib import re from backend.prospecting.evidence_context import note_key _RELATION_ACTION = r"(?:核对|校验|确认|检查|验证|追溯|还原|梳理|厘清|明确)" _RELATION_OBJECT = r"(?:完整(?:的)?回复链(?:路)?|直接(?:的)?父(?:评论|回复)|父回复|父评论[^,,。;;!?!?\n]{0,16}(?:根讨论|根评论))" _PATTERNS = { "comments": r"(?:进入|阅读|读取|查看|检查|看看|保存|记录|整理)[^,,。;;!?!?\n]{0,20}(?:父评论|评论区|评论)", "replies": r"(?:阅读|读取|查看|检查|看看|保存|记录|整理)[^,,。;;!?!?\n]{0,24}(?:子回复|回复)", "expansion": r"(?:展开[^,,。;;!?!?\n]{0,16}回复|回复[^,,。;;!?!?\n]{0,16}展开|(?:点击|打开|使用)[^,,。;;!?!?\n]{0,16}回复入口)", "relations": r"(?:(?:保留|保存|记录|核对|整理)[^,,。;;!?!?\n]{0,24}(?:父评论[^,,。;;!?!?\n]{0,8}(?:和|与|及)[^,,。;;!?!?\n]{0,8}子回复|父子关系|父根关系|回复关系|回复归属|完整(?:的)?回复链(?:路)?)|(?:父评论|根评论)[^,,。;;!?!?\n]{0,12}(?:关联|归属|对应关系)|" + _RELATION_ACTION + r"[^,,。;;!?!?\n]{0,32}" + _RELATION_OBJECT + r"|" + _RELATION_OBJECT + r"[^,,。;;!?!?\n]{0,8}" + _RELATION_ACTION + r")", # A complete discussion or an optional "parent comment or parent reply" # does not by itself request this specific second-level branch. "reply_to_reply": r"(?:回复(?:该|这条|那条|已保存的)?父回复|回复另一条回复)", } _LABELS = { "comments": "指定页面的评论取证", "replies": "指定页面的回复取证", "expansion": "展开同组回复并保存新增内容", "relations": "所保存回复的父评论与根讨论归属", "reply_to_reply": "展开后新增子回复正确关联已保存父回复", } def _requests(task: dict) -> list[str]: inputs, artifacts = task.get("inputs") or {}, task.get("artifacts") or {} messages = [row.get("text", "") for row in artifacts.get("messages") or [] if isinstance(row, dict) and row.get("role") == "user" and isinstance(row.get("text"), str)] goal = inputs.get("goal") if isinstance(goal, str) and goal and goal not in messages: messages.insert(0, goal) return messages def requirements(task: dict) -> set[str]: """Ground only direct requests, preserving explicit later cancellations.""" inputs = task.get("inputs") or {} if (inputs.get("intent") not in {"inspect", "research"} or inputs.get("target_candidates") is not None or not (inputs.get("subject_urls") or inputs.get("max_notes"))): return set() wanted = set() for text in _requests(task): text = re.sub(r"```[\s\S]*?```|https?://\S+", "", text) for sentence in re.split(r"[。;;!?!?\n]", text): if sentence.lstrip().startswith(">") or re.search(r"例如|比如|示例|(?:网页|页面|评论|原文)(?:上)?(?:说|写|显示)|引用", sentence): continue # A quoted button label followed by an actual click/expand request # is an object in the request, rather than a quoted instruction. sentence = re.sub(r'[“"「]展开\s*(?:X|x|[0-9一二三四五六七八九十]+)?\s*条?回复[”"」]', "回复入口", sentence) sentence = re.sub(r'“[^”]*”|‘[^’]*’|「[^」]*」|『[^』]*』|"[^"\n]*"|`[^`\n]*`', "", sentence) for clause in re.split(r"[,,]", sentence): if re.match(r"\s*(?:(?:若|如果)\s*)?(?:没有|未找到|找不到|不存在|加载失败)", clause): continue # The expansion instruction can follow its quoted button # object, e.g. 找到一组“展开X条回复”并展开. if "回复入口" in clause and re.search(r"(?:并|再|然后)展开", clause): wanted.add("expansion") if "回复入口" in sentence and re.search(r"(?:再|然后|接着)展开(?:它|这组|该组|一组)", clause): if not re.search(r"(?:不要|不用|无需|不必|不需要|别)", clause): wanted.add("expansion") for key, pattern in _PATTERNS.items(): for match in re.finditer(pattern, clause): prefix = clause[:match.start()] negated = re.search(r"(?:不要|不用|无需|不必|不需要|不要求|不是要求|别|不)[^,,]{0,6}$", prefix) if key == "relations": # Also preserve object-first cancellations such as # “完整回复链不用核对”, rather than reading its noun # and final verb as a fresh positive requirement. negated = negated or re.search( r"(?:不要|不用|无需|不必|不需要|不要求|不是要求|别)(?:再|继续|逐条)?" + _RELATION_ACTION, match.group(0)) if negated: wanted.discard(key) else: wanted.add(key) return wanted def _facts(task: dict, capability: str, source_kind: str) -> list[dict]: rows = [] for row in (task.get("artifacts") or {}).get("operation_evidence") or []: if not isinstance(row, dict) or row.get("capability") != capability or row.get("source_kind") != source_kind: continue refs, details = row.get("evidence_refs"), row.get("details") if not (row.get("verifier") and isinstance(refs, dict) and refs.get("pre") and refs.get("post") and isinstance(details, dict)): continue rows.append(row) return rows def _saved_ids(task: dict, kind: str, *, relationships: bool = False) -> set[str]: ids = set() for fact in _facts(task, "save_source", kind): details = fact["details"] pre, post = details.get("pre_snapshot") or {}, details.get("post_snapshot") or {} if (not isinstance(pre, dict) or not isinstance(post, dict) or fact.get("outcome") not in {"success", "unknown"} or pre.get("quote_matched") is not True or post.get("persisted") is not True or post.get("source_type") != kind): continue if relationships and (fact.get("outcome") != "success" or post.get("relations_checked") is not True): continue if isinstance(post.get("source_id"), str) and post["source_id"]: ids.add(post["source_id"]) return ids def _linked(reply: dict, by_id: dict[str, dict]) -> bool: if not isinstance(reply.get("root_source_id"), str): return False root = by_id.get(reply.get("root_source_id")) if not root or root.get("source_type") != "comment" or note_key(root) != note_key(reply): return False seen = {reply["source_id"]} child = reply while child.get("source_type") == "reply": parent_id = child.get("parent_source_id") if not isinstance(parent_id, str) or not parent_id or parent_id in seen: return False parent = by_id.get(parent_id) if not parent or note_key(parent) != note_key(reply) or parent.get("source_type") not in {"comment", "reply"}: return False seen.add(parent_id) child = parent return child.get("source_id") == root.get("source_id") def _saved_containers(task: dict, by_id: dict[str, dict], goal_version: int) -> dict[str, set[str]]: """Only a current program save observation binds excerpts to containers.""" result = {} for fact in _facts(task, "save_source", "reply"): details = fact["details"] pre, post, trace = details.get("pre_snapshot"), details.get("post_snapshot"), details.get("trace") if (fact.get("verifier") != "source_container_quote_and_persisted" or fact.get("outcome") not in {"success", "unknown"} or not all(isinstance(row, dict) for row in (pre, post, trace)) or trace.get("goal_version") != goal_version or pre.get("quote_matched") is not True or pre.get("source_type") != "reply" or post.get("source_type") != "reply" or post.get("persisted") is not True or not isinstance(post.get("source_id"), str)): continue source = by_id.get(post["source_id"]) fingerprint, excerpt = pre.get("container_fingerprint"), pre.get("observed_excerpt") if (not source or source.get("source_type") != "reply" or not source.get("note_id") or pre.get("note_id") != source["note_id"] or not source.get("source_selector") or pre.get("container_selector") != source["source_selector"] or not isinstance(fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", fingerprint) or not isinstance(excerpt, str) or re.sub(r"\s+", "", source["content"]) not in re.sub(r"\s+", "", excerpt)): continue result.setdefault(source["source_id"], set()).add(fingerprint) return result def _current_relation_ids(task: dict, by_id: dict[str, dict], goal_version: int) -> set[str]: """A deeper branch needs this goal's observed child and parent relations.""" result = set() for fact in _facts(task, "save_source", "reply"): details = fact["details"] pre, post, trace = details.get("pre_snapshot"), details.get("post_snapshot"), details.get("trace") if (fact.get("verifier") != "source_container_quote_and_persisted" or fact.get("outcome") != "success" or not all(isinstance(row, dict) for row in (pre, post, trace)) or trace.get("goal_version") != goal_version or pre.get("quote_matched") is not True or post.get("source_type") != "reply" or post.get("persisted") is not True or post.get("relations_checked") is not True or not isinstance(post.get("source_id"), str)): continue source = by_id.get(post["source_id"]) if (not source or source.get("source_type") != "reply" or not source.get("note_id") or not source.get("source_selector") or not _linked(source, by_id) or pre.get("note_id") != source.get("note_id") or pre.get("container_selector") != source.get("source_selector") or post.get("parent_source_id") != source.get("parent_source_id") or post.get("root_source_id") != source.get("root_source_id")): continue result.add(source["source_id"]) return result def conditions(task: dict) -> list[dict]: """Fixed delivery conditions, with actual saved evidence as the counts.""" wanted = requirements(task) if not wanted: return [] artifacts = task.get("artifacts") or {} sources = [row for row in artifacts.get("sources") or [] if isinstance(row, dict) and isinstance(row.get("source_id"), str) and row["source_id"] and isinstance(row.get("content"), str) and row["content"]] by_id = {row["source_id"]: row for row in sources} notes = {note_key(row) for row in sources if row.get("source_type") == "note"} requested = (task.get("inputs") or {}).get("subject_urls") or [] if requested: notes &= {note_key({"source_url": url}) for url in requested if isinstance(url, str)} scoped_sources = [row for row in sources if note_key(row) in notes] comment_ids, reply_ids = _saved_ids(task, "comment"), _saved_ids(task, "reply") comments = [row for row in scoped_sources if row.get("source_type") == "comment" and row["source_id"] in comment_ids] replies = [row for row in scoped_sources if row.get("source_type") == "reply" and row["source_id"] in reply_ids] relation_ids = _saved_ids(task, "reply", relationships=True) linked = [row for row in replies if row["source_id"] in relation_ids and _linked(row, by_id)] saved_replies = [row for row in scoped_sources if row.get("source_type") == "reply"] expanded_notes = set() expanded_reply_ids = set() goal_version = (task.get("inputs") or {}).get("_goal_version", 1) saved_containers = _saved_containers(task, by_id, goal_version) for fact in _facts(task, "expand_replies", "reply"): details = fact["details"] pre, post, trace = details.get("pre_snapshot") or {}, details.get("post_snapshot") or {}, details.get("trace") or {} if (not all(isinstance(item, dict) for item in (pre, post, trace)) or fact.get("outcome") != "success" or trace.get("goal_version") != goal_version or post.get("parent_scope_checked") is not True or post.get("status") != "expanded" or type(post.get("owner_reply_delta")) is not int or post["owner_reply_delta"] <= 0 or type(post.get("new_visible_replies")) is not int or post["new_visible_replies"] <= 0 or not pre.get("note_id") or pre.get("note_id") != post.get("note_id")): continue fingerprints = post.get("new_reply_fingerprints") fingerprints = set(fingerprints) if isinstance(fingerprints, list) and all(isinstance(item, str) for item in fingerprints) else set() containers = post.get("new_reply_container_fingerprints") containers = set(containers) if isinstance(containers, list) and len(containers) <= 5 and all( isinstance(item, str) and re.fullmatch(r"[0-9a-f]{64}", item) for item in containers) else set() for reply in replies: if reply.get("note_id") != post["note_id"] or not isinstance(reply.get("source_selector"), str): continue fingerprint = hashlib.sha256((reply.get("source_selector", "") + reply["content"]).encode()).hexdigest() if fingerprint in fingerprints or saved_containers.get(reply["source_id"], set()) & containers: expanded_notes.add(note_key(reply)) expanded_reply_ids.add(reply["source_id"]) current_relations = _current_relation_ids(task, by_id, goal_version) if "reply_to_reply" in wanted else set() nested_replies = [row for row in linked if row["source_id"] in expanded_reply_ids and row["source_id"] in current_relations and row.get("parent_source_id") in current_relations and by_id.get(row.get("parent_source_id"), {}).get("source_type") == "reply"] coverage = { "comments": ({note_key(row) for row in comments}, len(comments)), "replies": ({note_key(row) for row in replies}, len(replies)), "expansion": (expanded_notes, len(expanded_notes)), "reply_to_reply": ({note_key(row) for row in nested_replies}, len(nested_replies)), } result = [] for key in ("comments", "replies", "expansion", "relations", "reply_to_reply"): if key not in wanted: continue if key == "relations": met = bool(saved_replies) and len(linked) == len(saved_replies) current, target = len(linked), len(saved_replies) or 1 else: covered, count = coverage[key] met = bool(notes) and notes <= covered current, target = len(notes & covered), len(notes) or 1 result.append({"key": "discussion_" + key, "label": _LABELS[key], "required": True, "met": met, "current": current, "target": target}) return result