fix: harden model catalog projections
This commit is contained in:
@@ -19,6 +19,9 @@ from tht.evidence.contracts import (
|
||||
_NAMESPACED_ID = re.compile(r"^[a-z][a-z0-9_-]*:[A-Za-z0-9._:-]+$")
|
||||
_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
_EVIDENCE_ID = re.compile(r"^evidence:[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
_CANONICAL_MODEL_ID = re.compile(
|
||||
r"^[a-z][a-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"
|
||||
)
|
||||
_EVIDENCE_METADATA_KEYS = frozenset({
|
||||
"evidence_id", "evidence_kind", "purposes", "scope", "language", "provenance",
|
||||
})
|
||||
@@ -37,6 +40,12 @@ def _validate_hash(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_canonical_model_id(value: str) -> str:
|
||||
if not _CANONICAL_MODEL_ID.fullmatch(value):
|
||||
raise ValueError("model identifier must use canonical provider/model form")
|
||||
return value
|
||||
|
||||
|
||||
def _require_content_hash(content: str, content_hash: str) -> None:
|
||||
expected = f"sha256:{hashlib.sha256(content.encode('utf-8')).hexdigest()}"
|
||||
if content_hash != expected:
|
||||
@@ -146,6 +155,7 @@ class CorpusManifest(_WithMetadata):
|
||||
manifest_id: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
pipeline_version: str = Field(default="evidence-v1", min_length=1)
|
||||
embedding_id: str | None = None
|
||||
embedding_model: str | None = None
|
||||
embedding_dimensions: int | None = Field(default=None, gt=0)
|
||||
vector_generation: str | None = None
|
||||
@@ -155,6 +165,9 @@ class CorpusManifest(_WithMetadata):
|
||||
_manifest_id = field_validator("manifest_id")(
|
||||
lambda value: _validate_namespaced_id(value) if value is not None else None
|
||||
)
|
||||
_embedding_id = field_validator("embedding_id")(
|
||||
lambda value: _validate_canonical_model_id(value) if value is not None else None
|
||||
)
|
||||
_vector_generation = field_validator("vector_generation")(
|
||||
lambda value: _validate_namespaced_id(value) if value is not None else None
|
||||
)
|
||||
@@ -164,6 +177,10 @@ class CorpusManifest(_WithMetadata):
|
||||
def validate_generation(self) -> "CorpusManifest":
|
||||
if (self.embedding_model is None) != (self.embedding_dimensions is None):
|
||||
raise ValueError("embedding_model and embedding_dimensions must be set together")
|
||||
if self.embedding_id is not None and self.embedding_model is None:
|
||||
raise ValueError("embedding_id requires embedding model and dimensions")
|
||||
if self.schema_version >= 2 and self.embedding_model is not None and self.embedding_id is None:
|
||||
raise ValueError("schema version 2 embedding generations require embedding_id")
|
||||
if self.vector_generation is not None and self.embedding_model is None:
|
||||
raise ValueError("vector_generation requires embedding model and dimension compatibility")
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ class CorpusPipeline:
|
||||
def __init__(
|
||||
self, *, store: CorpusStore, sources: list[EvidenceSource], embedder,
|
||||
vector_store: VectorStore, embedding_model: str, embedding_dimensions: int,
|
||||
embedding_id: str | None = None,
|
||||
chunk_policy: ChunkPolicy, pipeline_version: str, retain_published_generations: int = 3,
|
||||
workspace_id: str | None = None, sparse_language: str = "italian",
|
||||
candidate_evaluator: Callable[[CorpusManifest], object] | None = None,
|
||||
@@ -133,6 +134,7 @@ class CorpusPipeline:
|
||||
self.sources = sources
|
||||
self.embedder = embedder
|
||||
self.vector_store = vector_store
|
||||
self.embedding_id = embedding_id or f"ollama/{embedding_model}"
|
||||
self.embedding_model = embedding_model
|
||||
self.embedding_dimensions = embedding_dimensions
|
||||
self.chunk_policy = chunk_policy
|
||||
@@ -284,6 +286,7 @@ class CorpusPipeline:
|
||||
source_by_id = {item.source_id: (source, item) for source, item in discovered}
|
||||
compatibility = _fingerprint({
|
||||
"pipeline": self.pipeline_version,
|
||||
"embedding_id": self.embedding_id,
|
||||
"model": self.embedding_model,
|
||||
"dimensions": self.embedding_dimensions,
|
||||
"chunk_policy": asdict(self.chunk_policy),
|
||||
@@ -294,6 +297,7 @@ class CorpusPipeline:
|
||||
"compatibility_fingerprint": compatibility,
|
||||
"pipeline_version": self.pipeline_version,
|
||||
"chunk_policy_version": self.chunk_policy.version,
|
||||
"embedding_id": self.embedding_id,
|
||||
"embedding_model": self.embedding_model,
|
||||
"embedding_dimensions": self.embedding_dimensions,
|
||||
}
|
||||
@@ -492,7 +496,9 @@ class CorpusPipeline:
|
||||
) for document in documents
|
||||
}
|
||||
manifest = CorpusManifest(
|
||||
schema_version=2,
|
||||
pipeline_version=self.pipeline_version,
|
||||
embedding_id=self.embedding_id,
|
||||
embedding_model=self.embedding_model,
|
||||
embedding_dimensions=self.embedding_dimensions,
|
||||
vector_generation=plan["generation"],
|
||||
@@ -723,7 +729,8 @@ class CorpusPipeline:
|
||||
prior_documents = {doc.source_id: doc for doc in previous.documents} if previous else {}
|
||||
fingerprints = {item.source_id: item.fingerprint for _, item in discovered}
|
||||
compatibility = _fingerprint({
|
||||
"pipeline": self.pipeline_version, "model": self.embedding_model,
|
||||
"pipeline": self.pipeline_version, "embedding_id": self.embedding_id,
|
||||
"model": self.embedding_model,
|
||||
"dimensions": self.embedding_dimensions, "chunk_policy": asdict(self.chunk_policy),
|
||||
})
|
||||
previous_compatibility = previous.metadata.get("compatibility_fingerprint") if previous else None
|
||||
@@ -761,7 +768,9 @@ class CorpusPipeline:
|
||||
for document in documents
|
||||
}
|
||||
manifest = CorpusManifest(
|
||||
schema_version=2,
|
||||
pipeline_version=self.pipeline_version,
|
||||
embedding_id=self.embedding_id,
|
||||
embedding_model=self.embedding_model,
|
||||
embedding_dimensions=self.embedding_dimensions,
|
||||
vector_generation=generation,
|
||||
|
||||
@@ -22,6 +22,7 @@ def build_preprocessing_pipeline(
|
||||
vector_store: VectorStore,
|
||||
embedding_model: str,
|
||||
embedding_dimensions: int,
|
||||
embedding_id: str | None = None,
|
||||
chunk_policy: ChunkPolicy,
|
||||
pipeline_version: str,
|
||||
retain_published_generations: int = 3,
|
||||
@@ -35,6 +36,7 @@ def build_preprocessing_pipeline(
|
||||
sources=sources,
|
||||
embedder=embedder,
|
||||
vector_store=vector_store,
|
||||
embedding_id=embedding_id,
|
||||
embedding_model=embedding_model,
|
||||
embedding_dimensions=embedding_dimensions,
|
||||
chunk_policy=chunk_policy,
|
||||
|
||||
Reference in New Issue
Block a user