152 lines
9.0 KiB
Python
152 lines
9.0 KiB
Python
"""Preflight hands off independent receipts, without list analysis or knowledge."""
|
||
from copy import deepcopy
|
||
import json
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
import pytest
|
||
|
||
from backend.prospecting import orchestrator as subject
|
||
from backend.prospecting.contracts import KNOWLEDGE_ID, ProspectingBrief
|
||
from backend.prospecting.delivery import project
|
||
from backend.prospecting.prompts import browser_prompt
|
||
|
||
|
||
REQUEST = '只做关系预检,不保存来源,最多查看2篇新笔记,不分析名单。'
|
||
NATURAL_REQUEST = ('独立做一轮只读预检,只访问此前已实际观察到的笔记入口,不搜索、不换笔记。最多1篇,'
|
||
'只检查第一组当前可见讨论,每次最多5条回复。严格排除ID为6adaf8a6000000001901d400和6a7f04b8000000003302f624的旧笔记。'
|
||
'先沿真实评论入口进入评论区,再使用inspect_reply_relations。不保存正文或来源,不保存知识,不分析客户名单。'
|
||
'有eligible=true也仅保留页面,本轮不自动进入取证。')
|
||
NOTE = 'a'*24
|
||
RUN = 'f'*32
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def enable_reply_relation_preflight(monkeypatch):
|
||
monkeypatch.setattr(subject.settings, 'reply_relation_preflight_enabled', True)
|
||
|
||
|
||
def test_pure_preflight_prompt_differentiates_editor_from_visible_discussion_and_requires_progress():
|
||
prompt = browser_prompt(ProspectingBrief(intent='inspect', user_notes=REQUEST), [])
|
||
assert 'comments_not_in_view' in prompt and '不得在原位重复inspect_reply_relations' in prompt
|
||
assert 'advance_comments(next)' in prompt and '已观察的真实评论滚动容器' in prompt
|
||
assert '空评论编辑器' in prompt and '不算进入讨论' in prompt
|
||
assert '仍未实际推进时如实停止' in prompt
|
||
assert '只允许检查一次' in prompt
|
||
|
||
|
||
def artifacts(*, state='visible_comments', status='no_visible_reply'):
|
||
return {'preflight_run': {'run_id': RUN, 'goal_version': 1},
|
||
'preflight_navigation': [{'id': 'preflight_nav_'+'1'*32, 'version': 1, 'run_id': RUN, 'goal_version': 1,
|
||
'status': 'entered_note', 'action': 'observe', 'note_id': NOTE, 'observed_at': '2026-10-29T10:00:00+00:00'}],
|
||
'preflight_observations': [{'id': 'preflight_'+'2'*32, 'version': 1, 'operation': 'inspect_reply_relations',
|
||
'run_id': RUN, 'goal_version': 1, 'note_id': NOTE, 'observed_at': '2026-10-29T10:00:01+00:00',
|
||
'status': status, 'checked_count': 0, 'eligible_count': 0,
|
||
'classification_counts': dict.fromkeys(('unique_dom_deep_reply', 'needs_native_identity', 'ambiguous', 'unknown'), 0),
|
||
'discussion_state': state, 'visible_comment_count': 1 if state=='visible_comments' else 0,
|
||
'has_unexpanded_replies': True, 'history_truncated': False}]}
|
||
|
||
|
||
@pytest.fixture
|
||
def memory(monkeypatch, tmp_path):
|
||
brief = ProspectingBrief(intent='inspect', user_notes=REQUEST, keywords=['杭州装修'])
|
||
row = {'id': 'preflight-execution-d44', 'knowledge_id': KNOWLEDGE_ID, 'status': 'running', 'current_step': 'search_read',
|
||
'browser_profile_path': '/fixture', 'inputs': {**brief.model_dump(), 'goal': REQUEST, '_goal_version': 1},
|
||
'artifacts': {'sources': [], 'messages': [], 'analysis_pending': False}}
|
||
monkeypatch.setattr(subject.db, 'get_task', lambda tid: deepcopy(row))
|
||
def update(tid, **fields):
|
||
row.update(deepcopy(fields))
|
||
return deepcopy(row)
|
||
monkeypatch.setattr(subject.db, 'update_task', update)
|
||
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)
|
||
monkeypatch.setattr(subject.runner, 'has_live_session', lambda tid: False)
|
||
engine = subject.ProspectingOrchestrator()
|
||
ranking = Mock(side_effect=AssertionError('No unrelated knowledge injection'))
|
||
analysis = AsyncMock(side_effect=AssertionError('No list analysis in explicit preflight'))
|
||
review = Mock(side_effect=AssertionError('No knowledge update in explicit preflight'))
|
||
monkeypatch.setattr(subject, 'ranked_experience', ranking)
|
||
monkeypatch.setattr(subject, 'analyze_sources', analysis)
|
||
monkeypatch.setattr(engine, '_review', review)
|
||
return row, engine, ranking, analysis, review
|
||
|
||
|
||
@pytest.mark.parametrize('state,status,completed', [('visible_comments', 'no_visible_reply', True),
|
||
('comments_not_in_view', 'no_visible_reply', False), ('unknown', 'unknown', False)])
|
||
async def test_zero_eligible_is_valid_only_when_program_confirmed_visible_preflight(memory, monkeypatch, state, status, completed):
|
||
row, engine, ranking, analysis, review = memory
|
||
browser = AsyncMock(return_value={'verdict': 'success', 'summary': 'model says checked 999',
|
||
'history': {'is_successful': True}, 'artifacts': artifacts(state=state, status=status)})
|
||
monkeypatch.setattr(subject.runner, 'run_browser_step', browser)
|
||
await engine._run_scoped(row['id'], engine._brief(row), '')
|
||
assert row['current_step'] == 'preflight_results'
|
||
assert row['status'] == ('completed' if completed else 'blocked')
|
||
assert row['artifacts']['sources'] == []
|
||
assert '999' not in row['artifacts']['summary']
|
||
assert browser.await_args.kwargs['preflight_policy'].active is True
|
||
assert browser.await_args.kwargs['keep_session'] is True
|
||
assert project(row)['fulfillment']['complete'] is completed
|
||
ranking.assert_not_called(); analysis.assert_not_awaited(); review.assert_not_called()
|
||
|
||
|
||
async def test_model_success_without_current_program_receipts_cannot_complete_or_backfill(memory, monkeypatch):
|
||
row, engine, *_ = memory
|
||
monkeypatch.setattr(subject.runner, 'run_browser_step', AsyncMock(return_value={'verdict': 'success',
|
||
'history': {'is_successful': True}, 'summary': 'checked2 eligible0', 'artifacts': {}}))
|
||
await engine._run_scoped(row['id'], engine._brief(row), '')
|
||
assert row['status'] == 'blocked' and row['current_step'] == 'preflight_results'
|
||
assert not row['artifacts'].get('preflight_observations')
|
||
assert project(row)['fulfillment']['complete'] is False
|
||
|
||
|
||
async def test_natural_read_only_wording_enables_gate_and_independent_delivery_without_knowledge_or_analysis(memory, monkeypatch):
|
||
row, engine, ranking, analysis, review = memory
|
||
row['inputs'].update(goal=NATURAL_REQUEST, intent='discover', max_notes=9, target_candidates=1,
|
||
user_notes='model paraphrase of ordinary research')
|
||
row['artifacts']['analysis_pending'] = True
|
||
observed = artifacts(status='checked')
|
||
receipt = observed['preflight_observations'][0]
|
||
receipt.update(checked_count=1, classification_counts={
|
||
'unique_dom_deep_reply': 0, 'needs_native_identity': 0, 'ambiguous': 0, 'unknown': 1})
|
||
browser = AsyncMock(return_value={'verdict': 'success', 'summary': 'model narrative must not supply counts',
|
||
'history': {'is_successful': True}, 'artifacts': observed})
|
||
monkeypatch.setattr(subject.runner, 'run_browser_step', browser)
|
||
await engine._run_scoped(row['id'], engine._brief(row), '')
|
||
policy = browser.await_args.kwargs['preflight_policy']
|
||
assert policy.active and policy.max_notes == 1 and len(policy.excluded_note_ids) == 2
|
||
assert browser.await_args.kwargs['keep_session'] is True
|
||
assert browser.await_args.kwargs['prospecting_limits']['max_notes'] == 1
|
||
assert 'report_learning_outcome' not in browser.await_args.kwargs['agent_task']
|
||
assert row['status'] == 'completed' and row['current_step'] == 'preflight_results'
|
||
assert row['artifacts']['sources'] == [] and row['artifacts']['experience_injected'] is False
|
||
delivery = project(row)
|
||
assert delivery['preflight']['checked_count'] == 1 and delivery['preflight']['eligible_count'] == 0
|
||
assert delivery['preflight']['folded_notes'] == 1 and delivery['fulfillment']['complete'] is True
|
||
ranking.assert_not_called(); analysis.assert_not_awaited(); review.assert_not_called()
|
||
|
||
|
||
def test_new_parser_does_not_backfill_old_failed_natural_wording_task_without_run_marker(memory):
|
||
row, _engine, *_ = memory
|
||
row['inputs']['goal'] = NATURAL_REQUEST
|
||
row['status'], row['current_step'] = 'blocked', 'stage_results'
|
||
row['artifacts'].update(artifacts(), browser_verdict='success', browser_history={'is_successful': True})
|
||
row['artifacts'].pop('preflight_run')
|
||
before = deepcopy(row)
|
||
delivery = project(row)
|
||
assert delivery['preflight']['scope_passed'] is None
|
||
assert delivery['fulfillment']['complete'] is False
|
||
assert row == before
|
||
|
||
|
||
def test_recovery_unions_receipts_and_keeps_current_database_run_marker(memory, tmp_path):
|
||
row, engine, *_ = memory
|
||
saved = artifacts()
|
||
current = deepcopy(saved)
|
||
current['preflight_run'] = {'run_id': 'e'*32, 'goal_version': 2}
|
||
current['preflight_observations'] = []
|
||
(tmp_path/'evidence.json').write_text(json.dumps(saved))
|
||
recovered = engine._recover_evidence(row['id'], current)
|
||
assert recovered['preflight_observations'] == saved['preflight_observations']
|
||
assert recovered['preflight_navigation'] == saved['preflight_navigation']
|
||
assert recovered['preflight_run'] == current['preflight_run']
|