Make the first RAG slice executable without risking production data
Some checks failed
verify / verify (push) Has been cancelled
Some checks failed
verify / verify (push) Has been cancelled
The Stage 1 foundation now proves provider contracts with mocks and validates PostgreSQL/pgvector ingestion, approval binding, retrieval, reranking, and idempotency using only synthetic data. Live Bailian validation remains gated on rotating the exposed key. Constraint: The key shown in chat is compromised and cannot be used or committed Rejected: Mark Stage 1 complete from mock and offline results | real three-model smoke is still required Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not enable real-data ingestion until Stage 3 cloud approval and outbound manifest controls are enforced end to end Tested: make verify; 41 pytest tests; strict mypy; Ruff; Compose config; pinned image build; empty-volume migration; role denial; two idempotent 20-vector seeds; database restart persistence Not-tested: Live Bailian calls require a newly rotated key; React product UI is not implemented
This commit is contained in:
@@ -1 +0,0 @@
|
||||
|
||||
175
backend/tests/contract/test_bailian_chat.py
Normal file
175
backend/tests/contract/test_bailian_chat.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.adapters.bailian.chat import BailianChatAdapter
|
||||
from app.ports.model_providers import ChatMessage, ModelProviderError
|
||||
|
||||
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||
API_KEY = "sk-test-chat-secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_disables_thinking_and_search() -> None:
|
||||
messages = [ChatMessage(role="user", content="Give a grounded answer")]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/compatible-mode/v1/chat/completions"
|
||||
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
|
||||
payload = json.loads(request.content)
|
||||
assert payload["model"] == "deepseek-v4-flash"
|
||||
assert payload["messages"] == [{"role": "user", "content": "Give a grounded answer"}]
|
||||
assert payload["enable_thinking"] is False
|
||||
assert payload["enable_search"] is False
|
||||
assert payload["stream"] is False
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "req_chat_1",
|
||||
"model": "deepseek-v4-flash",
|
||||
"choices": [
|
||||
{
|
||||
"message": {"role": "assistant", "content": "grounded"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 6,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianChatAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
http_client=client,
|
||||
)
|
||||
result = await adapter.complete(messages, max_tokens=32)
|
||||
|
||||
assert result.content == "grounded"
|
||||
assert result.finish_reason == "stop"
|
||||
assert result.usage.total_tokens == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_disables_thinking_and_search_and_parses_sse() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content)
|
||||
assert payload["enable_thinking"] is False
|
||||
assert payload["enable_search"] is False
|
||||
assert payload["stream"] is True
|
||||
assert payload["stream_options"] == {"include_usage": True}
|
||||
chunks = (
|
||||
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
|
||||
'"choices":[{"delta":{"content":"矿"},"finish_reason":null}]}\n\n'
|
||||
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
|
||||
'"choices":[{"delta":{"content":"体"},"finish_reason":"stop"}]}\n\n'
|
||||
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
|
||||
'"choices":[],"usage":{"prompt_tokens":4,"completion_tokens":2,'
|
||||
'"total_tokens":6}}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=chunks.encode(),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianChatAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
http_client=client,
|
||||
)
|
||||
events = [
|
||||
event
|
||||
async for event in adapter.stream(
|
||||
[ChatMessage(role="user", content="question")],
|
||||
max_tokens=32,
|
||||
)
|
||||
]
|
||||
|
||||
assert "".join(event.delta for event in events) == "矿体"
|
||||
assert events[1].finish_reason == "stop"
|
||||
assert events[2].usage.total_tokens == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_error_does_not_expose_key_or_message_body() -> None:
|
||||
sensitive_text = "CONFIDENTIAL_PROMPT_BODY"
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
401,
|
||||
json={
|
||||
"error": {
|
||||
"code": API_KEY,
|
||||
"message": f"{sensitive_text}: {API_KEY}",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianChatAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
http_client=client,
|
||||
)
|
||||
with pytest.raises(ModelProviderError) as exc_info:
|
||||
await adapter.complete(
|
||||
[ChatMessage(role="user", content=sensitive_text)],
|
||||
max_tokens=8,
|
||||
)
|
||||
|
||||
rendered = f"{exc_info.value!s} {exc_info.value!r}"
|
||||
assert API_KEY not in rendered
|
||||
assert sensitive_text not in rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_honors_retry_after_before_any_token() -> None:
|
||||
calls = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return httpx.Response(
|
||||
429,
|
||||
headers={"Retry-After": "0"},
|
||||
json={"error": {"code": "rate_limited"}},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=(
|
||||
'data: {"model":"deepseek-v4-flash",'
|
||||
'"choices":[{"delta":{"content":"正常"},"finish_reason":"stop"}]}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
).encode(),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianChatAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
http_client=client,
|
||||
max_retries=1,
|
||||
retry_base_seconds=0,
|
||||
)
|
||||
events = [
|
||||
event
|
||||
async for event in adapter.stream(
|
||||
[ChatMessage(role="user", content="question")],
|
||||
max_tokens=8,
|
||||
)
|
||||
]
|
||||
|
||||
assert "".join(event.delta for event in events) == "正常"
|
||||
assert calls == 2
|
||||
208
backend/tests/contract/test_bailian_embedding.py
Normal file
208
backend/tests/contract/test_bailian_embedding.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.adapters.bailian._base import response_model
|
||||
from app.adapters.bailian.embedding import BailianEmbeddingAdapter
|
||||
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||
|
||||
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||
API_KEY = "sk-test-embedding-secret"
|
||||
|
||||
|
||||
def _vector(marker: float = 1.0) -> list[float]:
|
||||
return [marker, *([0.0] * 1_023)]
|
||||
|
||||
|
||||
def test_embedding_rejects_non_bailian_endpoint_before_request() -> None:
|
||||
with pytest.raises(ModelProviderError) as exc_info:
|
||||
BailianEmbeddingAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url="https://attacker.invalid/compatible-mode/v1",
|
||||
)
|
||||
|
||||
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
|
||||
assert exc_info.value.provider_code == "invalid_base_url"
|
||||
|
||||
|
||||
def test_response_model_falls_back_when_provider_echoes_sensitive_value() -> None:
|
||||
assert (
|
||||
response_model(
|
||||
{"model": API_KEY},
|
||||
"text-embedding-v4",
|
||||
sensitive_values=(API_KEY,),
|
||||
)
|
||||
== "text-embedding-v4"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_uses_compatible_endpoint_and_restores_index_order() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/compatible-mode/v1/embeddings"
|
||||
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
|
||||
payload = json.loads(request.content)
|
||||
assert payload == {
|
||||
"model": "text-embedding-v4",
|
||||
"input": ["first", "second"],
|
||||
"dimensions": 1_024,
|
||||
"encoding_format": "float",
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "req_embedding_1",
|
||||
"model": "text-embedding-v4",
|
||||
"data": [
|
||||
{"index": 1, "embedding": _vector(2.0)},
|
||||
{"index": 0, "embedding": _vector(1.0)},
|
||||
],
|
||||
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianEmbeddingAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
)
|
||||
result = await adapter.embed_documents(["first", "second"])
|
||||
|
||||
assert len(result.vectors) == 2
|
||||
assert all(len(vector) == 1_024 for vector in result.vectors)
|
||||
assert result.vectors[0][0] == 1.0
|
||||
assert result.vectors[1][0] == 2.0
|
||||
assert result.request_id == "req_embedding_1"
|
||||
assert result.usage.input_tokens == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_enforces_10_8192_and_33000_token_boundaries() -> None:
|
||||
calls = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
payload = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": [
|
||||
{"index": index, "embedding": _vector()}
|
||||
for index, _ in enumerate(payload["input"])
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianEmbeddingAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
)
|
||||
|
||||
await adapter.embed_documents(["x"] * 10)
|
||||
await adapter.embed_documents(["x" * 8_192])
|
||||
await adapter.embed_documents([*(["x" * 8_192] * 4), "x" * 232])
|
||||
assert calls == 3
|
||||
|
||||
invalid_batches = (
|
||||
["x"] * 11,
|
||||
["x" * 8_193],
|
||||
[*(["x" * 8_192] * 4), "x" * 233],
|
||||
)
|
||||
for texts in invalid_batches:
|
||||
with pytest.raises(ModelProviderError) as exc_info:
|
||||
await adapter.embed_documents(texts)
|
||||
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
|
||||
assert calls == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_rejects_non_1024_dimensional_response() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"index": 0, "embedding": [1.0] * 1_023}]},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianEmbeddingAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
)
|
||||
with pytest.raises(ModelProviderError) as exc_info:
|
||||
await adapter.embed_query("sensitive geological paragraph")
|
||||
|
||||
assert exc_info.value.kind is ProviderErrorKind.INVALID_RESPONSE
|
||||
assert exc_info.value.provider_code == "invalid_embedding_dimensions"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_provider_error_does_not_expose_key_or_body() -> None:
|
||||
sensitive_text = "CONFIDENTIAL_GEOLOGICAL_BODY"
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={
|
||||
"id": sensitive_text,
|
||||
"error": {
|
||||
"code": API_KEY,
|
||||
"message": f"failed for {sensitive_text} using {API_KEY}",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianEmbeddingAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
)
|
||||
with pytest.raises(ModelProviderError) as exc_info:
|
||||
await adapter.embed_query(sensitive_text)
|
||||
|
||||
rendered = f"{exc_info.value!s} {exc_info.value!r}"
|
||||
assert API_KEY not in rendered
|
||||
assert sensitive_text not in rendered
|
||||
assert exc_info.value.provider_code is None
|
||||
assert exc_info.value.request_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_retries_timeout_with_a_fixed_bound() -> None:
|
||||
calls = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise httpx.ReadTimeout("simulated timeout", request=request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"index": 0, "embedding": _vector()}]},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianEmbeddingAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
max_retries=1,
|
||||
retry_base_seconds=0,
|
||||
)
|
||||
result = await adapter.embed_query("query")
|
||||
|
||||
assert len(result.vectors[0]) == 1_024
|
||||
assert calls == 2
|
||||
149
backend/tests/contract/test_bailian_rerank.py
Normal file
149
backend/tests/contract/test_bailian_rerank.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.adapters.bailian.rerank import BailianRerankerAdapter
|
||||
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||
|
||||
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||
API_KEY = "sk-test-rerank-secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rerank_uses_separate_endpoint_and_maps_result_indices() -> None:
|
||||
documents = ["copper evidence", "gold evidence", "irrelevant"]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/compatible-api/v1/reranks"
|
||||
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
|
||||
payload = json.loads(request.content)
|
||||
assert payload == {
|
||||
"model": "qwen3-rerank",
|
||||
"query": "where is gold",
|
||||
"documents": documents,
|
||||
"top_n": 2,
|
||||
"instruct": "rank geological evidence",
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "req_rerank_1",
|
||||
"model": "qwen3-rerank",
|
||||
"results": [
|
||||
{"index": 1, "relevance_score": 0.91},
|
||||
{"index": 0, "relevance_score": 0.72},
|
||||
],
|
||||
"usage": {"input_tokens": 42, "total_tokens": 42},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianRerankerAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
)
|
||||
result = await adapter.rerank(
|
||||
"where is gold",
|
||||
documents,
|
||||
top_n=2,
|
||||
instruct="rank geological evidence",
|
||||
)
|
||||
|
||||
assert [item.index for item in result.items] == [1, 0]
|
||||
assert [item.document for item in result.items] == [documents[1], documents[0]]
|
||||
assert [item.relevance_score for item in result.items] == [0.91, 0.72]
|
||||
assert result.request_id == "req_rerank_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rerank_enforces_500_4000_and_repeated_query_120000_formula() -> None:
|
||||
calls = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"results": [{"index": 0, "relevance_score": 1.0}]},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianRerankerAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
)
|
||||
|
||||
await adapter.rerank("q" * 4_000, ["d" * 4_000], top_n=1)
|
||||
exactly_120000 = ["d" * 40] * 500
|
||||
await adapter.rerank("q" * 200, exactly_120000, top_n=1)
|
||||
assert calls == 2
|
||||
|
||||
invalid_requests = (
|
||||
("q" * 4_001, ["d"]),
|
||||
("q", ["d" * 4_001]),
|
||||
("q", ["d"] * 501),
|
||||
("q" * 200, [*(["d" * 40] * 499), "d" * 41]),
|
||||
)
|
||||
for query, documents in invalid_requests:
|
||||
with pytest.raises(ModelProviderError) as exc_info:
|
||||
await adapter.rerank(query, documents, top_n=1)
|
||||
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rerank_rejects_invalid_provider_index() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"results": [{"index": 2, "relevance_score": 0.9}]},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianRerankerAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
)
|
||||
with pytest.raises(ModelProviderError) as exc_info:
|
||||
await adapter.rerank("query", ["only document"], top_n=1)
|
||||
|
||||
assert exc_info.value.kind is ProviderErrorKind.INVALID_RESPONSE
|
||||
assert exc_info.value.provider_code == "invalid_rerank_index"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rerank_retries_5xx_with_a_fixed_bound() -> None:
|
||||
calls = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return httpx.Response(503, json={"error": {"code": "temporarily_unavailable"}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"results": [{"index": 0, "relevance_score": 0.9}]},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = BailianRerankerAdapter(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
token_counter=len,
|
||||
http_client=client,
|
||||
max_retries=1,
|
||||
retry_base_seconds=0,
|
||||
)
|
||||
result = await adapter.rerank("query", ["document"], top_n=1)
|
||||
|
||||
assert result.items[0].index == 0
|
||||
assert calls == 2
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
183
backend/tests/integration/test_schema_contract.py
Normal file
183
backend/tests/integration/test_schema_contract.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
BACKEND = ROOT / "backend"
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
|
||||
from app.persistence.job_queue_sql import ( # noqa: E402
|
||||
CLAIM_JOB_SQL,
|
||||
COMPLETE_JOB_SQL,
|
||||
FAIL_OR_RETRY_JOB_SQL,
|
||||
HEARTBEAT_JOB_SQL,
|
||||
REAP_EXPIRED_JOBS_SQL,
|
||||
)
|
||||
|
||||
COMPOSE = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||
BOOTSTRAP = (ROOT / "ops/postgres/init/10-bootstrap-rag.sh").read_text(encoding="utf-8")
|
||||
MIGRATION = (BACKEND / "migrations/versions/0001_initial_schema.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _service_block(name: str) -> str:
|
||||
pattern = re.compile(rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)")
|
||||
match = pattern.search(COMPOSE)
|
||||
assert match is not None, f"missing Compose service: {name}"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
db = _service_block("db")
|
||||
migrate = _service_block("migrate")
|
||||
provider_smoke = _service_block("provider-smoke")
|
||||
seed_demo = _service_block("seed-demo")
|
||||
seed_demo_offline = _service_block("seed-demo-offline")
|
||||
|
||||
assert "postgres_bootstrap_password" in db
|
||||
assert "postgres_migrator_password" in db
|
||||
assert "postgres_app_password" in db
|
||||
|
||||
assert "postgres_migrator_password" in migrate
|
||||
assert "postgres_bootstrap_password" not in migrate
|
||||
assert "postgres_app_password" not in migrate
|
||||
|
||||
assert "bailian_api_key" in provider_smoke
|
||||
assert "postgres_" not in provider_smoke
|
||||
|
||||
assert "postgres_app_password" in seed_demo
|
||||
assert "postgres_bootstrap_password" not in seed_demo
|
||||
assert "postgres_migrator_password" not in seed_demo
|
||||
assert "bailian_api_key" in seed_demo
|
||||
assert "./data/samples/public:/demo:ro" in seed_demo
|
||||
|
||||
assert "postgres_app_password" in seed_demo_offline
|
||||
assert "bailian_api_key" not in seed_demo_offline
|
||||
assert "DEMO_PROVIDER_MODE: fake" in seed_demo_offline
|
||||
assert "./data/samples/public:/demo:ro" in seed_demo_offline
|
||||
|
||||
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
|
||||
assert re.search(r"(?m)^ edge:$", COMPOSE)
|
||||
assert re.search(r"(?m)^ egress:$", COMPOSE)
|
||||
|
||||
|
||||
def test_database_health_requires_tcp_and_atomic_bootstrap_sentinel() -> None:
|
||||
db = _service_block("db")
|
||||
assert "pg_isready -h 127.0.0.1 -p 5432" in db
|
||||
assert ".rag-bootstrap-complete" in db
|
||||
|
||||
assert "CREATE EXTENSION IF NOT EXISTS vector" in BOOTSTRAP
|
||||
assert 'CREATE ROLE :"migrator_user"' in BOOTSTRAP
|
||||
assert 'CREATE ROLE :"app_user"' in BOOTSTRAP
|
||||
assert "NOSUPERUSER" in BOOTSTRAP
|
||||
assert "CREATE SCHEMA rag AUTHORIZATION" in BOOTSTRAP
|
||||
assert "ALTER DEFAULT PRIVILEGES" in BOOTSTRAP
|
||||
assert BOOTSTRAP.index("COMMIT;") < BOOTSTRAP.index("mv -f")
|
||||
assert "\\getenv migrator_password RAG_MIGRATOR_PASSWORD" in BOOTSTRAP
|
||||
assert "--set=migrator_password" not in BOOTSTRAP
|
||||
assert "--set=app_password" not in BOOTSTRAP
|
||||
assert "set -x" not in BOOTSTRAP
|
||||
assert "official postgres entrypoint resolves POSTGRES_PASSWORD_FILE" in BOOTSTRAP
|
||||
assert 'bootstrap_password="${POSTGRES_PASSWORD:-}"' in BOOTSTRAP
|
||||
|
||||
|
||||
def test_initial_migration_has_vector_and_activation_contracts() -> None:
|
||||
normalized = " ".join(MIGRATION.lower().split())
|
||||
|
||||
assert "embedding vector(1024)" in normalized
|
||||
assert "using hnsw (embedding vector_cosine_ops)" in normalized
|
||||
assert "where searchable" in normalized
|
||||
assert "chunks_searchable_requires_ready_approved_embedding" in normalized
|
||||
assert "check (embedding_dimension = 1024)" in normalized
|
||||
assert "active_version_id uuid" in normalized
|
||||
assert "documents_active_version_fk" in normalized
|
||||
assert "deferrable initially deferred" in normalized
|
||||
assert "foreign key (id, active_version_id)" in normalized
|
||||
assert "references rag.document_versions (document_id, id)" in normalized
|
||||
assert "local_parsed_pending_cloud_review" in normalized
|
||||
assert "cloud_approved" in normalized
|
||||
assert "outbound_manifest_sha256" in normalized
|
||||
assert "cloud_processing_allowed" not in normalized
|
||||
assert "document_versions_cloud_approval_bound" in normalized
|
||||
assert "chunks_approval_binding_fk" in normalized
|
||||
assert "create table rag.outbound_manifest_items" in normalized
|
||||
assert "chunks_manifest_item_binding_fk" in normalized
|
||||
assert "outbound_manifest_items_immutable_after_approval" in normalized
|
||||
assert "chunks_guard_approved_mutation" in normalized
|
||||
assert "chunks_guard_ready_vector_update" in normalized
|
||||
assert "new.access_scope_id is distinct from old.access_scope_id" in normalized
|
||||
assert "new.document_version_id is distinct from old.document_version_id" in normalized
|
||||
assert "cloud_text text not null" in normalized
|
||||
assert "cloud_text_sha256 char(64) not null" in normalized
|
||||
assert "embedding_text = embedding_prefix || cloud_text" in normalized
|
||||
assert "sha256(convert_to(cloud_text, 'utf8'))" in normalized
|
||||
assert "sha256(convert_to(embedding_text, 'utf8'))" in normalized
|
||||
assert "approval_status = 'cloud_approved'" in normalized
|
||||
assert "document_versions_revoke_cloud_approval" in normalized
|
||||
assert "new.review_state is distinct from old.review_state" in normalized
|
||||
assert "set searchable = false" in normalized
|
||||
assert "approval_status = revoked_state" in normalized
|
||||
assert "embedding = null" in normalized
|
||||
assert "embedding_profile_hash = null" in normalized
|
||||
assert "where document_version_id = old.id" in normalized
|
||||
assert "chunks_enforce_active_search_projection" in normalized
|
||||
assert "document.active_version_id = new.document_version_id" in normalized
|
||||
assert "version.status = 'ready'" in normalized
|
||||
assert "documents_enforce_activation" in normalized
|
||||
|
||||
|
||||
def test_downgrade_drops_manifest_items_before_document_versions() -> None:
|
||||
normalized = " ".join(MIGRATION.lower().split())
|
||||
|
||||
manifest_drop = normalized.index("drop table if exists rag.outbound_manifest_items")
|
||||
version_drop = normalized.index("drop table if exists rag.document_versions")
|
||||
assert manifest_drop < version_drop
|
||||
|
||||
|
||||
def test_initial_migration_has_fenced_job_schema_and_indexes() -> None:
|
||||
normalized = " ".join(MIGRATION.lower().split())
|
||||
|
||||
assert "create table rag.background_jobs" in normalized
|
||||
assert "lease_owner text" in normalized
|
||||
assert "lease_token uuid" in normalized
|
||||
assert "lease_until timestamptz" in normalized
|
||||
assert "attempt <= max_attempts" in normalized
|
||||
assert "background_jobs_lease_consistent" in normalized
|
||||
assert "unique (job_type, idempotency_key)" in normalized
|
||||
assert "background_jobs_claim_queued" in normalized
|
||||
assert "background_jobs_reap_expired" in normalized
|
||||
|
||||
|
||||
def test_job_claim_and_terminal_updates_are_fenced() -> None:
|
||||
claim = " ".join(CLAIM_JOB_SQL.lower().split())
|
||||
heartbeat = " ".join(HEARTBEAT_JOB_SQL.lower().split())
|
||||
complete = " ".join(COMPLETE_JOB_SQL.lower().split())
|
||||
failure = " ".join(FAIL_OR_RETRY_JOB_SQL.lower().split())
|
||||
|
||||
assert "for update skip locked" in claim
|
||||
assert "lease_token = gen_random_uuid()" in claim
|
||||
assert "attempt = job.attempt + 1" in claim
|
||||
|
||||
for statement in (heartbeat, complete, failure):
|
||||
assert "job.status = 'running'" in statement
|
||||
assert "job.lease_owner = :worker_id" in statement
|
||||
assert "job.lease_token = :lease_token" in statement
|
||||
assert "returning" in statement
|
||||
|
||||
assert "when job.attempt < job.max_attempts then 'queued'" in failure
|
||||
assert "else 'failed'" in failure
|
||||
|
||||
|
||||
def test_reaper_uses_advisory_lock_and_handles_exhausted_attempts() -> None:
|
||||
reaper = " ".join(REAP_EXPIRED_JOBS_SQL.lower().split())
|
||||
|
||||
assert "pg_try_advisory_xact_lock" in reaper
|
||||
assert "job.status = 'running'" in reaper
|
||||
assert "job.lease_until < now()" in reaper
|
||||
assert "for update of job skip locked" in reaper
|
||||
assert "when job.attempt < job.max_attempts then 'queued'" in reaper
|
||||
assert "else 'failed'" in reaper
|
||||
assert "lease_owner = null" in reaper
|
||||
assert "lease_token = null" in reaper
|
||||
assert "lease_until = null" in reaper
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
74
backend/tests/unit/test_config_and_secrets.py
Normal file
74
backend/tests/unit/test_config_and_secrets.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.secrets import SecretFileError, read_secret_file
|
||||
|
||||
|
||||
def test_secret_file_strips_single_trailing_newline(tmp_path: Path) -> None:
|
||||
secret_path = tmp_path / "secret"
|
||||
secret_path.write_text("test-only-value\n", encoding="utf-8")
|
||||
|
||||
assert read_secret_file(secret_path) == "test-only-value"
|
||||
|
||||
|
||||
def test_secret_error_never_contains_secret_value(tmp_path: Path) -> None:
|
||||
secret_path = tmp_path / "secret"
|
||||
secret_path.write_text("first\nsecond\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SecretFileError) as captured:
|
||||
read_secret_file(secret_path)
|
||||
|
||||
assert "first" not in str(captured.value)
|
||||
assert "second" not in str(captured.value)
|
||||
|
||||
|
||||
def test_database_url_reads_password_from_file(tmp_path: Path) -> None:
|
||||
password_path = tmp_path / "postgres_password"
|
||||
password_path.write_text("local-test-password", encoding="utf-8")
|
||||
settings = Settings(postgres_password_file=password_path)
|
||||
|
||||
url = settings.database_url()
|
||||
|
||||
assert url.password == "local-test-password"
|
||||
assert "local-test-password" not in str(url)
|
||||
|
||||
|
||||
def test_base_urls_drop_trailing_slashes() -> None:
|
||||
settings = Settings(
|
||||
bailian_openai_base_url="https://example.invalid/compatible-mode/v1/",
|
||||
bailian_native_base_url="https://example.invalid/api/v1/",
|
||||
bailian_rerank_base_url="https://example.invalid/compatible-api/v1/",
|
||||
)
|
||||
|
||||
assert settings.bailian_openai_base_url.endswith("/v1")
|
||||
assert settings.bailian_rerank_base_url.endswith("/v1")
|
||||
|
||||
|
||||
def test_live_endpoints_must_share_one_beijing_workspace(tmp_path: Path) -> None:
|
||||
key_path = tmp_path / "key"
|
||||
key_path.write_text("test-key-value", encoding="utf-8")
|
||||
settings = Settings(
|
||||
dashscope_api_key_file=key_path,
|
||||
bailian_openai_base_url=(
|
||||
"https://workspace-a.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||
),
|
||||
bailian_rerank_base_url=(
|
||||
"https://workspace-b.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="same workspace"):
|
||||
settings.bailian_api_key()
|
||||
|
||||
|
||||
def test_live_endpoint_rejects_non_bailian_host_before_reading_key(tmp_path: Path) -> None:
|
||||
settings = Settings(
|
||||
dashscope_api_key_file=tmp_path / "missing",
|
||||
bailian_openai_base_url="https://attacker.invalid/compatible-mode/v1",
|
||||
bailian_rerank_base_url="https://attacker.invalid/compatible-api/v1",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="approved Beijing"):
|
||||
settings.bailian_api_key()
|
||||
37
backend/tests/unit/test_demo_data.py
Normal file
37
backend/tests/unit/test_demo_data.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
|
||||
|
||||
|
||||
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
|
||||
def test_demo_corpus_is_synthetic_pending_hash_bound_approval_and_unique() -> None:
|
||||
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
|
||||
|
||||
assert len(documents) == 20
|
||||
assert len({document["doc_id"] for document in documents}) == 20
|
||||
assert all(document["source_type"] == "synthetic" for document in documents)
|
||||
assert all(
|
||||
document["review_state"] == "LOCAL_PARSED_PENDING_CLOUD_REVIEW" for document in documents
|
||||
)
|
||||
assert all(document["cloud_policy_id"] == "synthetic-demo-v1" for document in documents)
|
||||
assert all("cloud_approved" not in document for document in documents)
|
||||
assert all(
|
||||
"虚构" in document["content"] or "演示" in document["content"] for document in documents
|
||||
)
|
||||
|
||||
|
||||
def test_demo_queries_only_reference_existing_documents() -> None:
|
||||
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
|
||||
queries = load_jsonl(SAMPLE_ROOT / "demo_queries.jsonl")
|
||||
document_ids = {document["doc_id"] for document in documents}
|
||||
|
||||
assert len(queries) == 10
|
||||
assert len({query["qid"] for query in queries}) == 10
|
||||
assert all(set(query["expected_doc_ids"]) <= document_ids for query in queries)
|
||||
assert any(not query["answerable"] and not query["expected_doc_ids"] for query in queries)
|
||||
47
backend/tests/unit/test_fake_models.py
Normal file
47
backend/tests/unit/test_fake_models.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker
|
||||
from app.ports.model_providers import ModelProviderError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_embedding_is_deterministic_normalized_and_1024_dimensional() -> None:
|
||||
provider = FakeEmbeddingProvider()
|
||||
|
||||
first = await provider.embed_documents(["斑岩铜矿 钾化 绢英岩化"])
|
||||
second = await provider.embed_query("斑岩铜矿 钾化 绢英岩化")
|
||||
|
||||
assert first.vectors[0] == second.vectors[0]
|
||||
assert len(first.vectors[0]) == 1024
|
||||
assert math.isclose(sum(value * value for value in first.vectors[0]), 1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_reranker_preserves_original_candidate_index() -> None:
|
||||
reranker = FakeReranker()
|
||||
documents = ["煤层测井对比", "斑岩铜矿钾化带", "铝土矿含矿层"]
|
||||
|
||||
result = await reranker.rerank("斑岩铜矿的钾化带", documents, top_n=2)
|
||||
|
||||
assert result.items[0].index == 1
|
||||
assert result.items[0].document == documents[1]
|
||||
assert result.items[0].relevance_score >= result.items[1].relevance_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_providers_reject_inputs_the_live_contract_rejects() -> None:
|
||||
embedder = FakeEmbeddingProvider()
|
||||
reranker = FakeReranker()
|
||||
|
||||
with pytest.raises(ModelProviderError):
|
||||
await embedder.embed_documents([])
|
||||
with pytest.raises(ModelProviderError):
|
||||
await embedder.embed_documents(["x"] * 11)
|
||||
with pytest.raises(ModelProviderError):
|
||||
await reranker.rerank("", ["document"], top_n=1)
|
||||
with pytest.raises(ModelProviderError):
|
||||
await reranker.rerank("query", [], top_n=1)
|
||||
with pytest.raises(ModelProviderError):
|
||||
await reranker.rerank("query", ["document"], top_n=0)
|
||||
30
backend/tests/unit/test_health_api.py
Normal file
30
backend/tests/unit/test_health_api.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveness_does_not_require_database_or_model_credentials() -> None:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/health/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meta_exposes_model_names_but_no_credentials() -> None:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/meta")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["models"] == {
|
||||
"embedding": "text-embedding-v4",
|
||||
"rerank": "qwen3-rerank",
|
||||
"generation": "deepseek-v4-flash",
|
||||
}
|
||||
assert "key" not in str(payload).lower()
|
||||
50
backend/tests/unit/test_offline_retrieval.py
Normal file
50
backend/tests/unit/test_offline_retrieval.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
|
||||
|
||||
|
||||
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
|
||||
def cosine(left: tuple[float, ...], right: tuple[float, ...]) -> float:
|
||||
return sum(
|
||||
left_value * right_value for left_value, right_value in zip(left, right, strict=True)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synthetic_questions_retrieve_expected_document_after_rerank() -> None:
|
||||
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
|
||||
queries = load_jsonl(SAMPLE_ROOT / "demo_queries.jsonl")
|
||||
embedder = FakeEmbeddingProvider()
|
||||
reranker = FakeReranker()
|
||||
document_texts = [f"{item['title']}\n{item['content']}" for item in documents]
|
||||
document_vectors: list[tuple[float, ...]] = []
|
||||
for offset in range(0, len(document_texts), 10):
|
||||
batch = await embedder.embed_documents(document_texts[offset : offset + 10])
|
||||
document_vectors.extend(batch.vectors)
|
||||
|
||||
hits = 0
|
||||
answerable_queries = [query for query in queries if query["answerable"]]
|
||||
for query in answerable_queries:
|
||||
query_vector = (await embedder.embed_query(query["query"])).vectors[0]
|
||||
candidate_indexes = sorted(
|
||||
range(len(documents)),
|
||||
key=lambda index: cosine(query_vector, document_vectors[index]),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
candidate_texts = [document_texts[index] for index in candidate_indexes]
|
||||
reranked = await reranker.rerank(query["query"], candidate_texts, top_n=3)
|
||||
result_ids = [documents[candidate_indexes[item.index]]["doc_id"] for item in reranked.items]
|
||||
if set(query["expected_doc_ids"]) & set(result_ids):
|
||||
hits += 1
|
||||
|
||||
assert hits / len(answerable_queries) >= 0.8
|
||||
26
backend/tests/unit/test_provider_smoke.py
Normal file
26
backend/tests/unit/test_provider_smoke.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||
from app.tools.provider_smoke import failed_probe
|
||||
|
||||
|
||||
def test_failed_probe_only_exposes_sanitized_provider_fields() -> None:
|
||||
error = ModelProviderError(
|
||||
operation="embedding.create",
|
||||
kind=ProviderErrorKind.AUTHENTICATION,
|
||||
status_code=401,
|
||||
provider_code="invalid_api_key",
|
||||
request_id="req-safe-1",
|
||||
)
|
||||
|
||||
result = failed_probe("embedding", error)
|
||||
|
||||
assert result.error_kind == "authentication"
|
||||
assert result.status_code == 401
|
||||
assert result.request_id == "req-safe-1"
|
||||
assert "invalid_api_key" not in repr(result)
|
||||
|
||||
|
||||
def test_unexpected_error_is_reduced_to_fixed_category() -> None:
|
||||
result = failed_probe("chat", RuntimeError("sensitive content"))
|
||||
|
||||
assert result.error_kind == "internal_contract_error"
|
||||
assert "sensitive content" not in repr(result)
|
||||
38
backend/tests/unit/test_secret_scanner.py
Normal file
38
backend/tests/unit/test_secret_scanner.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
SCANNER = PROJECT_ROOT / "scripts" / "check-secrets.sh"
|
||||
|
||||
|
||||
def run_git(repository: Path, *arguments: str) -> None:
|
||||
subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=repository,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_staged_scan_reads_index_blob_not_modified_worktree(tmp_path: Path) -> None:
|
||||
run_git(tmp_path, "init", "--quiet")
|
||||
run_git(tmp_path, "config", "user.email", "test@example.invalid")
|
||||
run_git(tmp_path, "config", "user.name", "Secret Scanner Test")
|
||||
candidate = tmp_path / "config.txt"
|
||||
candidate.write_text("sk-" + "A" * 30, encoding="utf-8")
|
||||
run_git(tmp_path, "add", "config.txt")
|
||||
candidate.write_text("safe working-tree replacement", encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
[str(SCANNER), "--staged"],
|
||||
cwd=tmp_path,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "config.txt" in result.stderr
|
||||
assert "safe working-tree replacement" not in result.stderr
|
||||
assert "A" * 30 not in result.stderr
|
||||
54
backend/tests/unit/test_seed_demo.py
Normal file
54
backend/tests/unit/test_seed_demo.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.fake import FakeEmbeddingProvider
|
||||
from app.core.config import Settings
|
||||
from app.tools.seed_demo import (
|
||||
embed_in_batches,
|
||||
embedding_profile_hash,
|
||||
load_documents,
|
||||
prepare_chunks,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
DOCUMENTS_PATH = PROJECT_ROOT / "data" / "samples" / "public" / "demo_documents.jsonl"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_preparation_batches_and_hash_binds_twenty_documents() -> None:
|
||||
documents = load_documents(DOCUMENTS_PATH)
|
||||
settings = Settings()
|
||||
provider = FakeEmbeddingProvider()
|
||||
texts = [
|
||||
f"标题:{item.title}\n地区:{item.region}\n矿种:{item.mineral}\n正文:{item.content}"
|
||||
for item in documents
|
||||
]
|
||||
|
||||
vectors, model = await embed_in_batches(provider, texts)
|
||||
chunks = prepare_chunks(
|
||||
documents,
|
||||
vectors,
|
||||
profile_hash=embedding_profile_hash(settings, "fake"),
|
||||
embedding_model=model,
|
||||
)
|
||||
|
||||
assert len(chunks) == 20
|
||||
assert all(len(item.vector) == 1024 for item in chunks)
|
||||
assert all(item.embedding_text == item.embedding_prefix + item.cloud_text for item in chunks)
|
||||
assert len({item.chunk_id for item in chunks}) == 20
|
||||
assert all(len(item.outbound_manifest_sha256) == 64 for item in chunks)
|
||||
|
||||
|
||||
def test_seed_rejects_fixture_not_explicitly_marked_synthetic(tmp_path: Path) -> None:
|
||||
path = tmp_path / "documents.jsonl"
|
||||
path.write_text(
|
||||
'{"doc_id":"x","title":"x","content":"x","region":"x",'
|
||||
'"mineral":"x","page_no":1,"source_type":"unknown",'
|
||||
'"review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW",'
|
||||
'"cloud_policy_id":"synthetic-demo-v1"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="non_synthetic_document"):
|
||||
load_documents(path)
|
||||
Reference in New Issue
Block a user