semantic-trust 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.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: semantic-trust
3
+ Version: 0.1.0
4
+ Summary: Score and certify dbt Semantic Layer models — trust bands, gates, and dbt-parse certification.
5
+ Requires-Python: >=3.11
6
+ License-File: LICENSE
7
+ Requires-Dist: mcp>=1.0
8
+ Provides-Extra: ci
9
+ Requires-Dist: pytest>=8.0; extra == "ci"
10
+ Requires-Dist: hypothesis>=6.0; extra == "ci"
11
+ Requires-Dist: pyyaml>=6.0; extra == "ci"
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: hypothesis>=6.0; extra == "dev"
15
+ Requires-Dist: pyyaml>=6.0; extra == "dev"
16
+ Requires-Dist: dbt-metricflow<0.14,>=0.13; extra == "dev"
17
+ Dynamic: license-file
@@ -0,0 +1,21 @@
1
+ semantic_trust-0.1.0.dist-info/licenses/LICENSE,sha256=1r0o_UhrOmuTUzlbt5iEVop1Fv5ZO8RjvYGMNjX4oWo,1068
2
+ trust/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ trust/cli.py,sha256=-4aJu9cjC6OJVB4K2iX6drH_UpdG5lh9Azu3gW-WtL0,4953
4
+ trust/compile.py,sha256=XbwwQI2OHyY46yybq9ZIXvDDh6_2H5Q4Yv4TNf7Kb7I,1060
5
+ trust/config.py,sha256=O2dIO9FdRy05Zv6jhxRoKZLumpaBDmdrz1R7zw5yVT4,1186
6
+ trust/joinability.py,sha256=HAW5eKfiC5tQKMBI4Zelh8hC_yaGdwyctNqJtKfuvgM,8471
7
+ trust/judgment.py,sha256=dIzYuuR54YCSgsH_1XjgQvpr1e7MSm8xo-eSHGZrD6Q,3899
8
+ trust/manifest_loader.py,sha256=gVu2X5iEAkgC8WCznhpSovFg4eA43aNqAS04cbRF6wg,6474
9
+ trust/manifest_scaffold.py,sha256=JTdCIDvkrb2QF5E29hKpIfrdmRollheeET7bOqoB404,3783
10
+ trust/mcp_server.py,sha256=rtopQD_TWnD-SjiXbBKFsDWC5BZvnAAHOx5EQSdPnik,9177
11
+ trust/normalized.py,sha256=OFNGR39W5yQtETFvjahahK8Gj-j2k8s8Q3W_yT8S5kU,869
12
+ trust/ownership.py,sha256=4SLmFQUAtJF9A-YhY7ZMJ6lewXOL_DuMwogMCMApAIE,3423
13
+ trust/report.py,sha256=hii2CQbNOPmhJcxF1LWaQF5pVzxonYy40KOwMnlMt6A,4329
14
+ trust/scorer.py,sha256=71cJFX0O7eC-LdsEesDDWEP1BZBTuMNVtDrCG_StwIU,11521
15
+ trust/spec_detect.py,sha256=lm2eRLsJZ3PkK8DcTqALUbEKpXvf3ewPCjOgVcohcbc,1375
16
+ trust/uniqueness.py,sha256=PZGyuvCj0iWlilz6Xit2MOmD3gQAXsvsfHDxmD6xHCU,2732
17
+ semantic_trust-0.1.0.dist-info/METADATA,sha256=DUxkvQVRmb-b42lwB1o387PTy9Ssyafs5ilSMbjEBLs,616
18
+ semantic_trust-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
19
+ semantic_trust-0.1.0.dist-info/entry_points.txt,sha256=_26KQWYsv1EnYiK0aBpCZIAL4Md13_dwmzDW8HWm-88,97
20
+ semantic_trust-0.1.0.dist-info/top_level.txt,sha256=NzvF6jjub_MhDLfrB0muRk25AxCu4iFX0dzIHx9d1uU,6
21
+ semantic_trust-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ semantic-trust = trust.cli:main_cli
3
+ semantic-trust-mcp = trust.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ravish Jain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ trust
trust/__init__.py ADDED
File without changes
trust/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ """
2
+ Local dev CLI for the trust scorer. NOT the skill-facing interface in production —
3
+ skills consume the MCP server (trust/mcp_server.py, AD-1).
4
+
5
+ Retained for: manual spot-checks during development, smoke tests, Task 3 wiring smoke.
6
+ """
7
+ import json, sys
8
+ from trust.manifest_loader import load_models, load_metrics
9
+ from trust.uniqueness import find_collisions
10
+ from trust.joinability import check_joinability
11
+ from trust.scorer import score_model
12
+
13
+
14
+ def _metrics_for_model(sm, all_metrics):
15
+ """
16
+ Return metrics owned by this semantic model.
17
+
18
+ Attribution is exact via each metric's owner_model field, stamped at load time:
19
+ - Latest spec: STRUCTURAL — a simple metric is owned by the model it is nested
20
+ under (semantic_model.metrics). No column/substring matching.
21
+ - Legacy spec: the semantic_model that DEFINES the measure the metric references
22
+ (type_params.measure → SM).
23
+ Cross-model metrics (latest derived/ratio) have owner_model=None and match no model.
24
+ """
25
+ return [m for m in all_metrics if m.owner_model == sm.name]
26
+
27
+
28
+ def _orphan_collision_warnings(all_metrics: list, collisions: list) -> list[str]:
29
+ """
30
+ Build warning strings for collisions that involve at least one unattributed
31
+ (owner_model=None) metric.
32
+
33
+ Orphan metrics are excluded from any single model's gate, so a collision among
34
+ them would otherwise pass clean-and-silent. This helper surfaces BOTH formula and
35
+ name collisions that involve an orphan so they are visible in the report.
36
+
37
+ Note: a name collision between two orphan metrics is unreachable via real dbt —
38
+ dbt enforces unique metric names at parse time. This path is retained as
39
+ defense-in-depth and is unit-tested directly in tests/test_cli.py.
40
+
41
+ Args:
42
+ all_metrics: full list of NormalizedMetric for the project.
43
+ collisions: output of find_collisions(all_metrics), each a dict with keys
44
+ {kind, a, b, files}.
45
+
46
+ Returns:
47
+ List of human-readable warning strings (may be empty).
48
+ """
49
+ unattributed_names = {m.name for m in all_metrics if m.owner_model is None}
50
+ warnings: list[str] = []
51
+ for c in collisions:
52
+ kind = c.get("kind")
53
+ if kind not in ("formula", "name"):
54
+ continue
55
+ label = "formula collision" if kind == "formula" else "name collision"
56
+ if c.get("a") in unattributed_names:
57
+ warnings.append(
58
+ f"unattributed metric '{c['a']}' has {label} with '{c['b']}'"
59
+ )
60
+ elif c.get("b") in unattributed_names:
61
+ warnings.append(
62
+ f"unattributed metric '{c['b']}' has {label} with '{c['a']}'"
63
+ )
64
+ return warnings
65
+
66
+
67
+ def build_report_object(project_dir: str, model_name: str):
68
+ """
69
+ Build the fully-populated ModelReport object for *model_name*.
70
+
71
+ Returns a ModelReport on success, or a dict {"model": ..., "error": ...} if the
72
+ semantic model is not found. Callers that need the object for apply_judgment must
73
+ check for the error dict first (isinstance check on dict).
74
+
75
+ This is the shared implementation beneath build_report (dict) and the MCP
76
+ validate_semantic_model handler (object). Back-compat: build_report's dict
77
+ contract is preserved by calling .to_dict() on the result.
78
+ """
79
+ from trust.report import ModelReport # avoid circular at module level
80
+ models = load_models(project_dir)
81
+ sm = next((m for m in models if m.name == model_name), None)
82
+ if sm is None:
83
+ return {"model": model_name, "error": "semantic model not found in project"}
84
+ all_metrics = load_metrics(project_dir)
85
+ collisions = find_collisions(all_metrics)
86
+ model_metrics = _metrics_for_model(sm, all_metrics)
87
+ # Joinability needs ALL models — compute cross-model issues then filter to this model.
88
+ all_j_issues = check_joinability(models)
89
+ model_j_issues = [i for i in all_j_issues if i.location == sm.source_file]
90
+ rep = score_model(
91
+ sm, model_metrics, collisions,
92
+ joinability_issues=model_j_issues,
93
+ project_dir=project_dir,
94
+ )
95
+ unattributed = [m for m in all_metrics if m.owner_model is None]
96
+ rep.warnings = _orphan_collision_warnings(all_metrics, collisions)
97
+ rep.unattributed_metrics = len(unattributed)
98
+ return rep
99
+
100
+
101
+ def build_report(project_dir: str, model_name: str) -> dict:
102
+ """Return the deterministic trust report as a plain dict (back-compat public API)."""
103
+ result = build_report_object(project_dir, model_name)
104
+ if isinstance(result, dict):
105
+ # error path — already a dict
106
+ return result
107
+ return result.to_dict()
108
+
109
+
110
+ def main_cli():
111
+ if len(sys.argv) < 3:
112
+ print("usage: python -m trust.cli <project_dir> <model_name>", file=sys.stderr)
113
+ sys.exit(1)
114
+ project_dir, model_name = sys.argv[1], sys.argv[2]
115
+ print(json.dumps(build_report(project_dir, model_name), indent=2))
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main_cli()
trust/compile.py ADDED
@@ -0,0 +1,28 @@
1
+ """Production-path helper: run the user's own `dbt parse` to (re)generate
2
+ target/semantic_manifest.json, then the engine reads it. Version-agnostic:
3
+ `dbt parse` works for both legacy (1.6-1.11) and latest (1.12+) specs.
4
+ (mf validate-configs is legacy-only — dbt-metricflow pins dbt-core <1.12 — so
5
+ dbt parse, not mf, is the universal compile gate.)"""
6
+ import os
7
+ import subprocess
8
+
9
+
10
+ def compile_manifest(project_dir: str, profiles_dir: str | None = None) -> dict:
11
+ env = dict(os.environ)
12
+ env["DBT_PROJECT_DIR"] = project_dir
13
+ if profiles_dir:
14
+ env["DBT_PROFILES_DIR"] = profiles_dir
15
+ proc = subprocess.run(
16
+ ["dbt", "parse"],
17
+ cwd=project_dir, env=env,
18
+ capture_output=True, text=True,
19
+ )
20
+ manifest_path = os.path.join(project_dir, "target", "semantic_manifest.json")
21
+ ok = proc.returncode == 0 and os.path.exists(manifest_path)
22
+ return {
23
+ "ok": ok,
24
+ "returncode": proc.returncode,
25
+ "stdout": proc.stdout,
26
+ "stderr": proc.stderr,
27
+ "manifest_path": manifest_path,
28
+ }
trust/config.py ADDED
@@ -0,0 +1,37 @@
1
+ """
2
+ trust/config.py — load optional project-level config from .semantic-trust.json.
3
+
4
+ Safe default: if file is missing or key is absent → empty list.
5
+ An empty approved_email_domains list means NO domain check is applied (not a failure).
6
+
7
+ Config file format (.semantic-trust.json):
8
+ {"approved_email_domains": ["example.com", "corp.io"]}
9
+ """
10
+ import json
11
+ import os
12
+
13
+
14
+ def load_config(project_dir: str) -> dict:
15
+ """
16
+ Read .semantic-trust.json from project_dir (if it exists).
17
+
18
+ Returns a dict with at minimum:
19
+ {"approved_email_domains": [...]}
20
+
21
+ SAFE DEFAULT: missing file, empty file, or absent key → empty list.
22
+ Never hardcodes any domain — the allowlist comes exclusively from the file.
23
+ """
24
+ config_path = os.path.join(project_dir, ".semantic-trust.json")
25
+ raw: dict = {}
26
+ if os.path.isfile(config_path):
27
+ with open(config_path, "r") as fh:
28
+ try:
29
+ loaded = json.load(fh)
30
+ if isinstance(loaded, dict):
31
+ raw = loaded
32
+ except json.JSONDecodeError:
33
+ pass
34
+
35
+ return {
36
+ "approved_email_domains": raw.get("approved_email_domains") or [],
37
+ }
trust/joinability.py ADDED
@@ -0,0 +1,216 @@
1
+ """
2
+ Cross-model joinability gate [J] (Task 3.1b).
3
+
4
+ Produces Issue(severity="warning", provenance="deterministic") for:
5
+ - joinability_orphan : an entity name appears on exactly one model
6
+ (cannot participate in a join).
7
+ - joinability_parity : the same *conceptual* join partner resolves to
8
+ different entity names across models — detected
9
+ when entity names share a common stem but differ
10
+ in suffix (e.g. "customer" vs "customer_id") while
11
+ being used as join-compatible types (primary/foreign).
12
+
13
+ Orphan detection strategy
14
+ --------------------------
15
+ MetricFlow ships `CommonEntitysRule` in
16
+ metricflow_semantic_interfaces.validations.common_entities
17
+ This rule does the same orphan check but requires constructing MetricFlow's
18
+ internal SemanticManifest/SemanticModel objects. We confirm the import
19
+ succeeds (proving the rule exists and the dependency is present) and then
20
+ implement the equivalent logic over NormalizedModel directly — avoiding the
21
+ overhead of building MetricFlow's internal object graph just to call one rule.
22
+ `CommonEntitysRule` is a NON-DEFAULT MetricFlow rule: it is NOT included in
23
+ the validator's DEFAULT_RULES set; callers must add it explicitly.
24
+
25
+ Parity detection strategy
26
+ --------------------------
27
+ MetricFlow has no built-in cross-model name-parity check. We implement it:
28
+ 1. Normalise each entity name to a *stem* (strip trailing `_id`, `_key`).
29
+ 2. Group entity names by stem across all models.
30
+ 3. When a stem maps to more than one distinct entity name, the names
31
+ are mismatched join-partner candidates — flag joinability_parity.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from collections import defaultdict
36
+ from typing import TYPE_CHECKING
37
+
38
+ from trust.report import Issue
39
+
40
+ if TYPE_CHECKING:
41
+ from trust.normalized import NormalizedModel
42
+
43
+ # --- MetricFlow import probe (non-default rule; light dependency) ---
44
+ # CommonEntitysRule is a non-default MetricFlow validation rule. We confirm
45
+ # the import works so users know the dependency is present, then fall back to
46
+ # our own equivalent scan over NormalizedModel.entities.
47
+ try:
48
+ from metricflow_semantic_interfaces.validations.common_entities import ( # noqa: F401
49
+ CommonEntitysRule,
50
+ )
51
+ _MF_COMMON_ENTITIES_AVAILABLE = True
52
+ except ImportError:
53
+ _MF_COMMON_ENTITIES_AVAILABLE = False
54
+
55
+ # Types that represent "one end" of a join (primary key side).
56
+ _PRIMARY_TYPES = {"primary", "natural"}
57
+ # Types that reference the primary side (foreign key side).
58
+ _FOREIGN_TYPES = {"foreign"}
59
+ # Both sides together — entities of other types (e.g. "unique") are skipped.
60
+ _JOIN_TYPES = _PRIMARY_TYPES | _FOREIGN_TYPES
61
+
62
+
63
+ def _stem(name: str) -> str:
64
+ """Normalise an entity name to a stem by stripping common id-suffix patterns."""
65
+ for suffix in ("_id", "_key", "_ref"):
66
+ if name.endswith(suffix) and len(name) > len(suffix):
67
+ return name[: -len(suffix)]
68
+ return name
69
+
70
+
71
+ def _orphan_issues(models: list) -> list[Issue]:
72
+ """
73
+ Flag entity names that appear on exactly one model.
74
+
75
+ Logic mirrors CommonEntitysRule._map_semantic_model_entities but operates
76
+ on NormalizedModel.entities dicts ({name, type}) instead of MetricFlow's
77
+ internal Entity/SemanticModel objects.
78
+
79
+ Source recorded as "own_scan" when MF rule is unavailable,
80
+ "mf_equivalent" when the import confirmed availability.
81
+ """
82
+ # Map entity_name -> set of model names that declare it.
83
+ entity_to_models: dict[str, set[str]] = defaultdict(set)
84
+
85
+ for model in models:
86
+ for e in (model.entities or []):
87
+ name = e.get("name", "")
88
+ if not name:
89
+ continue
90
+ entity_to_models[name].add(model.name)
91
+
92
+ # With only one model in the project there can be no join partners at all —
93
+ # every entity is "orphaned" by definition. Skip the check; it is
94
+ # vacuously true and produces false positives on single-model projects.
95
+ if len(models) < 2:
96
+ return []
97
+
98
+ issues: list[Issue] = []
99
+ source_note = (
100
+ "mf_equivalent" if _MF_COMMON_ENTITIES_AVAILABLE else "own_scan"
101
+ )
102
+
103
+ # Second pass — emit one Issue per (entity_name, model) pair that is orphaned.
104
+ for model in models:
105
+ for e in (model.entities or []):
106
+ name = e.get("name", "")
107
+ if not name:
108
+ continue
109
+ if len(entity_to_models[name]) == 1:
110
+ issues.append(
111
+ Issue(
112
+ severity="warning",
113
+ dimension="joinability",
114
+ rule="joinability_orphan",
115
+ message=(
116
+ f"Entity '{name}' on model '{model.name}' "
117
+ f"appears on no other model — it cannot "
118
+ f"participate in a join. "
119
+ f"(source: {source_note})"
120
+ ),
121
+ location=model.source_file,
122
+ provenance="deterministic",
123
+ )
124
+ )
125
+
126
+ return issues
127
+
128
+
129
+ def _parity_issues(models: list) -> list[Issue]:
130
+ """
131
+ Flag entity name/stem mismatches that indicate broken join partners.
132
+
133
+ Groups entity names by their normalised stem across all models. When a
134
+ stem maps to multiple distinct names, those names are mismatched join
135
+ candidates — e.g. "customer" (primary) and "customer_id" (foreign)
136
+ both stem to "customer" but differ in name, breaking MetricFlow joins.
137
+
138
+ MetricFlow has no built-in rule for this — entirely our check.
139
+ """
140
+ # stem -> set of (entity_name, model_name, source_file, entity_type)
141
+ stem_map: dict[str, list[dict]] = defaultdict(list)
142
+
143
+ for model in models:
144
+ for e in (model.entities or []):
145
+ name = e.get("name", "")
146
+ etype = e.get("type", "")
147
+ if not name or etype not in _JOIN_TYPES:
148
+ continue
149
+ s = _stem(name)
150
+ stem_map[s].append(
151
+ {
152
+ "name": name,
153
+ "type": etype,
154
+ "model": model.name,
155
+ "source_file": model.source_file,
156
+ }
157
+ )
158
+
159
+ issues: list[Issue] = []
160
+
161
+ for stem, entries in stem_map.items():
162
+ distinct_names = {e["name"] for e in entries}
163
+ if len(distinct_names) <= 1:
164
+ continue # all consistent — no mismatch
165
+
166
+ # Multiple distinct names for the same stem across models: parity mismatch.
167
+ names_str = ", ".join(sorted(distinct_names))
168
+ models_str = ", ".join(sorted({e["model"] for e in entries}))
169
+
170
+ # Emit one Issue per model that contributes a mismatched name.
171
+ seen_model_name = set()
172
+ for entry in entries:
173
+ if entry["model"] in seen_model_name:
174
+ continue
175
+ seen_model_name.add(entry["model"])
176
+ issues.append(
177
+ Issue(
178
+ severity="warning",
179
+ dimension="joinability",
180
+ rule="joinability_parity",
181
+ message=(
182
+ f"Entity stem '{stem}' has mismatched names "
183
+ f"across models [{models_str}]: [{names_str}]. "
184
+ f"MetricFlow joins require exact name match — "
185
+ f"these will not join correctly."
186
+ ),
187
+ location=entry["source_file"],
188
+ provenance="deterministic",
189
+ )
190
+ )
191
+
192
+ return issues
193
+
194
+
195
+ def check_joinability(models: list) -> list[Issue]:
196
+ """
197
+ Cross-model joinability check. Consumes all NormalizedModels for the project.
198
+
199
+ Returns:
200
+ list[Issue] — empty means fully joinable.
201
+
202
+ Checks performed:
203
+ 1. Orphan entities (joinability_orphan) — entity on only one model.
204
+ Leverages MetricFlow's CommonEntitysRule algorithm when the import
205
+ is available; falls back to own scan if not.
206
+ 2. Name/type parity mismatches (joinability_parity) — same conceptual
207
+ join partner has different entity names across models.
208
+ MetricFlow has no built-in rule for this; we build it ourselves.
209
+ """
210
+ if not models:
211
+ return []
212
+
213
+ issues: list[Issue] = []
214
+ issues.extend(_orphan_issues(models))
215
+ issues.extend(_parity_issues(models))
216
+ return issues
trust/judgment.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ Judgment combination + guardrail (Task 3.1d).
3
+
4
+ apply_judgment(report: ModelReport, judgment: dict) -> ModelReport
5
+
6
+ Takes the deterministic ModelReport (read-only) produced by scorer.py and an LLM
7
+ judgment payload (per eval/judge.md Step 6) and returns a NEW ModelReport that:
8
+ - adds document_quality per DocumentReport for each key in judgment["documents"]
9
+ - appends provenance="llm_judge" advisory issues to the matching DocumentReport
10
+
11
+ GUARDRAIL — the function enforces a strict read-only contract on the deterministic
12
+ output. Any key in the payload that would touch trust_score, band, gates, context,
13
+ quality, or any provenance="deterministic" issue is silently ignored. Only
14
+ "documents" is read; everything else (e.g. "override_gates") is ignored.
15
+
16
+ Two distinct scores stay distinct:
17
+ - trust_score / band : deterministic (unchanged)
18
+ - document_quality : advisory, per DocumentReport (from judgment)
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import copy
23
+ from typing import Any
24
+
25
+ from trust.report import DocumentReport, Issue, ModelReport
26
+
27
+
28
+ def apply_judgment(report: ModelReport, judgment: dict[str, Any]) -> ModelReport:
29
+ """
30
+ Combine a deterministic ModelReport with an LLM judgment payload.
31
+
32
+ Parameters
33
+ ----------
34
+ report:
35
+ The deterministic ModelReport from score_model(). Treated as read-only;
36
+ the caller's object is never mutated.
37
+ judgment:
38
+ Dict produced by the LLM following eval/judge.md Step 6. Only the
39
+ "documents" key is consumed; all other keys are ignored (guardrail).
40
+
41
+ Expected shape::
42
+
43
+ {
44
+ "documents": {
45
+ "<doc_type>": {
46
+ "quality": <int 0-100>,
47
+ "issues": [
48
+ {
49
+ "severity": "warning"|"info"|"critical",
50
+ "dimension": "<string>",
51
+ "rule": "<string>",
52
+ "message": "<string>",
53
+ "location": "<string>"
54
+ },
55
+ ...
56
+ ]
57
+ },
58
+ ...
59
+ }
60
+ }
61
+
62
+ Returns
63
+ -------
64
+ ModelReport
65
+ A deep copy of the input report with document_quality and llm_judge issues
66
+ attached to the relevant DocumentReports. trust_score, band, gates, context,
67
+ quality, and all deterministic issues are identical to the input.
68
+ """
69
+ # Deep-copy so the caller's report is never mutated (guardrail: immutability)
70
+ new_report: ModelReport = copy.deepcopy(report)
71
+
72
+ # GUARDRAIL: only read "documents"; ignore everything else in judgment
73
+ doc_judgments: dict[str, Any] = judgment.get("documents", {})
74
+
75
+ for doc_type, payload in doc_judgments.items():
76
+ if doc_type not in new_report.documents:
77
+ # Unknown doc_type from LLM — silently ignore
78
+ continue
79
+
80
+ doc: DocumentReport = new_report.documents[doc_type]
81
+
82
+ # Attach advisory document_quality score (separate from deterministic score)
83
+ # Clamp to [0, 100]; skip gracefully if quality is absent or not convertible.
84
+ quality_score = payload.get("quality")
85
+ if quality_score is not None:
86
+ try:
87
+ doc.document_quality = max(0, min(100, int(quality_score)))
88
+ except (ValueError, TypeError):
89
+ pass # non-int/None — leave document_quality unset
90
+
91
+ # Append llm_judge advisory issues (tagged provenance="llm_judge")
92
+ for raw in payload.get("issues", []):
93
+ doc.issues.append(Issue(
94
+ severity=raw.get("severity", "info"),
95
+ dimension=raw.get("dimension", ""),
96
+ rule=raw.get("rule", ""),
97
+ message=raw.get("message", ""),
98
+ location=raw.get("location", ""),
99
+ provenance="llm_judge",
100
+ ))
101
+
102
+ return new_report