252 lines
14 KiB
Python
252 lines
14 KiB
Python
"""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 == []
|