219 lines
14 KiB
Python
219 lines
14 KiB
Python
"""User-authorized preflight scope, independent of model/runtime state."""
|
||
import pytest
|
||
|
||
from backend.browser.preflight_policy import PreflightPolicy, compile_preflight_policy, instruction_text, note_id_from_url
|
||
from backend.config import settings
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def enable_reply_relation_preflight(monkeypatch):
|
||
monkeypatch.setattr(settings, 'reply_relation_preflight_enabled', True)
|
||
|
||
|
||
A = '6adaf8a6000000001901d400'
|
||
B = '6a7f04b8000000003302f624'
|
||
C = '6ad9c5e1000000001400c800'
|
||
URL = 'https://www.xiaohongshu.com/explore/'+A+'?xsec_token=fixture-private'
|
||
ACTUAL_REQUEST = (
|
||
'在小红书搜索“杭州装修”,本轮只做二级回复关系的只读预检,不找客户人数。最多查看2篇不同的新笔记,'
|
||
f'排除ID为{A}和{B}的已测笔记。每篇只预检第一组当前可见讨论,最多5条回复;'
|
||
'评论未进视口时沿真实评论入口推进。使用 inspect_reply_relations 检查是否有稳定且唯一的直接父为另一条回复的DOM依据。'
|
||
'只报告每篇checked_count、eligible_count和不合格原因,不保存正文或任何来源,不保存知识,不展开折叠回复,'
|
||
'不点击回复对象名字,不调用resolve_reply_target,不分析名单。发现eligible=true就保留页面并报告该样本可进入后续取证,'
|
||
'本轮仍不取证;两篇都没有合格样本就说明未展开范围并结束,不重复尝试,不把缺少依据当成没有评论。'
|
||
'不关注、不点赞、不收藏、不发评论、不私信。'
|
||
)
|
||
ACTUAL_SECOND_REQUEST = (
|
||
'独立做一轮只读预检,只访问下面这个此前已实际观察到的笔记入口,不搜索、不换笔记。'
|
||
'最多1篇,只检查第一组当前可见讨论,每次最多5条回复。'
|
||
f'严格排除ID为{A}和{B}的旧笔记。\n[实际入口省略]\n'
|
||
'先沿真实评论入口进入评论区,再使用 inspect_reply_relations。'
|
||
'若首组只有根评论或回复仍折叠,就按实际可见范围报告;不展开折叠回复,不点击回复对象名字,'
|
||
'不调用resolve_reply_target。不保存正文或来源,不保存知识,不分析客户名单,'
|
||
'不关注、不点赞、不收藏、不发评论、不私信。'
|
||
'只报告程序预检的篇数、checked_count、eligible_count和未读原因;'
|
||
'没有合格样本也正常结束,不能凭昵称或顺序补关系。有eligible=true也仅保留页面,本轮不自动进入取证。'
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize('message', [
|
||
'这轮只预检,不保存任何来源。最多浏览2篇新笔记。',
|
||
'先仅预检,不保存原话,至多阅读两篇笔记。',
|
||
'只做关系预检,不调用save_source。上限为2篇。',
|
||
'仅进行回复关系预检,禁止保存来源,最多2篇候选笔记。',
|
||
'只读预检,不保存正文或来源,最多2篇。',
|
||
'独立做一轮只读预检,不保存来源,最多2篇。',
|
||
])
|
||
def test_explicit_read_only_preflight_has_only_the_actual_user_note_limit(message):
|
||
policy = compile_preflight_policy(message, max_notes=99)
|
||
assert policy.active is True and policy.max_notes == 2
|
||
assert policy.excluded_note_ids == ()
|
||
|
||
|
||
@pytest.mark.parametrize('message', [
|
||
'预检一下关系,再保存来源。最多2篇。',
|
||
'只预检关系,稍后保存来源。',
|
||
'不保存来源,最多2篇。',
|
||
'只预检,正常保存来源。',
|
||
'不要只做关系预检,直接按正常流程保存来源。',
|
||
'只做关系预检,不保存;取消预检,改成正常取证并保存。',
|
||
'只预检,不保存;最后要保存来源。',
|
||
'“只预检,不保存,最多2篇”是引用,实际正常取证。',
|
||
'「只做关系预检,不保存」只是示例,正常整理。',
|
||
'```text\n只预检,不保存,最多2篇\n```\n正常整理。',
|
||
'> 只预检,不保存,最多2篇\n正常取证。',
|
||
'比如只预检、不保存;实际先讨论方案。',
|
||
'先解释只预检、不保存是什么意思,暂不操作。',
|
||
'只预检关系,不保存经验。',
|
||
'只预检关系,不保存截图。',
|
||
'只预检关系,不保存知识。',
|
||
'正常找客户,每篇只预检第一组,不保存评论原话。',
|
||
'只读讨论,不保存来源,最多1篇。',
|
||
'独立做一轮只读预检,不保存知识。',
|
||
'独立做一轮只读预检,之后保存来源。',
|
||
'不保存来源,独立做一轮只读预检;先预检后保存来源。',
|
||
'不保存来源,独立做一轮只读预检;先预检再保存来源。',
|
||
'不保存来源,独立做一轮只读预检;先预检,稍后保存来源。',
|
||
'独立做一轮只读预检,不保存来源;取消只读预检。',
|
||
'不做只读预检,不保存来源。',
|
||
'不进行只读预检,不保存来源。',
|
||
'不要独立做一轮只读预检,不保存来源。',
|
||
'“独立做一轮只读预检,不保存来源”是引用,先讨论方案。',
|
||
'比如独立做一轮只读预检、不保存来源;实际先讨论方案。',
|
||
'正常找客户,每篇做一轮只读预检,不保存来源。',
|
||
])
|
||
def test_ordinary_evidence_negation_quotes_examples_and_later_cancellation_are_inactive(message):
|
||
assert compile_preflight_policy(message, max_notes=2) == PreflightPolicy()
|
||
|
||
|
||
def test_no_default_or_model_invented_note_count_is_added():
|
||
assert compile_preflight_policy('只预检,不保存来源。', max_notes=2).max_notes is None
|
||
assert compile_preflight_policy('只预检,不保存来源,最多检查5条可见回复。', max_notes=2).max_notes is None
|
||
assert compile_preflight_policy('只预检,不保存,最多2到3篇笔记。').max_notes is None
|
||
assert compile_preflight_policy('只预检,不保存,最多2篇左右。').max_notes is None
|
||
assert compile_preflight_policy('只预检,不保存,最多2篇,篇数不限。').max_notes is None
|
||
|
||
|
||
def test_actual_d44_user_scope_is_grounded_without_relying_on_model_brief():
|
||
policy = compile_preflight_policy(ACTUAL_REQUEST, max_notes=9)
|
||
assert policy == PreflightPolicy(active=True, excluded_note_ids=(A, B), max_notes=2)
|
||
|
||
|
||
def test_actual_second_d44_read_only_preflight_keeps_user_limit_and_exclusions():
|
||
policy = compile_preflight_policy(ACTUAL_SECOND_REQUEST, max_notes=9)
|
||
assert policy == PreflightPolicy(active=True, excluded_note_ids=(A, B), max_notes=1)
|
||
|
||
|
||
def test_full_second_goal_with_fictional_entry_url_has_exactly_two_exclusions():
|
||
entry = 'https://www.xiaohongshu.com/explore/'+C+'?xsec_token=fictional&xsec_source=fixture'
|
||
full = ACTUAL_SECOND_REQUEST.replace('[实际入口省略]', entry)
|
||
policy = compile_preflight_policy(full, max_notes=1)
|
||
assert policy == PreflightPolicy(active=True, excluded_note_ids=(A, B), max_notes=1)
|
||
assert C not in policy.excluded_note_ids
|
||
|
||
|
||
@pytest.mark.parametrize('boundary', ['。\n', ';\n', ';\n', '!\n', '?\n', '\n'])
|
||
def test_inline_exclusion_never_continues_across_a_hard_sentence_or_bare_newline(boundary):
|
||
entry = 'https://www.xiaohongshu.com/explore/'+C+'?xsec_token=fictional'
|
||
policy = compile_preflight_policy(f'只读预检,不保存来源,排除旧笔记{A}和{B}{boundary}{entry}')
|
||
assert policy.excluded_note_ids == (A, B)
|
||
|
||
|
||
@pytest.mark.parametrize('ending', ['\n\n', '\n实际入口:', '\n当前页面:', '\n请访问', '\n先沿评论入口进入评论区\n'])
|
||
def test_explicit_multiline_list_ends_before_a_new_entry_or_action(ending):
|
||
entry = 'https://www.xiaohongshu.com/explore/'+C
|
||
goal = f'只读预检,不保存来源。排除以下笔记:\n"{A}"\n`{B}`{ending}{entry}'
|
||
assert compile_preflight_policy(goal).excluded_note_ids == (A, B)
|
||
|
||
|
||
def test_declared_multiline_url_list_keeps_queries_atomic_and_does_not_collect_query_ids():
|
||
first = 'https://www.xiaohongshu.com/explore/'+A+'?xsec_token='+C+'&label=用户&rule=最多9篇'
|
||
second = 'https://www.xiaohongshu.com/explore/'+B+'?query=排除'+C
|
||
goal = f'只读预检,不保存来源,最多1篇。排除以下笔记:\n{first}\n{second}'
|
||
policy = compile_preflight_policy(goal)
|
||
assert policy.excluded_note_ids == (A, B) and policy.max_notes == 1
|
||
|
||
|
||
@pytest.mark.parametrize('header', ['不要排除以下笔记:', '无需排除以下笔记:', '“排除以下笔记:”', '> 排除以下笔记:'])
|
||
def test_negated_or_quoted_header_cannot_open_an_exclusion_list(header):
|
||
assert compile_preflight_policy(f'只读预检,不保存来源。\n{header}\n{A}\n{B}').excluded_note_ids == ()
|
||
|
||
|
||
def test_list_cancellation_and_soft_comma_continuation_stay_explicit():
|
||
assert compile_preflight_policy(f'只读预检,不保存来源。排除{A},{B},实际入口:{C}').excluded_note_ids == (A, B)
|
||
assert compile_preflight_policy(f'只读预检,不保存来源。排除以下笔记:\n{A}\n{B}\n不排除{B}\n{C}').excluded_note_ids == (A,)
|
||
assert compile_preflight_policy(f'只读预检,不保存来源。排除{A};{B}').excluded_note_ids == (A,)
|
||
|
||
|
||
def test_url_directive_words_cannot_enable_preflight_save_ban_exclusion_or_note_cap():
|
||
url = 'https://www.xiaohongshu.com/explore/'+C+'?mode=只读预检&save=不保存来源&scope=排除'+A+'&cap=最多9篇'
|
||
assert compile_preflight_policy(url, max_notes=9) == PreflightPolicy()
|
||
policy = compile_preflight_policy('只读预检,不保存来源。实际入口:'+url)
|
||
assert policy == PreflightPolicy(active=True)
|
||
bounded = compile_preflight_policy(f'只读预检,不保存来源,最多1篇,排除{A}。实际入口:'+url)
|
||
assert bounded == PreflightPolicy(active=True, excluded_note_ids=(A,), max_notes=1)
|
||
punctuation_query = 'https://www.xiaohongshu.com/explore/'+C+'?data=sample,只读预检,不保存来源;排除'+A+',最多9篇'
|
||
assert compile_preflight_policy(punctuation_query) == PreflightPolicy()
|
||
assert compile_preflight_policy('只读预检,不保存来源。'+punctuation_query) == PreflightPolicy(active=True)
|
||
|
||
|
||
def test_url_terminal_sentence_mark_ends_exclusion_even_with_a_query():
|
||
excluded = 'https://www.xiaohongshu.com/explore/'+A+'?xsec_token=fictional'
|
||
entry = 'https://www.xiaohongshu.com/explore/'+C+'?xsec_token=entry'
|
||
assert compile_preflight_policy(f'只读预检,不保存来源。排除{excluded}。\n{entry}').excluded_note_ids == (A,)
|
||
|
||
|
||
def test_note_id_in_url_path_or_query_is_data_only_until_explicitly_bound():
|
||
url = 'https://www.xiaohongshu.com/explore/'+C+'?note='+B+'&hint=排除'+A
|
||
assert compile_preflight_policy('只读预检,不保存来源。'+url).excluded_note_ids == ()
|
||
assert compile_preflight_policy('只读预检,不保存来源。排除笔记 '+url).excluded_note_ids == (C,)
|
||
quoted = '“'+url+',只读预检,不保存来源”是引用'
|
||
assert compile_preflight_policy(quoted) == PreflightPolicy()
|
||
|
||
|
||
def test_real_explicit_exclusions_bind_only_note_identities_and_can_be_cancelled():
|
||
policy = compile_preflight_policy(f'只做只读关系预检,不保存来源,排除D42旧笔记 {A}、D43旧笔记 {B}。最多2篇新笔记。')
|
||
assert policy.excluded_note_ids == (A, B)
|
||
assert policy.max_notes == 2
|
||
changed = compile_preflight_policy(f'只预检,不保存,排除笔记 {A} 和 {B},不要排除笔记 {B}。')
|
||
assert changed.excluded_note_ids == (A,)
|
||
|
||
|
||
def test_excluded_note_urls_and_formatted_constants_do_not_turn_whole_quotes_into_instructions():
|
||
policy = compile_preflight_policy(f'只预检,不保存,排除笔记 `{A}` 和 `{URL}`。')
|
||
assert policy.excluded_note_ids == (A,)
|
||
ignored = compile_preflight_policy(f'只预检,不保存。“排除笔记 {A}”是引用,只检查真实新页面。')
|
||
assert ignored.excluded_note_ids == ()
|
||
assert URL in instruction_text(f'排除笔记 `{URL}`')
|
||
|
||
|
||
def test_raw_profile_ids_and_unsupported_links_are_not_note_exclusions():
|
||
policy = compile_preflight_policy(f'只预检,不保存,排除用户 {A};跳过 https://www.xiaohongshu.com/user/profile/{B};排除 https://evil.example/explore/{C}。')
|
||
assert policy.excluded_note_ids == ()
|
||
assert compile_preflight_policy(f'只预检,不保存,排除 {A}f。').excluded_note_ids == ()
|
||
|
||
|
||
def test_later_explicit_include_or_reset_removes_excluded_notes():
|
||
assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};允许打开笔记 {A}。').excluded_note_ids == ()
|
||
assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};排除列表改为 {B}。').excluded_note_ids == (B,)
|
||
assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};不排除任何笔记。').excluded_note_ids == ()
|
||
assert compile_preflight_policy(f'只预检,不保存,不需要排除笔记 {A}。').excluded_note_ids == ()
|
||
assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};不排除笔记 {A}。').excluded_note_ids == ()
|
||
assert compile_preflight_policy(f'只预检,不保存,不需要打开笔记 {A}。').excluded_note_ids == (A,)
|
||
|
||
|
||
@pytest.mark.parametrize('url,expected', [
|
||
(URL, A),
|
||
('https://xiaohongshu.com/discovery/item/'+A+'/', A),
|
||
('https://www.xiaohongshu.com:443/explore/'+A, A),
|
||
('http://www.xiaohongshu.com/explore/'+A, None),
|
||
('https://www.xiaohongshu.com:9443/explore/'+A, None),
|
||
('https://user@www.xiaohongshu.com/explore/'+A, None),
|
||
('https://www.xiaohongshu.com.evil.example/explore/'+A, None),
|
||
('https://www.xiaohongshu.com/user/profile/'+A, None),
|
||
('app://ui/index.html', None),
|
||
('https://www.xiaohongshu.com/explore/'+A+'f', None),
|
||
])
|
||
def test_note_identity_is_read_only_from_an_actual_valid_note_url(url, expected):
|
||
assert note_id_from_url(url) == expected
|