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