assistant/backend/browser/preflight_policy.py

182 lines
10 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.

"""Pure, explicit user scope for read-only relationship preflight tasks."""
from __future__ import annotations
from dataclasses import dataclass
import re
from urllib.parse import urlsplit
@dataclass(frozen=True)
class PreflightPolicy:
active: bool = False
excluded_note_ids: tuple[str, ...] = ()
max_notes: int | None = None
_NOTE_PATH = re.compile(r"/(?:explore|discovery/item)/([a-f0-9]{24})/?", re.I)
_ID = re.compile(r"(?<![a-f0-9])([a-f0-9]{24})(?![a-f0-9])", re.I)
_URL = re.compile(r"https?://[^\s,,。;;!?!?<>\"'`]+(?:\?[^\s<>\"'`]+)?", re.I)
_LITERAL = r"(?:[a-fA-F0-9]{24}|save_source|save_learning|https://(?:www\.)?xiaohongshu\.com/(?:explore|discovery/item)/[a-fA-F0-9]{24}[^\s,,。;;!?`\"'“”‘’「」『』]*)"
def note_id_from_url(actual_url: str) -> str | None:
"""Read identity from an actual XHS note URL; never create a destination."""
try:
parts = urlsplit(actual_url)
if (parts.scheme != "https" or parts.hostname not in {"www.xiaohongshu.com", "xiaohongshu.com"}
or parts.username or parts.password or parts.port not in {None, 443}):
return None
match = _NOTE_PATH.fullmatch(parts.path)
return match.group(1).lower() if match else None
except (ValueError, TypeError, AttributeError):
return None
def instruction_text(goal: str) -> str:
"""Keep formatted constants, not quoted requests, examples or documents."""
text = re.sub(r"```[\s\S]*?```", "", goal)
text = "\n".join('' if line.lstrip().startswith('>') else line for line in text.splitlines())
for left, right in (("`", "`"), ('"', '"'), ("'", "'"), ("“", "”"), ("‘", "’"), ("「", "」"), ("『", "』")):
text = re.sub(re.escape(left) + "(" + _LITERAL + ")" + re.escape(right), r"\1", text)
return re.sub(r'''“[^”]*”|‘[^’]*’|「[^」]*」|『[^』]*』|"[^"\n]*"|'[^'\n]*'|`[^`\n]*`''', "", text)
def _clauses(goal: str):
# URLs are target data, including their query punctuation and words. Keep
# them atomic while splitting sentences, and exclude them from directives.
urls = []
def protect(match):
value = match.group()
suffix = re.search(r'[,,。;;!?!?]+$', value)
ending = suffix.group() if suffix else ''
urls.append(value[:-len(ending)] if ending else value)
return '\ue000' + str(len(urls)-1) + '\ue001' + ending
text = _URL.sub(protect, instruction_text(goal))
boundaries = r"[,,。;;!?!?\n]|但是|不过|而是|但"
for clause in re.split('(' + boundaries + ')', text):
if re.fullmatch(boundaries, clause):
yield '', '', clause
continue
directive = re.sub(r'\ue000[0-9]+\ue001', '', clause).strip()
value = re.sub(r'\ue000([0-9]+)\ue001', lambda match: urls[int(match.group(1))], clause).strip()
if value and not re.search(r"例如|比如|示例|参考案例|只是.{0,8}(?:引用|日志|描述)|(?:日志|文档|截图|网页).{0,8}(?:写着|显示|提示|内容)|(?:解释|讨论|介绍).{0,8}(?:预检|不保存)", directive):
yield value, directive, ''
elif value:
yield '', '', 'ignored'
_ONLY = re.compile(r"只读预检|(?:只|仅)(?:做|进行)?(?:(?:只读)?(?:(?:二级)?回复关系|关系|回复)?|(?:(?:二级)?回复关系|关系|回复)的?只读)预检")
_NEGATIVE_BEFORE = re.compile(r"(?:不是|并非|不再|不做|不进行|不要|不用|无需|不必|不需要|取消|停止|放弃|先不|暂不|别)[^,,。;;]{0,10}$")
_NEGATIVE_AFTER = re.compile(r"\s*(?:先|暂时)?(?:取消|不用|无需|不要|不必|停止|不做)")
_SAVE = re.compile(r"保存(?:来源|原话|资料|笔记|评论|回复|证据)?|save_source")
_NO_SAVE_BEFORE = re.compile(r"(?:不|不要|不用|无需|不必|不需要|禁止|不允许|不得|别|不能)(?:再|实际|新增|任何|进行|调用|使用|去|做|将|把|本轮|本次|这些|所有|来源|原话|资料|笔记|评论|回复|证据|\s){0,12}$")
_POSITIVE_SAVE = re.compile(r"(?:改为|改成|开始|现在|然后|最后|后面|稍后|随后|之后|再|后|需要|可以|允许|要|请|执行|正常|直接|仍要|继续).{0,8}$")
_EXCLUDE = re.compile(r"排除|跳过|(?:不|不要|不用|无需|不必|不需要|不允许|不能|别|禁止|不得)(?:再)?(?:打开|访问|阅读|进入|读|看|预检)")
_INCLUDE = re.compile(r"(?:不|不要|不用|无需|不必|不需要|不再|取消|停止|别)(?:再)?(?:排除|跳过)|(?<!不)(?:允许|可以|改为|改成|需要)(?:再)?(?:打开|访问|阅读|进入|预检|包含)")
_NUM = r"(?:[1-9][0-9]*|[一二两三四五六七八九十百]+)"
_CAP = re.compile(r"(?:最多|至多|不超过|上限(?:是|为)?)(?:只)?(?:浏览|阅读|访问|检查|预检|查看|看|打开|挑选|找|寻找)?\s*(?:新的|新|不同的|不同|候选)?(?:笔记)?\s*(" + _NUM + r")\s*篇(?:新的|新|不同的|不同)?(?:候选)?(?:笔记)?")
_EXCLUSION_LIST = re.compile(r"(?:排除|跳过).{0,12}(?:以下|如下|下列|列表|清单).{0,8}[::]")
_NEW_INSTRUCTION = re.compile(r"保存|预检|打开|访问|阅读|检查|然后|最后|目标|入口|链接|当前页面|实际页面|先沿|只|仅|最多|至多|上限")
def _number(value: str) -> int | None:
if value.isascii() and value.isdigit():
return int(value)
digits = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
total, current = 0, 0
for char in value:
if char in digits:
if current:
return None
current = digits[char]
elif char in {"十", "百"}:
total += (current or 1) * {"十": 10, "百": 100}[char]
current = 0
else:
return None
return total + current or None
def _note_ids(clause: str, directive: str) -> list[str]:
found, spans = [], []
for match in _URL.finditer(clause):
spans.append(match.span())
note_id = note_id_from_url(match.group())
if note_id and note_id not in found:
found.append(note_id)
# Account/profile IDs and IDs embedded in unsupported URLs are not notes.
account_clause = bool(re.search(r"用户|账号|作者|发言人|主页|profile", directive, re.I)) and not re.search(r"笔记|note|旧篇|旧样本", directive, re.I)
if not account_clause:
for match in _ID.finditer(clause):
if not any(start <= match.start() < end for start, end in spans):
note_id = match.group(1).lower()
if note_id not in found:
found.append(note_id)
return found
def compile_preflight_policy(goal: str, max_notes: int | None = None) -> PreflightPolicy:
"""Only explicit preflight + no source persistence activates this policy.
``max_notes`` is accepted for the caller's existing brief contract. It never
creates or replaces a limit: the actual human instruction is authoritative.
No old task, source, model output or prior visit is consulted or modified.
"""
only, no_save, cap = False, False, None
excluded: list[str] = []
exclusion_continues = list_mode = False
line_has_clause = False
for raw_clause, clause, boundary in _clauses(goal):
if boundary:
if boundary == '\n':
if not list_mode or not line_has_clause:
exclusion_continues = list_mode = False
line_has_clause = False
elif boundary not in {',', ','}:
exclusion_continues = list_mode = False
continue
line_has_clause = True
for match in _ONLY.finditer(clause):
if re.search(r"(?:每|各)(?:篇|条|组|位)(?:先)?(?:做|进行)?(?:一轮|一次)?\s*$", clause[:match.start()]):
continue
only = not (_NEGATIVE_BEFORE.search(clause[:match.start()]) or _NEGATIVE_AFTER.match(clause[match.end():]))
if re.search(r"(?:取消|停止|放弃).{0,8}(?:只读)?(?:关系)?预检|(?:改为|改成).{0,8}(?:正式|正常|完整)取证", clause):
only = False
for match in _SAVE.finditer(clause):
before, after = clause[:match.start()], clause[match.end():]
if match.group() == "保存" and re.match(r"\s*(?:任何|本次|这些)?(?:学习|经验|知识|日志|文件|截图|草稿|配置|模型)", after):
continue
if _NO_SAVE_BEFORE.search(before):
no_save = not bool(_NEGATIVE_AFTER.match(after))
elif _POSITIVE_SAVE.search(before) or re.match(r"\s*(?:要|可以|允许|需要|照常|正常)做?", after):
no_save = False
if re.search(r"(?:不|无需|不用|不必|不要|别)(?:再)?(?:限制|限定)(?:笔记)?篇数|篇数(?:不限|不限制|不限定)", clause):
cap = None
for match in _CAP.finditer(clause):
if (_NEGATIVE_BEFORE.search(clause[:match.start()])
or re.match(r"\s*(?:左右|以上|以下|到|至|或|[-~~/])", clause[match.end():])):
continue
cap = _number(match.group(1))
if re.search(r"(?:不|不用|无需|取消).{0,5}排除(?:任何|所有|全部)笔记|排除(?:项|列表)(?:清空|取消)", clause):
excluded.clear()
exclusion_continues = list_mode = False
ids = _note_ids(raw_clause, clause)
include = bool(_INCLUDE.search(clause))
exclude = bool(_EXCLUDE.search(clause)) and not include
if include:
excluded = [note_id for note_id in excluded if note_id not in ids]
exclusion_continues = list_mode = False
elif exclude:
if re.search(r"排除(?:项|列表)?(?:改为|改成|只有|仅有)", clause):
excluded.clear()
excluded.extend(note_id for note_id in ids if note_id not in excluded)
exclusion_continues = True
list_mode = bool(_EXCLUSION_LIST.search(clause))
elif ids and exclusion_continues and not _NEW_INSTRUCTION.search(clause):
excluded.extend(note_id for note_id in ids if note_id not in excluded)
elif not ids or _NEW_INSTRUCTION.search(clause):
exclusion_continues = list_mode = False
if not only or not no_save:
return PreflightPolicy()
return PreflightPolicy(active=True, excluded_note_ids=tuple(excluded), max_notes=cap)