assistant/tests/test_preflight_execution_d4...

152 lines
9.0 KiB
Python
Raw 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.

"""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']