258 lines
16 KiB
Python
258 lines
16 KiB
Python
"""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
|