|
|
|
@@ -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"
|
|
|
|
|
)
|