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 +64 -0
- staleguard/adapters/__init__.py +11 -0
- staleguard/adapters/chroma.py +50 -0
- staleguard/adapters/json_adapter.py +67 -0
- staleguard/adapters/langchain.py +34 -0
- staleguard/alternatives/__init__.py +3 -0
- staleguard/alternatives/finder.py +35 -0
- staleguard/api.py +135 -0
- staleguard/audit_types.py +11 -0
- staleguard/auditor.py +444 -0
- staleguard/chroma_demo.py +143 -0
- staleguard/cli.py +60 -0
- staleguard/config.py +31 -0
- staleguard/conflicts/__init__.py +3 -0
- staleguard/conflicts/nli.py +68 -0
- staleguard/conflicts/rules.py +213 -0
- staleguard/engineering_demo.py +104 -0
- staleguard/eval.py +202 -0
- staleguard/main.py +31 -0
- staleguard/matching.py +179 -0
- staleguard/providers.py +166 -0
- staleguard/schema.py +124 -0
- staleguard/scorers/__init__.py +17 -0
- staleguard/scorers/freshness.py +195 -0
- staleguard-0.1.0.dist-info/METADATA +361 -0
- staleguard-0.1.0.dist-info/RECORD +30 -0
- staleguard-0.1.0.dist-info/WHEEL +5 -0
- staleguard-0.1.0.dist-info/entry_points.txt +2 -0
- staleguard-0.1.0.dist-info/licenses/LICENSE +21 -0
- staleguard-0.1.0.dist-info/top_level.txt +1 -0
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,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)
|