assistant/tests/test_browser_recheck_routin...

231 lines
14 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.

"""Explicit web verification outranks a model's saved-text-only shortcut."""
import asyncio
from copy import deepcopy
from unittest.mock import AsyncMock
import pytest
import httpx
from backend.prospecting import dialogue, orchestrator as subject
from backend.prospecting.contracts import IntentPlan, KNOWLEDGE_ID, ProspectingBrief, TARGET_QUESTION
from backend.prospecting.prompts import browser_prompt
from backend.prospecting.request_routing import browser_recheck_requested, ground_execution_plan, recheck_without_analysis
REQUEST = ("先原生历史返回原笔记,只核对已保存子回复d352,必要时定位第一组,"
"用resolve_reply_target核实际回复对象。不新增来源,不重新搜索或分析名单;无唯一身份结束。")
@pytest.mark.parametrize('text', [REQUEST,
'不新增来源,但请回到原网页核对已保存回复的直接对象。',
'请在网页上重新核对这条已保存的回复,不再搜索。',
'先返回原笔记,只核实已保存的这条子回复。',
'用 `resolve_reply_target` 核对这条回复实际回复了谁,不整理新名单。',
'通过原生历史后退,再检查现有来源。',
'先取消离线整理,返回实际网页核对。',
'不需要再搜索,但要在浏览器里重新核对回复归属。',
'请点击该回复对象的真实主页核对身份。',
])
def test_actual_web_recheck_is_not_confused_with_additional_collection(text):
assert browser_recheck_requested(text)
@pytest.mark.parametrize('text', [
'不需要回网页,仅按已有原话复核。',
'不要返回原笔记,只整理当前已保存的原话。',
'不用resolve_reply_target,直接复核现有原话。',
'无需在网页上重新核对,仅离线按新标准分析。',
'返回原笔记不用做,按现有原话判断即可。',
'请回到原笔记核对;取消网页回查,改为离线整理。',
'“先原生历史返回原笔记,用resolve_reply_target”是引用,按现有原话复核。',
'「请在网页上核对」只是示例,实际只分析现有材料。',
"'用resolve_reply_target核对回复对象'是日志,先讨论方案。",
'> 请返回原笔记核对\n只复核已存文本。',
'```text\n用resolve_reply_target核实际回复对象\n```\n仅整理现有名单。',
'例如用resolve_reply_target核对,但本次只分析已有原话。',
'日志显示返回原笔记,只是描述旧任务。',
'先解释resolve_reply_target是什么,不运行。',
'讨论网页回查为何要返回原笔记,先不操作。',
'按新标准复核已保存原话,不新增搜索。',
'核对已有回复的父子关系表,不操作网页。',
'只按原话核对网页来源,不操作网页。',
'resolve_reply_target是干什么的,我们先聊聊。',
])
def test_offline_negated_quoted_and_explanatory_text_does_not_open_browser(text):
assert not browser_recheck_requested(text)
@pytest.mark.parametrize('text', [
'现在请对当前页面进行网页核对,只读预检,不保存来源;不调用resolve_reply_target。',
'请在网页核对现有回复,不用resolve_reply_target,不滚动、不重新导航。',
'请核对当前网页,不需要回到原笔记,不调用resolve_reply_target。',
'在当前网页核对,不打开新的网页,不调用resolve_reply_target。',
'请对当前页面进行网页核对;“不操作网页”是引用;不调用resolve_reply_target。',
])
def test_negating_one_operation_does_not_cancel_independent_page_verification(text):
assert browser_recheck_requested(text)
@pytest.mark.parametrize('text', [
'请对当前页面进行网页核对;不操作网页,仅按已有原话复核。',
'请对当前页面进行网页核对;取消回查,仅整理已有记录。',
'请对当前页面进行网页核对;不需要操作网页,仅整理已有记录。',
'请对当前页面进行网页核对;不需要回查,仅整理已有记录。',
'先调用resolve_reply_target核对;不调用resolve_reply_target。',
'网页核对不用做,不调用resolve_reply_target。',
])
def test_explicit_page_cancellation_or_only_tool_cancellation_keeps_browser_off(text):
assert not browser_recheck_requested(text)
def test_only_explicit_prohibition_during_web_recheck_skips_analysis():
assert recheck_without_analysis(REQUEST)
assert recheck_without_analysis('返回原笔记核对,不要重新分析名单。')
assert not recheck_without_analysis('返回原笔记核对,然后重新分析名单。')
assert not recheck_without_analysis('返回原笔记核对,不要重新分析名单;最后仍要分析名单。')
assert not recheck_without_analysis('仅按现有原话复核,不要重新搜索。')
assert not recheck_without_analysis('返回原笔记核对,“不要分析名单”是引用。')
assert not recheck_without_analysis('返回原笔记核对,不要重新分析名单;最后再分析名单。')
def test_grounding_overrides_wrong_model_route_without_altering_other_user_bounds():
plan = IntentPlan(brief=ProspectingBrief(intent='discover', target_candidates=1, max_comments_per_note=5),
summary='只分析现有文字', missing=[TARGET_QUESTION], reuse_existing_sources=True)
corrected = ground_execution_plan(plan, REQUEST)
assert corrected.reuse_existing_sources is False and corrected.brief.intent == 'inspect'
assert corrected.brief.target_candidates == 1 and corrected.brief.max_comments_per_note == 5
assert TARGET_QUESTION not in corrected.missing
assert plan.reuse_existing_sources is True and plan.brief.intent == 'discover'
assert ground_execution_plan(plan, '不需要回网页,仅按已有原话复核。') == plan
async def test_real_intent_parser_corrects_reuse_flag_from_mocked_provider(monkeypatch):
wrong = IntentPlan(brief=ProspectingBrief(intent='research', user_notes='复核已保存文字'),
summary='仅复核已有资料', reuse_existing_sources=True)
cfg = {'provider': 'fixture', 'model': 'fixture-model', 'api_key': 'fixture-only', 'base_url': 'https://model.invalid'}
monkeypatch.setattr(dialogue, 'get_llm_config', lambda: cfg)
client_class = httpx.AsyncClient
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={
'model': cfg['model'], 'choices': [{'message': {'content': wrong.model_dump_json()}}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 10, 'total_tokens': 20}}))
monkeypatch.setattr(dialogue.httpx, 'AsyncClient', lambda **kwargs: client_class(transport=transport, **kwargs))
plan = await dialogue.interpret_message(REQUEST, ProspectingBrief(intent='research'), [])
assert plan.reuse_existing_sources is False
assert plan.brief.intent == 'research'
def test_recheck_prompt_starts_from_current_page_and_actual_history_instead_of_search_home(monkeypatch):
monkeypatch.setattr(subject.settings, 'reply_relation_preflight_enabled', True)
prompt = browser_prompt(ProspectingBrief(intent='inspect', user_notes=REQUEST), [])
assert '从当前页面观察起步' in prompt and '原生历史返回原笔记' in prompt
assert 'resolve_reply_target' in prompt
assert 'https://www.xiaohongshu.com/explore\n' not in prompt
assert '先打开下面的实际入口URL' not in prompt
assert '通过真实搜索入口→' not in prompt
@pytest.fixture
def memory(monkeypatch, tmp_path):
brief = ProspectingBrief(intent='research', user_notes=REQUEST, keywords=['装修'], max_notes=1)
row = {'id': 'recheck-routing-fixture', 'knowledge_id': KNOWLEDGE_ID, 'status': 'blocked',
'current_step': 'stage_results', 'browser_profile_path': '/fixture/browser',
'inputs': {**brief.model_dump(), 'goal': REQUEST, '_goal_version': 2,
'_execution_policy': subject.EXECUTION_POLICY, '_needs_interpretation': False,
'_requirements_grounding_version': subject.REQUIREMENTS_GROUNDING_VERSION},
'artifacts': {'messages': [{'role': 'user', 'text': REQUEST}], 'plan': {'missing': [], 'reuse_existing_sources': True},
'sources': [{'source_id': 'd352', 'source_type': 'reply', 'content': '实际原话'}],
'analysis_pending': True, 'analysis_goal_version': 1, 'analysis_cache': {'old': 'unchanged'},
'decisions': [{'source_id': 'd352', 'decision': 'keep', 'quote': '实际原话'}],
'leads': [{'source_id': 'd352', 'quote': '实际原话'}]}}
monkeypatch.setattr(subject.db, 'get_task', lambda tid: deepcopy(row))
def update(tid, **fields):
assert tid == row['id']
row.update(deepcopy(fields))
return deepcopy(row)
monkeypatch.setattr(subject.db, 'update_task', update)
monkeypatch.setattr(subject, 'get_llm_config', lambda: {'api_key': 'fixture-only'})
monkeypatch.setattr(subject, 'append_progress', lambda *args, **kwargs: None)
monkeypatch.setattr(subject, 'write_summary', lambda *args, **kwargs: None)
monkeypatch.setattr(subject, 'task_doc_dir', lambda tid: tmp_path)
engine = subject.ProspectingOrchestrator()
monkeypatch.setattr(engine, '_review', lambda tid, artifacts: artifacts)
monkeypatch.setattr(engine, '_learn', lambda tid, artifacts: None)
return row, engine
async def test_prepare_rechecks_raw_user_instruction_even_if_intent_model_returns_reuse(memory, monkeypatch):
row, engine = memory
row['inputs']['_needs_interpretation'] = True
wrong = IntentPlan(brief=ProspectingBrief(intent='discover', user_notes='只分析已保存文字', target_candidates=1),
summary='仅复核现有资料', reuse_existing_sources=True)
monkeypatch.setattr(subject, 'interpret_message', AsyncMock(return_value=wrong))
execute = AsyncMock()
monkeypatch.setattr(engine, '_run', execute)
result = await engine._prepare(deepcopy(row))
await engine.jobs[row['id']]
await asyncio.sleep(0)
assert result['artifacts']['analysis_pending'] is False
assert result['artifacts']['plan']['reuse_existing_sources'] is False
assert result['inputs']['intent'] == 'inspect'
assert execute.await_count == 1
async def test_page_verification_revision_preserves_view_when_one_tool_is_forbidden(memory, monkeypatch):
row, engine = memory
interrupt = AsyncMock(return_value=deepcopy(row))
monkeypatch.setattr(engine, '_interrupt_for_revision', interrupt)
monkeypatch.setattr(engine, '_prepare', AsyncMock(side_effect=lambda current: current))
text = '现在请对当前页面进行网页核对。本轮只读预检,不保存来源,最多1篇。不滚动、不重新导航、不调用resolve_reply_target。'
await engine.message(row['id'], text)
interrupt.assert_awaited_once_with(row['id'], preserve_browser=True)
@pytest.mark.parametrize('verdict', ['success', 'failed'])
async def test_live_route_guard_and_explicit_no_analysis_preserve_old_judgments(memory, monkeypatch, verdict):
row, engine = memory
original = deepcopy(row['artifacts'])
browser = AsyncMock(return_value={'verdict': verdict, 'history': {}, 'artifacts': {}, 'stop_reason': 'agent_done' if verdict == 'success' else 'no_progress'})
monkeypatch.setattr(subject.runner, 'run_browser_step', browser)
monkeypatch.setattr(subject.runner, 'has_live_session', lambda tid: False)
analyzer = AsyncMock(side_effect=AssertionError('User forbids a new paid list analysis'))
monkeypatch.setattr(subject, 'analyze_sources', analyzer)
await engine._run_scoped(row['id'], engine._brief(row), '')
browser.assert_awaited_once()
analyzer.assert_not_awaited()
assert '从当前页面观察起步' in browser.await_args.kwargs['agent_task']
assert browser.await_args.kwargs['require_existing_page'] is True
assert browser.await_args.kwargs['reuse_managed_view'] is True
for key in ('analysis_goal_version', 'analysis_cache', 'decisions', 'leads'):
assert row['artifacts'][key] == original[key]
assert row['artifacts']['analysis_pending'] is False
assert row['artifacts']['analysis_not_requested_goal_version'] == 2
assert row['artifacts']['browser_verdict'] == verdict
assert row['status'] == ('completed' if verdict == 'success' else 'blocked')
async def test_supplied_actual_entry_allows_a_new_view_without_relying_on_missing_history(memory, monkeypatch):
row, engine = memory
url = 'https://www.xiaohongshu.com/explore/'+'a'*24
row['inputs']['subject_urls'] = [url]
browser = AsyncMock(return_value={'verdict': 'failed', 'history': {}, 'artifacts': {}, 'stop_reason': 'no_progress'})
monkeypatch.setattr(subject.runner, 'run_browser_step', browser)
monkeypatch.setattr(subject.runner, 'has_live_session', lambda tid: False)
await engine._run_scoped(row['id'], engine._brief(row), '')
assert browser.await_args.kwargs['require_existing_page'] is False
assert '用户已提供的实际入口:'+url in browser.await_args.kwargs['agent_task']
async def test_pure_offline_review_keeps_normal_reuse_route(memory, monkeypatch):
from backend.prospecting import execution
row, engine = memory
row['inputs']['goal'] = '不需要回网页,仅按已有原话复核。'
row['inputs']['user_notes'] = row['inputs']['goal']
browser = AsyncMock(side_effect=AssertionError('Offline review must not open the browser'))
monkeypatch.setattr(subject.runner, 'run_browser_step', browser)
analyzer = AsyncMock(return_value={'leads': deepcopy(row['artifacts']['leads'])})
monkeypatch.setattr(execution, 'analyze_evidence', analyzer)
await engine._run_scoped(row['id'], engine._brief(row), '')
browser.assert_not_awaited()
analyzer.assert_awaited_once()
assert 'analysis_not_requested_goal_version' not in row['artifacts']