prismguard 0.1.2__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 (139) hide show
  1. prismguard/__init__.py +3 -0
  2. prismguard/app_cli.py +394 -0
  3. prismguard/calibration/__init__.py +0 -0
  4. prismguard/calibration/tune.py +165 -0
  5. prismguard/cli.py +210 -0
  6. prismguard/cli_check.py +66 -0
  7. prismguard/config/__init__.py +3 -0
  8. prismguard/config/loader.py +159 -0
  9. prismguard/config/storage.yaml +20 -0
  10. prismguard/config/triage.yaml +74 -0
  11. prismguard/context/__init__.py +31 -0
  12. prismguard/context/loader.py +131 -0
  13. prismguard/context/matcher.py +106 -0
  14. prismguard/context/models.py +50 -0
  15. prismguard/context/templates.py +60 -0
  16. prismguard/domains/__init__.py +0 -0
  17. prismguard/domains/finance/holdout.yaml +40 -0
  18. prismguard/domains/finance/overlay.yaml +40 -0
  19. prismguard/domains/finance/triage.yaml +8 -0
  20. prismguard/domains/healthcare/holdout.yaml +40 -0
  21. prismguard/domains/healthcare/overlay.yaml +40 -0
  22. prismguard/domains/healthcare/triage.yaml +8 -0
  23. prismguard/domains/law/holdout.yaml +100 -0
  24. prismguard/domains/law/overlay.yaml +100 -0
  25. prismguard/domains/law/triage.yaml +18 -0
  26. prismguard/domains/registry.py +46 -0
  27. prismguard/eval/__init__.py +3 -0
  28. prismguard/eval/probes.py +23 -0
  29. prismguard/eval/self_check.py +120 -0
  30. prismguard/feedback/__init__.py +3 -0
  31. prismguard/feedback/review.py +219 -0
  32. prismguard/feedback/store.py +146 -0
  33. prismguard/http/__init__.py +0 -0
  34. prismguard/http/service.py +182 -0
  35. prismguard/integrations/__init__.py +0 -0
  36. prismguard/integrations/chorusgraph.py +118 -0
  37. prismguard/licensing/__init__.py +21 -0
  38. prismguard/licensing/errors.py +6 -0
  39. prismguard/licensing/features.py +50 -0
  40. prismguard/licensing/keys.py +12 -0
  41. prismguard/licensing/validator.py +85 -0
  42. prismguard/models/__init__.py +10 -0
  43. prismguard/models/artifact_fetch.py +209 -0
  44. prismguard/models/artifacts/prism-pi-v1/added_tokens.json +3 -0
  45. prismguard/models/artifacts/prism-pi-v1/calibration.json +7 -0
  46. prismguard/models/artifacts/prism-pi-v1/corpus_manifest.json +33 -0
  47. prismguard/models/artifacts/prism-pi-v1/model_card.yaml +9 -0
  48. prismguard/models/artifacts/prism-pi-v1/special_tokens_map.json +51 -0
  49. prismguard/models/artifacts/prism-pi-v1/spm.model +0 -0
  50. prismguard/models/artifacts/prism-pi-v1/tokenizer.json +512186 -0
  51. prismguard/models/artifacts/prism-pi-v1/tokenizer_config.json +59 -0
  52. prismguard/models/artifacts/prism-pi-v1/train_metrics.json +45 -0
  53. prismguard/models/calibration.py +99 -0
  54. prismguard/models/cli.py +239 -0
  55. prismguard/models/constants.py +15 -0
  56. prismguard/models/corpus.py +328 -0
  57. prismguard/models/eval.py +168 -0
  58. prismguard/models/export.py +200 -0
  59. prismguard/models/fit_calibration.py +136 -0
  60. prismguard/models/hf_utils.py +39 -0
  61. prismguard/models/loader.py +52 -0
  62. prismguard/models/model_card.py +69 -0
  63. prismguard/models/onnx_classifier.py +221 -0
  64. prismguard/models/train.py +608 -0
  65. prismguard/models/verdict.py +18 -0
  66. prismguard/observability/__init__.py +3 -0
  67. prismguard/observability/metrics.py +77 -0
  68. prismguard/runtime/__init__.py +3 -0
  69. prismguard/runtime/check.py +1473 -0
  70. prismguard/runtime/fusion.py +99 -0
  71. prismguard/runtime/guard_model.py +132 -0
  72. prismguard/runtime/llm_judge.py +278 -0
  73. prismguard/runtime/normalize.py +47 -0
  74. prismguard/runtime/output_scan.py +55 -0
  75. prismguard/runtime/session.py +124 -0
  76. prismguard/runtime/stage_profiler.py +124 -0
  77. prismguard/runtime/structural.py +367 -0
  78. prismguard/runtime/thresholds.py +45 -0
  79. prismguard/runtime/verdict_cache.py +121 -0
  80. prismguard/seed/__init__.py +26 -0
  81. prismguard/seed/bundled.py +75 -0
  82. prismguard/seed/corpus/MANIFEST.md +107 -0
  83. prismguard/seed/corpus/authored/seed.yaml +339 -0
  84. prismguard/seed/corpus/external/neuralchemy/README.md +176 -0
  85. prismguard/seed/corpus/external/neuralchemy/core-test.parquet +0 -0
  86. prismguard/seed/corpus/external/neuralchemy/core-train.parquet +0 -0
  87. prismguard/seed/corpus/external/neuralchemy/core-validation.parquet +0 -0
  88. prismguard/seed/corpus/external/s-labs/README.md +150 -0
  89. prismguard/seed/corpus/external/s-labs/test.csv +2102 -0
  90. prismguard/seed/corpus/external/s-labs/train.csv +11092 -0
  91. prismguard/seed/corpus/external/s-labs/validation.csv +2102 -0
  92. prismguard/seed/corpus/external/yanismiraoui/LICENSE +201 -0
  93. prismguard/seed/corpus/external/yanismiraoui/NOTICE +11 -0
  94. prismguard/seed/corpus/external/yanismiraoui/prompt_injections.csv +1035 -0
  95. prismguard/seed/corpus/manifest.authored.txt +4 -0
  96. prismguard/seed/corpus/manifest.txt +11 -0
  97. prismguard/seed/formats/__init__.py +1 -0
  98. prismguard/seed/formats/csv_entries.py +28 -0
  99. prismguard/seed/formats/jsonl_entries.py +29 -0
  100. prismguard/seed/formats/markdown_seed.py +99 -0
  101. prismguard/seed/formats/neuralchemy_parquet.py +91 -0
  102. prismguard/seed/formats/slabs_csv.py +65 -0
  103. prismguard/seed/formats/yaml_taxonomy.py +92 -0
  104. prismguard/seed/formats/yanismiraoui_csv.py +35 -0
  105. prismguard/seed/importer.py +234 -0
  106. prismguard/seed/merge.py +110 -0
  107. prismguard/seed/models.py +65 -0
  108. prismguard/seed/normalize.py +22 -0
  109. prismguard/seed/parse.py +130 -0
  110. prismguard/seed/salience.py +58 -0
  111. prismguard/seed/validate.py +54 -0
  112. prismguard/storage/__init__.py +24 -0
  113. prismguard/storage/backends/__init__.py +6 -0
  114. prismguard/storage/backends/chroma.py +45 -0
  115. prismguard/storage/backends/pgvector.py +83 -0
  116. prismguard/storage/backends/pgvector_schema.py +136 -0
  117. prismguard/storage/backends/pgvector_stores.py +326 -0
  118. prismguard/storage/backends/pinecone.py +38 -0
  119. prismguard/storage/backends/weaviate.py +35 -0
  120. prismguard/storage/blobs.py +66 -0
  121. prismguard/storage/factory.py +68 -0
  122. prismguard/storage/memory.py +170 -0
  123. prismguard/storage/protocols.py +77 -0
  124. prismguard/storage/types.py +64 -0
  125. prismguard/taxonomy/__init__.py +19 -0
  126. prismguard/taxonomy/constants.py +4 -0
  127. prismguard/taxonomy/embedder.py +146 -0
  128. prismguard/taxonomy/graph.py +158 -0
  129. prismguard/taxonomy/ingest.py +104 -0
  130. prismguard/taxonomy/mapping.py +275 -0
  131. prismguard/taxonomy/pipeline.py +46 -0
  132. prismguard/taxonomy/report.py +145 -0
  133. prismguard/tools/profile_pipeline.py +15 -0
  134. prismguard-0.1.2.dist-info/METADATA +349 -0
  135. prismguard-0.1.2.dist-info/RECORD +139 -0
  136. prismguard-0.1.2.dist-info/WHEEL +5 -0
  137. prismguard-0.1.2.dist-info/entry_points.txt +6 -0
  138. prismguard-0.1.2.dist-info/licenses/LICENSE +17 -0
  139. prismguard-0.1.2.dist-info/top_level.txt +1 -0
