citesense 1.0.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.
Files changed (32) hide show
  1. citesense/__init__.py +6 -0
  2. citesense/__main__.py +5 -0
  3. citesense/_marketplace/__init__.py +1 -0
  4. citesense/_marketplace/audit_orchestrator/__init__.py +1 -0
  5. citesense/_marketplace/audit_orchestrator/compare_reports.py +238 -0
  6. citesense/_marketplace/audit_orchestrator/compose_report.py +395 -0
  7. citesense/_marketplace/audit_orchestrator/fix_snippets.py +479 -0
  8. citesense/_marketplace/audit_orchestrator/proactive_recommendations.py +241 -0
  9. citesense/_marketplace/audit_orchestrator/report_io.py +65 -0
  10. citesense/_marketplace/audit_orchestrator/run_audit.py +296 -0
  11. citesense/_marketplace/audit_orchestrator/scored.py +164 -0
  12. citesense/_marketplace/citation_simulator/__init__.py +1 -0
  13. citesense/_marketplace/citation_simulator/generate_queries.py +371 -0
  14. citesense/_marketplace/crawl_render_audit/__init__.py +1 -0
  15. citesense/_marketplace/crawl_render_audit/audit_common.py +322 -0
  16. citesense/_marketplace/crawl_render_audit/crawlability_check.py +230 -0
  17. citesense/_marketplace/crawl_render_audit/models.py +190 -0
  18. citesense/_marketplace/crawl_render_audit/render_and_data_scan.py +421 -0
  19. citesense/_marketplace/crawl_render_audit/site_inventory.py +911 -0
  20. citesense/_marketplace/engagement_audit/__init__.py +1 -0
  21. citesense/_marketplace/engagement_audit/engagement_scan.py +402 -0
  22. citesense/_marketplace/freshness_corroboration/__init__.py +1 -0
  23. citesense/_marketplace/freshness_corroboration/freshness_and_consistency_scan.py +214 -0
  24. citesense/_marketplace/trust_corroboration_graph/__init__.py +1 -0
  25. citesense/_marketplace/trust_corroboration_graph/corroboration_scorer.py +283 -0
  26. citesense/api.py +64 -0
  27. citesense/cli.py +11 -0
  28. citesense-1.0.0.dist-info/METADATA +279 -0
  29. citesense-1.0.0.dist-info/RECORD +32 -0
  30. citesense-1.0.0.dist-info/WHEEL +5 -0
  31. citesense-1.0.0.dist-info/entry_points.txt +2 -0
  32. citesense-1.0.0.dist-info/top_level.txt +1 -0
