assistant/tests/test_candidate_targets.py

252 lines
14 KiB
Python
Raw Permalink 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.

"""Explicit result scopes and person grouping, without model or platform calls."""
from __future__ import annotations
from copy import deepcopy
import json
import httpx
import pytest
from pydantic import ValidationError
from backend.prospecting import cards, people
from backend.prospecting import dialogue
from backend.prospecting.contracts import ProspectingBrief, TARGET_QUESTION
from backend.prospecting.prompts import browser_prompt
URL_A = "https://www.xiaohongshu.com/user/profile/aaaaaaaaaaaaaaaaaaaaaaaa"
URL_B = "https://www.xiaohongshu.com/user/profile/bbbbbbbbbbbbbbbbbbbbbbbb"
def task(**inputs):
return {"id": "candidate-fixture", "status": "collecting", "current_step": "brief",
"inputs": {**ProspectingBrief(user_notes="找杭州装修客户").model_dump(), "_goal_version": 1, **inputs},
"artifacts": {"plan": {"missing": []}, "sources": [], "leads": [], "profiles": []}}
def add_signal(row, sid, url=None, author="同名", **lead_updates):
row["artifacts"]["sources"].append({"source_id": sid, "source_type": "comment", "author": author,
**({"author_profile_url": url} if url else {})})
row["artifacts"]["leads"].append({"source_id": sid, "source_type": "comment", "author": author,
"quote": "我想装修", "decision": "keep", "status": "pending", **lead_updates})
def test_discovery_requires_explicit_endpoint_without_reading_quotas():
brief = ProspectingBrief(user_notes="我是做装修的,找杭州客户")
assert brief.missing() == [TARGET_QUESTION]
assert brief.max_notes is None and brief.max_comments_per_note is None
assert brief.model_copy(update={"target_candidates": 8}).missing() == []
@pytest.mark.parametrize("value", [0, -1, 1.5, True, "20"])
def test_target_is_positive_integer_without_boolean_or_numeric_string_coercion(value):
with pytest.raises(ValidationError):
ProspectingBrief(target_candidates=value)
@pytest.mark.parametrize("kwargs", [
{"intent": "research"}, {"intent": "inspect", "subject_urls": ["https://www.xiaohongshu.com/explore/fixture"]},
{"intent": "export"}, {"intent": "follow", "action_scope": "已核对的候选"},
{"intent": "message", "action_scope": "选中的人", "message_text": "你好"},
{"discovery_scope": "route_pass"}, {"max_notes": 5},
{"subject_urls": ["https://www.xiaohongshu.com/explore/fixture"]},
])
def test_already_finite_tasks_do_not_require_candidate_number(kwargs):
assert TARGET_QUESTION not in ProspectingBrief(user_notes="明确有限任务", **kwargs).missing()
def test_comment_limit_alone_is_not_a_task_endpoint():
assert ProspectingBrief(user_notes="找客户", max_comments_per_note=3).missing() == [TARGET_QUESTION]
def test_quantity_card_is_numeric_and_rejects_fractional_or_missing_answers():
row = task()
card = cards.clarification(row)
assert len(card["fields"]) == 1
field = card["fields"][0]
assert (field["key"], field["input_kind"], field["min"], field["step"]) == ("target_candidates", "number", 1, 1)
assert "10" in cards.validate_answers(row, card["id"], 1, {field["id"]: "10"})
for value in ["0", "-1", "1.5", "2e2", "", "不限"]:
with pytest.raises(ValueError):
cards.validate_answers(row, card["id"], 1, {field["id"]: value})
def test_quantity_is_not_asked_again_when_already_provided_even_if_model_missing_is_stale():
row = task(target_candidates=10)
row["artifacts"]["plan"]["missing"] = [TARGET_QUESTION]
assert cards.clarification(row) is None
def test_number_and_other_needed_question_are_combined_without_hiding_target():
row = task()
row["artifacts"]["plan"]["missing"] = ["你经营什么服务?"]
card = cards.clarification(row)
assert [field["key"] for field in card["fields"]] == ["context", "target_candidates"]
def test_same_source_author_is_one_candidate_with_all_evidence_and_stable_query_free_key():
row = task(target_candidates=2)
add_signal(row, "note", URL_A + "?source=one")
add_signal(row, "comment", URL_A + "?source=two")
add_signal(row, "other", URL_B, author="同名")
before = deepcopy(row)
grouped = people.group_candidates(row)
assert grouped["counts"] == {"candidates": 2, "signals": 3, "unresolved_sources": 0, "verified_accounts": 0}
assert len(grouped["groups"]) == 2
assert grouped["groups"][0]["source_ids"] == ["note", "comment"]
assert len(grouped["groups"][0]["evidence"]) == 2
assert "?" not in grouped["groups"][0]["person_key"]
assert row == before
assert people.target_reached(row)
def test_same_nickname_unknown_identity_does_not_merge_or_count_toward_target():
row = task(target_candidates=1)
add_signal(row, "unknown1", author="同名", account_id="aaaaaaaaaaaaaaaaaaaaaaaa", profile_url=URL_A)
add_signal(row, "unknown2", author="同名", author_profile_url=URL_A)
add_signal(row, "forged-host", "https://evil.example/user/profile/aaaaaaaaaaaaaaaaaaaaaaaa")
grouped = people.group_candidates(row)
assert len(grouped["groups"]) == 3
assert grouped["counts"]["candidates"] == 0 and grouped["counts"]["unresolved_sources"] == 3
assert not people.target_reached(row)
def test_verified_profile_matches_source_and_never_inherits_to_other_source():
row = task(target_candidates=3)
add_signal(row, "known", URL_A)
add_signal(row, "unknown")
add_signal(row, "mismatch", URL_B)
row["artifacts"]["profiles"] = [
{"source_id": "known", "profile_url": URL_A, "xiaohongshu_id": "visible_number", "observed_at": "2026-10-29T10:00:00+08:00", "evidence": "matched_source_author_link_and_visible_profile_header"},
{"source_id": "mismatch", "profile_url": URL_A, "xiaohongshu_id": "wrong_number", "observed_at": "2026-10-29T10:00:00+08:00", "evidence": "matched_source_author_link_and_visible_profile_header"},
]
grouped = people.group_candidates(row)
assert grouped["counts"] == {"candidates": 2, "signals": 3, "unresolved_sources": 1, "verified_accounts": 1}
assert grouped["groups"][0]["identity_status"] == "profile_verified"
assert grouped["groups"][1]["identity_status"] == "unresolved"
assert grouped["groups"][2]["account_verified"] is False
assert people.target_progress(row)["remaining"] == 1
def test_duplicated_lead_row_or_excluded_signal_never_inflates_people():
row = task(target_candidates=2)
add_signal(row, "a", URL_A)
row["artifacts"]["leads"].append(deepcopy(row["artifacts"]["leads"][0]))
add_signal(row, "b", URL_B, decision="exclude")
assert people.group_candidates(row)["counts"]["signals"] == 1
assert people.candidate_count(row) == 1
assert not people.target_reached(row)
def test_prompt_tells_person_target_apart_from_note_and_comment_scopes():
prompt = browser_prompt(ProspectingBrief(user_notes="找装修的人", target_candidates=10), [])
assert "10位去重候选" in prompt
assert "不能逐条当不同客户" in prompt
assert "最多阅读10篇" not in prompt
assert "总步数配额" in prompt
def test_route_pass_is_visible_as_finite_scope_without_fake_target():
progress = people.target_progress(task(discovery_scope="route_pass"))
assert progress["scope"] == "route_pass" and progress["target"] is None and progress["reached"] is False
assert people.target_progress(task(max_notes=5))["scope"] == "route_pass"
assert people.target_progress(task(subject_urls=["https://www.xiaohongshu.com/explore/a"]))["scope"] == "route_pass"
async def interpreted(monkeypatch, message, *, current=None, history=None, **model_scope):
"""Exercise the HTTP contract with an invented-model-output fixture."""
cfg = {"provider": "deepseek", "model": "scope-test", "api_key": "placeholder", "base_url": "https://model.invalid/v1"}
brief = ProspectingBrief(target_customer="杭州准备装修的人", **model_scope)
plan = {"brief": brief.model_dump(), "summary": "模型计划", "stages": ["搜索", "阅读"], "missing": []}
client_class = httpx.AsyncClient
monkeypatch.setattr(dialogue, "get_llm_config", lambda: cfg)
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"choices": [{"message": {"content": json.dumps(plan, ensure_ascii=False)}}]}))
monkeypatch.setattr(dialogue.httpx, "AsyncClient", lambda **kwargs: client_class(transport=transport, **kwargs))
return await dialogue.interpret_message(message, current or ProspectingBrief(), history or [])
@pytest.mark.parametrize("scope", [{"target_candidates": 10}, {"discovery_scope": "route_pass"}, {"max_notes": 5}])
async def test_interpretation_does_not_invent_people_or_a_finite_reading_scope(monkeypatch, scope):
plan = await interpreted(monkeypatch, "我是做装修的,帮我找客户", **scope)
assert plan.brief.target_candidates is None and plan.brief.max_notes is None
assert plan.brief.discovery_scope == "candidate_count"
assert plan.missing == [TARGET_QUESTION]
@pytest.mark.parametrize("message", ["评论和回复不限,先找找", "阅读量不限制,帮找客户", "不是先做一轮,帮我找客户", "参考示例如下:\n> 先做一轮\n帮我找客户"])
async def test_vague_or_quoted_routes_do_not_replace_the_quantity_question(monkeypatch, message):
plan = await interpreted(monkeypatch, message, discovery_scope="route_pass")
assert plan.brief.discovery_scope == "candidate_count"
assert plan.missing == [TARGET_QUESTION]
@pytest.mark.parametrize("message", ["先找8位候选", "帮我整理8个客户", "我要8人", "候选人数:8"])
async def test_explicit_people_quantity_wins_over_a_missing_or_wrong_model_number(monkeypatch, message):
plan = await interpreted(monkeypatch, message, target_candidates=20, max_notes=8)
assert plan.brief.target_candidates == 8
assert plan.brief.max_notes is None
assert plan.missing == []
async def test_a_reading_quantity_cannot_be_reinterpreted_as_people(monkeypatch):
plan = await interpreted(monkeypatch, "我是做装修的,先看5篇笔记", target_candidates=5)
assert plan.brief.target_candidates is None and plan.brief.max_notes == 5
assert plan.missing == []
async def test_an_account_note_count_is_not_an_instruction_to_limit_reading(monkeypatch):
plan = await interpreted(monkeypatch, "我的账号有5篇笔记,帮我找装修客户", max_notes=5)
assert plan.brief.max_notes is None and plan.missing == [TARGET_QUESTION]
async def test_an_explicit_keyword_pass_is_a_finite_scope_without_a_people_default(monkeypatch):
plan = await interpreted(monkeypatch, "按这些关键词先做一轮", target_candidates=10)
assert plan.brief.target_candidates is None
assert plan.brief.discovery_scope == "route_pass" and plan.missing == []
@pytest.mark.parametrize("message", ["预算300元,找装修客户", "我是做iPhone15配件的,找客户", "客户是20人以上的公司,帮我找客户", "找10到20位客户", "我没说10位客户,帮我找客户", "这是示例\n```text\n找10位客户\n```\n帮我找客户"])
async def test_unrelated_negated_quoted_or_range_numbers_do_not_authorize_model_people_count(monkeypatch, message):
plan = await interpreted(monkeypatch, message, target_candidates=20)
assert plan.brief.target_candidates is None and plan.missing == [TARGET_QUESTION]
async def test_existing_confirmed_goal_is_inherited_when_only_business_conditions_change(monkeypatch):
current = ProspectingBrief(target_customer="装修客户", target_candidates=12)
plan = await interpreted(monkeypatch, "地区改为上海", current=current, target_candidates=20)
assert plan.brief.target_candidates == 12 and plan.missing == []
@pytest.mark.parametrize("message", ["改成5个", "目标改为5", "5"])
async def test_explicit_short_revision_changes_an_existing_people_goal(monkeypatch, message):
current = ProspectingBrief(target_customer="装修客户", target_candidates=12)
plan = await interpreted(monkeypatch, message, current=current, target_candidates=5)
assert plan.brief.target_candidates == 5 and plan.missing == []
@pytest.mark.parametrize("message", ["7", "先要7位", TARGET_QUESTION + ":7"])
async def test_numeric_quantity_confirmation_is_grounded_without_erasing_business_context(monkeypatch, message):
current = ProspectingBrief(target_customer="杭州准备装修的人")
plan = await interpreted(monkeypatch, message, current=current)
assert plan.brief.target_candidates == 7 and plan.missing == []
assert plan.brief.target_customer == current.target_customer
@pytest.mark.parametrize("current", [ProspectingBrief(target_customer="装修客户", max_notes=4), ProspectingBrief(target_customer="装修客户", discovery_scope="route_pass")])
async def test_a_confirmed_finite_scope_is_inherited_without_new_people_question(monkeypatch, current):
plan = await interpreted(monkeypatch, "只把地区改成杭州", current=current)
assert plan.brief.max_notes == current.max_notes
assert plan.brief.discovery_scope == current.discovery_scope and plan.missing == []
async def test_explicit_people_quantity_in_user_history_can_ground_the_same_model_value(monkeypatch):
history = [{"role": "user", "text": "我是做装修的,找9位客户"}]
plan = await interpreted(monkeypatch, "地区杭州", history=history, target_candidates=9)
assert plan.brief.target_candidates == 9 and plan.missing == []
async def test_an_older_historical_quantity_cannot_override_a_confirmed_current_goal(monkeypatch):
current = ProspectingBrief(target_customer="装修客户", target_candidates=6)
history = [{"role": "user", "text": "先找20位客户"}]
plan = await interpreted(monkeypatch, "地区杭州", current=current, history=history, target_candidates=20)
assert plan.brief.target_candidates == 6 and plan.missing == []