staleguard 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
staleguard/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .api import StaleGuard
4
+ from .auditor import (
5
+ audit as audit_chunks,
6
+ audit_chroma_result,
7
+ audit_langchain_docs,
8
+ audit_retrieved,
9
+ )
10
+ from .config import StaleGuardConfig
11
+ from .audit_types import AuditResult
12
+ from .providers import ConflictProvider, EmbeddingProvider, register_conflict_provider, register_embedding_provider
13
+
14
+
15
+ def audit(
16
+ query: str,
17
+ retrieved: object | None = None,
18
+ corpus: list[dict] | None = None,
19
+ *,
20
+ retrieved_chunks: list[dict] | None = None,
21
+ use_nli: bool = False,
22
+ block_on_conflict: bool = False,
23
+ config: StaleGuardConfig | None = None,
24
+ ):
25
+ if retrieved_chunks is not None:
26
+ if retrieved is not None:
27
+ raise TypeError("Pass either retrieved or retrieved_chunks, not both.")
28
+ return audit_chunks(
29
+ query=query,
30
+ retrieved_chunks=retrieved_chunks,
31
+ corpus=corpus,
32
+ use_nli=use_nli,
33
+ block_on_conflict=block_on_conflict,
34
+ config=config,
35
+ )
36
+
37
+ return audit_retrieved(
38
+ query=query,
39
+ retrieved=retrieved,
40
+ corpus=corpus,
41
+ use_nli=use_nli,
42
+ block_on_conflict=block_on_conflict,
43
+ config=config,
44
+ )
45
+
46
+
47
+ def evaluate_case(*args, **kwargs):
48
+ from .eval import evaluate_case as _evaluate_case
49
+
50
+ return _evaluate_case(*args, **kwargs)
51
+
52
+
53
+ def run_eval(*args, **kwargs):
54
+ from .eval import run_eval as _run_eval
55
+
56
+ return _run_eval(*args, **kwargs)
57
+
58
+ __all__ = [
59
+ "audit",
60
+ "__version__",
61
+ "StaleGuard",
62
+ "StaleGuardConfig",
63
+ "AuditResult",
64
+ ]
@@ -0,0 +1,11 @@
1
+ from .chroma import normalize_chroma_result
2
+ from .json_adapter import normalize_chunk, normalize_chunks
3
+ from .langchain import normalize_langchain_doc, normalize_langchain_docs
4
+
5
+ __all__ = [
6
+ "normalize_chunk",
7
+ "normalize_chunks",
8
+ "normalize_chroma_result",
9
+ "normalize_langchain_doc",
10
+ "normalize_langchain_docs",
11
+ ]
@@ -0,0 +1,50 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+ from .json_adapter import normalize_chunk
5
+
6
+
7
+ def _unwrap_rows(values: Any) -> list[Any]:
8
+ if values is None:
9
+ return []
10
+
11
+ if isinstance(values, list) and values and isinstance(values[0], list):
12
+ return values[0]
13
+
14
+ return list(values)
15
+
16
+
17
+ def _value_at(values: list[Any], index: int) -> Any:
18
+ if index >= len(values):
19
+ return None
20
+
21
+ return values[index]
22
+
23
+
24
+ def normalize_chroma_result(result: Mapping[str, Any]) -> list[dict[str, Any]]:
25
+ ids = _unwrap_rows(result.get("ids"))
26
+ documents = _unwrap_rows(result.get("documents"))
27
+ metadatas = _unwrap_rows(result.get("metadatas"))
28
+ distances = _unwrap_rows(result.get("distances"))
29
+
30
+ count = max(len(ids), len(documents), len(metadatas), len(distances))
31
+ chunks = []
32
+
33
+ for index in range(count):
34
+ current_metadata = dict(_value_at(metadatas, index) or {})
35
+ current_distance = _value_at(distances, index)
36
+
37
+ if current_distance is not None:
38
+ current_metadata["chroma_distance"] = current_distance
39
+
40
+ raw_chunk = {
41
+ "id": _value_at(ids, index),
42
+ "text": _value_at(documents, index),
43
+ "metadata": current_metadata,
44
+ }
45
+
46
+ chunks.append(normalize_chunk(raw_chunk, index=index))
47
+
48
+ return chunks
49
+
50
+
@@ -0,0 +1,67 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from typing import Any
3
+
4
+ FIELD_CANDIDATES = {
5
+ "id": ("id", "chunk_id", "document_id", "doc_id"),
6
+ "text": ("text", "page_content", "content", "document"),
7
+ "product": ("product",),
8
+ "topic": ("topic",),
9
+ "version": ("version",),
10
+ "date_ts": ("date_ts", "timestamp", "created_at_ts", "updated_at_ts"),
11
+ "source": ("source", "path", "uri", "filename", "file_name"),
12
+ }
13
+
14
+
15
+ def normalize_chunk(
16
+ raw_chunk: Mapping[str, Any],
17
+ index: int = 0,
18
+ field_map: Mapping[str, str] | None = None,
19
+ ) -> dict[str, Any]:
20
+ metadata = raw_chunk.get("metadata")
21
+ metadata = dict(metadata) if isinstance(metadata, Mapping) else {}
22
+ resolved_map = dict(field_map or {})
23
+
24
+ normalized = {
25
+ "id": _resolve_value(raw_chunk, metadata, "id", resolved_map) or f"chunk_{index}",
26
+ "text": _resolve_value(raw_chunk, metadata, "text", resolved_map),
27
+ "product": _resolve_value(raw_chunk, metadata, "product", resolved_map),
28
+ "topic": _resolve_value(raw_chunk, metadata, "topic", resolved_map),
29
+ "version": _resolve_value(raw_chunk, metadata, "version", resolved_map),
30
+ "date_ts": _resolve_value(raw_chunk, metadata, "date_ts", resolved_map),
31
+ "source": _resolve_value(raw_chunk, metadata, "source", resolved_map),
32
+ "metadata": metadata,
33
+ }
34
+
35
+ if not normalized["text"]:
36
+ raise ValueError("retrieved chunk is missing text/page_content/content")
37
+
38
+ return normalized
39
+
40
+
41
+ def normalize_chunks(
42
+ raw_chunks: Iterable[Mapping[str, Any]],
43
+ field_map: Mapping[str, str] | None = None,
44
+ ) -> list[dict[str, Any]]:
45
+ return [
46
+ normalize_chunk(raw_chunk, index=index, field_map=field_map)
47
+ for index, raw_chunk in enumerate(raw_chunks)
48
+ ]
49
+
50
+
51
+ def _resolve_value(
52
+ raw_chunk: Mapping[str, Any],
53
+ metadata: Mapping[str, Any],
54
+ field_name: str,
55
+ field_map: Mapping[str, str],
56
+ ) -> Any:
57
+ explicit_name = field_map.get(field_name)
58
+ if explicit_name:
59
+ return raw_chunk.get(explicit_name, metadata.get(explicit_name))
60
+
61
+ for candidate in FIELD_CANDIDATES[field_name]:
62
+ if candidate in raw_chunk:
63
+ return raw_chunk[candidate]
64
+ if candidate in metadata:
65
+ return metadata[candidate]
66
+
67
+ return None
@@ -0,0 +1,34 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from typing import Any
3
+
4
+ from .json_adapter import normalize_chunk
5
+
6
+
7
+ def normalize_langchain_doc(
8
+ doc: Any,
9
+ index: int = 0,
10
+ field_map: Mapping[str, str] | None = None,
11
+ ) -> dict[str, Any]:
12
+ raw_chunk = {
13
+ "page_content": getattr(doc, "page_content", None),
14
+ "metadata": getattr(doc, "metadata", None),
15
+ }
16
+
17
+ if isinstance(doc, Mapping):
18
+ raw_chunk["page_content"] = doc.get("page_content", raw_chunk["page_content"])
19
+ raw_chunk["metadata"] = doc.get("metadata", raw_chunk["metadata"])
20
+ for key in ("id", "text", "source", "product", "topic", "version", "date_ts"):
21
+ if key in doc:
22
+ raw_chunk[key] = doc[key]
23
+
24
+ return normalize_chunk(raw_chunk, index=index, field_map=field_map)
25
+
26
+
27
+ def normalize_langchain_docs(
28
+ docs: Iterable[Any],
29
+ field_map: Mapping[str, str] | None = None,
30
+ ) -> list[dict[str, Any]]:
31
+ return [
32
+ normalize_langchain_doc(doc, index=index, field_map=field_map)
33
+ for index, doc in enumerate(docs)
34
+ ]
@@ -0,0 +1,3 @@
1
+ from .finder import find_fresh_alternatives
2
+
3
+ __all__ = ["find_fresh_alternatives"]
@@ -0,0 +1,35 @@
1
+ from ..config import StaleGuardConfig
2
+ from ..scorers.freshness import find_superseding_chunk, score_chunk_freshness
3
+
4
+
5
+ def find_fresh_alternatives(
6
+ retrieved_chunks: list[dict],
7
+ corpus: list[dict] | None,
8
+ query: str,
9
+ config: StaleGuardConfig | None = None,
10
+ now_ts: int | None = None,
11
+ ) -> list[dict]:
12
+ if not corpus:
13
+ return []
14
+
15
+ alternatives = []
16
+ for chunk in retrieved_chunks:
17
+ freshness = score_chunk_freshness(chunk, query, corpus, config=config, now_ts=now_ts)
18
+ if freshness["verdict"] != "STALE":
19
+ continue
20
+
21
+ replacement = find_superseding_chunk(chunk, corpus, config=config)
22
+ if not replacement:
23
+ continue
24
+
25
+ alternatives.append(
26
+ {
27
+ "instead_of": chunk["id"],
28
+ "use": replacement["id"],
29
+ "reason": (
30
+ f"Same product/topic, newer version available: "
31
+ f"{chunk.get('version')} -> {replacement.get('version')}"
32
+ ),
33
+ }
34
+ )
35
+ return alternatives
staleguard/api.py ADDED
@@ -0,0 +1,135 @@
1
+ from dataclasses import fields, replace
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from .audit_types import AuditResult
6
+ from .auditor import audit as audit_chunks_impl
7
+ from .auditor import audit_retrieved
8
+ from .config import StaleGuardConfig
9
+
10
+
11
+ CONFIG_FIELD_NAMES = {field.name for field in fields(StaleGuardConfig)}
12
+
13
+
14
+ def _clean_overrides(overrides: dict[str, Any]) -> dict[str, Any]:
15
+ return {
16
+ key: value
17
+ for key, value in overrides.items()
18
+ if key in CONFIG_FIELD_NAMES and value is not None
19
+ }
20
+
21
+
22
+ class StaleGuard:
23
+ def __init__(
24
+ self,
25
+ config: StaleGuardConfig | None = None,
26
+ **overrides: Any,
27
+ ) -> None:
28
+ cleaned = _clean_overrides(overrides)
29
+ self.config = replace(config, **cleaned) if config is not None else StaleGuardConfig(**cleaned)
30
+
31
+ @classmethod
32
+ def from_backends(
33
+ cls,
34
+ *,
35
+ embedding_backend: str = "local",
36
+ conflict_backend: str = "local",
37
+ **overrides: Any,
38
+ ) -> "StaleGuard":
39
+ return cls(
40
+ embedding_backend=embedding_backend,
41
+ conflict_backend=conflict_backend,
42
+ **overrides,
43
+ )
44
+
45
+ @classmethod
46
+ def from_providers(
47
+ cls,
48
+ *,
49
+ embedding_provider: Any = None,
50
+ conflict_provider: Any = None,
51
+ **overrides: Any,
52
+ ) -> "StaleGuard":
53
+ return cls(
54
+ embedding_provider=embedding_provider,
55
+ conflict_provider=conflict_provider,
56
+ **overrides,
57
+ )
58
+
59
+ def audit(
60
+ self,
61
+ query: str,
62
+ retrieved: object,
63
+ corpus: list[dict] | None = None,
64
+ ) -> AuditResult:
65
+ return audit_retrieved(
66
+ query=query,
67
+ retrieved=retrieved,
68
+ corpus=corpus,
69
+ config=self.config,
70
+ )
71
+
72
+ def audit_chunks(
73
+ self,
74
+ query: str,
75
+ retrieved_chunks: list[dict],
76
+ corpus: list[dict] | None = None,
77
+ ) -> AuditResult:
78
+ return audit_chunks_impl(
79
+ query=query,
80
+ retrieved_chunks=retrieved_chunks,
81
+ corpus=corpus,
82
+ config=self.config,
83
+ )
84
+
85
+ def evaluate_case(
86
+ self,
87
+ case: dict,
88
+ corpus: list[dict],
89
+ *,
90
+ use_nli: bool | None = None,
91
+ block_on_conflict: bool | None = None,
92
+ ) -> dict:
93
+ from .eval import evaluate_case
94
+
95
+ return evaluate_case(
96
+ case=case,
97
+ corpus=corpus,
98
+ config=self._config_with_runtime_flags(use_nli, block_on_conflict),
99
+ )
100
+
101
+ def run_eval(
102
+ self,
103
+ corpus_path: str | Path,
104
+ cases_path: str | Path,
105
+ *,
106
+ use_nli: bool | None = None,
107
+ block_on_conflict: bool | None = None,
108
+ ) -> dict:
109
+ from .eval import run_eval
110
+
111
+ return run_eval(
112
+ corpus_path=corpus_path,
113
+ cases_path=cases_path,
114
+ config=self._config_with_runtime_flags(use_nli, block_on_conflict),
115
+ )
116
+
117
+ def with_config(self, **overrides: Any) -> "StaleGuard":
118
+ return StaleGuard(self.config, **overrides)
119
+
120
+ def _config_with_runtime_flags(
121
+ self,
122
+ use_nli: bool | None,
123
+ block_on_conflict: bool | None,
124
+ ) -> StaleGuardConfig:
125
+ overrides = _clean_overrides(
126
+ {
127
+ "use_nli": self.config.use_nli if use_nli is None else use_nli,
128
+ "block_on_conflict": (
129
+ self.config.block_on_conflict
130
+ if block_on_conflict is None
131
+ else block_on_conflict
132
+ ),
133
+ }
134
+ )
135
+ return replace(self.config, **overrides)
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class AuditResult:
6
+ verdict: str
7
+ confidence: float
8
+ stale_chunks: list
9
+ conflicts: list
10
+ fresh_alternatives: list
11
+ provenance: dict