assistant/backend/prospecting/discussion_scope.py

258 lines
16 KiB
Python
Raw Permalink 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.

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