231 lines
14 KiB
Python
231 lines
14 KiB
Python
"""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']
|