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