assistant/apps/server/tests/test_analysis_axes.py

195 lines
11 KiB
Python

"""Independent demand/role/expression axes and deterministic business suggestions."""
from __future__ import annotations
import asyncio
import copy
import pytest
from fastapi import HTTPException
from apps.server.analysis import PROMPT_VERSION, RESULT_VERSION, SYSTEM_PROMPT, checked_results
from apps.server.db import digest, encoded, identifier, now
from apps.server.model_client import ModelCallError
from apps.server.tests.test_analysis import analysis_client, answer, attach, client, pg_client, seed, wire
CONDITIONS = {"service": "装修服务", "target_customer": "有装修需求的业主", "region": None,
"region_scope": "unrestricted", "exclude_service_promotions": True}
def source(text, ref="note", media=False):
return {"source_ref": ref, "text": text, "media_uninterpreted": media}
def result(text, *, demand="explicit", role="suspected_demand_side", expressions=("self_need",), relevance="relevant", region=None):
return {"source_ref": "note", "demand": demand, "evidence": [text], "role": role,
"role_evidence": [] if role == "unknown" else [text],
"expressions": [{"type": expression, "evidence": [] if expression == "unknown" else [text]} for expression in expressions],
"business_relevance": {"value": relevance, "evidence": [] if relevance == "unclear" else [text]},
"stage": None, "region": {"value": region, "evidence": text} if region else None, "budget": None}
def check(item, text, conditions=None):
return checked_results(encoded({"results": [item]}), [source(text)], "completed", conditions or CONDITIONS)[0]
def test_axes_self_need_coexists_with_question_advice_and_service_provider():
text = "我家准备装修,问大家家装顺序。"
current = check(result(text, expressions=("self_need", "question", "seek_advice")), text)
assert current["demand"] == "explicit" and current["match"] == "matched" and current["next_step"] == "review_author_need"
assert {entry["type"] for entry in current["expressions"]} == {"self_need", "question", "seek_advice"}
text = "我是设计师可以提供装修服务,同时我家也准备装修。"
provider = check(result(text, role="suspected_service_provider", expressions=("service_offer", "self_need")), text)
assert provider["match"] == "matched" and provider["next_step"] == "review_author_need"
assert "service_promotion_excluded" not in provider["match_reasons"]
@pytest.mark.parametrize("text,role,expressions,next_step", [
("你是哪里的嘛?", "unknown", ("question",), "skip"),
("开始装修了吗?", "unknown", ("question",), "skip"),
("我有设计师朋友,可以推荐给你", "suspected_service_provider", ("service_offer",), "inspect_comments"),
("展示一下我们装修公司的案例", "suspected_service_provider", ("service_offer", "case_share"), "inspect_comments"),
("建议先确定需求,再考虑半包", "participant", ("advice",), "skip"),
])
def test_axes_questions_offers_advice_do_not_inherit_authors_need(text, role, expressions, next_step):
current = check(result(text, demand="discussion", role=role, expressions=expressions), text)
assert current["match"] == "not_matched" and current["next_step"] == next_step
assert "no_self_need_signal" in current["match_reasons"]
if "service_offer" in expressions:
assert "service_promotion_excluded" in current["match_reasons"]
@pytest.mark.parametrize("scope,condition_region,own_region,match,reason", [
("unrestricted", None, None, "matched", "region_unrestricted"),
("unspecified", None, None, "insufficient", "region_unspecified"),
("specified", "杭州", None, "insufficient", "region_missing"),
("specified", "杭州", "杭州", "matched", "region_exact"),
("specified", "杭州", "余杭", "insufficient", "region_potential_mismatch"),
("specified", "杭州", "上海", "insufficient", "region_potential_mismatch"),
])
def test_axes_region_missing_and_different_words_are_not_guessed(scope, condition_region, own_region, match, reason):
text = "我家准备装修" + (",地点" + own_region if own_region else "")
current = check(result(text, region=own_region), text, {**CONDITIONS, "region_scope": scope, "region": condition_region})
assert current["match"] == match and reason in current["match_reasons"]
assert current["next_step"] == ("review_author_need" if match == "matched" else "need_more_context")
def test_axes_possible_need_comment_binding_irrelevant_unclear_and_uninterpreted_media():
text = "我也考虑装修,预算还没定"
item = result(text, demand="possible")
item["source_ref"] = "comment_2"
current = checked_results(encoded({"results": [item]}), [source(text, ref="comment_2")], "partial", CONDITIONS)[0]
assert current["match"] == "possible" and current["next_step"] == "review_comment_need"
assert "reading_partial" in current["match_reasons"]
assert check(result(text, relevance="irrelevant"), text)["match"] == "not_matched"
assert check(result(text, relevance="unclear"), text)["match"] == "insufficient"
media = checked_results('{"results":[]}', [source("", media=True)], "completed", CONDITIONS)[0]
assert media["role"] == "unknown" and media["demand"] == media["match"] == "insufficient"
assert media["business_relevance"] == {"value": "unclear", "evidence": []} and media["next_step"] == "need_more_context"
assert "media_uninterpreted" in media["missing_information"]
@pytest.mark.parametrize("mutation", [
lambda item: item.update(role_evidence=["其他来源的服务"]),
lambda item: item["expressions"][0].update(evidence=["其他来源的需求"]),
lambda item: item["business_relevance"].update(evidence=["商家条件中的装修服务"]),
lambda item: item["business_relevance"].update(evidence=[]),
lambda item: item.update(role="confirmed_owner"),
lambda item: item["expressions"].append(copy.deepcopy(item["expressions"][0])),
lambda item: item["expressions"].append({"type": "unknown", "evidence": []}),
lambda item: item.update(expressions=[{"type": "question", "evidence": ["我家准备装修"]}]),
lambda item: item.update(demand="discussion"),
lambda item: item.update(next_step="send_message"),
lambda item: item.update(match="matched"),
])
def test_axes_reject_fabricated_cross_source_dimensions_and_model_actions(mutation):
text = "我家准备装修"
item = result(text)
mutation(item)
with pytest.raises(ModelCallError) as error:
check(item, text)
assert error.value.code == "MODEL_OUTPUT_INVALID"
def test_axes_no_own_need_rejects_service_area_as_own_project_region():
text = "我在杭州提供装修服务"
item = result(text, demand="discussion", role="suspected_service_provider", expressions=("service_offer",), region="杭州")
with pytest.raises(ModelCallError):
check(item, text)
# A short question cannot support a service-provider role without an offer.
item = result("哪里", demand="discussion", role="suspected_service_provider", expressions=("question",))
with pytest.raises(ModelCallError):
check(item, "哪里")
assert "设计师所在地、服务覆盖地" in SYSTEM_PROMPT and "role应unknown" in SYSTEM_PROMPT
class Briefs:
def __init__(self, owner):
self.owner = owner
self.records = {}
self.reads = []
def add(self, key, **conditions):
value = {**CONDITIONS, **conditions}
self.records[key] = {"id": key, "digest": digest(value), "conditions": value}
return key
def get_confirmed(self, key, owner):
self.reads.append((key, owner))
if owner != self.owner or key not in self.records:
raise HTTPException(404, "不存在")
return self.records[key]
def test_axes_context_digest_reuse_old_records_and_sources_stay_immutable(analysis_client):
client = analysis_client
reading_id, owner = seed(client)
calls = []
def handler(request):
import json
payload = json.loads(json.loads(request.content)["messages"][1]["content"])
calls.append(payload)
assert set(payload) == {"business_context", "sources"}
assert not any("private-" in encoded(value) for value in payload.values())
return wire(answer(payload))
service = attach(client, handler)
briefs = service.business_briefs = Briefs(owner)
first_id = briefs.add("brief-first")
other_id = briefs.add("brief-other", region="杭州", region_scope="specified")
store = service.store
original_reading = store.one("SELECT data_json FROM reading_runs WHERE id=$1", (reading_id,))
old_id = identifier("analysis")
old = {"status": "completed", "reading_digest": "old", "prompt_version": "renovation-demand-v2", "result_version": "demand-result-v1", "results": [{"demand": "discussion"}]}
store.execute("INSERT INTO analysis_runs(id,owner_id,reading_run_id,data_json,created_at) VALUES($1,$2,$3,$4,$5)", (old_id, owner, reading_id, encoded(old), now()))
original_old = service.get(old_id, owner)
first = asyncio.run(service.create(reading_id, owner, first_id))
assert first["status"] == "completed" and first["prompt_version"] == PROMPT_VERSION and first["result_version"] == RESULT_VERSION
assert first["context"]["kind"] == "confirmed_business_brief" and first["context"]["business_brief_digest"] == briefs.records[first_id]["digest"]
assert asyncio.run(service.create(reading_id, owner, first_id)) == first and len(calls) == 1
second = asyncio.run(service.create(reading_id, owner, other_id))
assert second["id"] != first["id"] and len(calls) == 2
assert second["context"]["context_digest"] != first["context"]["context_digest"]
assert second["results"][0]["match"] == "insufficient" and "region_missing" in second["results"][0]["match_reasons"]
assert store.one("SELECT data_json FROM reading_runs WHERE id=$1", (reading_id,)) == original_reading
assert service.get(old_id, owner) == original_old and "context" not in original_old
assert service.get(first["id"], owner) == first
with pytest.raises(HTTPException):
asyncio.run(service.create(reading_id, owner, "draft-or-other-owner"))
assert len(calls) == 2
def test_axes_implicit_reading_context_and_explicit_override(client):
reading_id, owner = seed(client)
service = attach(client, lambda _: wire({"results": []}))
briefs = service.business_briefs = Briefs(owner)
first = briefs.add("brief-first")
other = briefs.add("brief-other", region_scope="unspecified")
reading = service.reading.get(reading_id, owner)
reading["business_brief_id"] = first
assert service.context(reading, owner)["business_brief_id"] == first
assert service.context(reading, owner, other)["business_brief_id"] == other
legacy = service.context(service.reading.get(reading_id, owner), owner)
assert legacy["kind"] == "discovery_conditions" and legacy["conditions"]["region_scope"] == "unspecified"