assistant/apps/server/tests/test_reading.py

250 lines
18 KiB
Python

"""Single immutable selection handoff; fake browser and isolated SQLite/PG stores."""
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.reading import detail_contract
from apps.server.tests.test_discovery import BASE, item, search_candidate
from apps.server.tests.test_learning import client, initialize
from apps.server.tests.test_postgres import pg_client
NOTE_ID = "a" * 24
TITLE = "准备装修求推荐靠谱公司"
NOTE_URL = BASE + "/explore/" + NOTE_ID
DETAIL = {"kind": "extract", "selector": "#note", "output_name": "note_detail", "limit": 1,
"fields": [{"name": name, "selector": "." + name, "source": "text", "required": True} for name in ("title", "body", "author")]}
COMMENTS = {"kind": "extract", "selector": "#comments", "row_selector": ".comment", "output_name": "comments", "limit": 5, "complete_only": True,
"fields": [{"name": "comment_id", "source": "attribute", "attribute": "id", "required": True}, {"name": "author", "selector": ".author", "source": "text", "required": True}, {"name": "body", "selector": ".body", "source": "text", "required": True}],
"batch": {"scroll_selector": "#comments", "pixels": 320, "max_batches": 3, "dedupe_field": "comment_id"}}
def detail_output():
return {"items": [{"title": TITLE, "body": "准备装修,希望了解材料和施工流程。🦁", "author": "合成作者"}], "count": 1, "truncated": True, "coverage": "limit_reached", "source_url": NOTE_URL}
def comment_output():
return {"items": [{"comment_id": "comment-a", "author": "合成评论者", "body": "也在了解装修方案"}], "count": 1, "truncated": True, "coverage": "bounded", "source_url": NOTE_URL,
"skipped_incomplete_count": 0, "batch_summary": {"batches_read": 1, "duplicates_removed": 0, "stop_reason": "boundary"}}
def learned_detail(client, *, seek=False, content=False):
response = client.post("/api/learning/tasks", json={"name": "单篇正文和首层评论", "platform": "demo", "entry_url": BASE + "/api/learning/demo-page", "goal": "单篇阅读", "account_key": "detail-account", "parameters": {"keyword": "装修", "note_title": TITLE}})
assert response.status_code == 201, response.text
task = response.json()
prefix = "/api/learning/tasks/" + task["id"]
worker = client.worker_reference
previous_capture, previous_call = worker.capture, worker.call
state = {"mode": "normal"}
def capture(task_id):
value = previous_capture(task_id)
value["elements"].extend({"selector": selector, "tag": "section", "kind": "read", "label": "合成内容", "unique": True, "sensitive": False, "in_viewport": True,
"scroll_y": {"can_scroll": False, "scroll_top": 0, "scroll_height": 500, "client_height": 500}} for selector in ("#note", "#comments"))
if seek:
value["elements"].append({"selector": "#seek", "tag": "div", "kind": "read", "label": "结果滚动区", "unique": True, "sensitive": False, "scroll_y": {"can_scroll": True}})
next(element for element in value["elements"] if element["selector"] == '[data-testid="detail-link"]')["collection"] = True
if content:
next(element for element in value["elements"] if element["selector"] == "#comments")["read_hints"] = [{"selector": ".picture", "tag": "img", "label": "图片元素(未解释)", "source": "media", "media_kind": "image", "loaded": True, "unique": True, "count": 1, "in_viewport": True, "link": False}]
return value
worker.capture = capture
async def call(method, params):
value = await previous_call(method, params)
if method == "action" and params["kind"] == "extract":
value["result"] = detail_output() if params["output_name"] == "note_detail" else comment_output()
if params["output_name"] == "comments":
value["batch_evidence"] = [{"batch_index": 1, "added_count": 1, "duplicate_count": 0, "capture": worker.capture(params["task_id"])}]
if content:
value["result"]["items"][0]["body"] = {"kind": "text", "text": "也在了解装修方案", "media": [], "media_truncated": False}
if method == "action" and params.get("until_target"):
value["result"] = {"scrolls": 1, "movements": [{"before": 0, "after": 320, "scroll_height": 1000, "client_height": 500}], "target_reached": True, "stop_reason": "target_reached"}
if method == "verify" and params.get("note_binding"):
detail, comments = detail_output(), comment_output()
proof = {"note_id": NOTE_ID, "href_checked": True, "url_checked": True, "source_url": NOTE_URL, "title": TITLE}
if state["mode"] == "missing_comments":
comments["items"][0].pop("body")
elif state["mode"] == "empty_comments":
comments.update(items=[], count=0, coverage="empty")
elif state["mode"] == "wrong_id":
proof["source_url"] = BASE + "/explore/" + "b" * 24
elif state["mode"] == "wrong_title":
detail["items"][0]["title"] = proof["title"] = "同一笔记出现新的标题"
elif state["mode"] == "wrong_comments_source":
comments["source_url"] = BASE + "/explore/" + "b" * 24
elif state["mode"] == "no_proof":
proof = None
value.update(outputs={"note_detail": detail, "comments": comments}, note_binding_result=proof, error_codes=[])
if seek:
value["verify_budget_ms"] = 95000
value["seek_results"] = [{"step_position": 4, "scrolls": 1, "movements": [{"before": 0, "after": 320, "scroll_height": 1000, "client_height": 500}], "target_reached": True, "stop_reason": "target_reached"}]
if state["mode"] == "seek_boundary":
value.update(passed=False, outputs={}, errors=["fixture seek stopped"], error_codes=["SCROLL_TARGET_BOUNDARY"], note_binding_result={"note_id": NOTE_ID, "href_checked": False, "url_checked": False, "source_url": None, "title": None})
value["seek_results"][0].update(target_reached=False, stop_reason="boundary")
elif state["mode"] == "bad_seek_summary":
value["seek_results"][0]["scrolls"] = 11
elif state["mode"] == "bad_budget":
value["verify_budget_ms"] = 90000
if content:
mode = state["mode"]
text = None if mode == "media" else "也在了解装修方案"
media = [{"kind": "image", "selector": ".picture"}] if mode in {"media", "mixed"} else []
value["outputs"]["comments"]["items"][0]["body"] = {"kind": "media" if mode == "media" else "mixed" if mode == "mixed" else "text", "text": text, "media": media, "media_truncated": False}
value["step_timings"] = [{"step_position": index, "kind": step["kind"], "status": "passed", "duration_ms": 10, "phase_ms": {"execute": 4, "capture_after": 6}} for index, step in enumerate(params["steps"], 1)]
value["verify_timing"] = {"duration_ms": 120, "entry_ms": 20, "close_ms": None}
if mode == "bad_timing":
value["step_timings"][0]["phase_ms"]["private-fixture"] = 1
return value
worker.call = call
assert client.post(prefix + "/browser/open").status_code == 200
assert client.post(prefix + "/browser/ready", json={"identity_label": "合成账号"}).status_code == 200
assert client.post(prefix + "/browser/capture", json={}).status_code == 200
configured_comments = copy.deepcopy(COMMENTS)
if content:
configured_comments["fields"][-1] = {"name": "body", "source": "content", "text_selector": ".body", "media_selector": ".picture", "max_media": 4, "required": True}
actions = [{"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}, {"kind": "click", "selector": '[data-testid="detail-link"]', "target": {"field": "text", "parameter": "note_title"}},
{"kind": "wait", "selector": "#note", "state": "visible"}, DETAIL, configured_comments]
if seek:
actions.insert(3, {"kind": "scroll", "selector": "#seek", "direction": "down", "pixels": 320, "max_scrolls": 3, "timeout_ms": 5000, "until_target": {"selector": '[data-testid="detail-link"]', "field": "text", "parameter": "note_title"}})
for action in actions:
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 task, candidate, state
@pytest.fixture(params=["client", "pg_client"])
def reading_client(request):
return request.getfixturevalue(request.param)
def prepare(client):
initialize(client)
_, search = search_candidate(client, [item(TITLE)])
result = client.post("/api/discovery/runs", json={"search_candidate_id": search["id"], "keyword": "准备装修"})
assert result.status_code == 201 and result.json()["status"] == "completed", result.text
run = result.json()
task, candidate, state = learned_detail(client)
return run, task, candidate, state
def test_reading_immutable_handoff_binding_and_owner_history(reading_client):
client = reading_client
run, task, candidate, _ = prepare(client)
endpoint = "/api/discovery/runs/" + run["id"] + "/readings"
store = client.app_reference.state.store
snapshot = store.one("SELECT data_json FROM discovery_runs WHERE id=$1", (run["id"],))
frozen = store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],))
result = client.post(endpoint, json={"detail_candidate_id": candidate["id"], "note_id": NOTE_ID})
assert result.status_code == 201, result.text
reading = result.json()
assert reading["status"] == "completed" and reading["model_connected"] is False
assert reading["note_title"] == TITLE and reading["note_url"] == NOTE_URL and reading["keyword"] == run["keyword"]
assert reading["detail_task_id"] == task["id"] and reading["candidate_digest"] == candidate["digest"]
assert reading["detail"] == detail_output() and reading["comments"] == comment_output()
assert reading["errors"] == reading["error_codes"] == []
assert reading["note_binding_result"]["href_checked"] and reading["note_binding_result"]["url_checked"]
assert client.get("/api/discovery/readings/" + reading["id"]).json() == reading
assert client.get(endpoint).json() == {"items": [reading], "total": 1}
validation = next(value for value in client.get("/api/learning/candidates/" + candidate["id"]).json()["validations"] if value["id"] == reading["validation_id"])
assert validation["mode"] == "exploration" and validation["note_binding"] == {"note_id": NOTE_ID}
assert validation["note_binding_result"] == reading["note_binding_result"]
assert next(value for value in validation["assertion_checks"] if value["name"] == "note_source_binding")["passed"]
verify_call = [params for method, params in client.worker_reference.calls if method == "verify"][-1]
assert verify_call["parameters"] == {"keyword": run["keyword"], "note_title": TITLE}
assert verify_call["note_binding"] == {"note_id": NOTE_ID}
assert verify_call["expected_outputs"] == {"note_detail": {"state": "nonempty"}, "comments": {"state": "nonempty"}}
assert all(client.get(value["screenshot_url"]).status_code == 200 for value in reading["evidence"])
service = client.app_reference.state.reading
owner = store.one("SELECT owner_id FROM reading_runs WHERE id=$1", (reading["id"],))["owner_id"]
with pytest.raises(HTTPException) as error:
service.get(reading["id"], owner + "_other")
assert error.value.status_code == 404
with pytest.raises(HTTPException) as error:
service.list(run["id"], owner + "_other")
assert error.value.status_code == 404
for query in ("UPDATE reading_runs SET data_json='{}' WHERE id=$1", "DELETE FROM reading_runs WHERE id=$1"):
with pytest.raises((sqlite3.IntegrityError, StoreConflict)):
store.execute(query, (reading["id"],))
if store.backend == "postgresql":
with pytest.raises(StoreConflict):
# Newer append-only analyses reference readings. CASCADE reaches
# the database's immutable guards instead of its FK precondition.
store.execute("TRUNCATE reading_runs CASCADE")
else:
assert Store(store.home).one("SELECT id FROM reading_runs WHERE id=$1", (reading["id"],))["id"] == reading["id"]
assert store.one("SELECT data_json FROM discovery_runs WHERE id=$1", (run["id"],)) == snapshot
assert store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],)) == frozen
assert store.one("SELECT version FROM schema_versions WHERE version=6")["version"] == 6
with TestClient(client.app_reference, base_url=BASE) as anonymous:
assert anonymous.get(endpoint).status_code == anonymous.get("/api/discovery/readings/" + reading["id"]).status_code == 401
assert anonymous.post(endpoint, json={"detail_candidate_id": candidate["id"], "note_id": NOTE_ID}).status_code in {401, 403}
def test_reading_partial_mismatch_not_ready_and_input_do_not_fake_success(reading_client):
client = reading_client
run, task, candidate, state = prepare(client)
endpoint = "/api/discovery/runs/" + run["id"] + "/readings"
payload = {"detail_candidate_id": candidate["id"], "note_id": NOTE_ID}
upper = client.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"note_detail": {"state": "nonempty"}, "comments": {"state": "nonempty"}}, "note_binding": {"note_id": NOTE_ID.upper()}})
assert upper.status_code == 200 and upper.json()["status"] == "passed"
assert upper.json()["note_binding"] == {"note_id": NOTE_ID}
for mode in ("missing_comments", "empty_comments"):
state["mode"] = mode
result = client.post(endpoint, json=payload)
assert result.status_code == 201, result.text
partial = result.json()
assert partial["status"] == "partial" and partial["detail"] == detail_output() and partial["evidence"]
assert partial["comments"] is None if mode == "missing_comments" else partial["comments"]["count"] == 0
assert partial["validation_id"] and partial["errors"] and partial["error_codes"]
for mode in ("wrong_id", "wrong_title", "wrong_comments_source", "no_proof"):
state["mode"] = mode
failed = client.post(endpoint, json=payload).json()
assert failed["status"] == "failed" and failed["detail"] is failed["comments"] is None
assert "NOTE_BINDING_MISMATCH" in failed["error_codes"] and failed["validation_id"] and failed["evidence"]
calls = len([call for call in client.worker_reference.calls if call[0] == "verify"])
for changes in ({"note_id": "b" * 24}, {"note_id": "bad"}, {"note_title": "不信任标题"}, {"url": "https://evil.test"}, {"note_id": None}):
assert client.post(endpoint, json={**payload, **changes}).status_code == 422
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == calls
wrong_contract = client.post(endpoint, json={**payload, "detail_candidate_id": run["search_candidate_id"]}).json()
assert wrong_contract["status"] == "failed" and wrong_contract["error_codes"] == ["READING_CONTRACT_INVALID"]
assert wrong_contract["validation_id"] is None
# The generic Verify DTO also enforces the strict closed binding shape, and
# binding cannot be used to turn a search candidate into a detail workflow.
verify_endpoint = "/api/learning/candidates/" + candidate["id"] + "/verify"
for binding in ({}, {"note_id": "bad"}, {"note_id": NOTE_ID, "url": NOTE_URL}):
assert client.post(verify_endpoint, json={"expected_outputs": {"note_detail": {"state": "nonempty"}}, "note_binding": binding}).status_code == 422
assert client.post("/api/learning/candidates/" + run["search_candidate_id"] + "/verify", json={"expected_outputs": {"search_results": {"state": "nonempty"}}, "note_binding": {"note_id": NOTE_ID}}).status_code == 422
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == calls
client.post("/api/learning/tasks/" + task["id"] + "/browser/close")
not_ready = client.post(endpoint, json=payload).json()
assert not_ready["status"] == "failed" and not_ready["validation_id"] is None and not_ready["detail"] is None
assert not_ready["error_codes"] == ["browser_session_required"]
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == calls
assert client.get("/api/discovery/readings/" + not_ready["id"]).json() == not_ready
def test_reading_business_contract_is_exact_without_hardcoded_css():
steps = [{"kind": "fill", "parameter": "keyword"}, {"kind": "press", "key": "Enter"}, {"kind": "wait", "state": "visible", "min_count": 1},
{"kind": "click", "target": {"field": "text", "parameter": "note_title"}}, {"kind": "wait", "state": "visible"}, DETAIL, COMMENTS]
manifest = {"platform": "demo", "entry_url": BASE, "parameters": {"keyword": "装修", "note_title": TITLE}, "steps": copy.deepcopy(steps)}
assert detail_contract(manifest)
changed = copy.deepcopy(manifest)
changed["steps"][5]["selector"] = "#different-observed-container"
assert detail_contract(changed)
for key, value in (("platform", "other"), ("entry_url", "https://example.test")):
assert not detail_contract({**manifest, key: value})
for mutate in (lambda m: m["steps"][5].update(limit=2), lambda m: m["steps"][6].update(allow_empty=True), lambda m: m["steps"][6]["fields"][0].update(required=False), lambda m: m["steps"][3].update(target={"field": "href", "parameter": "note_title"})):
changed = copy.deepcopy(manifest)
mutate(changed)
assert not detail_contract(changed)