citesense/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Public Python API for the CiteSense audit engine."""
2
+
3
+ from .api import run_full_audit
4
+
5
+ __all__ = ["run_full_audit"]
6
+ __version__ = "1.0.0"
citesense/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1 @@
1
+ """Private packaged copies of the existing marketplace skill scripts."""
@@ -0,0 +1 @@
1
+ """Audit orchestrator skill modules."""
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ compare_reports.py -- Diffs two audit reports for the same site (before/after a fix, or
4
+ two points in time) and reports what was resolved, what is new, and what persists.
5
+
6
+ Findings are matched by their stable `fingerprint`. When either side lacks a fingerprint
7
+ the pair falls back to (category, normalized title) -- the same fallback key
8
+ compose_report.dedupe uses, so matching stays consistent with how findings are merged.
9
+
10
+ Usage:
11
+ python compare_reports.py before.json after.json --output diff.json
12
+ """
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ current_dir = os.path.dirname(os.path.abspath(__file__))
20
+ if current_dir not in sys.path:
21
+ sys.path.insert(0, current_dir)
22
+
23
+ from compose_report import normalize_title
24
+ from report_io import ReportIOError, fail, load_report, write_text
25
+
26
+ # Net-improvement weights for score_delta. Deliberately a flat 4/3/2/1 severity ladder --
27
+ # this measures *how many findings of what rank* went away, and is intentionally distinct
28
+ # from the citability score's steeper penalties (see references/citability-score.md).
29
+ SEVERITY_WEIGHT = {"critical": 4, "high": 3, "medium": 2, "low": 1}
30
+
31
+ SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
32
+
33
+
34
+ def match_key(finding: Dict[str, Any]) -> Tuple[str, str]:
35
+ """
36
+ Stable identity for cross-report matching.
37
+ Returns ("fingerprint", <value>) when available, else ("title", "<category>|<norm title>").
38
+ """
39
+ fingerprint = finding.get("fingerprint")
40
+ if fingerprint:
41
+ return ("fingerprint", str(fingerprint))
42
+ return (
43
+ "title",
44
+ "{0}|{1}".format(
45
+ finding.get("category") or "", normalize_title(finding.get("title") or "")
46
+ ),
47
+ )
48
+
49
+
50
+ def _fallback_key(finding: Dict[str, Any]) -> Tuple[str, str]:
51
+ """The title-based key, computed even for findings that do have a fingerprint."""
52
+ return (
53
+ "title",
54
+ "{0}|{1}".format(
55
+ finding.get("category") or "", normalize_title(finding.get("title") or "")
56
+ ),
57
+ )
58
+
59
+
60
+ def _index(findings: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict[str, Any]]:
61
+ """Indexes findings by primary key, keeping the first occurrence on collision."""
62
+ index = {}
63
+ for f in findings:
64
+ key = match_key(f)
65
+ index.setdefault(key, f)
66
+ return index
67
+
68
+
69
+ def weighted_total(findings: List[Dict[str, Any]]) -> int:
70
+ """Sum of severity weights across a findings list."""
71
+ return sum(SEVERITY_WEIGHT.get(f.get("severity"), 0) for f in findings)
72
+
73
+
74
+ def _same_site(before_site: Any, after_site: Any) -> bool:
75
+ """True when both reports name the same origin (ignoring trailing-slash drift)."""
76
+ if not before_site or not after_site:
77
+ return True # Nothing to contradict; do not warn on incomplete metadata.
78
+ return str(before_site).rstrip("/") == str(after_site).rstrip("/")
79
+
80
+
81
+ def _brief(finding: Dict[str, Any]) -> Dict[str, Any]:
82
+ """Compact record of a finding for the diff output."""
83
+ out = {
84
+ "id": finding.get("id"),
85
+ "title": finding.get("title"),
86
+ "category": finding.get("category"),
87
+ "severity": finding.get("severity"),
88
+ "fingerprint": finding.get("fingerprint"),
89
+ }
90
+ return {k: v for k, v in out.items() if v is not None}
91
+
92
+
93
+ def compare_reports(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
94
+ """
95
+ Returns the diff object:
96
+ {"before": {...}, "after": {...}, "resolved": [...], "new": [...],
97
+ "unchanged": [...], "score_delta": int, "citability_score_delta": int|None,
98
+ "summary": {...}}
99
+ score_delta is sum(weights in before) - sum(weights in after): positive means the
100
+ site improved (findings went away or got less severe).
101
+ """
102
+ before_findings = before.get("findings") or []
103
+ after_findings = after.get("findings") or []
104
+
105
+ before_index = _index(before_findings)
106
+ after_index = _index(after_findings)
107
+
108
+ # Secondary lookup so a finding that gained or lost its fingerprint between runs still
109
+ # matches its counterpart by (category, normalized title).
110
+ after_by_title: Dict[Tuple[str, str], Dict[str, Any]] = {}
111
+ for f in after_findings:
112
+ after_by_title.setdefault(_fallback_key(f), f)
113
+ before_by_title: Dict[Tuple[str, str], Dict[str, Any]] = {}
114
+ for f in before_findings:
115
+ before_by_title.setdefault(_fallback_key(f), f)
116
+
117
+ matched_after_ids = set()
118
+ resolved: List[Dict[str, Any]] = []
119
+ unchanged: List[Dict[str, Any]] = []
120
+
121
+ for key, b in before_index.items():
122
+ a = after_index.get(key)
123
+ if a is None:
124
+ a = after_by_title.get(_fallback_key(b))
125
+ if a is None:
126
+ resolved.append(_brief(b))
127
+ continue
128
+
129
+ matched_after_ids.add(id(a))
130
+ entry = _brief(a)
131
+ entry["previous_id"] = b.get("id")
132
+ entry["previous_severity"] = b.get("severity")
133
+ b_sev, a_sev = b.get("severity"), a.get("severity")
134
+ entry["severity_changed"] = b_sev != a_sev
135
+ if b_sev != a_sev:
136
+ b_rank = SEVERITY_RANK.get(b_sev, 4)
137
+ a_rank = SEVERITY_RANK.get(a_sev, 4)
138
+ entry["severity_change"] = (
139
+ "worsened" if a_rank < b_rank else "improved" if a_rank > b_rank else "changed"
140
+ )
141
+ unchanged.append(entry)
142
+
143
+ new: List[Dict[str, Any]] = []
144
+ for key, a in after_index.items():
145
+ if id(a) in matched_after_ids:
146
+ continue
147
+ if key in before_index or _fallback_key(a) in before_by_title:
148
+ continue
149
+ new.append(_brief(a))
150
+
151
+ before_total = weighted_total(before_findings)
152
+ after_total = weighted_total(after_findings)
153
+
154
+ before_score = (before.get("summary") or {}).get("citability_score")
155
+ after_score = (after.get("summary") or {}).get("citability_score")
156
+ citability_delta = (
157
+ after_score - before_score
158
+ if isinstance(before_score, int) and isinstance(after_score, int)
159
+ else None
160
+ )
161
+
162
+ return {
163
+ "before": {
164
+ "site": before.get("site"),
165
+ "audited_at": before.get("audited_at"),
166
+ "total_findings": len(before_findings),
167
+ "weighted_severity_total": before_total,
168
+ },
169
+ "after": {
170
+ "site": after.get("site"),
171
+ "audited_at": after.get("audited_at"),
172
+ "total_findings": len(after_findings),
173
+ "weighted_severity_total": after_total,
174
+ },
175
+ "same_site": _same_site(before.get("site"), after.get("site")),
176
+ "resolved": resolved,
177
+ "new": new,
178
+ "unchanged": unchanged,
179
+ "score_delta": before_total - after_total,
180
+ "citability_score_delta": citability_delta,
181
+ "summary": {
182
+ "resolved_count": len(resolved),
183
+ "new_count": len(new),
184
+ "unchanged_count": len(unchanged),
185
+ "severity_changed_count": sum(1 for u in unchanged if u.get("severity_changed")),
186
+ "no_changes": not resolved
187
+ and not new
188
+ and not any(u.get("severity_changed") for u in unchanged),
189
+ },
190
+ }
191
+
192
+
193
+ def main(argv: Optional[List[str]] = None) -> int:
194
+ parser = argparse.ArgumentParser(
195
+ description="Diff two audit reports and summarize net improvement."
196
+ )
197
+ parser.add_argument("before", help="Path to the earlier report.json")
198
+ parser.add_argument("after", help="Path to the later report.json")
199
+ parser.add_argument("--output", "-o", help="Path to write the diff JSON (default: stdout)")
200
+ args = parser.parse_args(argv)
201
+
202
+ try:
203
+ before = load_report(args.before, label="before report")
204
+ after = load_report(args.after, label="after report")
205
+ diff = compare_reports(before, after)
206
+ text = json.dumps(diff, indent=2)
207
+ if args.output:
208
+ write_text(args.output, text)
209
+ except ReportIOError as exc:
210
+ return fail(exc)
211
+
212
+ if not diff["same_site"]:
213
+ sys.stderr.write(
214
+ "warning: comparing two different sites ({0} vs {1}); "
215
+ "resolved/new buckets reflect the site change, not a fix.\n".format(
216
+ diff["before"]["site"], diff["after"]["site"]
217
+ )
218
+ )
219
+
220
+ if args.output:
221
+ print(
222
+ json.dumps(
223
+ {
224
+ "status": "written",
225
+ "output": args.output,
226
+ "score_delta": diff["score_delta"],
227
+ "summary": diff["summary"],
228
+ },
229
+ indent=2,
230
+ )
231
+ )
232
+ else:
233
+ print(text)
234
+ return 0
235
+
236
+
237
+ if __name__ == "__main__":
238
+ raise SystemExit(main())
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ compose_report.py -- Merges, deduplicates, prioritizes, and validates audit findings into the final report.
4
+ Strictly adheres to report-schema.json while preserving candidate finding metadata (status, confidence, impact).
5
+ """
6
+ import sys
7
+ import os
8
+ import json
9
+ import argparse
10
+ import re
11
+ from datetime import datetime, timezone
12
+ from typing import List, Dict, Any, Set, Optional
13
+
14
+ current_dir = os.path.dirname(os.path.abspath(__file__))
15
+ crawl_scripts_dir = os.path.abspath(os.path.join(current_dir, "../../crawl-render-audit/scripts"))
16
+ for d in (current_dir, crawl_scripts_dir):
17
+ if d not in sys.path:
18
+ sys.path.insert(0, d)
19
+
20
+ from fix_snippets import attach_fix_snippets
21
+ from scored import annotate_report
22
+
23
+ # Single source of truth: these sets live in models.py. Redefining them here let the two
24
+ # drift silently, which would mis-split the discoverability/engagement summary counts.
25
+ from models import (
26
+ DISCOVERABILITY_CATEGORIES,
27
+ ENGAGEMENT_CATEGORIES,
28
+ VALID_CONFIDENCES,
29
+ VALID_IMPACTS,
30
+ VALID_SEVERITIES,
31
+ VALID_STATUSES,
32
+ )
33
+ REQUIRED_FINDING_FIELDS = {
34
+ "id", "title", "severity", "evidence", "severity_rationale", "suggested_action"
35
+ }
36
+
37
+ SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
38
+
39
+
40
+ def normalize_title(title: str) -> str:
41
+ """Normalizes finding titles for fuzzy deduplication."""
42
+ return re.sub(r"[^a-z0-9]+", " ", title.lower()).strip()
43
+
44
+
45
+ def load_findings(paths: List[str]) -> List[Dict[str, Any]]:
46
+ """Loads candidate findings from one or more JSON files."""
47
+ all_findings = []
48
+ for p in paths:
49
+ if not os.path.exists(p):
50
+ continue
51
+ with open(p, encoding="utf-8-sig") as f:
52
+ data = json.load(f)
53
+ if isinstance(data, list):
54
+ all_findings.extend(data)
55
+ elif isinstance(data, dict) and "findings" in data:
56
+ all_findings.extend(data["findings"])
57
+ else:
58
+ raise ValueError(f"{p} must contain a JSON array of finding objects")
59
+ return all_findings
60
+
61
+
62
+ def dedupe(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
63
+ """
64
+ Merge findings sharing the same root cause (by fingerprint or category+normalized title).
65
+ Consolidates evidence, tracks all reporting source skills, and keeps highest severity/impact.
66
+ """
67
+ merged = {}
68
+ order = []
69
+
70
+ for f in findings:
71
+ key = f.get("fingerprint") or (f.get("category"), normalize_title(f.get("title", "")))
72
+ if key in merged:
73
+ existing = merged[key]
74
+ extra_evidence = f.get("evidence", "")
75
+ skill = f.get("source_skill", "")
76
+
77
+ # Merge evidence without duplication
78
+ if extra_evidence and extra_evidence not in existing.get("evidence", ""):
79
+ existing["evidence"] = existing.get("evidence", "") + f"\nAdditional evidence ({skill or 'another check'}): {extra_evidence}"
80
+
81
+ refs = set(existing.get("evidence_refs", []))
82
+ refs.update(f.get("evidence_refs", []))
83
+ if refs:
84
+ existing["evidence_refs"] = sorted(refs)
85
+
86
+ # Merge source skills
87
+ sources = set(existing.get("source_skills", [existing.get("source_skill", "")]))
88
+ if skill:
89
+ sources.add(skill)
90
+ existing["source_skills"] = sorted(s for s in sources if s)
91
+
92
+ # Elevate severity to highest
93
+ existing_sev = existing.get("severity", "low")
94
+ new_sev = f.get("severity", "low")
95
+ if SEVERITY_RANK.get(new_sev, 4) < SEVERITY_RANK.get(existing_sev, 4):
96
+ existing["severity"] = new_sev
97
+ if existing.get("suggested_action") and f.get("suggested_action"):
98
+ existing["suggested_action"]["priority"] = new_sev
99
+
100
+ # Elevate impact to highest
101
+ existing_imp = existing.get("impact", existing_sev)
102
+ new_imp = f.get("impact", new_sev)
103
+ if SEVERITY_RANK.get(new_imp, 4) < SEVERITY_RANK.get(existing_imp, 4):
104
+ existing["impact"] = new_imp
105
+
106
+ else:
107
+ merged[key] = dict(f)
108
+ order.append(key)
109
+
110
+ return [merged[k] for k in order]
111
+
112
+
113
+ def assign_ids_and_validate(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
114
+ """Assigns sequential F-NNN IDs and enforces required report schema constraints."""
115
+ out = []
116
+ for i, f in enumerate(findings, start=1):
117
+ item = dict(f)
118
+ item["id"] = f"F-{i:03d}"
119
+ # CandidateFindings produced by current skills carry these fields. Keep the composition
120
+ # helper backward-compatible with older standalone skill output while ensuring every
121
+ # final report has the complete assessment contract.
122
+ item.setdefault("status", "confirmed")
123
+ item.setdefault("confidence", "high")
124
+ item.setdefault("impact", item.get("severity", "medium"))
125
+ item.setdefault(
126
+ "severity_rationale",
127
+ f"Rated {item.get('severity', 'low')} because the observed evidence indicates "
128
+ f"{item.get('impact', item.get('severity', 'low'))} impact; unavailable evidence is not "
129
+ "treated as a defect.",
130
+ )
131
+ missing = REQUIRED_FINDING_FIELDS - set(item.keys())
132
+ if missing:
133
+ raise ValueError(f"finding '{item.get('title')}' missing required fields: {missing}")
134
+ if item["severity"] not in VALID_SEVERITIES:
135
+ raise ValueError(f"finding '{item['id']}' has invalid severity '{item['severity']}'")
136
+ if item.get("category") not in DISCOVERABILITY_CATEGORIES | ENGAGEMENT_CATEGORIES:
137
+ raise ValueError(f"finding '{item['id']}' has invalid or missing category")
138
+ if item.get("status") not in VALID_STATUSES:
139
+ raise ValueError(f"finding '{item['id']}' has invalid or missing status")
140
+ if item.get("confidence") not in VALID_CONFIDENCES:
141
+ raise ValueError(f"finding '{item['id']}' has invalid or missing confidence")
142
+ if item.get("impact") not in VALID_IMPACTS:
143
+ raise ValueError(f"finding '{item['id']}' has invalid or missing impact")
144
+
145
+ sa = item.get("suggested_action", {})
146
+ if not isinstance(sa, dict) or not sa.get("summary") or not sa.get("priority") or not sa.get("how_to_fix"):
147
+ raise ValueError(f"finding '{item['id']}' suggested_action requires summary, priority, and how_to_fix")
148
+ if sa["priority"] not in VALID_SEVERITIES:
149
+ raise ValueError(f"finding '{item['id']}' has invalid action priority")
150
+ if not isinstance(item["evidence"], str) or len(item["evidence"].strip()) < 10:
151
+ raise ValueError(f"finding '{item['id']}' needs concrete evidence")
152
+
153
+ out.append(item)
154
+ return out
155
+
156
+
157
+ def compute_summary(findings: List[Dict[str, Any]]) -> Dict[str, Any]:
158
+ """Computes summary counts by severity and discoverability/engagement split."""
159
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
160
+ discoverability = 0
161
+ engagement = 0
162
+
163
+ for f in findings:
164
+ sev = f.get("severity", "low")
165
+ counts[sev] = counts.get(sev, 0) + 1
166
+ cat = f.get("category")
167
+ if cat in DISCOVERABILITY_CATEGORIES:
168
+ discoverability += 1
169
+ elif cat in ENGAGEMENT_CATEGORIES:
170
+ engagement += 1
171
+
172
+ return {
173
+ "total_findings": len(findings),
174
+ "critical": counts["critical"],
175
+ "high": counts["high"],
176
+ "medium": counts["medium"],
177
+ "low": counts["low"],
178
+ "discoverability_findings": discoverability,
179
+ "engagement_findings": engagement,
180
+ }
181
+
182
+
183
+ def build_final_report(
184
+ site_url: str,
185
+ findings: List[Dict[str, Any]],
186
+ proactive_recommendations: Optional[List[Dict[str, Any]]] = None,
187
+ ground_truth: Optional[Dict[str, Any]] = None,
188
+ pages: Optional[Dict[str, Any]] = None,
189
+ access_summary: Optional[Dict[str, Any]] = None,
190
+ assessments: Optional[List[Dict[str, Any]]] = None,
191
+ external_evidence: Optional[List[Dict[str, Any]]] = None,
192
+ ) -> Dict[str, Any]:
193
+ """Assembles, deduplicates, prioritizes, and formats the final report object.
194
+
195
+ When `ground_truth` (and optionally the cached `pages` snapshots) are supplied, every
196
+ qualifying structured-data finding also receives a pre-populated JSON-LD
197
+ `suggested_action.fix_snippet`. Snippets are only attached where the observed data is
198
+ rich enough to fill the entity's identifying fields -- see fix_snippets.py.
199
+ """
200
+ evidence_ledger = []
201
+ for finding in findings:
202
+ details = finding.get("evidence_details")
203
+ if isinstance(details, dict) and details.get("evidence_id"):
204
+ evidence_ledger.append(dict(details))
205
+
206
+ deduped = dedupe(findings)
207
+ # Sort by severity (critical first) then impact
208
+ deduped.sort(key=lambda f: (SEVERITY_RANK.get(f.get("severity"), 4), SEVERITY_RANK.get(f.get("impact", f.get("severity")), 4)))
209
+ validated_findings = assign_ids_and_validate(deduped)
210
+
211
+ # Auto-generate ready-to-paste JSON-LD fixes from data the crawl actually observed.
212
+ attach_fix_snippets(
213
+ validated_findings,
214
+ ground_truth=ground_truth,
215
+ pages=pages,
216
+ site_url=site_url,
217
+ )
218
+
219
+ report = {
220
+ "site": site_url,
221
+ "audited_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
222
+ "summary": compute_summary(validated_findings),
223
+ "findings": validated_findings,
224
+ }
225
+
226
+ if access_summary is not None:
227
+ report["access_summary"] = access_summary
228
+
229
+ if evidence_ledger:
230
+ report["evidence_ledger"] = evidence_ledger + list(external_evidence or [])
231
+ elif external_evidence:
232
+ report["evidence_ledger"] = list(external_evidence)
233
+
234
+ if assessments:
235
+ report["assessments"] = assessments
236
+
237
+ if external_evidence:
238
+ report["external_discovery"] = {
239
+ "source": "agent-supplied",
240
+ "network_lookup_performed": False,
241
+ "evidence_refs": [entry["evidence_id"] for entry in external_evidence],
242
+ }
243
+
244
+ if proactive_recommendations:
245
+ report["proactive_recommendations"] = proactive_recommendations
246
+
247
+ # Additive 0-100 AI-Citability Score (see references/citability-score.md).
248
+ annotate_report(report, pages=pages)
249
+
250
+ return report
251
+
252
+
253
+ def load_context(path: Optional[str]) -> Dict[str, Any]:
254
+ """
255
+ Loads fix-snippet context from a saved SiteInventory JSON (as written by
256
+ run_audit.py --save-inventory) or from a bare ground_truth JSON object.
257
+ Returns {"ground_truth": {...}, "pages": {...}}.
258
+ """
259
+ if not path or not os.path.exists(path):
260
+ return {"ground_truth": None, "pages": None}
261
+ with open(path, encoding="utf-8-sig") as f:
262
+ data = json.load(f)
263
+ if not isinstance(data, dict):
264
+ raise ValueError("--inventory file must contain a JSON object")
265
+ if "ground_truth" in data or "pages" in data:
266
+ return {"ground_truth": data.get("ground_truth"), "pages": data.get("pages")}
267
+ # A bare ground_truth object (brand_name/key_facts/...) was passed directly.
268
+ return {"ground_truth": data, "pages": None}
269
+
270
+
271
+ def merge_mode(args):
272
+ findings = load_findings(args.input)
273
+ proactive = None
274
+ if args.proactive and os.path.exists(args.proactive):
275
+ with open(args.proactive, encoding="utf-8-sig") as f:
276
+ proactive = json.load(f)
277
+ if not isinstance(proactive, list):
278
+ raise ValueError("--proactive file must contain a JSON array")
279
+
280
+ context = load_context(getattr(args, "inventory", None))
281
+ report = build_final_report(
282
+ args.site,
283
+ findings,
284
+ proactive_recommendations=proactive,
285
+ ground_truth=context["ground_truth"],
286
+ pages=context["pages"],
287
+ )
288
+ output_text = json.dumps(report, indent=2)
289
+
290
+ if args.output:
291
+ with open(args.output, "w", encoding="utf-8") as f:
292
+ f.write(output_text)
293
+ print(json.dumps({"status": "written", "output": args.output, "finding_count": len(report["findings"])}, indent=2))
294
+ else:
295
+ print(output_text)
296
+
297
+
298
+ def validate_mode(args):
299
+ with open(args.input[0], encoding="utf-8-sig") as f:
300
+ report = json.load(f)
301
+ errors = []
302
+ for field in ("site", "audited_at", "summary", "findings"):
303
+ if field not in report:
304
+ errors.append(f"missing top-level field: {field}")
305
+
306
+ for f in report.get("findings", []):
307
+ missing = REQUIRED_FINDING_FIELDS - set(f.keys())
308
+ if missing:
309
+ errors.append(f"finding {f.get('id', '?')} missing fields: {missing}")
310
+ if f.get("severity") not in VALID_SEVERITIES:
311
+ errors.append(f"finding {f.get('id', '?')} has invalid severity: {f.get('severity')}")
312
+ if f.get("category") not in DISCOVERABILITY_CATEGORIES | ENGAGEMENT_CATEGORIES:
313
+ errors.append(f"finding {f.get('id', '?')} has invalid or missing category")
314
+ if f.get("status") not in VALID_STATUSES:
315
+ errors.append(f"finding {f.get('id', '?')} has invalid or missing status")
316
+ if f.get("confidence") not in VALID_CONFIDENCES:
317
+ errors.append(f"finding {f.get('id', '?')} has invalid or missing confidence")
318
+ if f.get("impact") not in VALID_IMPACTS:
319
+ errors.append(f"finding {f.get('id', '?')} has invalid or missing impact")
320
+ action = f.get("suggested_action")
321
+ if not isinstance(action, dict) or not all(action.get(x) for x in ("summary", "priority", "how_to_fix")):
322
+ errors.append(f"finding {f.get('id', '?')} suggested_action requires summary, priority, how_to_fix")
323
+
324
+ summary = report.get("summary", {})
325
+ if isinstance(summary, dict):
326
+ for sev in VALID_SEVERITIES:
327
+ if not isinstance(summary.get(sev), int):
328
+ errors.append(f"summary missing integer {sev} count")
329
+ if summary.get("total_findings") != len(report.get("findings", [])):
330
+ errors.append("summary total_findings does not match findings")
331
+
332
+ ledger = report.get("evidence_ledger", [])
333
+ if not isinstance(ledger, list):
334
+ errors.append("evidence_ledger must be an array")
335
+ ledger = []
336
+ ledger_ids = {entry.get("evidence_id") for entry in ledger if isinstance(entry, dict)}
337
+ for f in report.get("findings", []):
338
+ refs = f.get("evidence_refs", [])
339
+ if not isinstance(refs, list) or not set(refs) <= ledger_ids:
340
+ errors.append(f"finding {f.get('id', '?')} has unresolved evidence_refs")
341
+
342
+ assessments = report.get("assessments", [])
343
+ if not isinstance(assessments, list):
344
+ errors.append("assessments must be an array")
345
+ else:
346
+ for assessment in assessments:
347
+ if not isinstance(assessment, dict):
348
+ errors.append("assessment must be an object")
349
+ continue
350
+ if assessment.get("status") not in VALID_STATUSES:
351
+ errors.append(f"assessment has invalid status: {assessment.get('status')}")
352
+ if assessment.get("result") not in {"passed", "assessed", "limited", "not-assessed"}:
353
+ errors.append(f"assessment has invalid result: {assessment.get('result')}")
354
+
355
+ external = report.get("external_discovery")
356
+ if external is not None:
357
+ if external.get("source") != "agent-supplied" or external.get("network_lookup_performed") is not False:
358
+ errors.append("external_discovery must identify agent-supplied evidence and no internal lookup")
359
+ if not set(external.get("evidence_refs", [])) <= ledger_ids:
360
+ errors.append("external_discovery has unresolved evidence_refs")
361
+
362
+ if errors:
363
+ print(json.dumps({"valid": False, "errors": errors}, indent=2))
364
+ sys.exit(1)
365
+ else:
366
+ print(json.dumps({"valid": True}, indent=2))
367
+
368
+
369
+ def main():
370
+ parser = argparse.ArgumentParser()
371
+ sub = parser.add_subparsers(dest="mode", required=True)
372
+
373
+ merge_p = sub.add_parser("merge")
374
+ merge_p.add_argument("--site", required=True)
375
+ merge_p.add_argument("--input", nargs="+", required=True)
376
+ merge_p.add_argument("--proactive")
377
+ merge_p.add_argument(
378
+ "--inventory",
379
+ help="Optional saved SiteInventory JSON (or bare ground_truth JSON) used to "
380
+ "pre-populate suggested_action.fix_snippet for structured-data findings",
381
+ )
382
+ merge_p.add_argument("--output")
383
+
384
+ validate_p = sub.add_parser("validate")
385
+ validate_p.add_argument("--input", nargs=1, required=True)
386
+
387
+ args = parser.parse_args()
388
+ if args.mode == "merge":
389
+ merge_mode(args)
390
+ else:
391
+ validate_mode(args)
392
+
393
+
394
+ if __name__ == "__main__":
395
+ main()