fix: validate retrieval-only chat questions before dispatch
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""Retrieval-only chat integration; synthetic evidence, never a generation call."""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
from test_catalog import document, source
|
||||
from test_retrieval import add_item
|
||||
from test_runtime import server
|
||||
|
||||
from app.models.rag import RagContext
|
||||
from app.models.retrieval import RetrievalRun
|
||||
from app.services import retrieval
|
||||
|
||||
|
||||
def test_overlong_chat_question_is_rejected_at_the_http_boundary(client):
|
||||
with TestClient(
|
||||
client.app, base_url="https://testserver", raise_server_exceptions=False
|
||||
) as public:
|
||||
response = public.post("/api/chat", json={"question": "q" * 501})
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"question": ""},
|
||||
{"question": " \t\n "},
|
||||
{"question": None},
|
||||
{"question": 123},
|
||||
{"question": "q" * 2000},
|
||||
{"question": "q" * 2001},
|
||||
{"question": "alpha", "model": "not-accepted"},
|
||||
{"question": "alpha", "messages": []},
|
||||
],
|
||||
)
|
||||
def test_invalid_chat_input_creates_no_retrieval_or_context(client, migrated_database, payload):
|
||||
response = client.post("/api/chat", json=payload)
|
||||
assert response.status_code == 422, response.text
|
||||
assert all("input" not in error for error in response.json()["detail"])
|
||||
with Session(migrated_database) as db:
|
||||
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
|
||||
assert db.scalar(select(func.count()).select_from(RagContext)) == 0
|
||||
|
||||
|
||||
def test_chat_query_boundary_normalization_and_public_history_permissions(client):
|
||||
response = client.post("/api/chat", json={"question": "q" * 500})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["status"] == "no_evidence"
|
||||
client.cookies.clear()
|
||||
public = client.post("/api/chat", json={"question": " alpha "})
|
||||
assert public.status_code == 200
|
||||
assert public.json()["status"] == "no_evidence" and public.json()["sources"] == []
|
||||
assert client.get(f"/api/retrieval/runs/{public.json()['retrieval_run_id']}").status_code == 401
|
||||
|
||||
|
||||
def test_chat_sources_match_audited_snapshot_and_history_stays_immutable(client, migrated_database):
|
||||
approved = add_item(client, title="verified alpha", content="alpha synthetic evidence")
|
||||
add_item(client, status="pending", title="pending alpha", content="alpha unreviewed")
|
||||
add_item(client, status="rejected", title="rejected alpha", content="alpha rejected")
|
||||
deleted = add_item(client, title="deleted alpha", content="alpha deleted")
|
||||
assert (
|
||||
client.delete(
|
||||
f"/api/knowledge/{deleted['id']}?expected_version={deleted['version']}"
|
||||
).status_code
|
||||
== 204
|
||||
)
|
||||
result = client.post("/api/chat", json={"question": " alpha "}).json()
|
||||
assert result["status"] == "retrieval_only" and "不生成" in result["answer"]
|
||||
history = client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json()
|
||||
assert history["query"] == "alpha" and history["mode"] == "keyword"
|
||||
assert [h["knowledge_id"] for h in history["results"]] == [approved["id"]]
|
||||
assert result["sources"] == [
|
||||
{"title": h["title"], "source_url": h["source_url"]} for h in history["results"]
|
||||
]
|
||||
saved = copy.deepcopy(history)
|
||||
assert (
|
||||
client.patch(
|
||||
f"/api/knowledge/{approved['id']}",
|
||||
json={"expected_version": approved["version"], "content": "revised alpha"},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.post("/api/chat", json={"question": "alpha"}).json()["status"] == "no_evidence"
|
||||
assert client.get(f"/api/retrieval/runs/{history['run_id']}").json() == saved
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rag/contexts",
|
||||
json={"retrieval_run_id": history["run_id"], "completion": {"model": "contract-model"}},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
with Session(migrated_database) as db:
|
||||
assert db.scalar(select(func.count()).select_from(RagContext)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["source", "document"])
|
||||
def test_chat_cannot_reuse_evidence_after_catalog_revision(client, change):
|
||||
src = source(client)
|
||||
doc = document(client, src["id"])
|
||||
add_item(client, document_id=doc["id"], content="alpha synthetic catalog evidence")
|
||||
assert client.post("/api/chat", json={"question": "alpha"}).json()["status"] == "retrieval_only"
|
||||
path, version, fields = (
|
||||
(f"sources/{src['id']}", src["version"], {"source_url": "https://example.com/new"})
|
||||
if change == "source"
|
||||
else (f"documents/{doc['id']}", doc["version"], {"content": "revised synthetic document"})
|
||||
)
|
||||
assert (
|
||||
client.patch("/api/" + path, json={"expected_version": version, **fields}).status_code
|
||||
== 200
|
||||
)
|
||||
result = client.post("/api/chat", json={"question": "alpha"}).json()
|
||||
assert result["status"] == "no_evidence" and result["sources"] == []
|
||||
|
||||
|
||||
def test_chat_capacity_failure_is_not_a_successful_empty_answer(
|
||||
client, migrated_database, monkeypatch
|
||||
):
|
||||
add_item(client, content="alpha synthetic capacity evidence")
|
||||
monkeypatch.setattr(retrieval, "MAX_ITEMS", 0)
|
||||
response = client.post("/api/chat", json={"question": "alpha"})
|
||||
assert response.status_code == 503 and "sources" not in response.json()
|
||||
with Session(migrated_database) as db:
|
||||
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
|
||||
|
||||
|
||||
def test_chat_failed_history_insert_does_not_leave_partial_rows(client, migrated_database):
|
||||
with migrated_database.begin() as db:
|
||||
db.execute(
|
||||
text("ALTER TABLE retrieval_runs ADD CONSTRAINT reject_chat_run CHECK(false) NOT VALID")
|
||||
)
|
||||
assert client.post("/api/chat", json={"question": "alpha"}).status_code == 409
|
||||
with Session(migrated_database) as db:
|
||||
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
|
||||
|
||||
|
||||
def test_chat_retrieval_history_survives_real_http_restart(migrated_database):
|
||||
with server(migrated_database) as client:
|
||||
assert client.post("/api/chat", json={"question": "q" * 501}).status_code == 422
|
||||
item = add_item(client, content="alpha real HTTP synthetic evidence")
|
||||
response = client.post("/api/chat", json={"question": " alpha "})
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
history = client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json()
|
||||
assert history["results"][0]["knowledge_id"] == item["id"]
|
||||
assert history["query"] == "alpha"
|
||||
with server(migrated_database) as client:
|
||||
assert client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json() == history
|
||||
exported = client.post(
|
||||
"/api/retrieval/exports",
|
||||
json={"run_ids": [result["retrieval_run_id"]], "include_contexts": True},
|
||||
)
|
||||
assert exported.status_code == 200
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post("/api/chat", json={"question": "alpha"}).json()["status"]
|
||||
== "retrieval_only"
|
||||
)
|
||||
Reference in New Issue
Block a user