assistant/apps/server/tests/test_discovery.py

185 lines
13 KiB
Python

"""Renovation rules and frozen search orchestration; no platform/model traffic."""
from __future__ import annotations
import copy
import sqlite3
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from apps.server.db import Store, StoreConflict
from apps.server.discovery import DiscoveryService, select_results
from apps.server.learning_checks import output_assertion_checks
from apps.server.tests.test_learning import client, initialize, task
from apps.server.tests.test_postgres import pg_client
FIELDS = [{"name": "title", "selector": ".title", "source": "text"}, {"name": "author", "selector": ".author", "source": "text"}, {"name": "url", "selector": "a", "source": "href", "required": True}]
EXTRACT = {"kind": "extract", "selector": '[data-testid="result"]', "output_name": "search_results", "fields": FIELDS, "limit": 20}
BASE = "http://127.0.0.1:8810"
def output(items):
return {"items": items, "count": len(items), "truncated": True, "source_url": BASE + "/search", "coverage": "bounded" if items else "empty"}
def item(title, letter="a", *, author="作者", url=None):
return {"title": title, "author": author, "url": url or BASE + "/explore/" + letter * 24}
def search_candidate(client, items):
created = task(client)
prefix = "/api/learning/tasks/" + created["id"]
worker = client.worker_reference
old_capture, old_call = worker.capture, worker.call
def capture(task_id):
value = old_capture(task_id)
for element in value["elements"]:
if element["selector"] == '[data-testid="search-input"]':
element["search_input"] = True
return value
worker.capture = capture
async def call(method, params):
result = await old_call(method, params)
if method == "action" and params["kind"] == "extract":
result["result"] = output(items)
if method == "verify":
result["outputs"]["search_results"] = output(items)
return result
worker.call = call
client.post(prefix + "/browser/open")
client.post(prefix + "/browser/ready", json={"identity_label": "测试账号"})
client.post(prefix + "/browser/capture", json={})
for action in ({"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "keyword"}, {"kind": "press", "selector": '[data-testid="search-input"]', "key": "Enter"}, {"kind": "wait", "selector": '[data-testid="result"]', "state": "visible", "min_count": 1}, EXTRACT):
result = client.post(prefix + "/browser/action", json=action)
assert result.status_code == 200, result.text
candidate = client.post(prefix + "/candidates", json={}).json()
assert client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()["status"] == "passed"
return created, candidate
@pytest.fixture(params=["client", "pg_client"])
def discovery_client(request):
return request.getfixturevalue(request.param)
def test_discovery_persists_frozen_provenance_owner_rules_and_conditions(discovery_client):
client = discovery_client
initialize(client)
created, candidate = search_candidate(client, [item("准备装修求推荐靠谱公司", "a"), item("新房装修实景案例", "b"), item(None, "c"), item("装修求助工长", "d")])
result = client.post("/api/discovery/runs", json={"search_candidate_id": candidate["id"], "keyword": "装修", "region": "", "max_selected": 2})
assert result.status_code == 201, result.text
run = result.json()
assert run["status"] == "completed" and run["analysis_mode"] == "rules" and run["model_connected"] is False
assert run["candidate_digest"] == candidate["digest"] and run["search_task_id"] == created["id"] and run["validation_id"]
assert run["conditions"] == {"service": "装修服务", "target_customer": "有装修需求的业主", "region": ""}
assert [row["note_title"] for row in run["selected"]] == ["准备装修求推荐靠谱公司", "装修求助工长"]
assert next(row for row in run["results"] if row["title"] == "新房装修实景案例")["classification"] == "peer_case"
assert run["evidence"] and all(row["uncertainties"] for row in run["results"])
assert client.get("/api/discovery/runs/" + run["id"]).json() == run
assert client.get("/api/discovery/runs").json()["items"][0] == run
frozen = client.get("/api/learning/candidates/" + candidate["id"]).json()
assert frozen["digest"] == candidate["digest"] and frozen["manifest"] == candidate["manifest"]
service = client.app_reference.state.discovery
owner = service.store.one("SELECT owner_id FROM discovery_runs WHERE id=$1", (run["id"],))["owner_id"]
with pytest.raises(HTTPException) as error:
service.get(run["id"], owner + "_other")
assert error.value.status_code == 404 and service.list(owner + "_other") == []
for query in ("UPDATE discovery_runs SET data_json='{}' WHERE id=$1", "DELETE FROM discovery_runs WHERE id=$1"):
with pytest.raises((StoreConflict, sqlite3.IntegrityError)):
service.store.execute(query, (run["id"],))
if service.store.backend == "postgresql":
with pytest.raises(StoreConflict):
# Include dependent reading facts so PostgreSQL reaches the
# immutable trigger instead of rejecting the FK dependency first.
service.store.execute("TRUNCATE discovery_runs CASCADE")
else:
assert Store(service.store.home).one("SELECT id FROM discovery_runs WHERE id=$1", (run["id"],))["id"] == run["id"]
with TestClient(client.app_reference, base_url=BASE) as anonymous:
assert anonymous.get("/api/discovery/runs").status_code == 401
assert anonymous.post("/api/discovery/runs", json={"search_candidate_id": candidate["id"], "keyword": "装修"}).status_code in {401, 403}
def test_discovery_failure_has_safe_persistent_record_and_strict_nonempty(discovery_client):
client = discovery_client
initialize(client)
sample_items = [item("准备装修求推荐", "a")]
created, candidate = search_candidate(client, sample_items)
prefix = "/api/learning/candidates/" + candidate["id"]
valid = client.post(prefix + "/verify", json={"expected_outputs": {"search_results": {"state": "nonempty"}}})
assert valid.status_code == 200 and valid.json()["status"] == "passed"
for invalid in ({"state": "nonempty", "extra": True}, {"state": "other"}):
assert client.post(prefix + "/verify", json={"expected_outputs": {"search_results": invalid}}).status_code == 422
sample_items.clear()
empty = client.post("/api/discovery/runs", json={"search_candidate_id": candidate["id"], "keyword": "装修"}).json()
assert empty["status"] == "search_failed" and empty["validation_id"] and not empty["selected"]
assert client.get("/api/discovery/runs/" + empty["id"]).json() == empty
sample_items.append(item("准备装修求推荐", "a"))
previous = client.worker_reference.call
async def foreign_source(method, params):
value = await previous(method, params)
if method == "verify":
value["outputs"]["search_results"]["source_url"] = "https://example.test/search?token=private-fixture"
return value
client.worker_reference.call = foreign_source
foreign = client.post("/api/discovery/runs", json={"search_candidate_id": candidate["id"], "keyword": "装修"}).json()
assert foreign["status"] == "search_failed" and foreign["error_codes"] == ["DISCOVERY_SOURCE_MISMATCH"]
assert "private-fixture" not in str(foreign)
client.worker_reference.call = previous
before = len([call for call in client.worker_reference.calls if call[0] == "verify"])
client.post("/api/learning/tasks/" + created["id"] + "/browser/close")
failed = client.post("/api/discovery/runs", json={"search_candidate_id": candidate["id"], "keyword": "装修"}).json()
assert failed["status"] == "failed" and failed["validation_id"] is None and failed["results"] == []
assert failed["error_codes"] == ["browser_session_required"] and failed["errors"]
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == before
assert client.get("/api/discovery/runs/" + failed["id"]).json() == failed
# Candidate shape/source mismatches never cause browser execution.
service = client.app_reference.state.discovery
for platform, entry in (("other", BASE), ("douyin", BASE), ("demo", "https://example.test"), ("xiaohongshu", BASE)):
altered = copy.deepcopy(candidate)
altered["manifest"].update(platform=platform, entry_url=entry)
assert not service.supported_candidate(altered)
calls = len([call for call in client.worker_reference.calls if call[0] == "verify"])
run_count = client.get("/api/discovery/runs").json()["total"]
for invalid in ({"keyword": " "}, {"max_selected": True}, {"max_selected": 4}, {"region": None}, {"service": "二手车"}, {"target_customer": "二手车买家"}, {"service": "二手车", "target_customer": "二手车买家"}):
assert client.post("/api/discovery/runs", json={"search_candidate_id": candidate["id"], "keyword": "装修", **invalid}).status_code == 422
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == calls
assert client.get("/api/discovery/runs").json()["total"] == run_count
def test_discovery_nonempty_never_means_semantic_or_empty_success():
contract = {**EXTRACT, "limit": 20}
def checks(value, current=contract):
return output_assertion_checks({"search_results": value}, {"search_results": {"state": "nonempty"}}, {"search_results": current})
assert all(row["passed"] for row in checks(output([item(None, author=None)])))
for changed in (output([]), {**output([item("装修")]), "count": 2}, {**output([item("装修")]), "items": [{"title": "装修", "author": None, "url": None}]}, {**output([item("装修")]), "unexpected": True}):
assert not all(row["passed"] for row in checks(changed))
assert not all(row["passed"] for row in checks([{"text": "装修"}], {"kind": "read"}))
def test_discovery_rules_are_source_bound_deduplicated_and_do_not_infer_owners():
rows = [item("准备装修求推荐装修公司", "a"), item("装修完工求推荐沙发", "b"), item("装修真好看", "c"), item("好看", "d"), item("相同装修标题", "e"), item("相同装修标题", "f"), item("不应保存无效来源", "g", url="https://evil.test/explore/" + "g" * 24), item("新房装修", "a", url=BASE + "/search_result/" + "a" * 24 + "?token=private-fixture")]
results, selected = select_results(rows, BASE + "/search", 3)
assert len([row for row in results if row["note_id"] == "a" * 24]) == 1
assert next(row for row in results if row["note_id"] == "a" * 24)["classification"] == "ambiguous"
assert all(row["classification"] == "ambiguous" and not row["selected"] for row in results if row["title"] == "相同装修标题")
assert next(row for row in results if row["title"] == "装修完工求推荐沙发")["classification"] == "peer_case"
assert next(row for row in results if row["title"] == "装修真好看")["classification"] == "related_content"
assert next(row for row in results if row["title"] == "好看")["classification"] == "unrelated"
assert next(row for row in results if row["title"] == "不应保存无效来源")["url"] is None
assert all("?" not in row["note_url"] for row in selected)
assert all(row["uncertainties"] and "真实装修需求尚未核实" in row["uncertainties"][0] for row in results)
masked, chosen = select_results([item("装修求助[手机号已隐藏]", "a")], BASE, 1)
assert not chosen and masked[0]["classification"] == "ambiguous" and masked[0]["url"]
missing, chosen = select_results([item(None, "a"), item(" ", "b")], BASE, 2)
assert not chosen and all(row["classification"] == "ambiguous" and row["url"] for row in missing)
capped, chosen = select_results([item("准备装修求推荐", "a"), item("新房装修实景案例", "b"), item("装修经验分享材料流程", "c")], BASE, 1)
assert [row["note_title"] for row in chosen] == ["准备装修求推荐"]
assert all(not row["selected"] and any("达到本次保留数量上限" in reason for reason in row["reasons"]) for row in capped[1:])
assert not any("数量上限" in reason for reason in capped[0]["reasons"])
for word in ("完工", "竣工", "交付", "完装", "装完", "入住"):
for negative in ("没", "未"):
denied, chosen = select_results([item(f"装修还{negative}{word},求推荐工长", "a")], BASE, 1)
assert denied[0]["classification"] == "consumer_help" and chosen
assert not any("已完工或入住" in reason for reason in denied[0]["reasons"])