792 lines
39 KiB
Python
792 lines
39 KiB
Python
"""Prospecting evidence must come from the active page, within task limits."""
|
||
|
||
import json
|
||
import base64
|
||
import asyncio
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from backend.browser import tools as subject
|
||
from backend.browser import runner
|
||
|
||
|
||
NOTE_A = "65a1234567890abcdef12345"
|
||
NOTE_B = "65b1234567890abcdef12345"
|
||
URL_A = f"https://www.xiaohongshu.com/explore/{NOTE_A}"
|
||
URL_B = f"https://www.xiaohongshu.com/explore/{NOTE_B}"
|
||
|
||
|
||
class ObservedPage:
|
||
def __init__(self, url=URL_A, text="原帖:想找装修公司。评论小明:我也想找靠谱团队。", metadata=None):
|
||
self.url = url
|
||
self.text = text
|
||
self.error = None
|
||
self.observed_selectors = []
|
||
self.metadata = metadata or {}
|
||
|
||
async def evaluate(self, code, selector):
|
||
self.observed_selectors.append(selector)
|
||
if self.error:
|
||
return json.dumps({"error": self.error})
|
||
# Browser Use 0.13.10 Page.evaluate serializes objects to a JSON string.
|
||
return json.dumps({"url": self.url, "text": self.text, **self.metadata}, ensure_ascii=False)
|
||
|
||
async def get_url(self):
|
||
return self.url
|
||
|
||
|
||
class ObservedSession:
|
||
def __init__(self, page=None):
|
||
self.page = page or ObservedPage()
|
||
self.agent_focus_target_id = None
|
||
self.cdp_client = SimpleNamespace()
|
||
|
||
async def get_current_page(self):
|
||
return self.page
|
||
|
||
async def get_current_page_url(self):
|
||
return self.page.url
|
||
|
||
|
||
@pytest.fixture
|
||
def context(tmp_path, monkeypatch):
|
||
monkeypatch.setattr(subject, "task_doc_dir", lambda _task_id: tmp_path)
|
||
monkeypatch.setattr(runner, "task_doc_dir", lambda _task_id: tmp_path)
|
||
monkeypatch.setattr(runner, "append_progress", lambda *args, **kwargs: None)
|
||
subject.clear_task_context("test_prospecting")
|
||
subject.set_prospecting_context("test_prospecting", max_notes=2, max_comments_per_note=1)
|
||
yield "test_prospecting", tmp_path
|
||
subject.clear_task_context("test_prospecting")
|
||
|
||
|
||
async def save(task_id, session=None, **overrides):
|
||
return await subject._save_source(task_id, session or ObservedSession(), **{
|
||
"source_type": "note", "source_url": URL_A, "content": "想找装修公司。", "source_selector": "#note-body", **overrides,
|
||
})
|
||
|
||
|
||
def test_profile_has_no_school_or_unsafe_generic_actions(context):
|
||
registered = subject.build_prospecting_tools(context[0]).registry.registry.actions
|
||
assert {"ask_user", "save_source", "save_lead", "save_learning", "report_learning_outcome"} <= registered.keys()
|
||
for name in ["report_blocker", "save_artifact", *subject.PROSPECTING_EXCLUDED_ACTIONS]:
|
||
assert name not in registered
|
||
assert "browser_session" not in registered["save_source"].param_model.model_fields
|
||
assert registered["save_source"].param_model.model_fields["source_url"].default == ""
|
||
assert registered["save_lead"].param_model.model_fields["source_url"].default == ""
|
||
assert "inspect_source_candidates" in registered
|
||
|
||
|
||
@pytest.mark.parametrize("enabled", [False, True])
|
||
def test_reply_relation_tools_are_an_explicit_opt_in_in_model_action_schema(context, monkeypatch, enabled):
|
||
monkeypatch.setattr(subject.settings, "reply_relation_preflight_enabled", enabled)
|
||
tools = subject.build_prospecting_tools(context[0])
|
||
schema = tools.registry.create_action_model().model_json_schema()
|
||
# Browser-use 0.13.10 exposes a root union of individual action models.
|
||
fields = {name for model in schema["$defs"].values() for name in model.get("properties", {})}
|
||
for name in ("inspect_reply_relations", "resolve_reply_target"):
|
||
assert (name in tools.registry.registry.actions) is enabled
|
||
assert (name in fields) is enabled
|
||
assert {"inspect_source_candidates", "save_source", "advance_comments", "expand_replies"} <= fields
|
||
|
||
|
||
def test_explicit_exclusion_still_wins_when_relation_feature_is_enabled(context, monkeypatch):
|
||
monkeypatch.setattr(subject.settings, "reply_relation_preflight_enabled", True)
|
||
tools = subject.build_prospecting_tools(context[0], exclude=["inspect_reply_relations", "resolve_reply_target"])
|
||
schema = tools.registry.create_action_model().model_json_schema()
|
||
fields = {name for model in schema["$defs"].values() for name in model.get("properties", {})}
|
||
assert not {"inspect_reply_relations", "resolve_reply_target"}.intersection(fields)
|
||
|
||
|
||
def test_actual_search_ai_url_records_bounded_decoded_coverage_only(context):
|
||
task_id, directory = context
|
||
urls = ["https://www.xiaohongshu.com/search_result_ai?keyword=%25E6%259D%25AD%25E5%25B7%259E%25E8%25B7%259F%25E5%25A6%2586",
|
||
"https://www.xiaohongshu.com/search_result?keyword=杭州新娘跟妆",
|
||
"https://www.xiaohongshu.com/search_result_ai?keyword=杭州跟妆"]
|
||
for url in urls:
|
||
subject.record_navigation_observation(task_id, url)
|
||
artifacts = subject.get_task_artifacts(task_id)
|
||
assert artifacts["coverage"]["search_keywords_observed"] == ["杭州跟妆", "杭州新娘跟妆"]
|
||
assert not artifacts.get("sources")
|
||
assert json.loads((directory / "evidence.json").read_text())["coverage"]["search_keywords_observed"] == ["杭州跟妆", "杭州新娘跟妆"]
|
||
|
||
|
||
@pytest.mark.parametrize("url", [
|
||
"https://www.xiaohongshu.com.evil.test/search_result_ai?keyword=杭州",
|
||
"https://www.xiaohongshu.com/explore/example?keyword=杭州",
|
||
"file://www.xiaohongshu.com/search_result_ai?keyword=杭州",
|
||
"https://www.xiaohongshu.com/search_result_ai?keyword=%250A杭州",
|
||
"https://www.xiaohongshu.com/search_result_ai?keyword=" + "a" * 81,
|
||
])
|
||
def test_non_search_or_invalid_keyword_is_not_coverage(context, url):
|
||
assert subject.observed_search_keyword(url) is None
|
||
|
||
|
||
async def test_source_is_checked_and_checkpointed_with_actual_url(context):
|
||
task_id, directory = context
|
||
session = ObservedSession(ObservedPage(url=URL_A + "?xsec_source=pc_search"))
|
||
entry, created = await save(task_id, session)
|
||
assert created and entry["source_type"] == "note"
|
||
assert entry["source_url"] == session.page.url
|
||
assert entry["status"] == "observed" and entry["observed_at"]
|
||
snapshot = json.loads((directory / "evidence.json").read_text())
|
||
assert snapshot["sources"] == [entry]
|
||
assert json.loads((directory / "sources.json").read_text()) == [entry]
|
||
assert not list(directory.glob(".evidence-*"))
|
||
assert "leads" not in subject.get_task_artifacts(task_id)
|
||
|
||
|
||
async def test_omitted_source_url_uses_actual_page_without_exposing_signed_url_to_model(context):
|
||
task_id, directory = context
|
||
actual_url = URL_A + "?xsec_token=fixture-token&xsec_source=pc_search"
|
||
session = ObservedSession(ObservedPage(url=actual_url))
|
||
registry = subject.build_prospecting_tools(task_id).registry
|
||
result = await registry.execute_action("save_source", {
|
||
"source_type": "note", "content": "想找装修公司。", "source_selector": "#note-body",
|
||
}, browser_session=session)
|
||
assert "xsec_token" not in result.extracted_content
|
||
assert "fixture-token" not in result.extracted_content
|
||
assert json.loads((directory / "evidence.json").read_text())["sources"][0]["source_url"] == actual_url
|
||
|
||
|
||
class CandidatePage(ObservedPage):
|
||
def __init__(self, url=URL_A, *, candidates=None, error=None):
|
||
super().__init__(url=url)
|
||
self.candidates = candidates if candidates is not None else [
|
||
{"source_type": "note", "source_selector": "#detail-desc", "content": "想找装修公司。"},
|
||
{"source_type": "comment", "source_selector": "#noteContainer > div:nth-child(2) > span:nth-child(1)", "content": "我也想找靠谱团队。"},
|
||
]
|
||
self.inspection_error = error
|
||
self.inspections = 0
|
||
|
||
async def evaluate(self, code, maximum):
|
||
self.inspections += 1
|
||
if self.inspection_error:
|
||
return json.dumps({"error": self.inspection_error})
|
||
return json.dumps({"url": self.url, "candidates": self.candidates})
|
||
|
||
|
||
async def test_candidate_inspector_returns_bounded_scoped_text_without_current_url(context):
|
||
page = CandidatePage(url=URL_A + "?xsec_token=fixture-secret")
|
||
result = await subject._inspect_source_candidates(ObservedSession(page), max_comments=1)
|
||
assert result["note_id"] == NOTE_A
|
||
assert len(result["candidates"]) == 2
|
||
assert all(item["coverage"] == "visible_excerpt" for item in result["candidates"])
|
||
assert result["scope"] == "current_viewport_only" and result["comments_complete"] is False
|
||
assert "fixture-secret" not in json.dumps(result)
|
||
assert "url" not in result
|
||
assert page.inspections == 1
|
||
|
||
|
||
async def test_inspector_comment_bound_is_visible_in_model_schema_and_validated_before_dom(context):
|
||
registry = subject.build_prospecting_tools(context[0]).registry
|
||
action = registry.registry.actions["inspect_source_candidates"]
|
||
schema = action.param_model.model_json_schema()["properties"]["max_comments"]
|
||
assert schema["type"] == "integer"
|
||
assert schema["minimum"] == 0 and schema["maximum"] == 5
|
||
assert schema["default"] == 5
|
||
assert "不是本轮" in schema["description"]
|
||
page = CandidatePage()
|
||
with pytest.raises(RuntimeError, match="Invalid parameters"):
|
||
await registry.execute_action("inspect_source_candidates", {"max_comments": 10}, browser_session=ObservedSession(page))
|
||
assert page.inspections == 0
|
||
|
||
|
||
async def test_candidate_inspector_stops_before_dom_read_on_search_or_non_note(context):
|
||
page = CandidatePage(url="https://www.xiaohongshu.com/search_result?keyword=x")
|
||
with pytest.raises(ValueError, match="具体笔记"):
|
||
await subject._inspect_source_candidates(ObservedSession(page))
|
||
assert page.inspections == 0
|
||
|
||
|
||
@pytest.mark.parametrize("case", ["too_many", "duplicate_note", "hidden_root", "six_comments"])
|
||
async def test_candidate_inspector_refuses_invalid_or_over_budget_observations(context, case):
|
||
page = CandidatePage()
|
||
maximum = 1
|
||
if case == "too_many":
|
||
page.candidates *= 3
|
||
elif case == "duplicate_note":
|
||
page.candidates = [page.candidates[0], page.candidates[0]]
|
||
elif case == "hidden_root":
|
||
page.inspection_error = "note_root_not_visible"
|
||
else:
|
||
maximum = 6
|
||
with pytest.raises(ValueError):
|
||
await subject._inspect_source_candidates(ObservedSession(page), maximum)
|
||
|
||
|
||
@pytest.mark.parametrize("overrides,message", [
|
||
({"content": "作者正在找杭州的装修公司"}, "原文未在"),
|
||
({"source_url": URL_B}, "实际页面不一致"),
|
||
({"source_url": "https://www.xiaohongshu.com.evil.test/explore/" + NOTE_A}, "HTTPS 笔记"),
|
||
({"source_url": "https://www.xiaohongshu.com/search_result?keyword=x"}, "具体笔记"),
|
||
])
|
||
async def test_unobserved_or_misattributed_source_is_rejected(context, overrides, message):
|
||
with pytest.raises(ValueError, match=message):
|
||
await save(context[0], **overrides)
|
||
assert subject.get_task_artifacts(context[0]).get("sources", []) == []
|
||
|
||
|
||
async def test_actual_page_identity_is_checked_independently(context):
|
||
with pytest.raises(ValueError, match="HTTPS 笔记"):
|
||
await save(context[0], ObservedSession(ObservedPage(url="app://ui/index.html")))
|
||
|
||
|
||
async def test_comments_need_separate_container_and_saved_note(context):
|
||
task_id, _ = context
|
||
with pytest.raises(ValueError, match="独立来源容器"):
|
||
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector="")
|
||
with pytest.raises(ValueError, match="先保存这篇笔记正文"):
|
||
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector="#comment-1")
|
||
await save(task_id)
|
||
session = ObservedSession(ObservedPage(text="小明:我也想找靠谱团队。"))
|
||
comment, created = await save(
|
||
task_id, session, source_type="comment", content="我也想找靠谱团队。",
|
||
author="小明", source_selector="#comment-1",
|
||
)
|
||
assert created and comment["source_type"] == "comment"
|
||
assert session.page.observed_selectors == ["#comment-1"]
|
||
|
||
|
||
async def test_duplicate_does_not_consume_saved_source_budget(context):
|
||
task_id, _ = context
|
||
first, _ = await save(task_id)
|
||
again, created = await save(task_id)
|
||
assert not created and again["source_id"] == first["source_id"]
|
||
with pytest.raises(ValueError, match="不同摘录重复计数"):
|
||
await save(task_id, content="原帖:想找装修公司。")
|
||
|
||
|
||
async def test_comment_budget_is_per_note_and_notes_have_a_total_cap(context):
|
||
task_id, _ = context
|
||
await save(task_id)
|
||
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector="#first")
|
||
with pytest.raises(ValueError, match="用户明确指定.*评论"):
|
||
await save(task_id, source_type="comment", content="评论小明", source_selector="#second")
|
||
session = ObservedSession(ObservedPage(url=URL_B))
|
||
await save(task_id, session, source_url=URL_B)
|
||
await save(task_id, session, source_url=URL_B, source_type="comment", content="我也想找靠谱团队。", source_selector="#other-note")
|
||
session.page.url = "https://www.xiaohongshu.com/explore/65c1234567890abcdef12345"
|
||
with pytest.raises(ValueError, match="用户明确指定.*笔记"):
|
||
await save(task_id, session, source_url=session.page.url)
|
||
|
||
|
||
async def test_non_unique_or_hidden_dom_source_cannot_be_saved(context):
|
||
session = ObservedSession()
|
||
session.page.error = "source_not_unique"
|
||
with pytest.raises(ValueError, match="来源容器未通过"):
|
||
await save(context[0], session)
|
||
|
||
|
||
async def test_learning_draft_needs_actual_page_evidence(context):
|
||
task_id, directory = context
|
||
registry = subject.build_prospecting_tools(task_id).registry
|
||
session = ObservedSession(ObservedPage(text="搜索笔记和用户"))
|
||
result = await registry.execute_action("save_learning", {
|
||
"key": "搜索入口", "value": "在搜索框输入关键词并回车", "category": "control_tip", "evidence": "搜索笔记和用户",
|
||
}, browser_session=session)
|
||
assert "草稿" in result.extracted_content
|
||
candidate = subject.get_task_artifacts(task_id)["learning_candidates"][0]
|
||
assert candidate["status"] == "draft"
|
||
assert json.loads((directory / "evidence.json").read_text())["learning_candidates"] == [candidate]
|
||
with pytest.raises(RuntimeError, match="依据未在"):
|
||
await registry.execute_action("save_learning", {
|
||
"key": "不存在的按钮", "value": "点击快速采集", "category": "control_tip", "evidence": "快速采集",
|
||
}, browser_session=session)
|
||
|
||
|
||
async def test_personal_contact_is_not_an_operation_memory(context):
|
||
registry = subject.build_prospecting_tools(context[0]).registry
|
||
with pytest.raises(RuntimeError, match="联系方式"):
|
||
await registry.execute_action("save_learning", {
|
||
"key": "用户信息", "value": "下次联系13812345678", "category": "note", "evidence": "原帖",
|
||
}, browser_session=ObservedSession())
|
||
|
||
|
||
async def test_confirmed_learning_outcome_needs_page_evidence(context):
|
||
task_id, directory = context
|
||
registry = subject.build_prospecting_tools(task_id).registry
|
||
session = ObservedSession()
|
||
with pytest.raises(RuntimeError, match="逐字证据"):
|
||
await registry.execute_action("report_learning_outcome", {
|
||
"entry_id": 5, "outcome": "confirmed", "note": "我觉得是这样的",
|
||
}, browser_session=session)
|
||
await registry.execute_action("report_learning_outcome", {
|
||
"entry_id": 5, "outcome": "confirmed", "note": "页面有此文字", "evidence": "原帖",
|
||
}, browser_session=session)
|
||
outcomes = json.loads((directory / "evidence.json").read_text())["learning_outcomes"]
|
||
assert outcomes == [{"entry_id": 5, "outcome": "confirmed", "note": "页面有此文字", "evidence": "原帖"}]
|
||
|
||
|
||
def test_hydrated_evidence_is_independent_and_limits_validate(context):
|
||
task_id, _ = context
|
||
saved = {"sources": [{"source_id": "stored"}]}
|
||
subject.set_prospecting_context(task_id, artifacts=saved)
|
||
saved["sources"].clear()
|
||
assert len(subject.get_task_artifacts(task_id)["sources"]) == 1
|
||
subject.set_prospecting_context(task_id, max_notes=500, max_comments_per_note=200)
|
||
assert subject._prospecting_limits[task_id] == {"max_notes": 500, "max_comments_per_note": 200}
|
||
with pytest.raises(ValueError):
|
||
subject.set_prospecting_context(task_id, max_notes=0)
|
||
subject.clear_task_context(task_id)
|
||
assert task_id not in subject._prospecting_limits
|
||
|
||
|
||
async def test_unverified_optional_author_does_not_block_valid_excerpt(context):
|
||
entry, created = await save(context[0], author="不存在的作者")
|
||
assert created and entry["author"] == ""
|
||
assert entry["metadata_warnings"]
|
||
|
||
|
||
async def test_author_is_observed_in_corresponding_outer_region_not_guessed_from_quote(context):
|
||
page = ObservedPage(text="想找装修公司。", metadata={
|
||
"source_type": "note", "author": "页面作者", "author_profile_url": PROFILE_A,
|
||
"page_time": "昨天 15:05", "ip_location": "浙江",
|
||
})
|
||
entry, _ = await save(context[0], ObservedSession(page), author="模型猜的昵称")
|
||
assert entry["author"] == "页面作者"
|
||
assert entry["author_profile_url"] == PROFILE_A
|
||
assert entry["page_time"] == "昨天 15:05" and entry["ip_location"] == "浙江"
|
||
assert "region" not in entry
|
||
|
||
|
||
async def test_replies_keep_thread_identity_and_speaker_separate(context):
|
||
task_id, directory = context
|
||
subject.set_prospecting_context(task_id)
|
||
await save(task_id)
|
||
root, _ = await save(task_id, ObservedSession(ObservedPage(text="我在杭州装修", metadata={
|
||
"source_type": "comment", "author": "甲", "author_profile_url": PROFILE_A,
|
||
"ip_location": "浙江",
|
||
})), source_type="comment", content="我在杭州装修", source_selector="#root-comment")
|
||
reply, _ = await save(task_id, ObservedSession(ObservedPage(text="想咨询下价格", metadata={
|
||
"source_type": "reply", "author": "乙", "parent_source_selector": "#root-comment",
|
||
"root_source_selector": "#root-comment", "reply_to": "甲",
|
||
})), source_type="reply", content="想咨询下价格", source_selector="#reply-1")
|
||
assert reply["parent_source_id"] == reply["root_source_id"] == root["source_id"]
|
||
assert reply["author"] == "乙" and reply["reply_to"] == "甲"
|
||
assert reply["ip_location"] == reply["author_profile_url"] == ""
|
||
assert len(json.loads((directory / "evidence.json").read_text())["sources"]) == 3
|
||
|
||
|
||
async def test_reply_type_must_be_observed_and_cannot_impersonate_root(context):
|
||
await save(context[0])
|
||
with pytest.raises(ValueError, match="层级"):
|
||
await save(context[0], ObservedSession(ObservedPage(metadata={"source_type": "reply"})),
|
||
source_type="comment", source_selector="#reply")
|
||
with pytest.raises(ValueError, match="回复必须"):
|
||
await save(context[0], source_type="reply", source_selector="#reply")
|
||
|
||
|
||
async def test_identical_comments_from_different_speakers_are_not_deduplicated(context):
|
||
subject.set_prospecting_context(context[0])
|
||
await save(context[0])
|
||
first, _ = await save(context[0], source_type="comment", content="我也想找靠谱团队。", source_selector="#speaker-a")
|
||
second, created = await save(context[0], source_type="comment", content="我也想找靠谱团队。", source_selector="#speaker-b")
|
||
assert created and first["source_id"] != second["source_id"]
|
||
|
||
|
||
async def test_default_scope_has_no_note_or_comment_business_quota(context):
|
||
task_id, _ = context
|
||
subject.set_prospecting_context(task_id)
|
||
for i in range(7):
|
||
url = "https://www.xiaohongshu.com/explore/" + f"{i + 1:024x}"
|
||
page = ObservedPage(url=url)
|
||
await save(task_id, ObservedSession(page), source_url=url)
|
||
if i == 0:
|
||
for comment in range(12):
|
||
await save(task_id, ObservedSession(page), source_url=url, source_type="comment",
|
||
content="我也想找靠谱团队。", source_selector=f"#comment-{comment}")
|
||
sources = subject.get_task_artifacts(task_id)["sources"]
|
||
assert sum(item["source_type"] == "note" for item in sources) == 7
|
||
assert sum(item["source_type"] == "comment" for item in sources) == 12
|
||
|
||
|
||
async def test_incremental_inspector_reports_only_new_sources_and_empty_reason(context):
|
||
task_id, _ = context
|
||
subject.set_prospecting_context(task_id)
|
||
await save(task_id, source_selector="#detail-desc")
|
||
selector = "#noteContainer > div:nth-child(2) > span:nth-child(1)"
|
||
await save(task_id, source_type="comment", content="我也想找靠谱团队。", source_selector=selector)
|
||
page = CandidatePage()
|
||
empty = await subject._inspect_source_candidates(ObservedSession(page), task_id=task_id)
|
||
assert empty["candidates"] == [] and empty["new_count"] == 0 and empty["saved_count"] == 2
|
||
assert empty["empty_reason"] == "visible_sources_already_saved"
|
||
page.candidates.append({"source_type": "reply", "source_selector": "#new-reply", "content": "多少钱?"})
|
||
result = await subject._inspect_source_candidates(ObservedSession(page), task_id=task_id)
|
||
assert result["new_count"] == 1 and result["candidates"][0]["source_type"] == "reply"
|
||
assert result["comments_complete"] is False
|
||
|
||
|
||
PROFILE_A = "https://www.xiaohongshu.com/user/profile/66a1234567890abcdef12345"
|
||
PROFILE_B = "https://www.xiaohongshu.com/user/profile/66b1234567890abcdef12345"
|
||
|
||
|
||
class ProfilePage(ObservedPage):
|
||
def __init__(self, url=PROFILE_A, **fields):
|
||
super().__init__(url=url)
|
||
self.fields = {"nickname": "甲", "xiaohongshu_id": "user_visible_123", "identity_label": "小红书号:user_visible_123", **fields}
|
||
|
||
async def evaluate(self, _code):
|
||
return json.dumps({"url": self.url, **self.fields}, ensure_ascii=False)
|
||
|
||
|
||
async def test_profile_requires_matching_observed_author_link_and_visible_id_label(context):
|
||
task_id, directory = context
|
||
source, _ = await save(task_id, ObservedSession(ObservedPage(metadata={
|
||
"author": "甲", "author_profile_url": PROFILE_A,
|
||
})))
|
||
with pytest.raises(ValueError, match="不一致"):
|
||
await subject._inspect_profile(task_id, ObservedSession(ProfilePage(PROFILE_B)), source["source_id"])
|
||
with pytest.raises(ValueError, match="标签依据"):
|
||
await subject._inspect_profile(task_id, ObservedSession(ProfilePage(identity_label="")), source["source_id"])
|
||
profile = await subject._inspect_profile(task_id, ObservedSession(ProfilePage()), source["source_id"])
|
||
assert profile["xiaohongshu_id"] == "user_visible_123"
|
||
assert profile["profile_id"] != profile["xiaohongshu_id"]
|
||
assert source["xiaohongshu_id"] == profile["xiaohongshu_id"]
|
||
assert json.loads((directory / "evidence.json").read_text())["profiles"] == [profile]
|
||
|
||
|
||
async def test_profile_without_observed_source_avatar_link_stays_unattributed(context):
|
||
source, _ = await save(context[0])
|
||
with pytest.raises(ValueError, match="尚未核对作者头像"):
|
||
await subject._inspect_profile(context[0], ObservedSession(ProfilePage()), source["source_id"])
|
||
assert not subject.get_task_artifacts(context[0]).get("profiles")
|
||
|
||
|
||
class ExpansionPage(CandidatePage):
|
||
def __init__(self, *, loads_reply=True):
|
||
super().__init__()
|
||
self.loads_reply = loads_reply
|
||
self.clicks = 0
|
||
|
||
async def evaluate(self, code, options):
|
||
if code == subject._OBSERVE_REPLY_CONTROL:
|
||
return json.dumps({"url": self.url, "text": "展开 1 条回复",
|
||
"fingerprint": "root" + ("loaded reply" if self.clicks and self.loads_reply else ""),
|
||
"click_point": {"x": 100, "y": 80, "viewport_width": 400, "viewport_height": 600},
|
||
"reply_count": int(bool(self.clicks and self.loads_reply))})
|
||
result = [*self.candidates]
|
||
if self.clicks and self.loads_reply:
|
||
result.append({"source_type": "reply", "source_selector": "#actual-reply", "content": "我想问价格"})
|
||
return json.dumps({"url": self.url, "candidates": result})
|
||
|
||
async def get_elements_by_css_selector(self, _selector):
|
||
async def click():
|
||
raise AssertionError("Expansion must not use an implicit-scrolling element click")
|
||
return [SimpleNamespace(click=click)]
|
||
|
||
@property
|
||
async def mouse(self):
|
||
async def click(**point):
|
||
assert point == {"x": 100, "y": 80}
|
||
self.clicks += 1
|
||
return SimpleNamespace(click=click)
|
||
|
||
|
||
async def test_expand_uses_one_native_click_and_confirms_new_reply(context):
|
||
page = ExpansionPage()
|
||
result = await subject._expand_replies(context[0], ObservedSession(page), "#expand")
|
||
assert result["status"] == "expanded" and result["new_visible_replies"] == 1
|
||
assert result["comments_complete"] is False and page.clicks == 1
|
||
|
||
|
||
async def test_verbatim_reply_excerpt_keeps_program_full_container_fingerprint(context):
|
||
task_id, _ = context
|
||
await save(task_id)
|
||
full = "回复: 原始的完整内容。\n包括足够上下文"
|
||
page = ObservedPage(text=full, metadata={"source_type": "reply", "source_key": "stable-reply"})
|
||
entry, added = await save(task_id, ObservedSession(page), source_type="reply", source_selector="#actual", content="原始的完整内容")
|
||
assert added
|
||
pre = subject.get_task_artifacts(task_id)["operation_evidence"][-1]["details"]["pre_snapshot"]
|
||
import hashlib
|
||
expected = hashlib.sha256(f"{NOTE_A}\n#actual\n{subject._compact_text(full)}".encode()).hexdigest()
|
||
assert pre["container_fingerprint"] == expected
|
||
assert "container_fingerprint" not in entry
|
||
|
||
|
||
@pytest.mark.parametrize("enabled", [False, True])
|
||
@pytest.mark.parametrize("parent_selector", ["", "#not-yet-saved-parent"])
|
||
async def test_saved_reply_with_unknown_parent_is_successful_without_parent_repair_guidance(
|
||
context, monkeypatch, enabled, parent_selector,
|
||
):
|
||
task_id, directory = context
|
||
monkeypatch.setattr(subject.settings, "reply_relation_preflight_enabled", enabled)
|
||
await save(task_id)
|
||
page = ObservedPage(text="我需要装修方案", metadata={"source_type": "reply", "source_key": "reply-key",
|
||
"parent_resolution": "target_identity_missing", "parent_source_selector": parent_selector,
|
||
"root_source_selector": parent_selector, "reply_to_selector": "#reply-to"})
|
||
registry = subject.build_prospecting_tools(task_id).registry
|
||
result = await registry.execute_action("save_source", {
|
||
"source_type": "reply", "content": "我需要装修方案", "source_selector": "#actual-reply",
|
||
}, browser_session=ObservedSession(page))
|
||
receipt = json.loads(result.extracted_content)
|
||
fact = subject.get_task_artifacts(task_id)["operation_evidence"][-1]
|
||
assert fact["outcome"] == "success"
|
||
assert fact["verifier"] == "source_container_quote_and_persisted"
|
||
assert fact["details"]["pre_snapshot"]["quote_matched"] is True
|
||
assert fact["details"]["post_snapshot"]["persisted"] is True
|
||
assert fact["details"]["post_snapshot"]["relations_checked"] is False
|
||
assert receipt["relations_checked"] is False
|
||
assert receipt["parent_resolution"] == "target_identity_missing"
|
||
assert receipt["parent_source_id"] is None and receipt["root_source_id"] is None
|
||
assert receipt["parent_source_selector"] == parent_selector
|
||
assert receipt["reply_to_selector"] == "#reply-to"
|
||
for instruction in ("先检查并保存", "重新保存", "resolve_reply_target"):
|
||
assert instruction not in receipt["next_action"]
|
||
snapshot = json.loads((directory / "evidence.json").read_text())
|
||
assert snapshot["operation_evidence"][-1] == fact
|
||
assert snapshot["sources"][-1]["parent_resolution"] == "target_identity_missing"
|
||
assert snapshot["sources"][-1]["content"] == "我需要装修方案"
|
||
|
||
|
||
async def test_no_progress_expand_is_not_clicked_repeatedly(context):
|
||
page = ExpansionPage(loads_reply=False)
|
||
first = await subject._expand_replies(context[0], ObservedSession(page), "#expand")
|
||
assert first["status"] == "no_new_content"
|
||
with pytest.raises(ValueError, match="不重复点击"):
|
||
await subject._expand_replies(context[0], ObservedSession(page), "#expand")
|
||
assert page.clicks == 1
|
||
|
||
|
||
class OwnedExpansionPage(ExpansionPage):
|
||
"""A native-click page with independently observable parent-discussion facts."""
|
||
def __init__(self, *, loads_reply=True, owner_delta=1, owner_error=False, owner_identity="parent-comment",
|
||
owner_url=URL_A, loading_change=False, reply_elsewhere=False):
|
||
super().__init__(loads_reply=loads_reply)
|
||
self.owner_delta = owner_delta
|
||
self.owner_error = owner_error
|
||
self.owner_identity = owner_identity
|
||
self.owner_url = owner_url
|
||
self.loading_change = loading_change
|
||
self.reply_elsewhere = reply_elsewhere
|
||
self.candidates.extend([
|
||
{"source_type": "reply", "source_selector": "#parent .reply-one", "content": "已读回复甲"},
|
||
{"source_type": "reply", "source_selector": "#parent .reply-two", "content": "已读回复乙"},
|
||
])
|
||
|
||
async def evaluate(self, code, options):
|
||
if code == subject._OBSERVE_REPLY_CONTROL:
|
||
changed = bool(self.clicks and (self.owner_delta or self.loading_change))
|
||
return json.dumps({"url": self.url, "text": "展开 1 条回复", "owner_selector": "#parent",
|
||
"click_point": {"x": 100, "y": 80, "viewport_width": 400, "viewport_height": 600},
|
||
"owner_identity": "parent-comment", "reply_count": 2 + (self.owner_delta if self.clicks else 0),
|
||
"visible_reply_selectors": ["#parent .reply-one", "#parent .reply-two"] + (["#actual-reply"] if self.clicks and self.loads_reply else []),
|
||
"reply_snapshot_complete": True,
|
||
"fingerprint": "parent content" + (" changed" if changed else "")})
|
||
if code == subject._OBSERVE_REPLY_OWNER:
|
||
assert options == {"selector": "#parent"}
|
||
if self.owner_error:
|
||
return json.dumps({"error": "owner_not_unique"})
|
||
return json.dumps({"url": self.owner_url, "owner_identity": self.owner_identity,
|
||
"reply_count": 2 + self.owner_delta,
|
||
"visible_reply_selectors": ["#parent .reply-one", "#parent .reply-two"] + (["#actual-reply"] if self.loads_reply else []),
|
||
"reply_snapshot_complete": True})
|
||
observed = json.loads(await super().evaluate(code, options))
|
||
if self.clicks and self.loads_reply and self.reply_elsewhere:
|
||
observed["candidates"][-1]["source_selector"] = "#different-parent .new-reply"
|
||
return json.dumps(observed)
|
||
|
||
|
||
async def test_expand_program_fact_requires_new_independent_reply_and_same_owner_count_growth(context):
|
||
task_id, directory = context
|
||
page = OwnedExpansionPage()
|
||
result = await subject._expand_replies(task_id, ObservedSession(page), "#expand")
|
||
facts = subject.get_task_artifacts(task_id)["operation_evidence"]
|
||
assert result["status"] == "expanded" and result["parent_scope_checked"] is True
|
||
assert page.clicks == 1 and len(facts) == 1
|
||
fact = facts[0]
|
||
assert (fact["capability"], fact["source_kind"], fact["outcome"], fact["verifier"]) == (
|
||
"expand_replies", "reply", "success", "reply_delta_and_parent")
|
||
assert fact["evidence_refs"]["pre"] != fact["evidence_refs"]["post"]
|
||
assert fact["details"]["pre_snapshot"]["reply_count"] == 2
|
||
after = fact["details"]["post_snapshot"]
|
||
assert after["parent_scope_checked"] is True and after["owner_reply_delta"] == 1
|
||
assert after["reply_count"] == 3 and after["new_visible_replies"] == 1
|
||
assert len(after["new_reply_fingerprints"]) == 1
|
||
assert json.loads((directory / "evidence.json").read_text())["operation_evidence"] == facts
|
||
|
||
|
||
@pytest.mark.parametrize("case,options,owner_checked,owner_delta,new_count", [
|
||
("same_owner_short_wait_without_new_content", {"loads_reply": False, "owner_delta": 0}, True, 0, 0),
|
||
("loading_text_only", {"loads_reply": False, "owner_delta": 0, "loading_change": True}, True, 0, 0),
|
||
("count_increase_without_new_independent_reply", {"loads_reply": False}, True, 1, 0),
|
||
("reply_appears_under_another_owner", {"owner_delta": 0, "reply_elsewhere": True}, True, 0, 1),
|
||
("parent_identity_changed", {"owner_identity": "another-comment"}, False, None, 1),
|
||
("parent_cannot_be_uniquely_observed", {"owner_error": True}, False, None, 1),
|
||
("owner_belongs_to_different_note", {"owner_url": URL_B}, False, None, 1),
|
||
])
|
||
async def test_expand_insufficient_change_is_unknown_not_method_contradiction(
|
||
context, monkeypatch, case, options, owner_checked, owner_delta, new_count,
|
||
):
|
||
waits = []
|
||
async def short_render_wait(delay):
|
||
waits.append(delay)
|
||
monkeypatch.setattr(subject.asyncio, "sleep", short_render_wait)
|
||
task_id, directory = context
|
||
page = OwnedExpansionPage(**options)
|
||
result = await subject._expand_replies(task_id, ObservedSession(page), "#expand")
|
||
facts = subject.get_task_artifacts(task_id)["operation_evidence"]
|
||
assert page.clicks == 1 and result["comments_complete"] is False, case
|
||
assert len(facts) == 1 and facts[0]["outcome"] == "unknown", case
|
||
assert facts[0]["verifier"] == "reply_delta_and_parent"
|
||
after = facts[0]["details"]["post_snapshot"]
|
||
assert after["parent_scope_checked"] is owner_checked, case
|
||
assert after["owner_reply_delta"] == owner_delta, case
|
||
assert after["new_visible_replies"] == new_count, case
|
||
if not new_count:
|
||
assert waits == [0.25, 0.25, 0.25]
|
||
if case == "same_owner_short_wait_without_new_content":
|
||
assert result["status"] == "no_new_content"
|
||
if case == "reply_appears_under_another_owner":
|
||
assert result["status"] == "expanded" and result["parent_scope_checked"] is True
|
||
assert any(row["source_selector"] == "#different-parent .new-reply" for row in result["candidates"])
|
||
assert all(fact["outcome"] != "contradiction" for fact in facts)
|
||
assert json.loads((directory / "evidence.json").read_text())["operation_evidence"] == facts
|
||
|
||
|
||
class CumulativeHistory:
|
||
def __init__(self):
|
||
self.count = 0
|
||
|
||
def number_of_steps(self):
|
||
return self.count
|
||
|
||
def total_duration_seconds(self):
|
||
return 1.0
|
||
|
||
def is_done(self):
|
||
return True
|
||
|
||
def is_successful(self):
|
||
return True
|
||
|
||
def urls(self):
|
||
return [URL_A]
|
||
|
||
def action_names(self):
|
||
return []
|
||
|
||
def errors(self):
|
||
return []
|
||
|
||
def final_result(self):
|
||
return "本轮完成"
|
||
|
||
|
||
class CumulativeAgent:
|
||
"""Imitates the installed 0.13.10's inclusive cumulative run ceiling."""
|
||
|
||
def __init__(self):
|
||
self.state = SimpleNamespace(n_steps=1)
|
||
self.history = CumulativeHistory()
|
||
self.limits = []
|
||
|
||
async def run(self, max_steps, on_step_start=None):
|
||
self.limits.append(max_steps)
|
||
while self.state.n_steps <= max_steps:
|
||
if on_step_start:
|
||
await on_step_start(self)
|
||
self.history.count += 1
|
||
self.state.n_steps += 1
|
||
return self.history
|
||
|
||
|
||
async def test_same_agent_resumes_with_incremental_budget_instead_of_recounting_history(context):
|
||
agent = CumulativeAgent()
|
||
session = runner.LiveSession(agent=agent, browser=None)
|
||
first = await runner._run_agent(context[0], session, agent, max_steps=2, keep_session=True)
|
||
second = await runner._run_agent(context[0], session, agent, max_steps=3, keep_session=True)
|
||
assert agent.limits == [2, 5]
|
||
assert first["history"]["steps_this_run"] == 2
|
||
assert second["history"]["steps_this_run"] == 3
|
||
assert second["history"]["steps"] == 5 # Retained as explicitly cumulative history.
|
||
|
||
|
||
async def test_attempt_count_does_not_include_a_non_step_history_sentinel(context):
|
||
agent = CumulativeAgent()
|
||
original_run = agent.run
|
||
|
||
async def with_sentinel(max_steps, on_step_start=None):
|
||
history = await original_run(max_steps, on_step_start=on_step_start)
|
||
history.count += 1
|
||
return history
|
||
|
||
agent.run = with_sentinel
|
||
result = await runner._run_agent(context[0], runner.LiveSession(agent, None), agent, max_steps=1, keep_session=True)
|
||
assert result["history"]["steps"] == 2
|
||
assert result["history"]["steps_this_run"] == 1
|
||
|
||
|
||
async def test_new_agent_screenshot_sequence_cannot_overwrite_previous_evidence(tmp_path, monkeypatch):
|
||
(tmp_path / "step_001.png").write_bytes(b"first run")
|
||
rows = []
|
||
monkeypatch.setattr(runner, "append_progress", lambda *args, **kwargs: rows.append(args))
|
||
observation = SimpleNamespace(screenshot=base64.b64encode(b"second run").decode(), url=URL_A)
|
||
output = SimpleNamespace(next_goal="观察页面", evaluation_previous_goal="", action=[])
|
||
callback = runner._make_step_callback("same_task", tmp_path)
|
||
await callback(observation, output, 1)
|
||
assert (tmp_path / "step_001.png").read_bytes() == b"first run"
|
||
assert (tmp_path / "step_002.png").read_bytes() == b"second run"
|
||
assert rows[0][1] == "步骤 2"
|
||
|
||
|
||
async def test_budget_checkpoint_is_not_charged_again_by_finally(context):
|
||
task_id, directory = context
|
||
agent = CumulativeAgent()
|
||
session = runner.LiveSession(agent, None)
|
||
await runner._run_agent(task_id, session, agent, max_steps=2, keep_session=True)
|
||
first = json.loads((directory / "browser-budget.json").read_text())
|
||
assert first["total_steps_used"] == first["last_run_steps"] == 2
|
||
await runner._run_agent(task_id, session, agent, max_steps=3, keep_session=True)
|
||
second = json.loads((directory / "browser-budget.json").read_text())
|
||
assert second["total_steps_used"] == 5 and second["last_run_steps"] == 3
|
||
assert first["last_run_id"] != second["last_run_id"]
|
||
|
||
|
||
@pytest.mark.parametrize("failure", [RuntimeError("fixture browser failure"), asyncio.CancelledError()])
|
||
async def test_failed_or_cancelled_inflight_step_is_persisted_before_counter_advances(context, failure):
|
||
task_id, directory = context
|
||
agent = CumulativeAgent()
|
||
|
||
async def fail_mid_step(max_steps, on_step_start=None):
|
||
await on_step_start(agent)
|
||
during = json.loads((directory / "browser-budget.json").read_text())
|
||
assert during["total_steps_used"] == 1
|
||
raise failure
|
||
|
||
agent.run = fail_mid_step
|
||
with pytest.raises(type(failure)):
|
||
await runner._run_agent(task_id, runner.LiveSession(agent, None), agent, max_steps=3)
|
||
after = json.loads((directory / "browser-budget.json").read_text())
|
||
assert after["total_steps_used"] == after["last_run_steps"] == 1
|
||
assert agent.state.n_steps == 1
|
||
|
||
|
||
async def test_new_agent_uses_remaining_task_budget_and_keeps_legacy_floor(context):
|
||
task_id, directory = context
|
||
agent = CumulativeAgent()
|
||
result = await runner._run_agent(
|
||
task_id, runner.LiveSession(agent, None), agent, max_steps=40,
|
||
keep_session=True, budget_steps_used=58,
|
||
)
|
||
assert agent.limits == [2]
|
||
assert result["history"]["steps_this_run"] == 2
|
||
record = json.loads((directory / "browser-budget.json").read_text())
|
||
assert record["total_steps_used"] == 60
|
||
another = CumulativeAgent()
|
||
with pytest.raises(RuntimeError, match="额度已用完"):
|
||
await runner._run_agent(task_id, runner.LiveSession(another, None), another, max_steps=2, keep_session=True)
|
||
assert not another.limits
|
||
assert runner.read_browser_budget(task_id)["total_steps_used"] == 60
|
||
|
||
|
||
async def test_damaged_budget_ledger_is_not_reset(context):
|
||
task_id, directory = context
|
||
path = directory / "browser-budget.json"
|
||
path.write_text('{"total_steps_used":"bad","last_run_steps":0}')
|
||
agent = CumulativeAgent()
|
||
with pytest.raises(RuntimeError, match="台账无法核对"):
|
||
await runner._run_agent(task_id, runner.LiveSession(agent, None), agent, max_steps=2)
|
||
assert not agent.limits
|
||
assert json.loads(path.read_text())["total_steps_used"] == "bad"
|