prismguard/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PrismGuard — self-hosted prompt-injection firewall."""
2
+
3
+ __version__ = "0.1.2"
prismguard/app_cli.py ADDED
@@ -0,0 +1,394 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from prismguard.config.loader import load_triage_config
10
+ from prismguard.context.loader import (
11
+ load_lexicon_file,
12
+ load_lexicon_from_sql_table,
13
+ resolve_lexicon_path,
14
+ )
15
+ from prismguard.context.templates import lexicon_to_parsed_seed
16
+ from prismguard.domains.registry import get_domain_pack, list_domains
17
+ from prismguard.runtime.guard_model import create_guard_model
18
+ from prismguard.seed import import_bundled_seed, import_seeds
19
+ from prismguard.storage import create_storage, create_storage_from_env
20
+
21
+
22
+ def _storage_for_cli(backend: str | None):
23
+ if backend is not None:
24
+ return create_storage(backend)
25
+ if os.environ.get("PRISMGUARD_STORAGE_BACKEND"):
26
+ return create_storage_from_env()
27
+ if os.environ.get("PRISMGUARD_STORAGE_DSN"):
28
+ return create_storage("pgvector", dsn=os.environ["PRISMGUARD_STORAGE_DSN"])
29
+ return create_storage("memory")
30
+
31
+
32
+ def _import_domain(storage, domain: str, *, dry_run: bool) -> dict:
33
+ pack = get_domain_pack(domain)
34
+ from prismguard.seed.parse import parse_seed_file
35
+
36
+ parsed = parse_seed_file(pack.overlay_path)
37
+ report = import_seeds(
38
+ storage,
39
+ parsed,
40
+ mode="update",
41
+ dry_run=dry_run,
42
+ skip_taxonomy=dry_run,
43
+ )
44
+ return {
45
+ "domain": pack.name,
46
+ "label": pack.label,
47
+ "overlay": str(pack.overlay_path),
48
+ "inserted": report.inserted,
49
+ "updated": report.updated,
50
+ "skipped": report.skipped,
51
+ "dry_run": report.dry_run,
52
+ }
53
+
54
+
55
+ def _import_tenant_context(
56
+ storage,
57
+ lexicon_path: Path | None,
58
+ *,
59
+ dry_run: bool,
60
+ apply_templates: bool,
61
+ ) -> dict:
62
+ if lexicon_path is None:
63
+ return {"skipped": True, "reason": "no tenant lexicon provided"}
64
+ lexicon = load_lexicon_file(lexicon_path)
65
+ result: dict = {
66
+ "source": str(lexicon_path),
67
+ "domain": lexicon.domain,
68
+ "entity_count": len(lexicon.entities),
69
+ "dry_run": dry_run,
70
+ }
71
+ if not apply_templates:
72
+ result["templates_skipped"] = True
73
+ return result
74
+ parsed = lexicon_to_parsed_seed(lexicon)
75
+ report = import_seeds(storage, parsed, mode="update", dry_run=dry_run, skip_taxonomy=dry_run)
76
+ result.update(
77
+ {
78
+ "inserted": report.inserted,
79
+ "updated": report.updated,
80
+ "skipped_entries": report.skipped,
81
+ "template_entries": len(parsed.entries),
82
+ }
83
+ )
84
+ return result
85
+
86
+
87
+ def cmd_init(args: argparse.Namespace) -> int:
88
+ if args.context_file or args.context_table:
89
+ from prismguard.licensing.features import ENTERPRISE_TENANT, require_feature
90
+
91
+ require_feature(ENTERPRISE_TENANT)
92
+ storage = _storage_for_cli(args.backend)
93
+ output: dict = {"steps": []}
94
+ try:
95
+ bundled = import_bundled_seed(
96
+ storage,
97
+ profile=args.profile,
98
+ mode="update",
99
+ dry_run=args.dry_run,
100
+ skip_taxonomy=args.dry_run,
101
+ )
102
+ output["steps"].append(
103
+ {
104
+ "step": "bundled_seed",
105
+ "profile": args.profile,
106
+ "inserted": bundled.inserted,
107
+ "updated": bundled.updated,
108
+ "dry_run": bundled.dry_run,
109
+ }
110
+ )
111
+
112
+ if args.domain:
113
+ output["steps"].append(
114
+ _import_domain(storage, args.domain, dry_run=args.dry_run)
115
+ )
116
+
117
+ lexicon_path: Path | None = None
118
+ if args.context_file:
119
+ lexicon_path = Path(args.context_file)
120
+ elif args.context_table and args.context_dsn:
121
+ lexicon = load_lexicon_from_sql_table(args.context_dsn, args.context_table)
122
+ if args.dry_run:
123
+ output["steps"].append(
124
+ {
125
+ "step": "tenant_context",
126
+ "source": f"sql:{args.context_table}",
127
+ "entity_count": len(lexicon.entities),
128
+ "dry_run": True,
129
+ }
130
+ )
131
+ else:
132
+ tmp = Path.cwd() / ".prismguard_tenant_lexicon.yaml"
133
+ import yaml
134
+
135
+ tmp.write_text(
136
+ yaml.safe_dump(lexicon.model_dump(), sort_keys=False),
137
+ encoding="utf-8",
138
+ )
139
+ lexicon_path = tmp
140
+ elif args.context_file is None and not args.context_table:
141
+ auto_path = resolve_lexicon_path()
142
+ if auto_path is not None:
143
+ lexicon_path = auto_path
144
+
145
+ if lexicon_path is not None or (args.context_table and args.dry_run):
146
+ if lexicon_path is not None:
147
+ output["steps"].append(
148
+ _import_tenant_context(
149
+ storage,
150
+ lexicon_path,
151
+ dry_run=args.dry_run,
152
+ apply_templates=not args.context_lexicon_only,
153
+ )
154
+ )
155
+ if not args.dry_run and not args.context_lexicon_only:
156
+ os.environ["PRISMGUARD_TENANT_LEXICON_PATH"] = str(lexicon_path)
157
+
158
+ if args.write_config and not args.dry_run:
159
+ cfg_path = Path(args.write_config)
160
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
161
+ cfg = load_triage_config()
162
+ updates = {
163
+ "tenant_context": {
164
+ "enabled": lexicon_path is not None,
165
+ "lexicon_path": str(lexicon_path) if lexicon_path else "",
166
+ }
167
+ }
168
+ if args.domain:
169
+ updates["tenant_context"]["domain"] = args.domain # type: ignore[index]
170
+ import yaml
171
+
172
+ raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) if cfg_path.is_file() else {}
173
+ if not isinstance(raw, dict):
174
+ raw = {}
175
+ raw.setdefault("tenant_context", {}).update(updates["tenant_context"])
176
+ cfg_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
177
+ output["config_written"] = str(cfg_path)
178
+
179
+ output["success"] = True
180
+ finally:
181
+ storage.close()
182
+
183
+ print(json.dumps(output, indent=2))
184
+ return 0
185
+
186
+
187
+ def cmd_doctor(args: argparse.Namespace) -> int:
188
+ report: dict = {"checks": [], "ok": True}
189
+ cfg = load_triage_config(Path(args.config) if args.config else None)
190
+
191
+ report["checks"].append({"name": "python", "ok": True, "detail": sys.version.split()[0]})
192
+
193
+ coherence_ok = True
194
+ if cfg.gray_zone_policy == "escalate" and cfg.guard_model.enabled:
195
+ model = create_guard_model(cfg.guard_model)
196
+ ready = model is not None and model.is_ready
197
+ report["checks"].append({"name": "guard_model", "ok": ready, "artifact_id": cfg.guard_model.artifact_id})
198
+ if not ready:
199
+ coherence_ok = False
200
+ else:
201
+ report["checks"].append({"name": "guard_model", "ok": True, "detail": "not required for current policy"})
202
+
203
+ lexicon_path = cfg.tenant_context.lexicon_path or os.environ.get("PRISMGUARD_TENANT_LEXICON_PATH", "")
204
+ if cfg.tenant_context.enabled:
205
+ exists = bool(lexicon_path and Path(lexicon_path).is_file())
206
+ report["checks"].append({"name": "tenant_lexicon", "ok": exists, "path": lexicon_path})
207
+ if not exists:
208
+ coherence_ok = False
209
+ else:
210
+ report["checks"].append({"name": "tenant_lexicon", "ok": True, "detail": "optional — not enabled"})
211
+
212
+ report["checks"].append({"name": "domains_available", "ok": True, "domains": list_domains()})
213
+ report["checks"].append(
214
+ {
215
+ "name": "classifier_mode",
216
+ "ok": True,
217
+ "mode": cfg.guard_model.classifier_mode,
218
+ "disagreement_escalation": cfg.guard_model.disagreement_escalation,
219
+ "veto_enabled": cfg.guard_model.veto_enabled,
220
+ }
221
+ )
222
+
223
+ storage = _storage_for_cli(args.backend)
224
+ try:
225
+ categories = storage.relational.list_categories()
226
+ entries = sum(
227
+ len(storage.vector.list_seed_entries_by_category(c.slug)) for c in categories
228
+ )
229
+ report["checks"].append({"name": "seed_corpus", "ok": entries > 0, "entries": entries, "categories": len(categories)})
230
+ if entries == 0:
231
+ coherence_ok = False
232
+ except Exception as exc:
233
+ report["checks"].append({"name": "seed_corpus", "ok": False, "error": str(exc)})
234
+ coherence_ok = False
235
+ finally:
236
+ storage.close()
237
+
238
+ report["ok"] = coherence_ok
239
+ print(json.dumps(report, indent=2))
240
+ return 0 if coherence_ok else 1
241
+
242
+
243
+ def cmd_check(args: argparse.Namespace) -> int:
244
+ from prismguard.cli_check import format_check_result, run_check
245
+
246
+ result = run_check(args.text)
247
+ if args.json:
248
+ print(
249
+ json.dumps(
250
+ {
251
+ "decision": result.decision,
252
+ "resolution_gate": result.resolution_gate,
253
+ "matched_category": result.matched_category,
254
+ "details": result.details,
255
+ },
256
+ indent=2,
257
+ )
258
+ )
259
+ else:
260
+ print(format_check_result(result))
261
+ return 0 if result.decision == "allow" else 1
262
+
263
+
264
+ def cmd_context_import(args: argparse.Namespace) -> int:
265
+ if args.apply:
266
+ from prismguard.licensing.features import ENTERPRISE_TENANT, require_feature
267
+
268
+ require_feature(ENTERPRISE_TENANT)
269
+ path = Path(args.source)
270
+ lexicon = load_lexicon_file(path)
271
+ print(
272
+ json.dumps(
273
+ {
274
+ "domain": lexicon.domain,
275
+ "entities": len(lexicon.entities),
276
+ "source": lexicon.source or str(path),
277
+ },
278
+ indent=2,
279
+ )
280
+ )
281
+ if args.dry_run:
282
+ parsed = lexicon_to_parsed_seed(lexicon)
283
+ print(json.dumps({"dry_run": True, "template_entries": len(parsed.entries)}, indent=2))
284
+ return 0
285
+
286
+ storage = _storage_for_cli(args.backend)
287
+ try:
288
+ if args.apply:
289
+ parsed = lexicon_to_parsed_seed(lexicon)
290
+ report = import_seeds(storage, parsed, mode="update", skip_taxonomy=False)
291
+ print(
292
+ json.dumps(
293
+ {
294
+ "applied": True,
295
+ "inserted": report.inserted,
296
+ "updated": report.updated,
297
+ "skipped": report.skipped,
298
+ },
299
+ indent=2,
300
+ )
301
+ )
302
+ finally:
303
+ storage.close()
304
+ return 0
305
+
306
+
307
+ def _build_parser() -> argparse.ArgumentParser:
308
+ parser = argparse.ArgumentParser(prog="prismguard", description="PrismGuard setup and diagnostics")
309
+ sub = parser.add_subparsers(dest="command", required=True)
310
+
311
+ init_cmd = sub.add_parser("init", help="Bootstrap bundled seed, optional domain pack, optional tenant context")
312
+ init_cmd.add_argument("--domain", choices=list_domains(), help="Optional domain pack (law, healthcare, finance)")
313
+ init_cmd.add_argument("--profile", default="authored", choices=["authored", "full"])
314
+ init_cmd.add_argument("--context-file", type=Path, help="Optional tenant lexicon (.yaml, .json, .csv)")
315
+ init_cmd.add_argument("--context-table", help="Optional SQL table name with term rows")
316
+ init_cmd.add_argument("--context-dsn", help="Database DSN for --context-table")
317
+ init_cmd.add_argument(
318
+ "--context-lexicon-only",
319
+ action="store_true",
320
+ help="Load lexicon for runtime only; do not import template seed entries",
321
+ )
322
+ init_cmd.add_argument("--dry-run", action="store_true")
323
+ init_cmd.add_argument("--backend", default=None)
324
+ init_cmd.add_argument("--write-config", type=Path, help="Write tenant_context settings to triage yaml")
325
+
326
+ doctor_cmd = sub.add_parser("doctor", help="Verify storage, seed, guard model, and optional tenant lexicon")
327
+ doctor_cmd.add_argument("--config", type=Path, default=None)
328
+ doctor_cmd.add_argument("--backend", default=None)
329
+
330
+ check_cmd = sub.add_parser("check", help="Check one prompt and print an auditable decision")
331
+ check_cmd.add_argument("text", help="Prompt text to classify")
332
+ check_cmd.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
333
+
334
+ ctx_cmd = sub.add_parser("context", help="Tenant context lexicon tools")
335
+ ctx_sub = ctx_cmd.add_subparsers(dest="context_command", required=True)
336
+ import_cmd = ctx_sub.add_parser("import", help="Validate or import optional tenant lexicon")
337
+ import_cmd.add_argument("source", type=Path)
338
+ import_cmd.add_argument("--dry-run", action="store_true")
339
+ import_cmd.add_argument("--apply", action="store_true", help="Import template-generated seed entries")
340
+ import_cmd.add_argument("--backend", default=None)
341
+
342
+ domains_cmd = sub.add_parser("domains", help="List available domain packs")
343
+ domains_cmd.add_argument("--json", action="store_true")
344
+
345
+ eval_cmd = sub.add_parser("eval", help="Install verification (not internal holdout benchmark)")
346
+ eval_sub = eval_cmd.add_subparsers(dest="eval_command", required=True)
347
+ self_check_cmd = eval_sub.add_parser(
348
+ "self-check",
349
+ help="Verify classifier + fresh probes after pip install (holdout is eval-only, never imported)",
350
+ )
351
+ self_check_cmd.add_argument(
352
+ "--skip-runtime",
353
+ action="store_true",
354
+ help="Config checks only (no ONNX runtime probes)",
355
+ )
356
+
357
+ return parser
358
+
359
+
360
+ def main(argv: list[str] | None = None) -> None:
361
+ parser = _build_parser()
362
+ args = parser.parse_args(argv)
363
+
364
+ if args.command == "init":
365
+ raise SystemExit(cmd_init(args))
366
+ if args.command == "doctor":
367
+ raise SystemExit(cmd_doctor(args))
368
+ if args.command == "check":
369
+ raise SystemExit(cmd_check(args))
370
+ if args.command == "context":
371
+ if args.context_command == "import":
372
+ raise SystemExit(cmd_context_import(args))
373
+ parser.error(f"Unknown context command {args.context_command!r}")
374
+ if args.command == "domains":
375
+ packs = [get_domain_pack(name) for name in list_domains()]
376
+ if args.json:
377
+ print(json.dumps([{"name": p.name, "label": p.label} for p in packs], indent=2))
378
+ else:
379
+ for pack in packs:
380
+ print(f"{pack.name}\t{pack.label}\t{pack.overlay_path}")
381
+ return
382
+ if args.command == "eval":
383
+ if args.eval_command == "self-check":
384
+ from prismguard.eval.self_check import format_report, run_user_verify
385
+
386
+ report = run_user_verify(skip_runtime=args.skip_runtime)
387
+ print(format_report(report))
388
+ raise SystemExit(0 if report.ok else 1)
389
+ parser.error(f"Unknown eval command {args.eval_command!r}")
390
+ parser.error(f"Unknown command {args.command!r}")
391
+
392
+
393
+ if __name__ == "__main__":
394
+ main()
File without changes
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from prismguard.config.loader import TriageConfig, load_triage_config
11
+ from prismguard.runtime.check import RuntimeChecker
12
+ from prismguard.seed import load_bundled_seed
13
+ from prismguard.storage import create_storage
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class TuneResult:
18
+ block_threshold: float
19
+ allow_threshold: float
20
+ w_clf: float
21
+ holdout_block_rate: float
22
+ normal_allow_rate: float
23
+ score: float
24
+
25
+
26
+ def _holdout_rows(domain: str = "law") -> list[dict[str, str]]:
27
+ from prismguard.domains.registry import get_domain_pack
28
+
29
+ pack = get_domain_pack(domain)
30
+ holdout_path = pack.holdout_path
31
+ if holdout_path is None or not holdout_path.is_file():
32
+ return []
33
+ with holdout_path.open(encoding="utf-8") as handle:
34
+ raw = yaml.safe_load(handle)
35
+ rows: list[dict[str, str]] = []
36
+ for entry in raw.get("entries") or []:
37
+ slug = entry.get("category_slug", "")
38
+ kind = "attack" if slug != "benign_adjacent" else "benign_adjacent"
39
+ rows.append({"text": entry["text"], "traffic_kind": kind, "category_slug": slug})
40
+ return rows
41
+
42
+
43
+ def _normal_rows() -> list[dict[str, str]]:
44
+ from benchmark.law.shared.normal_scenarios import load_normal_scenarios
45
+
46
+ return [
47
+ {"text": s.text, "traffic_kind": "normal", "category_slug": s.category_hint}
48
+ for s in load_normal_scenarios()
49
+ ]
50
+
51
+
52
+ def _evaluate_config(
53
+ checker: RuntimeChecker,
54
+ cfg: TriageConfig,
55
+ rows: list[dict[str, str]],
56
+ ) -> tuple[float, float]:
57
+ """Return (attack_block_rate, normal_allow_rate) for holdout + normal rows."""
58
+ attacks = [r for r in rows if r["traffic_kind"] == "attack"]
59
+ normals = [r for r in rows if r["traffic_kind"] == "normal"]
60
+ blocked = 0
61
+ for row in attacks:
62
+ result = checker.check(row["text"])
63
+ if result.decision == "block":
64
+ blocked += 1
65
+ attack_rate = blocked / len(attacks) if attacks else 0.0
66
+ allowed = 0
67
+ for row in normals:
68
+ result = checker.check(row["text"])
69
+ if result.decision == "allow":
70
+ allowed += 1
71
+ normal_rate = allowed / len(normals) if normals else 1.0
72
+ return attack_rate, normal_rate
73
+
74
+
75
+ def tune_thresholds(
76
+ *,
77
+ domain: str = "law",
78
+ block_grid: list[float] | None = None,
79
+ allow_grid: list[float] | None = None,
80
+ w_clf_grid: list[float] | None = None,
81
+ config_path: Path | None = None,
82
+ ) -> TuneResult:
83
+ block_grid = block_grid or [0.68, 0.72, 0.76, 0.78]
84
+ allow_grid = allow_grid or [0.38, 0.42, 0.46]
85
+ w_clf_grid = w_clf_grid or [0.25, 0.30, 0.35, 0.40]
86
+
87
+ base_cfg = load_triage_config(config_path, domain=domain)
88
+ base_cfg = base_cfg.model_copy(deep=True)
89
+ base_cfg.embedding.prefer_transformer = False
90
+ base_cfg.embedding.corpus_path_enabled = True # fusion tuning requires ANN path (HashEmbedder when prefer_transformer=False)
91
+ base_cfg.guard_model.enabled = False
92
+ base_cfg.gray_zone_policy = "fail_closed"
93
+ base_cfg.guard_model = base_cfg.guard_model.model_copy(update={"classifier_mode": "gray_only"})
94
+ storage = create_storage("memory")
95
+ parsed = load_bundled_seed(profile="authored")
96
+ checker = RuntimeChecker.from_storage(storage, parsed, config=base_cfg)
97
+ rows = _holdout_rows(domain) + _normal_rows()
98
+
99
+ best: TuneResult | None = None
100
+ for block_t in block_grid:
101
+ for allow_t in allow_grid:
102
+ for w_clf in w_clf_grid:
103
+ checker._config.triage.block_threshold = block_t # noqa: SLF001
104
+ checker._config.triage.allow_threshold = allow_t # noqa: SLF001
105
+ checker._config.fusion.w_clf = w_clf # noqa: SLF001
106
+ attack_rate, normal_rate = _evaluate_config(checker, checker._config, rows)
107
+ if normal_rate < 1.0:
108
+ continue
109
+ score = attack_rate
110
+ candidate = TuneResult(
111
+ block_threshold=block_t,
112
+ allow_threshold=allow_t,
113
+ w_clf=w_clf,
114
+ holdout_block_rate=round(attack_rate, 4),
115
+ normal_allow_rate=round(normal_rate, 4),
116
+ score=round(score, 4),
117
+ )
118
+ if best is None or candidate.score > best.score:
119
+ best = candidate
120
+ storage.close()
121
+ if best is None:
122
+ raise RuntimeError("No threshold configuration satisfied normal-scenario pass constraint")
123
+ return best
124
+
125
+
126
+ def write_tuned_config(result: TuneResult, output_path: Path, *, base_path: Path | None = None) -> None:
127
+ from prismguard.config.loader import _default_config_path
128
+
129
+ source = base_path or _default_config_path()
130
+ base = yaml.safe_load(source.read_text(encoding="utf-8"))
131
+ if not isinstance(base, dict):
132
+ base = {}
133
+ base.setdefault("triage", {})
134
+ base["triage"]["block_threshold"] = result.block_threshold
135
+ base["triage"]["allow_threshold"] = result.allow_threshold
136
+ base.setdefault("fusion", {})
137
+ base["fusion"]["w_clf"] = result.w_clf
138
+ output_path.parent.mkdir(parents=True, exist_ok=True)
139
+ output_path.write_text(yaml.safe_dump(base, sort_keys=False), encoding="utf-8")
140
+
141
+
142
+ def main(argv: list[str] | None = None) -> int:
143
+ import argparse
144
+
145
+ parser = argparse.ArgumentParser(prog="prismguard-calibrate", description="Holdout-safe threshold tuning")
146
+ parser.add_argument("--domain", default="law", choices=["law", "healthcare", "finance"])
147
+ parser.add_argument("--output", type=Path, default=Path("triage.tuned.yaml"))
148
+ parser.add_argument("--json", action="store_true")
149
+ args = parser.parse_args(argv)
150
+ result = tune_thresholds(domain=args.domain)
151
+ write_tuned_config(result, args.output)
152
+ payload = {
153
+ "block_threshold": result.block_threshold,
154
+ "allow_threshold": result.allow_threshold,
155
+ "w_clf": result.w_clf,
156
+ "holdout_block_rate": result.holdout_block_rate,
157
+ "normal_allow_rate": result.normal_allow_rate,
158
+ "output": str(args.output),
159
+ }
160
+ print(json.dumps(payload, indent=2))
161
+ return 0
162
+
163
+
164
+ if __name__ == "__main__":
165
+ raise SystemExit(main())