assistant/apps/server/tests/test_analysis_reviews.py

195 lines
14 KiB
Python

"""Source-bound human labels; no browser/provider traffic or real account data."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import copy
import sqlite3
from fastapi import HTTPException
from fastapi.testclient import TestClient
import pytest
from apps.server.analysis import checked_results, make_sources
from apps.server.analysis_reviews import AXES, model_labels
from apps.server.db import StoreConflict, digest, encoded, identifier, now
from apps.server.main import create_app
from apps.server.tests.test_analysis import answer, seed
from apps.server.tests.test_learning import FakeWorker, client
from apps.server.tests.test_postgres import pg_client
@pytest.fixture(params=["client", "pg_client"])
def review_client(request):
return request.getfixturevalue(request.param)
def prepared(client, *, region_scope="unrestricted", version="demand-result-v2", status="completed"):
reading_id, owner = seed(client)
service = client.app_reference.state.analysis_reviews
reading = service.analysis.reading.get(reading_id, owner)
sources, coverage = make_sources(reading)
conditions = {"service": "装修服务", "target_customer": "有装修需求的业主", "region": "杭州" if region_scope == "specified" else None,
"region_scope": region_scope, "exclude_service_promotions": False}
content = answer({"sources": [source for source in sources if source["text"].strip()]})
content["results"][0]["region"] = {"value": "杭州", "evidence": "坐标杭州"}
results = checked_results(encoded(content), sources, "completed", conditions)
record = {"status": status, "reading_id": reading_id, "sources": sources, "results": results, "coverage": coverage,
"result_version": version, "context": {"conditions": conditions}}
run_id = identifier("analysis")
service.store.execute("INSERT INTO analysis_runs(id,owner_id,reading_run_id,data_json,created_at) VALUES($1,$2,$3,$4,$5)", (run_id, owner, reading_id, encoded(record), now()))
return service, owner, service.analysis.get(run_id, owner)
def payload(analysis, source_ref="note", **changes):
result = next(item for item in analysis["results"] if item["source_ref"] == source_ref)
return {"source_ref": source_ref, "previous_review_id": None, "decision": "confirmed", "labels": model_labels(result), "evidence": [], "note": "", **changes}
def test_review_confirm_correct_chain_latest_summary_and_frozen_sources(review_client):
client = review_client
service, owner, analysis = prepared(client)
endpoint = "/api/discovery/analyses/" + analysis["id"]
original = service.store.one("SELECT data_json FROM analysis_runs WHERE id=$1", (analysis["id"],))
original_reading = service.analysis.reading.get(analysis["reading_id"], owner)
calls = copy.deepcopy(client.worker_reference.calls)
first = client.post(endpoint + "/reviews", json=payload(analysis))
assert first.status_code == 201, first.text
first = first.json()
assert first["labels"] == first["model_labels"] == payload(analysis)["labels"] and not any(first["axis_diff"].values())
assert first["analysis_digest"] == digest(analysis) and first["source_digest"] == digest(analysis["sources"][0]) and first["reviewer_id"] == owner
assert first["previous_review_id"] is None and first["contract_version"] == "analysis-review-v1"
corrected_labels = {**first["labels"], "role": "unknown"}
correction = payload(analysis, previous_review_id=first["id"], decision="corrected", labels=corrected_labels, note="保留自身需求,角色仍待人工确认", evidence=["找装修公司"])
second = client.post(endpoint + "/reviews", json=correction)
assert second.status_code == 201, second.text
second = second.json()
assert second["previous_review_id"] == first["id"] and second["axis_diff"] == {axis: axis == "role" for axis in AXES}
for ref, decision in (("comment_1", "uncertain"), ("comment_2", "unreviewable")):
response = client.post(endpoint + "/reviews", json=payload(analysis, ref, decision=decision, labels=None, note="缺少足够文字上下文,保持未判定"))
assert response.status_code == 201, response.text
summary = client.get(endpoint + "/review-summary").json()
assert summary["eligible"] and summary["total"] == summary["reviewed"] == 3 and summary["pending"] == 0 and summary["judged"] == 1
assert summary["decision_counts"] == {"confirmed": 0, "corrected": 1, "uncertain": 1, "unreviewable": 1}
assert summary["agreement_count"] == 0 and summary["disagreement_count"] == 1 and summary["agreement_rate"] == 0
assert summary["axis_diff_counts"] == {axis: int(axis == "role") for axis in AXES}
assert len(summary["latest_reviews"]) == 3 and summary["latest_reviews"][0] == second and "非准确率" in summary["metric_label"]
history = client.get(endpoint + "/reviews").json()
assert history["total"] == 4 and history["items"][-1] == first
assert client.get("/api/discovery/reviews/" + first["id"]).json() == first
assert service.store.one("SELECT data_json FROM analysis_runs WHERE id=$1", (analysis["id"],)) == original
assert service.analysis.reading.get(analysis["reading_id"], owner) == original_reading and client.worker_reference.calls == calls
expected = StoreConflict if service.store.backend == "postgresql" else sqlite3.IntegrityError
for operation in ("UPDATE analysis_reviews SET data_json='{}' WHERE id=$1", "DELETE FROM analysis_reviews WHERE id=$1"):
with pytest.raises(expected):
service.store.execute(operation, (second["id"],))
if service.store.backend == "postgresql":
with pytest.raises(StoreConflict):
service.store.execute("TRUNCATE analysis_reviews CASCADE")
restarted = create_app(service.store.home, FakeWorker(service.store.home), database_url=client.test_database_url, database_schema=client.test_schema)
assert restarted.state.analysis_reviews.get(first["id"], owner) == first
else:
restarted = create_app(service.store.home, FakeWorker(service.store.home), database_url="")
assert restarted.state.analysis_reviews.get(first["id"], owner) == first
def test_review_optimistic_source_chain_and_owner_checks(review_client):
client = review_client
service, owner, analysis = prepared(client)
first = service.create(analysis["id"], payload(analysis), owner)
cases = [payload(analysis), payload(analysis, previous_review_id="review_nonexistent"), payload(analysis, "comment_1", previous_review_id=first["id"])]
for body in cases:
with pytest.raises(HTTPException) as error:
service.create(analysis["id"], body, owner)
assert error.value.status_code == 409 and error.value.detail["code"] == "REVIEW_STALE"
for operation in (lambda: service.create(analysis["id"], payload(analysis), "other-owner"), lambda: service.list(analysis["id"], "other-owner"),
lambda: service.get(first["id"], "other-owner"), lambda: service.summary(analysis["id"], "other-owner")):
with pytest.raises(HTTPException) as error:
operation()
assert error.value.status_code == 404
endpoint = "/api/discovery/analyses/" + analysis["id"]
with TestClient(client.app_reference, base_url="http://127.0.0.1:8810") as anonymous:
assert anonymous.get(endpoint + "/reviews").status_code == anonymous.get(endpoint + "/review-summary").status_code == 401
assert anonymous.post(endpoint + "/reviews", json=payload(analysis)).status_code in {401, 403}
bad = client.post(endpoint + "/reviews", json={**payload(analysis), "arbitrary": "not allowed"})
assert bad.status_code == 422 and "not allowed" not in bad.text
assert len(service.list(analysis["id"], owner)) == 1
def test_review_concurrent_first_and_successor_single_winner(review_client):
service, owner, analysis = prepared(review_client)
def compete(body):
def create(_):
try:
return service.create(analysis["id"], body, owner)
except HTTPException as error:
return error.detail["code"]
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(create, range(2)))
assert len([result for result in results if isinstance(result, dict)]) == results.count("REVIEW_STALE") == 1
return next(result for result in results if isinstance(result, dict))
first = compete(payload(analysis))
second = compete(payload(analysis, previous_review_id=first["id"], decision="uncertain", labels=None, note="现有文字不足以继续判定"))
assert second["previous_review_id"] == first["id"] and len(service.list(analysis["id"], owner)) == 2
summary = service.summary(analysis["id"], owner)
assert summary["reviewed"] == 1 and summary["judged"] == 0 and summary["agreement_rate"] is None and summary["pending"] == 2
def test_review_contract_quotes_sensitive_notes_and_media_are_not_judged(review_client):
service, owner, analysis = prepared(review_client)
changed = {**payload(analysis)["labels"], "role": "unknown"}
invalid = [
(payload(analysis, decision="corrected", labels=changed, note="", evidence=["找装修公司"]), "REVIEW_DECISION_INVALID"),
(payload(analysis, decision="corrected", labels=changed, note="角色待核", evidence=[]), "REVIEW_DECISION_INVALID"),
(payload(analysis, decision="corrected", note="实际没有改变标签", evidence=["找装修公司"]), "REVIEW_DECISION_INVALID"),
(payload(analysis, labels=changed), "REVIEW_DECISION_INVALID"),
(payload(analysis, decision="uncertain", labels=None), "REVIEW_DECISION_INVALID"),
(payload(analysis, decision="unreviewable", note="理由已填写但不应有标签"), "REVIEW_DECISION_INVALID"),
(payload(analysis, evidence=["好看,分享一下已完工的效果"]), "REVIEW_EVIDENCE_INVALID"),
(payload(analysis, note="手机号13812345678"), "REVIEW_SENSITIVE_INPUT"),
(payload(analysis, note="api_key=synthetic-secret-value"), "REVIEW_SENSITIVE_INPUT"),
(payload(analysis, note="请联系 a@example.test"), "REVIEW_SENSITIVE_INPUT"),
(payload(analysis, "comment_2"), "REVIEW_MEDIA_UNREVIEWABLE"),
(payload(analysis) | {"source_ref": "comment_missing"}, "REVIEW_SOURCE_NOT_FOUND"),
]
for body, code in invalid:
with pytest.raises(HTTPException) as error:
service.create(analysis["id"], body, owner)
assert error.value.detail["code"] == code
assert service.list(analysis["id"], owner) == []
media = service.create(analysis["id"], payload(analysis, "comment_2", decision="uncertain", labels=None, note="图片未解释,无法判断发言需求"), owner)
assert media["labels"] is None and not any(media["axis_diff"].values())
def test_review_label_consistency_region_guard_and_set_comparison(review_client):
service, owner, analysis = prepared(review_client, region_scope="unspecified")
original = payload(analysis)["labels"]
invalid_labels = [original | {"expressions": ["question"]}, original | {"demand": "discussion"}, original | {"role": "suspected_service_provider"},
original | {"expressions": ["self_need", "self_need"]}, original | {"expressions": ["self_need", "unknown"]}, original | {"match": "matched"}, original | {"match": "possible"}]
for labels in invalid_labels:
with pytest.raises(HTTPException) as error:
service.create(analysis["id"], payload(analysis, decision="corrected", labels=labels, note="重新核对标签", evidence=["找装修公司"]), owner)
assert error.value.detail["code"] == "REVIEW_LABELS_INVALID"
unknown = service.create(analysis["id"], payload(analysis), owner)
assert unknown["labels"]["match"] == "insufficient" and unknown["decision"] == "confirmed"
# Expressions compare as a set regardless of maintainer order.
labels = {**original, "expressions": ["self_need", "question"]}
first = service.create(analysis["id"], payload(analysis, previous_review_id=unknown["id"], decision="corrected", labels=labels, note="同时表达自身需求和提问", evidence=["找装修公司"]), owner)
repeated = service.create(analysis["id"], payload(analysis, previous_review_id=first["id"], decision="corrected", labels={**labels, "expressions": list(reversed(labels["expressions"]))}, note="顺序不影响表达集合", evidence=["找装修公司"]), owner)
assert repeated["labels"] == first["labels"] and repeated["axis_diff"]["expressions"]
def test_review_legacy_failed_and_initial_summary_are_readonly(review_client):
service, owner, analysis = prepared(review_client, version="demand-result-v1")
initial = service.summary(analysis["id"], owner)
assert not initial["eligible"] and initial["total"] == initial["pending"] == initial["judged"] == 0 and initial["agreement_rate"] is None
assert service.list(analysis["id"], owner) == []
with pytest.raises(HTTPException) as error:
service.create(analysis["id"], payload(analysis), owner)
assert error.value.detail["code"] == "REVIEW_ANALYSIS_INELIGIBLE"
# Failed records of the new result contract are not eligible either.
run_id = identifier("analysis")
service.store.execute("INSERT INTO analysis_runs(id,owner_id,reading_run_id,data_json,created_at) VALUES($1,$2,$3,$4,$5)", (run_id, owner, analysis["reading_id"], encoded({**analysis, "status": "failed", "result_version": "demand-result-v2"}), now()))
assert not service.summary(run_id, owner)["eligible"]
with pytest.raises(HTTPException):
service.create(run_id, payload(analysis), owner)