fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
220 lines
7.7 KiB
Python
220 lines
7.7 KiB
Python
"""Tests for SourceConfig parse/validation (lenient read, strict write)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from application.storage.db.source_config import (
|
|
ChunkingConfig,
|
|
GraphConfig,
|
|
PreScreenConfig,
|
|
RetrievalConfig,
|
|
SourceConfig,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestParseLenient:
|
|
def test_parse_empty_dict_is_all_defaults(self):
|
|
cfg = SourceConfig.parse({})
|
|
assert cfg == SourceConfig()
|
|
assert cfg.kind == "classic"
|
|
assert cfg.chunking == ChunkingConfig()
|
|
assert cfg.retrieval == RetrievalConfig()
|
|
assert cfg.graph == GraphConfig()
|
|
|
|
def test_parse_none_is_all_defaults(self):
|
|
cfg = SourceConfig.parse(None)
|
|
assert cfg == SourceConfig()
|
|
|
|
def test_chunking_defaults_match_worker(self):
|
|
# Byte-identical-ingest guarantee: defaults equal worker MAX/MIN_TOKENS.
|
|
c = ChunkingConfig()
|
|
assert c.strategy == "classic_chunk"
|
|
assert c.max_tokens == 1250
|
|
assert c.min_tokens == 150
|
|
assert c.duplicate_headers is False
|
|
|
|
def test_retrieval_defaults(self):
|
|
r = RetrievalConfig()
|
|
assert r.retriever == "classic"
|
|
assert r.exposure == "prefetch"
|
|
assert r.chunks == 2
|
|
assert r.score_threshold is None
|
|
assert r.rephrase_query is True
|
|
assert r.reranker is None
|
|
assert r.prescreen is None
|
|
|
|
def test_parse_partial_merges_onto_defaults(self):
|
|
cfg = SourceConfig.parse({"retrieval": {"chunks": 5}})
|
|
assert cfg.retrieval.chunks == 5
|
|
# Untouched fields keep their defaults.
|
|
assert cfg.retrieval.retriever == "classic"
|
|
assert cfg.chunking.max_tokens == 1250
|
|
|
|
def test_parse_bad_type_falls_back_to_defaults(self):
|
|
# Lenient read: a non-dict / invalid blob never crashes the caller.
|
|
assert SourceConfig.parse("not-a-dict") == SourceConfig()
|
|
assert SourceConfig.parse({"retrieval": {"chunks": "lots"}}) == SourceConfig()
|
|
assert SourceConfig.parse({"unknown_key": 1}) == SourceConfig()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStrictWrite:
|
|
def test_model_validate_rejects_bad_type(self):
|
|
with pytest.raises(ValidationError):
|
|
SourceConfig.model_validate({"retrieval": {"chunks": "lots"}})
|
|
|
|
def test_model_validate_rejects_unknown_key(self):
|
|
with pytest.raises(ValidationError):
|
|
SourceConfig.model_validate({"unexpected": True})
|
|
|
|
def test_chunks_upper_bound_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
RetrievalConfig(chunks=501)
|
|
|
|
def test_chunks_lower_bound_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
RetrievalConfig(chunks=0)
|
|
|
|
def test_chunks_accepts_small_and_ceiling_values(self):
|
|
assert RetrievalConfig(chunks=2).chunks == 2
|
|
assert RetrievalConfig(chunks=500).chunks == 500
|
|
|
|
def test_model_validate_accepts_full_valid_config(self):
|
|
cfg = SourceConfig.model_validate(
|
|
{
|
|
"kind": "classic",
|
|
"chunking": {
|
|
"strategy": "classic_chunk",
|
|
"max_tokens": 800,
|
|
"min_tokens": 100,
|
|
"duplicate_headers": True,
|
|
},
|
|
"retrieval": {
|
|
"retriever": "classic",
|
|
"exposure": "prefetch",
|
|
"chunks": 4,
|
|
"score_threshold": 0.2,
|
|
"rephrase_query": False,
|
|
},
|
|
}
|
|
)
|
|
assert cfg.chunking.max_tokens == 800
|
|
assert cfg.retrieval.score_threshold == 0.2
|
|
assert cfg.retrieval.rephrase_query is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPreScreenConfig:
|
|
def test_defaults(self):
|
|
ps = PreScreenConfig()
|
|
assert ps.candidate_k == 40
|
|
assert ps.model is None
|
|
assert ps.batch_size == 10
|
|
assert ps.max_keep == 8
|
|
|
|
def test_max_keep_must_not_exceed_candidate_k(self):
|
|
with pytest.raises(ValidationError):
|
|
PreScreenConfig(candidate_k=5, max_keep=10)
|
|
|
|
def test_bounds_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
PreScreenConfig(candidate_k=0)
|
|
with pytest.raises(ValidationError):
|
|
PreScreenConfig(batch_size=1000)
|
|
|
|
def test_prescreen_default_off(self):
|
|
assert RetrievalConfig().prescreen is None
|
|
assert RetrievalConfig().prescreen_config() is None
|
|
|
|
def test_retrieval_validates_prescreen_dict_strictly(self):
|
|
# candidate_k < chunks is rejected on the write path.
|
|
with pytest.raises(ValidationError):
|
|
RetrievalConfig.model_validate(
|
|
{"chunks": 20, "prescreen": {"candidate_k": 5}}
|
|
)
|
|
|
|
def test_retrieval_normalises_prescreen(self):
|
|
rc = RetrievalConfig.model_validate(
|
|
{"chunks": 2, "prescreen": {"candidate_k": 30, "max_keep": 4}}
|
|
)
|
|
ps = rc.prescreen_config()
|
|
assert ps.candidate_k == 30
|
|
assert ps.max_keep == 4
|
|
# Defaults filled in.
|
|
assert ps.batch_size == 10
|
|
|
|
def test_prescreen_config_lenient_read(self):
|
|
# A garbage stored prescreen blob never crashes the read path.
|
|
rc = RetrievalConfig.model_construct(prescreen={"candidate_k": "x"})
|
|
assert rc.prescreen_config() is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGraphConfig:
|
|
def test_defaults(self):
|
|
g = GraphConfig()
|
|
assert g.extraction_model is None
|
|
assert g.max_chunks is None
|
|
assert g.gleanings == 0
|
|
|
|
def test_parse_graph_block_round_trips(self):
|
|
raw = {
|
|
"kind": "graphrag",
|
|
"graph": {
|
|
"extraction_model": "gpt-4o-mini",
|
|
"max_chunks": 500,
|
|
"gleanings": 1,
|
|
},
|
|
}
|
|
cfg = SourceConfig.parse(raw)
|
|
assert cfg.kind == "graphrag"
|
|
assert cfg.graph.extraction_model == "gpt-4o-mini"
|
|
assert cfg.graph.max_chunks == 500
|
|
assert cfg.graph.gleanings == 1
|
|
dumped = cfg.model_dump()
|
|
assert SourceConfig.model_validate(dumped) == cfg
|
|
assert dumped["graph"] == {
|
|
"extraction_model": "gpt-4o-mini",
|
|
"max_chunks": 500,
|
|
"gleanings": 1,
|
|
}
|
|
|
|
def test_forbid_unknown_graph_key(self):
|
|
with pytest.raises(ValidationError):
|
|
SourceConfig.model_validate({"graph": {"unknown": 1}})
|
|
with pytest.raises(ValidationError):
|
|
GraphConfig(unknown=1)
|
|
|
|
def test_negative_values_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
GraphConfig(gleanings=-1)
|
|
with pytest.raises(ValidationError):
|
|
GraphConfig(max_chunks=0)
|
|
|
|
def test_parse_lenient_on_garbage_graph(self):
|
|
# A non-dict / invalid graph blob falls back to all-defaults on read.
|
|
assert SourceConfig.parse({"graph": "nope"}) == SourceConfig()
|
|
assert SourceConfig.parse({"graph": {"gleanings": "lots"}}) == SourceConfig()
|
|
# Missing graph block still yields the default GraphConfig.
|
|
cfg = SourceConfig.parse({"kind": "graphrag"})
|
|
assert cfg.graph == GraphConfig()
|
|
|
|
def test_graph_enabled_sets_kind_and_retriever(self):
|
|
out = SourceConfig().graph_enabled()
|
|
assert out["kind"] == "graphrag"
|
|
assert out["retrieval"]["retriever"] == "graphrag"
|
|
# Round-trips through strict validation.
|
|
assert SourceConfig.model_validate(out).kind == "graphrag"
|
|
|
|
def test_graph_enabled_preserves_other_fields(self):
|
|
cfg = SourceConfig.parse(
|
|
{"retrieval": {"chunks": 7, "exposure": "agentic_tool"}}
|
|
)
|
|
out = cfg.graph_enabled()
|
|
assert out["retrieval"]["chunks"] == 7
|
|
assert out["retrieval"]["exposure"] == "agentic_tool"
|
|
assert out["retrieval"]["retriever"] == "graphrag"
|