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.
- prismguard/__init__.py +3 -0
- prismguard/app_cli.py +394 -0
- prismguard/calibration/__init__.py +0 -0
- prismguard/calibration/tune.py +165 -0
- prismguard/cli.py +210 -0
- prismguard/cli_check.py +66 -0
- prismguard/config/__init__.py +3 -0
- prismguard/config/loader.py +159 -0
- prismguard/config/storage.yaml +20 -0
- prismguard/config/triage.yaml +74 -0
- prismguard/context/__init__.py +31 -0
- prismguard/context/loader.py +131 -0
- prismguard/context/matcher.py +106 -0
- prismguard/context/models.py +50 -0
- prismguard/context/templates.py +60 -0
- prismguard/domains/__init__.py +0 -0
- prismguard/domains/finance/holdout.yaml +40 -0
- prismguard/domains/finance/overlay.yaml +40 -0
- prismguard/domains/finance/triage.yaml +8 -0
- prismguard/domains/healthcare/holdout.yaml +40 -0
- prismguard/domains/healthcare/overlay.yaml +40 -0
- prismguard/domains/healthcare/triage.yaml +8 -0
- prismguard/domains/law/holdout.yaml +100 -0
- prismguard/domains/law/overlay.yaml +100 -0
- prismguard/domains/law/triage.yaml +18 -0
- prismguard/domains/registry.py +46 -0
- prismguard/eval/__init__.py +3 -0
- prismguard/eval/probes.py +23 -0
- prismguard/eval/self_check.py +120 -0
- prismguard/feedback/__init__.py +3 -0
- prismguard/feedback/review.py +219 -0
- prismguard/feedback/store.py +146 -0
- prismguard/http/__init__.py +0 -0
- prismguard/http/service.py +182 -0
- prismguard/integrations/__init__.py +0 -0
- prismguard/integrations/chorusgraph.py +118 -0
- prismguard/licensing/__init__.py +21 -0
- prismguard/licensing/errors.py +6 -0
- prismguard/licensing/features.py +50 -0
- prismguard/licensing/keys.py +12 -0
- prismguard/licensing/validator.py +85 -0
- prismguard/models/__init__.py +10 -0
- prismguard/models/artifact_fetch.py +209 -0
- prismguard/models/artifacts/prism-pi-v1/added_tokens.json +3 -0
- prismguard/models/artifacts/prism-pi-v1/calibration.json +7 -0
- prismguard/models/artifacts/prism-pi-v1/corpus_manifest.json +33 -0
- prismguard/models/artifacts/prism-pi-v1/model_card.yaml +9 -0
- prismguard/models/artifacts/prism-pi-v1/special_tokens_map.json +51 -0
- prismguard/models/artifacts/prism-pi-v1/spm.model +0 -0
- prismguard/models/artifacts/prism-pi-v1/tokenizer.json +512186 -0
- prismguard/models/artifacts/prism-pi-v1/tokenizer_config.json +59 -0
- prismguard/models/artifacts/prism-pi-v1/train_metrics.json +45 -0
- prismguard/models/calibration.py +99 -0
- prismguard/models/cli.py +239 -0
- prismguard/models/constants.py +15 -0
- prismguard/models/corpus.py +328 -0
- prismguard/models/eval.py +168 -0
- prismguard/models/export.py +200 -0
- prismguard/models/fit_calibration.py +136 -0
- prismguard/models/hf_utils.py +39 -0
- prismguard/models/loader.py +52 -0
- prismguard/models/model_card.py +69 -0
- prismguard/models/onnx_classifier.py +221 -0
- prismguard/models/train.py +608 -0
- prismguard/models/verdict.py +18 -0
- prismguard/observability/__init__.py +3 -0
- prismguard/observability/metrics.py +77 -0
- prismguard/runtime/__init__.py +3 -0
- prismguard/runtime/check.py +1473 -0
- prismguard/runtime/fusion.py +99 -0
- prismguard/runtime/guard_model.py +132 -0
- prismguard/runtime/llm_judge.py +278 -0
- prismguard/runtime/normalize.py +47 -0
- prismguard/runtime/output_scan.py +55 -0
- prismguard/runtime/session.py +124 -0
- prismguard/runtime/stage_profiler.py +124 -0
- prismguard/runtime/structural.py +367 -0
- prismguard/runtime/thresholds.py +45 -0
- prismguard/runtime/verdict_cache.py +121 -0
- prismguard/seed/__init__.py +26 -0
- prismguard/seed/bundled.py +75 -0
- prismguard/seed/corpus/MANIFEST.md +107 -0
- prismguard/seed/corpus/authored/seed.yaml +339 -0
- prismguard/seed/corpus/external/neuralchemy/README.md +176 -0
- prismguard/seed/corpus/external/neuralchemy/core-test.parquet +0 -0
- prismguard/seed/corpus/external/neuralchemy/core-train.parquet +0 -0
- prismguard/seed/corpus/external/neuralchemy/core-validation.parquet +0 -0
- prismguard/seed/corpus/external/s-labs/README.md +150 -0
- prismguard/seed/corpus/external/s-labs/test.csv +2102 -0
- prismguard/seed/corpus/external/s-labs/train.csv +11092 -0
- prismguard/seed/corpus/external/s-labs/validation.csv +2102 -0
- prismguard/seed/corpus/external/yanismiraoui/LICENSE +201 -0
- prismguard/seed/corpus/external/yanismiraoui/NOTICE +11 -0
- prismguard/seed/corpus/external/yanismiraoui/prompt_injections.csv +1035 -0
- prismguard/seed/corpus/manifest.authored.txt +4 -0
- prismguard/seed/corpus/manifest.txt +11 -0
- prismguard/seed/formats/__init__.py +1 -0
- prismguard/seed/formats/csv_entries.py +28 -0
- prismguard/seed/formats/jsonl_entries.py +29 -0
- prismguard/seed/formats/markdown_seed.py +99 -0
- prismguard/seed/formats/neuralchemy_parquet.py +91 -0
- prismguard/seed/formats/slabs_csv.py +65 -0
- prismguard/seed/formats/yaml_taxonomy.py +92 -0
- prismguard/seed/formats/yanismiraoui_csv.py +35 -0
- prismguard/seed/importer.py +234 -0
- prismguard/seed/merge.py +110 -0
- prismguard/seed/models.py +65 -0
- prismguard/seed/normalize.py +22 -0
- prismguard/seed/parse.py +130 -0
- prismguard/seed/salience.py +58 -0
- prismguard/seed/validate.py +54 -0
- prismguard/storage/__init__.py +24 -0
- prismguard/storage/backends/__init__.py +6 -0
- prismguard/storage/backends/chroma.py +45 -0
- prismguard/storage/backends/pgvector.py +83 -0
- prismguard/storage/backends/pgvector_schema.py +136 -0
- prismguard/storage/backends/pgvector_stores.py +326 -0
- prismguard/storage/backends/pinecone.py +38 -0
- prismguard/storage/backends/weaviate.py +35 -0
- prismguard/storage/blobs.py +66 -0
- prismguard/storage/factory.py +68 -0
- prismguard/storage/memory.py +170 -0
- prismguard/storage/protocols.py +77 -0
- prismguard/storage/types.py +64 -0
- prismguard/taxonomy/__init__.py +19 -0
- prismguard/taxonomy/constants.py +4 -0
- prismguard/taxonomy/embedder.py +146 -0
- prismguard/taxonomy/graph.py +158 -0
- prismguard/taxonomy/ingest.py +104 -0
- prismguard/taxonomy/mapping.py +275 -0
- prismguard/taxonomy/pipeline.py +46 -0
- prismguard/taxonomy/report.py +145 -0
- prismguard/tools/profile_pipeline.py +15 -0
- prismguard-0.1.2.dist-info/METADATA +349 -0
- prismguard-0.1.2.dist-info/RECORD +139 -0
- prismguard-0.1.2.dist-info/WHEEL +5 -0
- prismguard-0.1.2.dist-info/entry_points.txt +6 -0
- prismguard-0.1.2.dist-info/licenses/LICENSE +17 -0
- prismguard-0.1.2.dist-info/top_level.txt +1 -0
prismguard/__init__.py
ADDED
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())
|